This is an automated email from the ASF dual-hosted git repository.

sarathsubramanian pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/atlas.git


The following commit(s) were added to refs/heads/master by this push:
     new 319a1d0d0 ATLAS-5338: Add Trino extractor integration and unit test 
suite (#691)
319a1d0d0 is described below

commit 319a1d0d082042db90e333b61a76352808927936
Author: Ramachandran Krishnan <[email protected]>
AuthorDate: Thu Jul 9 23:29:08 2026 +0530

    ATLAS-5338: Add Trino extractor integration and unit test suite (#691)
    
    * ATLAS-5338: Add Trino extractor integration and unit test suite
    
    Implement live integration tests that run the standalone extractor tarball
    against Atlas + Trino, plus unit tests for cron validation and concurrent
    job guards. Adds the trino-extractor-it Maven profile and README.
    
    https://issues.apache.org/jira/browse/ATLAS-5338
    
    * ATLAS-5338: Document components required for Trino extractor ITs
    
    * ATLAS-5338: Skip Trino extractor ITs in default Maven verify.
    
    Live-stack integration tests require Atlas and Trino; opt in with
    -Ptrino-extractor-it so CI mvn verify does not invoke Failsafe here.
    
    * ATLAS-5338: Use Java 8 compatible List helpers in TrinoExtractorITSupport
    
    Replace List.of() with Arrays.asList() so test code compiles on JDK 8
    (CI docker-build matrix).
    
    * Harden atlas-hbase Docker startup for CI compose smoke test
    
    RegionServer often lags Master on slow runners; the old healthcheck
    (start_period 40s, 30s interval) and tail-on-HMaster-only entrypoint
    caused flaky "atlas-hbase is unhealthy" failures during compose up --wait.
    
    - Add shared atlas-hbase-healthcheck.sh (wget/curl) for Master + RS
    - Wait up to 180s for both endpoints before marking container ready
    - Keep container alive while Master or RegionServer JVMs are running
    - Install wget/curl in atlas-base; tune healthcheck (120s start_period)
    - CI: --wait-timeout 300 and dump logs when compose up fails
    
    * Stabilize ImportTaskListenerImplTest async consumer tests.
    
    Use per-test import request mocks and a synchronous executor so Mockito 
stubs are not accessed concurrently, fixing the JDK 8 ClassCastException flake 
in testStartImportConsumer_Successful.
    
    Co-authored-by: Cursor <[email protected]>
    
    ---------
    
    Co-authored-by: ramk <[email protected]>
    Co-authored-by: Cursor <[email protected]>
---
 .github/workflows/ci.yml                           |   9 +-
 addons/trino-extractor/README.md                   | 185 +++++++
 addons/trino-extractor/pom.xml                     | 116 +++-
 .../test/assemblies/trino-extractor-tarball.xml    |  59 ++
 .../apache/atlas/trino/cli/TrinoExtractorIT.java   | 329 +++++++++++-
 .../atlas/trino/cli/TrinoExtractorITSupport.java   | 598 +++++++++++++++++++++
 .../apache/atlas/trino/cli/TrinoExtractorTest.java |  47 ++
 dev-support/atlas-docker/Dockerfile.atlas-base     |   2 +-
 dev-support/atlas-docker/Dockerfile.atlas-hbase    |   4 +-
 .../atlas-docker/docker-compose.atlas-hbase.yml    |  10 +-
 .../{atlas-hbase.sh => atlas-hbase-healthcheck.sh} |  48 +-
 dev-support/atlas-docker/scripts/atlas-hbase.sh    |  33 +-
 .../notification/ImportTaskListenerImplTest.java   |  86 +--
 13 files changed, 1415 insertions(+), 111 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ededed192..909843307 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 000000000..0f6172320
--- /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 9a15f3edf..3ddce7de5 100644
--- a/addons/trino-extractor/pom.xml
+++ b/addons/trino-extractor/pom.xml
@@ -35,7 +35,7 @@
     <properties>
         <checkstyle.failOnViolation>true</checkstyle.failOnViolation>
         <checkstyle.skip>false</checkstyle.skip>
-        <!-- No live-stack ITs yet; avoid Failsafe/TestNG on default mvn 
verify (see ATLAS-5338). -->
+        <!-- Live-stack ITs are opt-in via -Ptrino-extractor-it (default CI 
verify skips them). -->
         <skipITs>true</skipITs>
     </properties>
 
@@ -64,6 +64,18 @@
             <artifactId>quartz</artifactId>
             <version>2.3.2</version>
         </dependency>
+
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.testng</groupId>
+            <artifactId>testng</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
     <build>
         <plugins>
@@ -90,4 +102,106 @@
             </plugin>
         </plugins>
     </build>
+
+    <profiles>
+        <profile>
+            <id>trino-extractor-it</id>
+            <properties>
+                <skipITs>false</skipITs>
+            </properties>
+            <build>
+                <plugins>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-antrun-plugin</artifactId>
+                        <executions>
+                            <execution>
+                                <id>refresh-trino-extractor-lib</id>
+                                <goals>
+                                    <goal>run</goal>
+                                </goals>
+                                <phase>pre-integration-test</phase>
+                                <configuration>
+                                    <target>
+                                        <delete 
dir="${project.build.directory}/dependency/trino" quiet="true" />
+                                    </target>
+                                </configuration>
+                            </execution>
+                        </executions>
+                    </plugin>
+
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-dependency-plugin</artifactId>
+                        <executions>
+                            <execution>
+                                <id>copy-dependencies-for-it-tarball</id>
+                                <goals>
+                                    <goal>copy-dependencies</goal>
+                                </goals>
+                                <phase>pre-integration-test</phase>
+                                <configuration>
+                                    <excludeScope>test</excludeScope>
+                                    <includeScope>compile</includeScope>
+                                    
<outputDirectory>${project.build.directory}/dependency/trino</outputDirectory>
+                                    <overWriteReleases>true</overWriteReleases>
+                                    
<overWriteSnapshots>true</overWriteSnapshots>
+                                    <overWriteIfNewer>true</overWriteIfNewer>
+                                </configuration>
+                            </execution>
+                        </executions>
+                    </plugin>
+
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-assembly-plugin</artifactId>
+                        <executions>
+                            <execution>
+                                <id>package-trino-extractor-tarball</id>
+                                <goals>
+                                    <goal>single</goal>
+                                </goals>
+                                <phase>pre-integration-test</phase>
+                                <configuration>
+                                    <attach>false</attach>
+                                    <descriptors>
+                                        
<descriptor>src/test/assemblies/trino-extractor-tarball.xml</descriptor>
+                                    </descriptors>
+                                    
<finalName>apache-atlas-${project.version}</finalName>
+                                    
<outputDirectory>${project.build.directory}/trino-extractor-dist</outputDirectory>
+                                    <skipAssembly>false</skipAssembly>
+                                    <tarLongFileMode>gnu</tarLongFileMode>
+                                </configuration>
+                            </execution>
+                        </executions>
+                    </plugin>
+
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-failsafe-plugin</artifactId>
+                        <configuration>
+                            <includes>
+                                <include>**/*IT.java</include>
+                            </includes>
+                        </configuration>
+                        <dependencies>
+                            <dependency>
+                                <groupId>org.apache.maven.surefire</groupId>
+                                <artifactId>surefire-testng</artifactId>
+                                <version>${failsafe.version}</version>
+                            </dependency>
+                        </dependencies>
+                        <executions>
+                            <execution>
+                                <goals>
+                                    <goal>integration-test</goal>
+                                    <goal>verify</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                    </plugin>
+                </plugins>
+            </build>
+        </profile>
+    </profiles>
 </project>
diff --git 
a/addons/trino-extractor/src/test/assemblies/trino-extractor-tarball.xml 
b/addons/trino-extractor/src/test/assemblies/trino-extractor-tarball.xml
new file mode 100644
index 000000000..03f4debb5
--- /dev/null
+++ b/addons/trino-extractor/src/test/assemblies/trino-extractor-tarball.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+<assembly xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+          
xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.2";
+          
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.2
 http://maven.apache.org/xsd/assembly-1.1.2.xsd";>
+    <formats>
+        <format>tar.gz</format>
+    </formats>
+    <id>trino-extractor</id>
+    
<baseDirectory>apache-atlas-trino-extractor-${project.version}</baseDirectory>
+    <fileSets>
+        <fileSet>
+            <directory>src/main/conf</directory>
+            <outputDirectory>/conf</outputDirectory>
+            <includes>
+                <include>atlas-trino-extractor-logback.xml</include>
+                <include>atlas-trino-extractor.properties</include>
+            </includes>
+            <fileMode>0755</fileMode>
+            <directoryMode>0755</directoryMode>
+        </fileSet>
+        <fileSet>
+            <directory>src/main/bin</directory>
+            <outputDirectory>/bin</outputDirectory>
+            <includes>
+                <include>run-trino-extractor.sh</include>
+            </includes>
+            <fileMode>0755</fileMode>
+            <directoryMode>0755</directoryMode>
+        </fileSet>
+        <fileSet>
+            <directory>target/dependency/trino</directory>
+            <outputDirectory>/lib</outputDirectory>
+        </fileSet>
+        <fileSet>
+            <directory>target</directory>
+            <outputDirectory>/lib</outputDirectory>
+            <includes>
+                <include>atlas-trino-extractor-*.jar</include>
+            </includes>
+        </fileSet>
+    </fileSets>
+</assembly>
diff --git 
a/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorIT.java
 
b/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorIT.java
index e0e74ff9b..b3474c300 100644
--- 
a/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorIT.java
+++ 
b/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorIT.java
@@ -17,24 +17,315 @@
  */
 package org.apache.atlas.trino.cli;
 
+import org.testng.SkipException;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertTrue;
+
+/**
+ * Live integration tests for the standalone Trino extractor tarball against 
Atlas + Trino.
+ * <p>
+ * 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<String, String> overrides = new HashMap<>();
+        overrides.put("atlas.trino.catalogs.registered", config.catalog);
+        overrides.remove("atlas.trino.extractor.catalog");
+        overrides.remove("atlas.trino.extractor.schema");
+        overrides.remove("atlas.trino.extractor.table");
+
+        TrinoExtractorITSupport.rewriteProperties(workDir, config, overrides);
+
+        int exitCode = TrinoExtractorITSupport.runExtractorScript(workDir, 
config, "-c", config.catalog, "-s", config.schema, "-t", config.table);
+        assertEquals(exitCode, 0);
+    }
+
+    @Test(priority = 4)
+    public void testUnregisteredCatalogViaCommandLine() throws Exception {
+        Map<String, String> overrides = new HashMap<>();
+        overrides.put("atlas.trino.catalogs.registered", 
"not_registered_in_trino");
+        TrinoExtractorITSupport.rewriteProperties(workDir, config, overrides);
+
+        int exitCode = TrinoExtractorITSupport.runExtractorScript(workDir, 
config, "-c", config.catalog, "-s", config.schema, "-t", config.table);
+        assertEquals(exitCode, 0, "CLI -c should work even when catalog is not 
in catalogs.registered");
+    }
+
+    @Test(priority = 5)
+    public void testDeletedTable() throws Exception {
+        if (!TrinoExtractorITSupport.isEnabled("TRINO_IT_DELETE_SYNC")) {
+            throw new SkipException("Set TRINO_IT_DELETE_SYNC=1 to verify 
stale trino_table removal (REST-seeded stale entities are not removed in 
current runs)");
+        }
+
+        assertEquals(runSingleTableExtract(), 0);
+
+        String staleTableName = "stale_trino_table_it_" + 
System.currentTimeMillis();
+        TrinoExtractorITSupport.seedStaleTrinoTable(config, staleTableName);
+
+        int exitCode = TrinoExtractorITSupport.runExtractorScript(workDir, 
config, "-c", config.catalog, "-s", config.schema);
+        assertEquals(exitCode, 0);
+
+        String staleTableQn = 
TrinoExtractorITSupport.trinoQualifiedName(config, "trino_table", 
config.catalog, config.schema, staleTableName);
+        TrinoExtractorITSupport.assertEntityAbsent(config, "trino_table", 
staleTableQn);
+    }
+
+    @Test(priority = 5)
+    public void testDeletedSchema() throws Exception {
+        if (!TrinoExtractorITSupport.isEnabled("TRINO_IT_DELETE_SYNC")) {
+            throw new SkipException("Set TRINO_IT_DELETE_SYNC=1 to verify 
stale trino_schema removal");
+        }
+
+        assertEquals(runSingleTableExtract(), 0);
+
+        String staleSchemaName = "stale_trino_schema_it_" + 
System.currentTimeMillis();
+        TrinoExtractorITSupport.seedStaleTrinoSchema(config, staleSchemaName);
+
+        int exitCode = TrinoExtractorITSupport.runExtractorScript(workDir, 
config, "-c", config.catalog);
+        assertEquals(exitCode, 0);
+
+        String staleSchemaQn = 
TrinoExtractorITSupport.trinoQualifiedName(config, "trino_schema", 
config.catalog, staleSchemaName);
+        TrinoExtractorITSupport.assertEntityAbsent(config, "trino_schema", 
staleSchemaQn);
+    }
+
+    @Test(priority = 5)
+    public void testDeletedCatalog() throws Exception {
+        if (!TrinoExtractorITSupport.isEnabled("TRINO_IT_DELETE_SYNC")) {
+            throw new SkipException("Set TRINO_IT_DELETE_SYNC=1 to verify 
stale trino_catalog removal");
+        }
+
+        assertEquals(runSingleTableExtract(), 0);
+
+        String staleCatalogName = "stale_trino_catalog_it_" + 
System.currentTimeMillis();
+        TrinoExtractorITSupport.seedStaleTrinoCatalog(config, 
staleCatalogName);
+
+        Map<String, String> overrides = new HashMap<>();
+        overrides.put("atlas.trino.catalogs.registered", config.catalog);
+        TrinoExtractorITSupport.rewriteProperties(workDir, config, overrides);
+
+        int exitCode = TrinoExtractorITSupport.runExtractorScript(workDir, 
config);
+        assertEquals(exitCode, 0);
+
+        String staleCatalogQn = 
TrinoExtractorITSupport.trinoQualifiedName(config, "trino_catalog", 
staleCatalogName);
+        TrinoExtractorITSupport.assertEntityAbsent(config, "trino_catalog", 
staleCatalogQn);
+    }
+
+    @Test(priority = 6)
+    public void testRenameSchemaCreatesNewEntityAndDropsStale() throws 
Exception {
+        if (!TrinoExtractorITSupport.isEnabled("TRINO_IT_DELETE_SYNC")) {
+            throw new SkipException("Set TRINO_IT_DELETE_SYNC=1 to verify 
stale trino_schema cleanup after Trino rename");
+        }
+
+        assertEquals(runSingleTableExtract(), 0);
+
+        String staleSchemaName = "stale_trino_schema_it_" + 
System.currentTimeMillis();
+        TrinoExtractorITSupport.seedStaleTrinoSchema(config, staleSchemaName);
+
+        int exitCode = TrinoExtractorITSupport.runExtractorScript(workDir, 
config, "-c", config.catalog);
+        assertEquals(exitCode, 0);
+
+        String staleSchemaQn = 
TrinoExtractorITSupport.trinoQualifiedName(config, "trino_schema", 
config.catalog, staleSchemaName);
+        TrinoExtractorITSupport.assertEntityAbsent(config, "trino_schema", 
staleSchemaQn);
+
+        String liveSchemaQn = 
TrinoExtractorITSupport.trinoQualifiedName(config, "trino_schema", 
config.catalog, config.schema);
+        TrinoExtractorITSupport.assertEntityExists(config, "trino_schema", 
liveSchemaQn);
+    }
+
+    @Test(priority = 6)
+    public void testRenameCatalogCreatesNewEntityAndDropsStale() throws 
Exception {
+        if (!TrinoExtractorITSupport.isEnabled("TRINO_IT_DELETE_SYNC")) {
+            throw new SkipException("Set TRINO_IT_DELETE_SYNC=1 to verify 
stale trino_catalog cleanup after Trino rename");
+        }
+
+        assertEquals(runSingleTableExtract(), 0);
+
+        String staleCatalogName = "stale_trino_catalog_it_" + 
System.currentTimeMillis();
+        TrinoExtractorITSupport.seedStaleTrinoCatalog(config, 
staleCatalogName);
+
+        Map<String, String> overrides = new HashMap<>();
+        overrides.put("atlas.trino.catalogs.registered", config.catalog);
+        TrinoExtractorITSupport.rewriteProperties(workDir, config, overrides);
+
+        int exitCode = TrinoExtractorITSupport.runExtractorScript(workDir, 
config);
+        assertEquals(exitCode, 0);
+
+        String staleCatalogQn = 
TrinoExtractorITSupport.trinoQualifiedName(config, "trino_catalog", 
staleCatalogName);
+        TrinoExtractorITSupport.assertEntityAbsent(config, "trino_catalog", 
staleCatalogQn);
+
+        String liveCatalogQn = 
TrinoExtractorITSupport.trinoQualifiedName(config, "trino_catalog", 
config.catalog);
+        TrinoExtractorITSupport.assertEntityExists(config, "trino_catalog", 
liveCatalogQn);
+    }
+
+    @Test(priority = 7)
+    public void testHookEntityLinkedToTrinoColumn() throws Exception {
+        if (!TrinoExtractorITSupport.isEnabled("TRINO_IT_HOOK_ENABLED")) {
+            throw new SkipException("Set TRINO_IT_HOOK_ENABLED=1 when Hive 
hook metadata exists in Atlas (namespace " + config.hiveNamespace + ")");
+        }
+
+        runHookEnabledExtract();
+
+        String columnQn = TrinoExtractorITSupport.trinoQualifiedName(config, 
"trino_column", config.catalog, config.schema, config.table, config.column);
+        String body = TrinoExtractorITSupport.getEntityBody(config, 
"trino_column", columnQn);
+
+        assertTrue(body != null && body.contains("hive_column"), "trino_column 
should link to hive_column when hook is enabled");
+    }
+
+    @Test(priority = 7)
+    public void testTagPropagatedToTrinoColumn() throws Exception {
+        if (!TrinoExtractorITSupport.isEnabled("TRINO_IT_TAG_PROPAGATION")) {
+            throw new SkipException("Set TRINO_IT_TAG_PROPAGATION=1 when 
hive_column PII tag propagation is configured");
+        }
+
+        runHookEnabledExtract();
+
+        String hiveColumnQn = config.schema + "." + config.table + "." + 
config.column + "@" + config.hiveNamespace;
+        String hiveGuid = TrinoExtractorITSupport.getEntityGuid(config, 
"hive_column", hiveColumnQn);
+
+        if (hiveGuid == null) {
+            throw new SkipException("hive_column not found for tag propagation 
test: " + hiveColumnQn);
+        }
+
+        classifyEntity(config, hiveGuid, "PII");
+
+        assertEquals(runSingleTableExtract(), 0);
+
+        String trinoColumnQn = 
TrinoExtractorITSupport.trinoQualifiedName(config, "trino_column", 
config.catalog, config.schema, config.table, config.column);
+        String body = TrinoExtractorITSupport.getEntityBody(config, 
"trino_column", trinoColumnQn);
+
+        assertTrue(body != null && body.contains("PII"), "PII tag should 
propagate to trino_column when hook link exists");
+    }
+
+    @Test(priority = 8)
+    public void testDeletedColumnNotSupportedYet() {
+        throw new SkipException("Extractor does not delete orphan trino_column 
entities when a column is dropped in Trino");
+    }
+
+    @Test(priority = 8)
+    public void testCronDoesNotOverlapConcurrentRuns() {
+        throw new SkipException("Concurrent cron scheduling is guarded by 
@DisallowConcurrentExecution; covered in TrinoExtractorTest");
+    }
+
+    private static int runSingleTableExtract() throws Exception {
+        return TrinoExtractorITSupport.runExtractorScript(workDir, config, 
"-c", config.catalog, "-s", config.schema, "-t", config.table);
+    }
+
+    private void runHookEnabledExtract() throws Exception {
+        Map<String, String> overrides = new HashMap<>();
+        overrides.put("atlas.trino.catalog.hook.enabled." + config.catalog, 
"true");
+        overrides.put("atlas.trino.catalog.hook.enabled." + config.catalog + 
".namespace", config.hiveNamespace);
+        TrinoExtractorITSupport.rewriteProperties(workDir, config, overrides);
+        assertEquals(runSingleTableExtract(), 0);
+    }
+
+    private static void classifyEntity(TrinoExtractorITSupport.LiveStackConfig 
cfg, String guid, String classification) throws IOException {
+        String payload = "[{\"typeName\":\"" + classification + "\"}]";
+        HttpURLConnection connection = openClassificationsPost(cfg, guid, 
payload);
+
+        try {
+            int status = connection.getResponseCode();
+            assertTrue(status >= 200 && status < 300, "classification POST 
failed: HTTP " + status);
+        } finally {
+            connection.disconnect();
+        }
+    }
+
+    private static HttpURLConnection 
openClassificationsPost(TrinoExtractorITSupport.LiveStackConfig cfg, String 
guid, String payload) throws IOException {
+        HttpURLConnection connection = (HttpURLConnection) new 
URL(cfg.atlasRestUrl + "api/atlas/v2/entity/guid/" + guid + 
"/classifications").openConnection();
+        connection.setRequestMethod("POST");
+        connection.setDoOutput(true);
+        connection.setRequestProperty("Content-Type", "application/json");
+        String credentials = cfg.atlasUsername + ":" + cfg.atlasPassword;
+        String encoded = 
Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+        connection.setRequestProperty("Authorization", "Basic " + encoded);
+        
connection.getOutputStream().write(payload.getBytes(StandardCharsets.UTF_8));
+        return connection;
+    }
 }
diff --git 
a/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorITSupport.java
 
b/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorITSupport.java
new file mode 100644
index 000000000..362ff46e7
--- /dev/null
+++ 
b/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorITSupport.java
@@ -0,0 +1,598 @@
+/**
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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<String> jerseyClientJars = new ArrayList<>();
+
+        try (DirectoryStream<Path> stream = Files.newDirectoryStream(libDir, 
"jersey-client*.jar")) {
+            for (Path jar : stream) {
+                jerseyClientJars.add(jar.getFileName().toString());
+            }
+        }
+
+        assertFalse(jerseyClientJars.isEmpty(), "jersey-client jar missing 
from tarball lib/");
+        assertTrue(jerseyClientJars.stream().anyMatch(name -> 
name.contains("jersey-client-1.19")),
+                "expected jersey-client 1.19 in tarball lib/, found: " + 
jerseyClientJars);
+        assertFalse(jerseyClientJars.stream().anyMatch(name -> 
name.matches("jersey-client-1\\.9\\.jar")),
+                "jersey-client 1.9 must not be in tarball lib/: " + 
jerseyClientJars);
+    }
+
+    static int runExtractorJava(Path workDir, LiveStackConfig config, 
String... javaArgs) throws IOException, InterruptedException {
+        Files.createDirectories(workDir.resolve("log"));
+
+        List<String> command = new ArrayList<>();
+        command.add("java");
+        command.add("-Datlas.log.dir=" + workDir.resolve("log"));
+        command.add("-Datlas.log.file=atlas-trino-extractor.log");
+        
command.add("-Dlogback.configurationFile=atlas-trino-extractor-logback.xml");
+        command.add("-Datlas.properties=atlas-trino-extractor.properties");
+        command.add("-cp");
+        command.add(buildClasspath(workDir));
+        command.add("org.apache.atlas.trino.cli.TrinoExtractor");
+        command.addAll(Arrays.asList(javaArgs));
+
+        ProcessBuilder processBuilder = new ProcessBuilder(command);
+        processBuilder.directory(workDir.toFile());
+        processBuilder.redirectErrorStream(true);
+        processBuilder.environment().put("ATLAS_USERNAME", 
config.atlasUsername);
+        processBuilder.environment().put("ATLAS_PASSWORD", 
config.atlasPassword);
+
+        Process process = processBuilder.start();
+
+        try (BufferedReader reader = new BufferedReader(new 
InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+            String output = 
reader.lines().collect(Collectors.joining(System.lineSeparator()));
+
+            if (!output.isEmpty()) {
+                LOG.debug("TrinoExtractor java output:\n{}", output);
+            }
+        }
+
+        return process.waitFor();
+    }
+
+    static int runExtractorScript(Path workDir, LiveStackConfig config, 
String... extraArgs) throws IOException, InterruptedException {
+        List<String> command = new ArrayList<>();
+        
command.add(workDir.resolve("bin").resolve("run-trino-extractor.sh").toString());
+        command.addAll(Arrays.asList(extraArgs));
+
+        ProcessBuilder processBuilder = new ProcessBuilder(command);
+        processBuilder.directory(workDir.toFile());
+        processBuilder.redirectErrorStream(true);
+        processBuilder.environment().put("ATLAS_USERNAME", 
config.atlasUsername);
+        processBuilder.environment().put("ATLAS_PASSWORD", 
config.atlasPassword);
+
+        Process process = processBuilder.start();
+        String output;
+
+        try (BufferedReader reader = new BufferedReader(new 
InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
+            output = 
reader.lines().collect(Collectors.joining(System.lineSeparator()));
+        }
+
+        int exitCode = process.waitFor();
+
+        if (exitCode != 0) {
+            LOG.error("run-trino-extractor.sh {} exit={} output:\n{}", 
extraArgs, exitCode, output);
+            Path logFile = 
workDir.resolve("log").resolve("atlas-trino-extractor.log");
+
+            if (Files.isRegularFile(logFile)) {
+                LOG.error("extractor log tail:\n{}", tailFile(logFile, 8000));
+            }
+        } else {
+            LOG.info("run-trino-extractor.sh {} completed successfully", 
Arrays.toString(extraArgs));
+        }
+
+        return exitCode;
+    }
+
+    static void rewriteProperties(Path workDir, LiveStackConfig config, 
Map<String, String> overrides) throws IOException {
+        writeExtractorProperties(workDir, config, overrides);
+    }
+
+    static String trinoQualifiedName(LiveStackConfig config, String type, 
String... parts) {
+        switch (type) {
+            case "trino_instance":
+                return config.trinoNamespace;
+            case "trino_catalog":
+                return String.format(Locale.ROOT, "%s@%s", parts[0], 
config.trinoNamespace);
+            case "trino_schema":
+                return String.format(Locale.ROOT, "%s.%s@%s", parts[0], 
parts[1], config.trinoNamespace);
+            case "trino_table":
+                return String.format(Locale.ROOT, "%s.%s.%s@%s", parts[0], 
parts[1], parts[2], config.trinoNamespace);
+            case "trino_column":
+                return String.format(Locale.ROOT, "%s.%s.%s.%s@%s", parts[0], 
parts[1], parts[2], parts[3], config.trinoNamespace);
+            default:
+                throw new IllegalArgumentException("unsupported type: " + 
type);
+        }
+    }
+
+    static void assertEntityExists(LiveStackConfig config, String typeName, 
String qualifiedName) throws IOException {
+        assertEquals(getEntityStatus(config, typeName, qualifiedName), 200,
+                typeName + " missing for qualifiedName=" + qualifiedName);
+    }
+
+    static void assertEntityAbsent(LiveStackConfig config, String typeName, 
String qualifiedName) throws IOException {
+        assertEquals(getEntityStatus(config, typeName, qualifiedName), 404,
+                typeName + " still present for qualifiedName=" + 
qualifiedName);
+    }
+
+    static String getEntityBody(LiveStackConfig config, String typeName, 
String qualifiedName) throws IOException {
+        String requestUrl = config.atlasRestUrl + 
"api/atlas/v2/entity/uniqueAttribute/type/" + typeName
+                + "?attr:qualifiedName=" + urlEncode(qualifiedName);
+        HttpURLConnection connection = openGet(requestUrl, config);
+
+        try {
+            int status = connection.getResponseCode();
+
+            if (status != 200) {
+                return null;
+            }
+
+            return readBody(connection);
+        } finally {
+            connection.disconnect();
+        }
+    }
+
+    static String getEntityGuid(LiveStackConfig config, String typeName, 
String qualifiedName) throws IOException {
+        String body = getEntityBody(config, typeName, qualifiedName);
+        return parseEntityGuid(body);
+    }
+
+    private static String parseEntityGuid(String body) {
+        if (body == null) {
+            return null;
+        }
+
+        int entityIdx = body.indexOf("\"entity\"");
+        int searchFrom = entityIdx >= 0 ? entityIdx : 0;
+        int guidIdx = body.indexOf("\"guid\":\"", searchFrom);
+
+        if (guidIdx < 0) {
+            return null;
+        }
+
+        int start = guidIdx + 8;
+        int end   = body.indexOf('"', start);
+
+        return end > start ? body.substring(start, end) : null;
+    }
+
+    static void deleteEntityByGuid(LiveStackConfig config, String guid) throws 
IOException {
+        if (guid == null) {
+            return;
+        }
+
+        HttpURLConnection connection = openDelete(config.atlasRestUrl + 
"api/atlas/v2/entity/guid/" + guid, config);
+
+        try {
+            connection.getResponseCode();
+        } finally {
+            connection.disconnect();
+        }
+    }
+
+    static void seedStaleTrinoTable(LiveStackConfig config, String tableName) 
throws IOException {
+        String schemaQn = trinoQualifiedName(config, "trino_schema", 
config.catalog, config.schema);
+        String tableQn  = trinoQualifiedName(config, "trino_table", 
config.catalog, config.schema, tableName);
+
+        deleteIfExists(config, "trino_table", tableQn);
+
+        String schemaGuid = getEntityGuid(config, "trino_schema", schemaQn);
+        assertTrue(schemaGuid != null, "schema must exist before stale 
trino_table test: " + schemaQn);
+
+        String payload = 
"{\"entity\":{\"typeName\":\"trino_table\",\"attributes\":{\"qualifiedName\":\""
 + tableQn + "\",\"name\":\"" + tableName + 
"\"},\"relationshipAttributes\":{\"trinoschema\":{\"guid\":\"" + schemaGuid + 
"\",\"typeName\":\"trino_schema\"}}}}";
+
+        postJson(config, "api/atlas/v2/entity", payload);
+        assertEntityExists(config, "trino_table", tableQn);
+    }
+
+    static void seedStaleTrinoSchema(LiveStackConfig config, String 
schemaName) throws IOException {
+        String catalogQn = trinoQualifiedName(config, "trino_catalog", 
config.catalog);
+        String schemaQn  = trinoQualifiedName(config, "trino_schema", 
config.catalog, schemaName);
+
+        deleteIfExists(config, "trino_schema", schemaQn);
+
+        String catalogGuid = getEntityGuid(config, "trino_catalog", catalogQn);
+        assertTrue(catalogGuid != null, "catalog must exist before stale 
trino_schema test: " + catalogQn);
+
+        String payload = 
"{\"entity\":{\"typeName\":\"trino_schema\",\"attributes\":{\"qualifiedName\":\""
 + schemaQn + "\",\"name\":\"" + schemaName + 
"\"},\"relationshipAttributes\":{\"catalog\":{\"guid\":\"" + catalogGuid + 
"\",\"typeName\":\"trino_catalog\"}}}}";
+
+        postJson(config, "api/atlas/v2/entity", payload);
+        assertEntityExists(config, "trino_schema", schemaQn);
+    }
+
+    static void seedStaleTrinoCatalog(LiveStackConfig config, String 
catalogName) throws IOException {
+        String catalogQn = trinoQualifiedName(config, "trino_catalog", 
catalogName);
+
+        deleteIfExists(config, "trino_catalog", catalogQn);
+
+        String instanceGuid = getEntityGuid(config, "trino_instance", 
config.trinoNamespace);
+        assertTrue(instanceGuid != null, "trino_instance must exist before 
stale trino_catalog test");
+
+        String payload = 
"{\"entity\":{\"typeName\":\"trino_catalog\",\"attributes\":{\"qualifiedName\":\""
 + catalogQn + "\",\"name\":\"" + catalogName + 
"\",\"connectorType\":\"hive\"},\"relationshipAttributes\":{\"instance\":{\"guid\":\""
 + instanceGuid + "\",\"typeName\":\"trino_instance\"}}}}";
+
+        postJson(config, "api/atlas/v2/entity", payload);
+        assertEntityExists(config, "trino_catalog", catalogQn);
+    }
+
+    static boolean isEnabled(String envName) {
+        return "1".equals(System.getenv(envName)) || 
"true".equalsIgnoreCase(System.getenv(envName));
+    }
+
+    private static void deleteIfExists(LiveStackConfig config, String 
typeName, String qualifiedName) throws IOException {
+        if (getEntityStatus(config, typeName, qualifiedName) == 200) {
+            deleteEntityByGuid(config, getEntityGuid(config, typeName, 
qualifiedName));
+        }
+    }
+
+    private static String buildClasspath(Path workDir) throws IOException {
+        List<String> entries = new ArrayList<>();
+        entries.add(workDir.resolve("conf").toString());
+
+        try (DirectoryStream<Path> stream = 
Files.newDirectoryStream(workDir.resolve("lib"), "*.jar")) {
+            for (Path jar : stream) {
+                entries.add(jar.toString());
+            }
+        }
+
+        return String.join(":", entries);
+    }
+
+    private static int getEntityStatus(LiveStackConfig config, String 
typeName, String qualifiedName) throws IOException {
+        String requestUrl = config.atlasRestUrl + 
"api/atlas/v2/entity/uniqueAttribute/type/" + typeName
+                + "?attr:qualifiedName=" + urlEncode(qualifiedName);
+        HttpURLConnection connection = openGet(requestUrl, config);
+
+        try {
+            return connection.getResponseCode();
+        } finally {
+            connection.disconnect();
+        }
+    }
+
+    private static void postJson(LiveStackConfig config, String path, String 
json) throws IOException {
+        HttpURLConnection connection = openPost(config.atlasRestUrl + path, 
config);
+
+        try {
+            
connection.getOutputStream().write(json.getBytes(StandardCharsets.UTF_8));
+            int status = connection.getResponseCode();
+            String body  = readResponseBody(connection, status);
+
+            if (status < 200 || status >= 300) {
+                throw new IOException("POST " + path + " failed: HTTP " + 
status + " body=" + body);
+            }
+        } finally {
+            connection.disconnect();
+        }
+    }
+
+    private static String readResponseBody(HttpURLConnection connection, int 
status) throws IOException {
+        java.io.InputStream stream = status >= 400 ? 
connection.getErrorStream() : connection.getInputStream();
+
+        if (stream == null) {
+            return "";
+        }
+
+        try (BufferedReader reader = new BufferedReader(new 
InputStreamReader(stream, StandardCharsets.UTF_8))) {
+            return reader.lines().collect(Collectors.joining());
+        }
+    }
+
+    private static boolean isAtlasReachable(LiveStackConfig config) {
+        try {
+            HttpURLConnection connection = openGet(config.atlasRestUrl + 
"api/atlas/admin/version", config);
+
+            try {
+                return connection.getResponseCode() == 200;
+            } finally {
+                connection.disconnect();
+            }
+        } catch (IOException e) {
+            LOG.warn("Atlas reachability check failed: {}", e.toString());
+            return false;
+        }
+    }
+
+    private static HttpURLConnection openGet(String requestUrl, 
LiveStackConfig config) throws IOException {
+        HttpURLConnection connection = (HttpURLConnection) new 
URL(requestUrl).openConnection();
+        connection.setRequestMethod("GET");
+        connection.setConnectTimeout(10_000);
+        connection.setReadTimeout(30_000);
+        addBasicAuth(connection, config);
+        return connection;
+    }
+
+    private static HttpURLConnection openPost(String requestUrl, 
LiveStackConfig config) throws IOException {
+        HttpURLConnection connection = (HttpURLConnection) new 
URL(requestUrl).openConnection();
+        connection.setRequestMethod("POST");
+        connection.setDoOutput(true);
+        connection.setConnectTimeout(10_000);
+        connection.setReadTimeout(30_000);
+        connection.setRequestProperty("Content-Type", "application/json");
+        addBasicAuth(connection, config);
+        return connection;
+    }
+
+    private static HttpURLConnection openDelete(String requestUrl, 
LiveStackConfig config) throws IOException {
+        HttpURLConnection connection = (HttpURLConnection) new 
URL(requestUrl).openConnection();
+        connection.setRequestMethod("DELETE");
+        connection.setConnectTimeout(10_000);
+        connection.setReadTimeout(30_000);
+        addBasicAuth(connection, config);
+        return connection;
+    }
+
+    private static void addBasicAuth(HttpURLConnection connection, 
LiveStackConfig config) {
+        String credentials = config.atlasUsername + ":" + config.atlasPassword;
+        String encoded     = 
Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+        connection.setRequestProperty("Authorization", "Basic " + encoded);
+    }
+
+    private static String readBody(HttpURLConnection connection) throws 
IOException {
+        try (BufferedReader reader = new BufferedReader(new 
InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
+            return reader.lines().collect(Collectors.joining());
+        }
+    }
+
+    private static Path resolveTarball() throws IOException {
+        String explicitTarball = System.getenv("TRINO_EXTRACTOR_TARBALL");
+
+        if (explicitTarball != null && !explicitTarball.isEmpty()) {
+            Path path = Paths.get(explicitTarball);
+
+            if (!Files.isRegularFile(path)) {
+                throw new SkipException("TRINO_EXTRACTOR_TARBALL does not 
exist: " + explicitTarball);
+            }
+
+            return path;
+        }
+
+        Path moduleDir = 
Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
+        List<Path> candidates = Arrays.asList(
+                moduleDir.resolve("target/trino-extractor-dist"),
+                moduleDir.resolve("../../distro/target").normalize());
+
+        for (Path dir : candidates) {
+            Path match = findTarballInDirectory(dir);
+
+            if (match != null) {
+                return match;
+            }
+        }
+
+        throw new SkipException("Trino extractor tarball not found. Build with 
"
+                + "mvn -pl addons/trino-extractor -Ptrino-extractor-it package 
"
+                + "or set TRINO_EXTRACTOR_TARBALL");
+    }
+
+    private static Path findTarballInDirectory(Path directory) throws 
IOException {
+        if (!Files.isDirectory(directory)) {
+            return null;
+        }
+
+        try (Stream<Path> paths = Files.list(directory)) {
+            return paths
+                    .filter(Files::isRegularFile)
+                    .filter(path -> 
path.getFileName().toString().contains("trino-extractor"))
+                    .filter(path -> 
path.getFileName().toString().endsWith(".tar.gz"))
+                    .sorted()
+                    .findFirst()
+                    .orElse(null);
+        }
+    }
+
+    private static void unpackTarball(Path tarball, Path destination) throws 
IOException, InterruptedException {
+        Files.createDirectories(destination);
+
+        ProcessBuilder processBuilder = new ProcessBuilder(
+                "tar", "xzf", tarball.toAbsolutePath().toString(),
+                "-C", destination.toAbsolutePath().toString(),
+                "--strip-components=1");
+        processBuilder.redirectErrorStream(true);
+
+        Process process = processBuilder.start();
+        process.waitFor();
+        assertEquals(process.exitValue(), 0, "failed to unpack tarball " + 
tarball);
+    }
+
+    private static void writeExtractorProperties(Path workDir, LiveStackConfig 
config, Map<String, String> overrides) throws IOException {
+        Properties properties = new Properties();
+        properties.setProperty("atlas.rest.address", config.atlasRestUrl);
+        properties.setProperty("atlas.trino.jdbc.address", 
config.trinoJdbcUrl);
+        properties.setProperty("atlas.trino.jdbc.user", 
firstNonBlank(System.getenv("TRINO_JDBC_USER"), "admin"));
+        properties.setProperty("atlas.trino.namespace", config.trinoNamespace);
+        properties.setProperty("atlas.trino.catalogs.registered", 
config.catalog);
+
+        if (overrides != null) {
+            for (Map.Entry<String, String> entry : overrides.entrySet()) {
+                if (entry.getValue() == null) {
+                    properties.remove(entry.getKey());
+                } else {
+                    properties.setProperty(entry.getKey(), entry.getValue());
+                }
+            }
+        }
+
+        Path confDir = workDir.resolve("conf");
+        Files.createDirectories(confDir);
+
+        try (OutputStream out = 
Files.newOutputStream(confDir.resolve("atlas-trino-extractor.properties"))) {
+            properties.store(out, "TrinoExtractorIT");
+        }
+    }
+
+    private static void makeExecutable(Path script) throws IOException {
+        if (!Files.exists(script)) {
+            return;
+        }
+
+        try {
+            Files.setPosixFilePermissions(script, EnumSet.of(
+                    PosixFilePermission.OWNER_READ,
+                    PosixFilePermission.OWNER_WRITE,
+                    PosixFilePermission.OWNER_EXECUTE,
+                    PosixFilePermission.GROUP_READ,
+                    PosixFilePermission.GROUP_EXECUTE,
+                    PosixFilePermission.OTHERS_READ,
+                    PosixFilePermission.OTHERS_EXECUTE));
+        } catch (UnsupportedOperationException e) {
+            LOG.debug("POSIX permissions not supported for {}", script);
+        }
+    }
+
+    private static String tailFile(Path file, int maxChars) throws IOException 
{
+        String content = new String(Files.readAllBytes(file), 
StandardCharsets.UTF_8);
+        return content.length() <= maxChars ? content : 
content.substring(content.length() - maxChars);
+    }
+
+    private static String normalizeAtlasUrl(String url) {
+        return url.endsWith("/") ? url : url + "/";
+    }
+
+    private static String urlEncode(String value) {
+        try {
+            return java.net.URLEncoder.encode(value, "UTF-8");
+        } catch (java.io.UnsupportedEncodingException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    private static String firstNonBlank(String... values) {
+        if (values == null) {
+            return null;
+        }
+
+        for (String value : values) {
+            if (value != null && !value.trim().isEmpty()) {
+                return value.trim();
+            }
+        }
+
+        return null;
+    }
+
+    static final class LiveStackConfig {
+        String atlasRestUrl;
+        String atlasUsername;
+        String atlasPassword;
+        String trinoJdbcUrl;
+        String trinoNamespace;
+        String catalog;
+        String schema;
+        String table;
+        String column;
+        String hiveNamespace;
+    }
+}
diff --git 
a/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorTest.java
 
b/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorTest.java
new file mode 100644
index 000000000..d85e10387
--- /dev/null
+++ 
b/addons/trino-extractor/src/test/java/org/apache/atlas/trino/cli/TrinoExtractorTest.java
@@ -0,0 +1,47 @@
+/**
+ * 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
+ * <p>
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * <p>
+ * 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 abc2b68d3..a7af42844 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 a05bda3a1..c4e7e7910 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 9d46702c2..82bdf10dc 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.sh 
b/dev-support/atlas-docker/scripts/atlas-hbase-healthcheck.sh
old mode 100755
new mode 100644
similarity index 50%
copy from dev-support/atlas-docker/scripts/atlas-hbase.sh
copy to dev-support/atlas-docker/scripts/atlas-hbase-healthcheck.sh
index e139a9377..ddab30595
--- a/dev-support/atlas-docker/scripts/atlas-hbase.sh
+++ b/dev-support/atlas-docker/scripts/atlas-hbase-healthcheck.sh
@@ -1,5 +1,4 @@
 #!/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
@@ -16,38 +15,19 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-service ssh start
-
-if [ ! -e ${HBASE_HOME}/.setupDone ]
-then
-  su -c "ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa" hbase
-  su -c "cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys" hbase
-  su -c "chmod 0600 ~/.ssh/authorized_keys" hbase
-
-  echo "ssh" > /etc/pdsh/rcmd_default
-
-  ${ATLAS_SCRIPTS}/atlas-hbase-setup.sh
-
-  touch ${HBASE_HOME}/.setupDone
-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
-    break
+# 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
+}
 
-  sleep 2
-done
-
-if [ -z "${HBASE_MASTER_PID}" ]; then
-  echo "HBase HMaster failed to start" >&2
-  exit 1
-fi
-
-# prevent the container from exiting
-tail --pid=$HBASE_MASTER_PID -f /dev/null
+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 e139a9377..3c986c80c 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 c0fdfddf5..422f7d4cf 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 class ImportTaskListenerImplTest {
     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 class ImportTaskListenerImplTest {
     @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 class ImportTaskListenerImplTest {
 
     @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 class ImportTaskListenerImplTest {
             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 class ImportTaskListenerImplTest {
 
     @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 class ImportTaskListenerImplTest {
 
     @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 class ImportTaskListenerImplTest {
             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 class ImportTaskListenerImplTest {
         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;

Reply via email to