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

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino-playground.git


The following commit(s) were added to refs/heads/main by this push:
     new d1e85b3  Fix Iceberg JDBC catalog init failure in Trino; seed Iceberg 
demo data; persist catalog data; playground.sh and README fixes (#157)
d1e85b3 is described below

commit d1e85b3efdc4f6f2380e130c710603d000e0c5af
Author: Mark Hoerth <[email protected]>
AuthorDate: Tue Jul 14 19:08:16 2026 -0700

    Fix Iceberg JDBC catalog init failure in Trino; seed Iceberg demo data; 
persist catalog data; playground.sh and README fixes (#157)
    
    Trino's Iceberg connector initializes JdbcCatalog with
    init-catalog-tables disabled, so querying catalog_iceberg from Trino
    before any Spark activity fails with `Cannot check and eventually update
    SQL schema`, caused by `Table 'db.iceberg_tables' doesn't exist`. Spark
    auto-creates the tables, which masks the issue when Spark runs first.
    
    ### What changes were proposed in this pull request?
    
    **Bug fix.** Add `CREATE TABLE IF NOT EXISTS` statements for
    `iceberg_tables` and `iceberg_namespace_properties` in the `db` database
    to `init/mysql/init.sql`, so the Iceberg JDBC catalog backing tables
    exist as soon as MySQL initializes. Also adds `CREATE DATABASE IF NOT
    EXISTS db` for robustness, though the compose file already creates it
    via `MYSQL_DATABASE`.
    
    **Iceberg seed data.** The playground seeds Hive and PostgreSQL with
    demo data but left the Iceberg catalog empty. A new startup step
    (`init/spark/iceberg-seed.sh`, hooked into the Spark entrypoint) seeds
    `analytics.orders`, a region-partitioned Iceberg table written in two
    commits so snapshot history and time travel are demonstrable
    immediately. The seed waits for the Iceberg REST service, is idempotent
    (skips when the namespace exists), runs with the same identity that
    initializes the metalake so it works with and without `--enable-auth`,
    and logs to `/tmp/iceberg-seed.log` in the Spark container.
    
    **Persist catalog data across stops.** MySQL, PostgreSQL, and the hive
    container's HDFS tree and internal metastore database now use named
    Docker volumes, so `./playground.sh stop` followed by `start` preserves
    catalog contents. Previously these lived in anonymous volumes and were
    silently lost on every stop, while Gravitino's own metadata
    (bind-mounted) survived, leaving Gravitino aware of catalogs whose
    contents had vanished. The full reset (`docker compose -p
    gravitino-playground down -v` plus removing the `data` directory)
    remains the way to start fresh, and the README's Stop section now states
    the behavior accurately.
    
    **playground.sh fixes.** Robust hello-world cleanup after the Docker
    preflight test (the previous image-listing parse produced `No such
    image` errors), corrected the Postgres port in the preflight check
    (`15342` -> `15432`), added Spark's `14040`, and aligned the RAM
    requirement with the README (8 GB).
    
    **README overhaul.** While fixing the bug required reverse-engineering
    the environment, the README got a full pass:
    
    - New Environment configuration section stating what the playground
    actually runs: authentication and authorization posture, plain HTTP
    transport, the H2 entity store, the MySQL-backed Iceberg JDBC catalog,
    HDFS table storage, and demo credentials
    - Usage section rework: HTTPS clone URL instead of SSH, the two install
    paths presented as explicit alternatives, a shared Playground management
    subsection, a pointer to the `--enable-auth` and `--enable-ranger`
    flags, and documented full-reset instructions (`docker compose down -v`
    plus removing the `data` directory, neither of which was documented)
    - Documentation of the seeded `analytics.orders` table with showcase
    queries including `$snapshots` and `FOR VERSION AS OF`
    - Structure: code blocks indented under their numbered list items, the
    two step-labeled demos converted to the same numbered convention as the
    rest of the document, single-child headings collapsed, gerund headings
    converted to noun phrases, the Examples subsections reordered so the two
    Iceberg REST sections are adjacent and the governance demos form a
    contiguous block
    - Accuracy: removed the stale ASF Incubator disclaimer (Gravitino is a
    TLP), removed version-gated claims (1.0+, 1.1), corrected the access
    control intro to claim catalog/schema/table level privileges rather than
    fine-grained authorization, and replaced the vague security remediation
    with concrete guidance (OAuth2 token validation per the Gravitino
    security docs)
    - Correctness fixes: a broken four-backtick code fence, `//` comments in
    a Spark SQL block (invalid syntax), the intro service list completed to
    match the compose stack, and a note that `catalog_iceberg` in the role
    definitions is the same catalog as `catalog_rest` in Spark
    - Style: consistent imperative second person, "Log in to" throughout,
    filler and marketing phrasing removed, normalized notebook pointers
    
    Reviewer note: the README diff is large but much of the line count is
    list re-indentation; the hide-whitespace toggle on the Files changed
    view shows the substance.
    
    ### Why are the changes needed?
    
    Fixes a bug. The playground currently works only if Spark touches
    `catalog_iceberg` before Trino does, because Spark's JdbcCatalog
    auto-creates its backing tables and Trino's does not. A user who starts
    the playground and queries the Iceberg catalog from Trino first gets a
    `GENERIC_INTERNAL_ERROR` with no indication of the cause. Creating the
    tables at MySQL init time removes the engine-ordering dependency.
    
    Repro: start the playground per the README, then run `SHOW SCHEMAS FROM
    catalog_iceberg` in Trino before running anything in Spark.
    
    The seed data brings the Iceberg catalog to parity with the Hive and
    PostgreSQL catalogs, which ship populated, and gives the Iceberg REST
    demo something to show before the user creates anything, including
    Iceberg-specific capabilities (snapshots, time travel) that a
    single-insert table cannot demonstrate.
    
    The README changes make the environment describable and the document
    consistent: before this change the README could not answer basic
    questions about the environment it documents (is authorization on, where
    is metadata stored, how do I reset it), carried an obsolete incubation
    disclaimer, and overstated the access control capability.
    
    ### Does this PR introduce any user-facing change?
    
    No API or property key changes. The Trino-first query path now works
    instead of failing, the Iceberg catalog comes up seeded with
    `analytics.orders`, catalog data now persists across stop/start, the
    preflight check validates the correct ports and RAM figure, and the
    README is restructured as described above.
    
    ### How was this patch tested?
    
    Reproduced the failure on a fresh playground (Gravitino 1.3.0, Trino,
    MySQL 8.0). All verification was performed on clean clones of this
    branch with fresh volumes and a removed `data` directory:
    
    - Default start (`./playground.sh start`): both catalog tables present
    in MySQL at startup; `SHOW SCHEMAS FROM catalog_iceberg` succeeds in
    Trino with no prior Spark activity; the seed completes in the background
    within a few minutes of startup; `analytics.orders` returns 12 rows with
    correct regional aggregates; `orders$snapshots` lists two snapshots;
    `FOR VERSION AS OF` the first snapshot returns 10 rows.
    - Auth-enabled start (`./playground.sh start --enable-auth`): the seed
    succeeds with the init identity and the same queries return the same
    results. (An earlier revision seeded as a named user, which the Iceberg
    REST server rejects under auth because the user does not exist in the
    metalake; the seed now uses the same identity that initializes the
    metalake.)
    - Restart without reset (`./playground.sh stop` then `start`): with the
    named volumes, a marker table created in MySQL before the stop is
    present after it, the seed detects the existing `analytics` namespace
    and skips, and `orders$snapshots` returns the same snapshot ids as
    before the stop, proving MySQL and HDFS survived coherently. The seed's
    namespace check also fails safe: if the check itself errors, it skips
    rather than risking duplicate inserts.
    - The fixed preflight was observed during startup: corrected port list,
    no image-removal error after the Docker test.
    - All statements in `init.sql` and the seed are idempotent (`IF NOT
    EXISTS` plus the namespace check), so they are harmless when Spark has
    already created objects or on restarts against existing volumes.
    - README verified in GitHub's rendered view on the branch.
    
    ---------
    
    Co-authored-by: Mark Hoerth <[email protected]>
---
 README.md                   | 907 ++++++++++++++++++++++----------------------
 docker-compose.yaml         |  10 +
 init/mysql/init.sql         |  20 +
 init/spark/iceberg-seed.sh  |  53 +++
 init/spark/iceberg-seed.sql |  35 ++
 init/spark/init.sh          |   1 +
 playground.sh               |  12 +-
 7 files changed, 589 insertions(+), 449 deletions(-)

diff --git a/README.md b/README.md
old mode 100644
new mode 100755
index 3ab072a..d4dfd7b
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
 
 ## Playground introduction
 
-The playground is a complete Apache Gravitino Docker runtime environment with 
`Hive`, `HDFS`, `Trino`, `MySQL`, `PostgreSQL`, `Jupyter`, and a `Gravitino` 
server.
+The playground is a complete Apache Gravitino Docker runtime environment with 
`Hive`, `HDFS`, `Trino`, `Spark`, `MySQL`, `PostgreSQL`, `Ranger`, `Jupyter`, 
`Prometheus`, `Grafana`, and a `Gravitino` server.
 
 Depending on your network and computer, startup time may take 3-5 minutes. 
Once the playground environment has started, you can open 
[http://localhost:8090](http://localhost:8090) in a browser to access the 
Gravitino Web UI.
 
@@ -29,7 +29,7 @@ Install Git (optional), Docker, Docker Compose.
 
 ## System Resource Requirements
 
-2 CPU cores, 8 GB RAM, 25 GB disk storage, MacOS or Linux OS (Verified 
Ubuntu22.04 Ubuntu24.04 AmazonLinux).
+2 CPU cores, 8 GB RAM, 25 GB disk storage, macOS or Linux (verified on Ubuntu 
22.04, Ubuntu 24.04, and Amazon Linux).
 
 ## TCP ports used
 
@@ -48,53 +48,84 @@ The playground runs several services. The TCP ports used 
may clash with existing
 | playground-prometheus | 19090                  |
 | playground-grafana    | 13000                  |
 
+## Environment configuration
+
+The playground is preconfigured for local evaluation and is not a production 
reference architecture. The defaults reflect that:
+
+| Aspect                   | Configuration                       | Notes       
                                                                                
                                                             |
+| ------------------------ | ----------------------------------- | 
--------------------------------------------------------------------------------------------------------------------------------------------------------
 |
+| Authentication           | None                                | Gravitino 
trusts the username presented by clients. See the security note in the access 
control demo below.                                              |
+| Authorization            | Disabled                            | Enable 
Gravitino native access control with `--enable-auth`, or Ranger enforcement for 
Hive with `--enable-ranger`.                                      |
+| Transport                | Plain HTTP                          | No TLS on 
any service, including Trino and the Gravitino API.                             
                                                               |
+| Gravitino metadata store | Embedded H2 in the `data` directory | Only the 
Gravitino server accesses this store, so an embedded database suffices. Wiped 
by the full reset.                                                |
+| Iceberg catalog backend  | JDBC, MySQL `db` database           | Shared by 
`catalog_iceberg` (Gravitino, Trino) and `catalog_rest` (Spark, through the 
Iceberg REST service), so it needs a database all three can reach. |
+| Table storage            | HDFS in the `hive` container        | 
`hdfs://hive:9000` for both Hive and Iceberg warehouses. No object storage is 
involved.                                                                  |
+| Credentials              | Hardcoded demo values               | For 
example, MySQL uses `mysql`/`mysql`.                                            
                                                                     |
+
+The playground has no authentication or TLS, so any reachable port grants full 
access to that service. If you run the playground on a remote host, control who 
can reach the ports, for example with firewall rules scoped to your address or 
an SSH tunnel.
+
 ## Playground usage
 
-### One curl command launch playground
-```shell
+There are two ways to get the playground. Use one or the other, not both.
+
+### Option 1: One-command install and launch
+
+Downloads the playground and starts it in a single step:
+
+```bash
 /bin/bash -c "$(curl -fsSL 
https://raw.githubusercontent.com/apache/gravitino-playground/HEAD/install.sh)"
 ```
 
-### Use git to download and launch playground
+### Option 2: Clone and launch with git
 
-```shell
-git clone [email protected]:apache/gravitino-playground.git
+```bash
+git clone https://github.com/apache/gravitino-playground.git
 cd gravitino-playground
+./playground.sh start
 ```
 
-### Start
+The start command accepts optional flags for the access control demos 
described below: `--enable-auth` or `--enable-ranger` (the two cannot be 
combined).
 
-```shell
-./playground.sh start
-```
+### Playground management
 
-### Check status
-```shell
+Run these from the playground directory, whichever option you used to install 
it (`gravitino-playground` for git, `gravitino-playground-main` for the 
installer).
+
+#### Check status
+
+```bash
 ./playground.sh status
 ```
 
-### Stop
-```shell
+When all containers are healthy, open the Gravitino Web UI at 
<http://localhost:8090>.
+
+#### Stop
+
+```bash
 ./playground.sh stop
 ```
 
-## Experiencing Apache Gravitino with Trino SQL
+Stopping keeps all data: Gravitino metadata in the `data` directory of this 
repo, and MySQL, PostgreSQL, and HDFS contents in named Docker volumes. To 
remove everything and start completely fresh:
 
-### Using Trino CLI in Docker Container
+```bash
+docker compose -p gravitino-playground down -v
+rm -rf data
+```
 
-1. Login to the Gravitino playground Trino Docker container using the 
following command:
+## Trino CLI
 
-```shell
-docker exec -it playground-trino bash
-```
+1. Log in to the Trino container:
 
-2. Open the Trino CLI in the container.
+   ```shell
+   docker exec -it playground-trino bash
+   ```
 
-```shell
-trino
-```
+2. Open the Trino CLI:
 
-## Using Jupyter Notebook
+   ```shell
+   trino
+   ```
+
+## Jupyter Notebook
 
 1. Open the Jupyter Notebook in the browser at 
[http://localhost:18888](http://localhost:18888).
 
@@ -102,33 +133,33 @@ trino
 
 3. Start the notebook and run the cells.
 
-## Using Spark client
+## Spark SQL client
 
-1. Login to the Gravitino playground Spark Docker container using the 
following command:
+1. Log in to the Spark container:
 
-```shell
-docker exec -it playground-spark bash
-````
+   ```shell
+   docker exec -it playground-spark bash
+   ```
 
-2. Open the Spark SQL client in the container.
+2. Open the Spark SQL client:
 
-```shell
-cd /opt/spark && /bin/bash bin/spark-sql
-```
+   ```shell
+   cd /opt/spark && /bin/bash bin/spark-sql
+   ```
 
-## Monitoring Gravitino
+## Grafana dashboards
 
-1. Open the Grafana in the browser at 
[http://localhost:13000](http://localhost:13000).
+1. Open Grafana in the browser at 
[http://localhost:13000](http://localhost:13000).
 
 2. In the navigation menu, click **Dashboards** -> **Gravitino Playground**.
 
 3. Experiment with the default template.
 
-## Example
+## Examples
 
 ### Simple Trino queries
 
-You can use simple queries to test in the Trino CLI.
+Test the setup with simple queries in the Trino CLI.
 
 ```SQL
 SHOW CATALOGS;
@@ -160,9 +191,9 @@ SHOW TABLES from catalog_hive.company;
 
 ### Cross-catalog queries
 
-In a company, there may be different departments using different data stacks. 
In this example, the HR department uses Apache Hive to store its data, and the 
sales department uses PostgreSQL. You can run some interesting queries by 
joining the two departments' data together with Gravitino.
+Different departments often run different data stacks. In this example, HR 
stores its data in Hive and sales uses PostgreSQL. Gravitino lets you join data 
across both.
 
-To know which employee has the largest sales amount, run this SQL:
+To find the employee with the largest sales amount:
 
 ```SQL
 SELECT given_name, family_name, job_title, sum(total_amount) AS total_sales
@@ -174,7 +205,7 @@ ORDER BY total_sales DESC
 LIMIT 1;
 ```
 
-To know the top customers who bought the most by state, run this SQL:
+To find the top customers by state:
 
 ```SQL
 SELECT customer_name, location, SUM(total_amount) AS total_spent
@@ -186,7 +217,7 @@ GROUP BY location, customer_name
 ORDER BY location, SUM(total_amount) DESC;
 ```
 
-To know the employee's average performance rating and total sales, run this 
SQL:
+To get each employee's average performance rating and total sales:
 
 ```SQL
 SELECT e.employee_id, given_name, family_name, AVG(rating) AS average_rating, 
SUM(total_amount) AS total_sales
@@ -197,47 +228,46 @@ WHERE e.employee_id = p.employee_id AND p.employee_id = 
s.employee_id
 GROUP BY e.employee_id,  given_name, family_name;
 ```
 
-### Using Spark and Trino
+### Spark and Trino together
 
-You might also consider generating data with SparkSQL and then querying this 
data using Trino. Give it a try with Gravitino:
+You can also generate data with Spark SQL and query it with Trino:
 
-1. Login Spark container and execute the SQLs:
+1. Log in to the Spark container and run the SQL:
 
-```sql
-// using Hive catalog to create Hive table
-USE catalog_hive;
-CREATE DATABASE product;
-USE product;
-
-CREATE TABLE IF NOT EXISTS employees (
-    id INT,
-    name STRING,
-    age INT
-)
-PARTITIONED BY (department STRING)
-STORED AS PARQUET;
-DESC TABLE EXTENDED employees;
+   ```sql
+   -- using Hive catalog to create Hive table
+   USE catalog_hive;
+   CREATE DATABASE product;
+   USE product;
 
-INSERT OVERWRITE TABLE employees PARTITION(department='Engineering') VALUES 
(1, 'John Doe', 30), (2, 'Jane Smith', 28);
-INSERT OVERWRITE TABLE employees PARTITION(department='Marketing') VALUES (3, 
'Mike Brown', 32);
-```
+   CREATE TABLE IF NOT EXISTS employees (
+       id INT,
+       name STRING,
+       age INT
+   )
+   PARTITIONED BY (department STRING)
+   STORED AS PARQUET;
+   DESC TABLE EXTENDED employees;
 
-2. Login Trino container and execute SQLs:
+   INSERT OVERWRITE TABLE employees PARTITION(department='Engineering') VALUES 
(1, 'John Doe', 30), (2, 'Jane Smith', 28);
+   INSERT OVERWRITE TABLE employees PARTITION(department='Marketing') VALUES 
(3, 'Mike Brown', 32);
+   ```
 
-```sql
-SELECT * FROM catalog_hive.product.employees WHERE department = 'Engineering';
-```
+2. Log in to the Trino container and run the query:
 
-The demo is located in the `jupyter` folder, and you can open the 
`gravitino-spark-trino-example.ipynb`
-demo via Jupyter Notebook by [http://localhost:18888](http://localhost:18888).
+   ```sql
+   SELECT * FROM catalog_hive.product.employees WHERE department = 
'Engineering';
+   ```
 
-### Using Apache Iceberg REST service
+The demo is also available as `gravitino-spark-trino-example.ipynb` in Jupyter 
at [http://localhost:18888](http://localhost:18888).
 
-Suppose you want to migrate your business from Hive to Iceberg. Some tables 
will use Hive, and the other tables will use Iceberg.
-Gravitino provides an Iceberg REST catalog service, too. You can use Spark to 
access the REST catalog to write the table data.
-Then, you can use Trino to read the data from the Hive table joining the 
Iceberg table.
+### Iceberg REST service
 
-`spark-defaults.conf` is as follows (It's already configured in the 
playground):
+A common migration scenario: some tables remain in Hive while others move to 
Iceberg.
+Gravitino provides an Iceberg REST catalog service for exactly this. In the 
example below, Spark writes
+table data through the REST catalog, and Trino joins the new Iceberg table 
with an existing Hive table.
+
+The playground ships with the following `spark-defaults.conf`:
 
 ```text
 spark.sql.extensions 
org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
@@ -247,201 +277,171 @@ spark.sql.catalog.catalog_rest.uri 
http://gravitino:9001/iceberg/
 spark.locality.wait.node 0
 ```
 
-Please note that `catalog_rest` in SparkSQL and `catalog_iceberg` in Gravitino 
and Trino share the same Iceberg JDBC backend, implying they can access the 
same dataset.
-
-1. Login Spark container and execute the steps.
-
-```shell
-docker exec -it playground-spark bash
-```
-
-```shell
-spark@container_id:/$ cd /opt/spark && /bin/bash bin/spark-sql
-```
+Note that `catalog_rest` in Spark and `catalog_iceberg` in Gravitino and Trino 
share the same Iceberg JDBC backend, so they access the same dataset.
 
-```SQL
-use catalog_rest;
-create database sales;
-use sales;
-create table customers (customer_id int, customer_name varchar(100), 
customer_email varchar(100));
-describe extended customers;
-insert into customers (customer_id, customer_name, customer_email) values 
(11,'Rory Brown','[email protected]');
-insert into customers (customer_id, customer_name, customer_email) values 
(12,'Jerry Washington','[email protected]');
-```
+1. Log in to the Spark container and run the steps:
 
-2. Login Trino container and execute the steps.
-   You can get all the customers from both the Hive and Iceberg table.
+   ```shell
+   docker exec -it playground-spark bash
+   ```
 
-```shell
-docker exec -it playground-trino bash
-```
+   ```shell
+   spark@container_id:/$ cd /opt/spark && /bin/bash bin/spark-sql
+   ```
 
-```shell
-trino@container_id:/$ trino
-```
+   ```SQL
+   use catalog_rest;
+   create database sales;
+   use sales;
+   create table customers (customer_id int, customer_name varchar(100), 
customer_email varchar(100));
+   describe extended customers;
+   insert into customers (customer_id, customer_name, customer_email) values 
(11,'Rory Brown','[email protected]');
+   insert into customers (customer_id, customer_name, customer_email) values 
(12,'Jerry Washington','[email protected]');
+   ```
 
-```SQL
-select * from catalog_hive.sales.customers
-union
-select * from catalog_iceberg.sales.customers;
-```
-
-The demo is located in the `jupyter` folder, you can open the 
`gravitino-spark-trino-example.ipynb`
-demo via Jupyter Notebook by [http://localhost:18888](http://localhost:18888).
+2. Log in to the Trino container and query all customers across the Hive and 
Iceberg tables:
 
-### Using Gravitino with LlamaIndex
+   ```shell
+   docker exec -it playground-trino bash
+   ```
 
-The Gravitino Playground also provides a simple RAG demo with LlamaIndex. This 
demo will show you the
-the ability to use Gravitino to manage both tabular and non-tabular datasets, 
connecting to
-LlamaIndex as a unified data source, then use LlamaIndex and LLM to query both 
tabular and
-non-tabular data with one natural language query.
+   ```shell
+   trino@container_id:/$ trino
+   ```
 
-The demo is located in the `jupyter` folder, and you can open the 
`gravitino_llama_index_demo.ipynb`
-demo via Jupyter Notebook by [http://localhost:18888](http://localhost:18888).
+   ```SQL
+   select * from catalog_hive.sales.customers
+   union
+   select * from catalog_iceberg.sales.customers;
+   ```
 
-The scenario of this demo is that basic structured city statistics data is 
stored in MySQL, and
-detailed city introductions are stored in PDF files. The user wants to know 
the answers to the
-cities both in the structured data and the PDF files.
+The demo is also available as `gravitino-spark-trino-example.ipynb` in Jupyter 
at [http://localhost:18888](http://localhost:18888).
 
-In this demo, you will use Gravitino to manage the MySQL table using a 
relational catalog, pdf
-files using a fileset catalog, treating Gravitino as a unified data source for 
LlamaIndex to build
-indexes on both tabular and non-tabular data. Then you will use LLM to query 
the data with natural
-language queries.
+The playground also seeds the Iceberg catalog with a demo table at startup: 
`analytics.orders`,
+partitioned by region and written in two commits, so the table has snapshot 
history from the
+first query you run:
 
-Note: to run this demo, you need to set `OPENAI_API_KEY` in the 
`gravitino_llama_index_demo.ipynb`,
-like below, `OPENAI_API_BASE` is optional.
-
-```python
-import os
+```sql
+SELECT region, SUM(amount) FROM catalog_iceberg.analytics.orders GROUP BY 
region;
 
-os.environ["OPENAI_API_KEY"] = ""
-os.environ["OPENAI_API_BASE"] = ""
+SELECT * FROM catalog_iceberg."analytics"."orders$snapshots";
 ```
 
-### Using Gravitino with Ranger authorization
+The second query lists the table's snapshots; pick a `snapshot_id` from it to 
read the table
+as of an earlier commit:
 
-Gravitino supports to provide the ability of access control for Hive tables 
using Ranger plugin.
+```sql
+SELECT COUNT(*) FROM catalog_iceberg.analytics.orders FOR VERSION AS OF 
<snapshot_id>;
+```
 
-For example, there are a manager and staffs in your company. Manager creates a 
Hive catalog and create different roles.
-The manager can give different roles to different staffs.
+### Iceberg REST server access control
 
-You can run the command
+Gravitino provides built-in access control for the Iceberg REST server, 
enforcing catalog,
+schema, and table level privileges without requiring an external authorization 
service like
+Ranger. You manage users, roles, and privileges through the Gravitino API, and 
the Iceberg
+REST server enforces them.
 
-```shell
-./playground.sh start --enable-ranger
-```
+**Security note**: the examples below use HTTP Basic Authentication only to 
pass a username.
+Gravitino does not verify the password and trusts the supplied username for 
access control
+decisions, so any client that can reach the REST endpoint can act as any user. 
That is
+acceptable for the playground and nothing else. Production deployments 
configure real
+authentication, such as OAuth2 token validation, as described in the
+[Gravitino security 
documentation](https://gravitino.apache.org/docs/latest/security/access-control).
 
-The demo is located in the `jupyter` folder, you can open the 
`gravitino-access-control-example.ipynb`
-demo via Jupyter Notebook by [http://localhost:18888](http://localhost:18888).
+1. Start the playground with auth enabled:
 
-### Using Gravitino Iceberg REST Server with Access Control
+   ```shell
+   ./playground.sh start --enable-auth
+   ```
 
-Gravitino 1.1 introduced built-in access control for the Iceberg REST server, 
enabling fine-grained
-authorization for Iceberg tables without requiring external authorization 
services like Ranger. 
-This feature allows you to manage user permissions through Gravitino's unified 
API with native 
-access control enforcement at the REST API level.
+   **Note**: The `--enable-auth` flag enables Gravitino's access control by 
removing the PassThroughAuthorizer, which allows proper privilege enforcement 
for the Iceberg REST catalog.
 
-**Security note (authentication)**: The Iceberg REST catalog examples shown 
here use HTTP Basic Authentication only as a transport to pass the username 
through the `Authorization` header. Gravitino currently **does not verify the 
Basic Auth password** and instead fully trusts the username provided in the 
header for access control decisions. As a result, this mechanism **does not 
provide real authentication**: any client that can reach the REST endpoint 
could impersonate any user by choos [...]
+2. Create users through Gravitino's REST API:
 
-This behavior is intended **for local/demo use only** (such as when running 
the playground) and **must not be relied upon in production** or any 
environment exposed to untrusted clients. For secure deployments, you must 
front the Iceberg REST server with a real authentication mechanism (for 
example, an authenticating reverse proxy, API gateway, or other identity 
provider) and configure Gravitino to validate the authenticated identity, 
rather than trusting arbitrary usernames from the `Au [...]
-#### Demo Steps
+   ```shell
+   # Add manager user
+   curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -d '{"name":"manager"}' \
+     http://localhost:8090/api/metalakes/metalake_demo/users
 
-**Step 1: Start the Playground with Auth Enabled**
-
-```shell
-./playground.sh start --enable-auth
-```
+   # Add data_analyst user
+   curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -d '{"name":"data_analyst"}' \
+     http://localhost:8090/api/metalakes/metalake_demo/users
 
-**Note**: The `--enable-auth` flag enables Gravitino's access control by 
removing the PassThroughAuthorizer, which allows proper privilege enforcement 
for the Iceberg REST catalog.
+   # Set manager as owner of the metalake
+   curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -d '{"name":"manager","type":"USER"}' \
+     
http://localhost:8090/api/metalakes/metalake_demo/owners/metalake/metalake_demo
+   ```
 
-**Step 2: Create Users**
+3. Create a database and table as the manager:
 
-Create users through Gravitino's REST API:
+   Log in to the Spark container:
 
-```shell
-# Add manager user
-curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -d '{"name":"manager"}' \
-  http://localhost:8090/api/metalakes/metalake_demo/users
-
-# Add data_analyst user
-curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -d '{"name":"data_analyst"}' \
-  http://localhost:8090/api/metalakes/metalake_demo/users
-
-# Set manager as owner of the metalake
-curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -d '{"name":"manager","type":"USER"}' \
-  
http://localhost:8090/api/metalakes/metalake_demo/owners/metalake/metalake_demo
-```
+   ```shell
+   docker exec -it playground-spark bash
+   ```
 
-**Step 3: Create Database and Table with Manager**
+   Start spark-sql as manager:
 
-Login to Spark container:
+   ```shell
+   cd /opt/spark && /bin/bash bin/spark-sql --conf 
spark.sql.catalog.catalog_rest.rest.auth.type=basic --conf 
spark.sql.catalog.catalog_rest.rest.auth.basic.username=manager --conf 
spark.sql.catalog.catalog_rest.rest.auth.basic.password=123
+   ```
 
-```shell
-docker exec -it playground-spark bash
-```
+   Create database and table:
 
-Start spark-sql as manager:
+   ```sql
+   USE catalog_rest;
+   CREATE DATABASE IF NOT EXISTS demo_db;
+   USE demo_db;
 
-```shell
-cd /opt/spark && /bin/bash bin/spark-sql --conf 
spark.sql.catalog.catalog_rest.rest.auth.type=basic --conf 
spark.sql.catalog.catalog_rest.rest.auth.basic.username=manager --conf 
spark.sql.catalog.catalog_rest.rest.auth.basic.password=123
-```
+   CREATE TABLE IF NOT EXISTS employees (
+       employee_id INT,
+       name STRING,
+       department STRING,
+       salary DECIMAL(10,2)
+   ) USING iceberg;
 
-Create database and table:
+   INSERT INTO employees VALUES
+     (1, 'Alice Johnson', 'Engineering', 95000.00),
+     (2, 'Bob Smith', 'Sales', 75000.00);
 
-```sql
-USE catalog_rest;
-CREATE DATABASE IF NOT EXISTS demo_db;
-USE demo_db;
-
-CREATE TABLE IF NOT EXISTS employees (
-    employee_id INT,
-    name STRING,
-    department STRING,
-    salary DECIMAL(10,2)
-) USING iceberg;
-
-INSERT INTO employees VALUES
-  (1, 'Alice Johnson', 'Engineering', 95000.00),
-  (2, 'Bob Smith', 'Sales', 75000.00);
-
-SELECT * FROM employees;
-```
+   SELECT * FROM employees;
+   ```
 
-**Step 4: Test Access Control Before Granting Privileges**
+4. Test access control before granting privileges:
 
 
-Exit spark-sql and start a new session as data_analyst (without any privileges 
yet):
+   Exit spark-sql and start a new session as data_analyst (without any 
privileges yet):
 
-```shell
-export HADOOP_USER_NAME=data_analyst
-cd /opt/spark
-/bin/bash bin/spark-sql  --conf 
spark.sql.catalog.catalog_rest.rest.auth.type=basic --conf 
spark.sql.catalog.catalog_rest.rest.auth.basic.username=data_analyst --conf 
spark.sql.catalog.catalog_rest.rest.auth.basic.password=123
-```
+   ```shell
+   export HADOOP_USER_NAME=data_analyst
+   cd /opt/spark
+   /bin/bash bin/spark-sql  --conf 
spark.sql.catalog.catalog_rest.rest.auth.type=basic --conf 
spark.sql.catalog.catalog_rest.rest.auth.basic.username=data_analyst --conf 
spark.sql.catalog.catalog_rest.rest.auth.basic.password=123
+   ```
 
-Try to query the table (this should FAIL):
+   Try to query the table. The query should fail:
 
-```sql
-USE catalog_rest.demo_db;
+   ```sql
+   USE catalog_rest.demo_db;
 
--- This should FAIL - schema doesn't exist, because we don't have USE_SCHEMA 
privilege
-```
+   -- Fails: the schema is not visible without the USE_SCHEMA privilege
+   ```
 
-**Step 5: Create Role with Privileges and Assign to User**
+5. Create a role with privileges and assign it to the user:
 
-Exit spark-sql and create a role with the necessary privileges:
+   Exit spark-sql and create a role with the necessary privileges. Note that 
the role references `catalog_iceberg`, the catalog name in Gravitino; 
`catalog_rest` in Spark is the same catalog exposed through the Iceberg REST 
endpoint:
 
-```shell
-# Create role with all required privileges
-curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -u manager:123 \
-  -d '{
+   ```shell
+   # Create role with all required privileges
+   curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -u manager:123 \
+     -d '{
     "name": "analyst_role",
     "securableObjects": [
       {
@@ -466,217 +466,229 @@ curl -X POST -H "Accept: 
application/vnd.gravitino.v1+json" \
         ]
       }
     ]
-  }' \
-  http://localhost:8090/api/metalakes/metalake_demo/roles
-
-# Assign role to user
-curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -u manager:123 \
-  -d '{
+     }' \
+     http://localhost:8090/api/metalakes/metalake_demo/roles
+
+   # Assign role to user
+   curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -u manager:123 \
+     -d '{
     "roleNames": ["analyst_role"]
-}' 
http://localhost:8090/api/metalakes/metalake_demo/permissions/users/data_analyst/grant
-```
+   }' 
http://localhost:8090/api/metalakes/metalake_demo/permissions/users/data_analyst/grant
+   ```
 
-Start spark-sql as data_analyst again and test:
+   Start spark-sql as data_analyst again and test:
 
-```shell
-cd /opt/spark && /bin/bash bin/spark-sql \
-  --conf spark.sql.catalog.catalog_rest.rest.auth.type=basic \
-  --conf spark.sql.catalog.catalog_rest.rest.auth.basic.username=data_analyst \
-  --conf spark.sql.catalog.catalog_rest.rest.auth.basic.password=123
-```
+   ```shell
+   cd /opt/spark && /bin/bash bin/spark-sql \
+     --conf spark.sql.catalog.catalog_rest.rest.auth.type=basic \
+     --conf 
spark.sql.catalog.catalog_rest.rest.auth.basic.username=data_analyst \
+     --conf spark.sql.catalog.catalog_rest.rest.auth.basic.password=123
+   ```
 
-Try to query the table again (this should SUCCEED now):
+   Try to query the table again. The query should succeed now:
 
-```sql
-USE catalog_rest.demo_db;
+   ```sql
+   USE catalog_rest.demo_db;
 
--- This should succeed - now has SELECT_TABLE privilege
-SELECT * FROM employees;
-```
+   -- Succeeds: the role now grants SELECT_TABLE
+   SELECT * FROM employees;
+   ```
 
-This demonstrates how Gravitino's access control works:
-- Before granting privileges: Access denied
-- After granting privileges: Access allowed
+The demo shows Gravitino's access control at work: access is denied before the 
privileges are
+granted and allowed after.
 
 For more details, refer to the [Gravitino 
documentation](https://gravitino.apache.org/docs/latest/security/access-control).
 
-### Using Gravitino Policies, Statistics, and Jobs to Drop Unused Tables
+### Ranger authorization with Hive
+
+Gravitino provides access control for Hive tables using the Ranger plugin.
+
+For example, a company has a manager and several staff members. The manager 
creates a Hive catalog and defines different roles,
+then assigns those roles to staff members.
+
+Start the playground with Ranger enabled:
+
+```shell
+./playground.sh start --enable-ranger
+```
+
+The demo notebook is `gravitino-access-control-example.ipynb` in Jupyter at 
[http://localhost:18888](http://localhost:18888).
+
+### Unused table cleanup with policies, statistics, and jobs
 
-Gravitino 1.0+ provides a powerful combination of policies, statistics, and 
jobs that enables automated data governance tasks. This demo shows how to 
identify and drop tables that haven't been accessed for a long time, helping 
you manage data lifecycle and reduce storage costs.
+Gravitino provides a powerful combination of policies, statistics, and jobs 
that enables automated, metadata-driven data governance. The demo shows how to 
identify and drop tables that haven't been accessed for a long time, reducing 
storage costs.
 
 **Workflow Overview:**
 1. **Statistics** - Track table usage with custom statistics (e.g., 
`custom-lastAccessTime`)
 2. **Policies** - Define rules for identifying unused tables (e.g., not 
accessed for 90 days)
 3. **Jobs** - Execute automated actions to drop unused tables
 
-#### Demo Steps
+1. Start the playground:
 
-**Step 1: Start the Playground**
+   ```shell
+   ./playground.sh start
+   ```
 
-```shell
-./playground.sh start
-```
+2. Update statistics for an existing table:
 
-**Step 2: Update Statistics for an Existing Table**
+   The playground already has tables in the Hive catalog. Update the 
statistics of an existing table to simulate an old, unused table:
 
-The playground already has tables in the Hive catalog. We'll use one of the 
existing tables and update its statistics to simulate an old, unused table:
-
-```shell
-# First, verify the existing table
-docker exec -it playground-trino trino --execute "SELECT * FROM 
catalog_hive.sales.customers LIMIT 5"
+   ```shell
+   # First, verify the existing table
+   docker exec -it playground-trino trino --execute "SELECT * FROM 
catalog_hive.sales.customers LIMIT 5"
 
-# Calculate a date 100 days ago (more than the 90-day threshold)
-OLD_DATE=$(date -u -d '100 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date 
-u -v-100d +%Y-%m-%dT%H:%M:%SZ)
+   # Calculate a date 100 days ago (more than the 90-day threshold)
+   OLD_DATE=$(date -u -d '100 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || 
date -u -v-100d +%Y-%m-%dT%H:%M:%SZ)
 
-# Update last access time for the table to make it appear unused
-curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -d "{
+   # Update last access time for the table to make it appear unused
+   curl -X PUT -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -d "{
     \"updates\": {
       \"custom-lastAccessTime\": \"$OLD_DATE\",
       \"custom-rowCount\": \"10\"
     }
-  }" \
-  
http://localhost:8090/api/metalakes/metalake_demo/objects/table/catalog_hive.sales.customers/statistics
-
-# Check statistics to verify they were set
-curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \
-  
http://localhost:8090/api/metalakes/metalake_demo/objects/table/catalog_hive.sales.customers/statistics
-```
-
-You should see output like:
-```json
-{
-  "statistics": {
+     }" \
+     
http://localhost:8090/api/metalakes/metalake_demo/objects/table/catalog_hive.sales.customers/statistics
+
+   # Check statistics to verify they were set
+   curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \
+     
http://localhost:8090/api/metalakes/metalake_demo/objects/table/catalog_hive.sales.customers/statistics
+   ```
+
+   You should see output like:
+   ```json
+   {
+     "statistics": {
     "custom-lastAccessTime": {
       "value": "2024-09-08T10:30:00Z"
     },
     "custom-rowCount": {
       "value": "10"
     }
-  }
-}
-```
-
-**Step 3: Create a Policy for Unused Tables**
-
-Create a custom policy to identify tables not accessed for more than 90 days:
-
-```shell
-curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -d '{
-    "name": "unused_table_policy",
-    "comment": "Policy to identify tables not accessed for 90+ days",
-    "policyType": "custom",
-    "enabled": true,
-    "content": {
-      "customRules": {
-        "maxIdleDays": 90,
-        "action": "drop"
-      },
-      "supportedObjectTypes": ["TABLE"],
-      "properties": {
-        "checkStatistic": "custom-lastAccessTime",
-        "threshold": "90d"
-      }
-    }
-  }' \
-  http://localhost:8090/api/metalakes/metalake_demo/policies
-```
-
-**Step 4: Associate Policy with Tables**
-
-Associate the policy with the existing customers table:
-
-```shell
-# Associate policy with the customers table
-curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -d '{
+     }
+   }
+   ```
+
+3. Create a policy for unused tables:
+
+   Create a custom policy to identify tables not accessed for more than 90 
days:
+
+   ```shell
+   curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -d '{
+       "name": "unused_table_policy",
+       "comment": "Policy to identify tables not accessed for 90+ days",
+       "policyType": "custom",
+       "enabled": true,
+       "content": {
+         "customRules": {
+           "maxIdleDays": 90,
+           "action": "drop"
+         },
+         "supportedObjectTypes": ["TABLE"],
+         "properties": {
+           "checkStatistic": "custom-lastAccessTime",
+           "threshold": "90d"
+         }
+       }
+     }' \
+     http://localhost:8090/api/metalakes/metalake_demo/policies
+   ```
+
+4. Associate the policy with tables:
+
+   Associate the policy with the existing customers table:
+
+   ```shell
+   # Associate policy with the customers table
+   curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -d '{
     "policiesToAdd": ["unused_table_policy"]
-  }' \
-  
http://localhost:8090/api/metalakes/metalake_demo/objects/table/catalog_hive.sales.customers/policies
+     }' \
+     
http://localhost:8090/api/metalakes/metalake_demo/objects/table/catalog_hive.sales.customers/policies
 
-# Verify the policy was associated
-curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \
-  
http://localhost:8090/api/metalakes/metalake_demo/objects/table/catalog_hive.sales.customers/policies
-```
+   # Verify the policy was associated
+   curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \
+     
http://localhost:8090/api/metalakes/metalake_demo/objects/table/catalog_hive.sales.customers/policies
+   ```
 
-Alternatively, you can associate the policy with the entire schema to monitor 
all tables:
+   Alternatively, you can associate the policy with the entire schema to 
monitor all tables:
 
-```shell
-# Associate with the entire schema (will apply to all tables in sales)
-curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -d '{
+   ```shell
+   # Associate with the entire schema (will apply to all tables in sales)
+   curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -d '{
     "policiesToAdd": ["unused_table_policy"]
-  }' \
-  
http://localhost:8090/api/metalakes/metalake_demo/objects/schema/catalog_hive.sales/policies
-```
-
-**Step 5: Register a Job Template to Drop Unused Tables**
-
-Create a shell script job template to drop tables:
-
-```shell
-# First, create the drop script on the host
-cat > /tmp/drop_unused_tables.sh << 'EOF'
-#!/bin/bash
-# Script to drop unused tables based on policy evaluation
-CATALOG=$1
-SCHEMA=$2
-TABLE=$3
-
-echo "Checking if table ${CATALOG}.${SCHEMA}.${TABLE} should be dropped..."
-
-# Get table statistics (use localhost since script runs on host or in 
container with port mapping)
-STATS=$(curl -s -X GET -H "Accept: application/vnd.gravitino.v1+json" \
-  
"http://localhost:8090/api/metalakes/metalake_demo/objects/table/${CATALOG}.${SCHEMA}.${TABLE}/statistics";)
-
-echo "Statistics response: $STATS"
-
-# Parse the statistics array to find custom-lastAccessTime
-LAST_ACCESS=$(echo $STATS | jq -r '.statistics[] | 
select(.name=="custom-lastAccessTime") | .value')
-echo "Last access time: $LAST_ACCESS"
-
-# Calculate days since last access
-if [ -n "$LAST_ACCESS" ] && [ "$LAST_ACCESS" != "null" ]; then
-  CURRENT_DATE=$(date +%s)
-  LAST_ACCESS_DATE=$(date -d "$LAST_ACCESS" +%s 2>/dev/null || date -j -f 
"%Y-%m-%dT%H:%M:%SZ" "$LAST_ACCESS" +%s)
-  DAYS_IDLE=$(( ($CURRENT_DATE - $LAST_ACCESS_DATE) / 86400 ))
-  
-  echo "Days since last access: $DAYS_IDLE"
-  
-  if [ $DAYS_IDLE -gt 90 ]; then
+     }' \
+     
http://localhost:8090/api/metalakes/metalake_demo/objects/schema/catalog_hive.sales/policies
+   ```
+
+5. Register a job template to drop unused tables:
+
+   Create a shell script job template to drop tables:
+
+   ```shell
+   # First, create the drop script on the host
+   cat > /tmp/drop_unused_tables.sh << 'EOF'
+   #!/bin/bash
+   # Script to drop unused tables based on policy evaluation
+   CATALOG=$1
+   SCHEMA=$2
+   TABLE=$3
+
+   echo "Checking if table ${CATALOG}.${SCHEMA}.${TABLE} should be dropped..."
+
+   # Get table statistics (use localhost since script runs on host or in 
container with port mapping)
+   STATS=$(curl -s -X GET -H "Accept: application/vnd.gravitino.v1+json" \
+     
"http://localhost:8090/api/metalakes/metalake_demo/objects/table/${CATALOG}.${SCHEMA}.${TABLE}/statistics";)
+
+   echo "Statistics response: $STATS"
+
+   # Parse the statistics array to find custom-lastAccessTime
+   LAST_ACCESS=$(echo $STATS | jq -r '.statistics[] | 
select(.name=="custom-lastAccessTime") | .value')
+   echo "Last access time: $LAST_ACCESS"
+
+   # Calculate days since last access
+   if [ -n "$LAST_ACCESS" ] && [ "$LAST_ACCESS" != "null" ]; then
+     CURRENT_DATE=$(date +%s)
+     LAST_ACCESS_DATE=$(date -d "$LAST_ACCESS" +%s 2>/dev/null || date -j -f 
"%Y-%m-%dT%H:%M:%SZ" "$LAST_ACCESS" +%s)
+     DAYS_IDLE=$(( ($CURRENT_DATE - $LAST_ACCESS_DATE) / 86400 ))
+     
+     echo "Days since last access: $DAYS_IDLE"
+     
+     if [ $DAYS_IDLE -gt 90 ]; then
     echo "Table has been idle for more than 90 days. Dropping table..."
     # Drop table via Gravitino API
     DROP_RESPONSE=$(curl -s -X DELETE -H "Accept: 
application/vnd.gravitino.v1+json" \
       
"http://localhost:8090/api/metalakes/metalake_demo/catalogs/${CATALOG}/schemas/${SCHEMA}/tables/${TABLE}";)
     echo "Drop response: $DROP_RESPONSE"
     echo "Table ${CATALOG}.${SCHEMA}.${TABLE} dropped successfully"
-  else
+     else
     echo "Table is still active. No action needed."
-  fi
-else
-  echo "No last access time found. Skipping..."
-fi
-EOF
+     fi
+   else
+     echo "No last access time found. Skipping..."
+   fi
+   EOF
 
-chmod +x /tmp/drop_unused_tables.sh
+   chmod +x /tmp/drop_unused_tables.sh
 
-# Copy the script into the Gravitino container
-docker cp /tmp/drop_unused_tables.sh 
playground-gravitino:/tmp/drop_unused_tables.sh
+   # Copy the script into the Gravitino container
+   docker cp /tmp/drop_unused_tables.sh 
playground-gravitino:/tmp/drop_unused_tables.sh
 
-# Make it executable in the container
-docker exec playground-gravitino chmod +x /tmp/drop_unused_tables.sh
+   # Make it executable in the container
+   docker exec playground-gravitino chmod +x /tmp/drop_unused_tables.sh
 
-# Register the job template
-curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -d '{
+   # Register the job template
+   curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -d '{
     "jobTemplate": {
       "name": "drop_unused_table_job",
       "jobType": "shell",
@@ -687,91 +699,100 @@ curl -X POST -H "Accept: 
application/vnd.gravitino.v1+json" \
       "customFields": {},
       "scripts": []
     }
-  }' \
-  http://localhost:8090/api/metalakes/metalake_demo/jobs/templates
-```
+     }' \
+     http://localhost:8090/api/metalakes/metalake_demo/jobs/templates
+   ```
 
-**Step 6: Run the Job to Drop Unused Tables**
+6. Run the job to drop unused tables:
 
-Execute the job for the customers table:
+   Execute the job for the customers table:
 
-```shell
-# Run job for the customers table (should drop it since it's > 90 days old)
-curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
-  -H "Content-Type: application/json" \
-  -d '{
+   ```shell
+   # Run job for the customers table (should drop it since it's > 90 days old)
+   curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
+     -H "Content-Type: application/json" \
+     -d '{
     "jobTemplateName": "drop_unused_table_job",
     "jobConf": {
       "catalog": "catalog_hive",
       "schema": "sales",
       "table": "customers"
     }
-  }' \
-  http://localhost:8090/api/metalakes/metalake_demo/jobs/runs
-```
+     }' \
+     http://localhost:8090/api/metalakes/metalake_demo/jobs/runs
+   ```
 
-The response will contain a `jobRunId` that you can use to check the job 
status.
+   The response will contain a `jobRunId` that you can use to check the job 
status.
 
-**Step 7: Verify the Job Result**
+7. Verify the job result:
 
-Check the job execution status and result:
+   Check the job execution status and result:
 
-```shell
-# Get the job run details (replace {jobRunId} with the actual ID from Step 6 
response)
-curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \
-  http://localhost:8090/api/metalakes/metalake_demo/jobs/runs/{jobRunId}
+   ```shell
+   # Get the job run details (replace {jobRunId} with the actual ID from the 
response in the previous step)
+   curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \
+     http://localhost:8090/api/metalakes/metalake_demo/jobs/runs/{jobRunId}
 
-# Example: If jobRunId is "job-123"
-curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \
-  http://localhost:8090/api/metalakes/metalake_demo/jobs/runs/job-123
-```
+   # Example: If jobRunId is "job-123"
+   curl -X GET -H "Accept: application/vnd.gravitino.v1+json" \
+     http://localhost:8090/api/metalakes/metalake_demo/jobs/runs/job-123
+   ```
 
-The response will show:
-- **status**: Job status (`QUEUED`, `RUNNING`, `SUCCEEDED`, `FAILED`, 
`CANCELLING`, `CANCELLED`)
-- **startTime**: When the job started
-- **endTime**: When the job completed
-- **output**: Job execution output/logs
+   The response will show:
+   - **status**: Job status (`QUEUED`, `RUNNING`, `SUCCEEDED`, `FAILED`, 
`CANCELLING`, `CANCELLED`)
+   - **startTime**: When the job started
+   - **endTime**: When the job completed
+   - **output**: Job execution output/logs
 
-You can also verify the table was actually dropped:
+   Verify the table was dropped:
 
-```shell
-# Check if the table still exists (should show it's gone)
-docker exec -it playground-trino trino --execute "SHOW TABLES FROM 
catalog_hive.sales"
+   ```shell
+   # Check if the table still exists (should show it's gone)
+   docker exec -it playground-trino trino --execute "SHOW TABLES FROM 
catalog_hive.sales"
 
-# Or try to query the dropped table (should fail with "Table not found")
-docker exec -it playground-trino trino --execute "SELECT * FROM 
catalog_hive.sales.customers LIMIT 1"
-```
+   # Or try to query the dropped table (should fail with "Table not found")
+   docker exec -it playground-trino trino --execute "SELECT * FROM 
catalog_hive.sales.customers LIMIT 1"
+   ```
 
-If the table was successfully dropped, you'll see an error like:
-```
-Query failed: line 1:15: Table 'hive.sales.customers' does not exist
-```
-
-**Key Concepts:**
-
-- **Statistics**: Track custom metrics like `custom-lastAccessTime` to monitor 
table usage
-- **Policies**: Define governance rules to identify tables that meet certain 
criteria (e.g., idle for 90+ days)
-- **Jobs**: Execute automated actions (drop tables) based on policy evaluation
-- **Metadata-driven actions**: Use Gravitino's metadata (statistics, policies) 
to drive data governance decisions
-
-This approach enables:
-- ✅ Automated data lifecycle management
-- ✅ Cost reduction by removing unused data
-- ✅ Compliance with data retention policies
-- ✅ Centralized governance across multiple catalogs
+   If the table was successfully dropped, you'll see an error like:
+   ```
+   Query failed: line 1:15: Table 'hive.sales.customers' does not exist
+   ```
 
 For more details, refer to:
 - [Manage Statistics in 
Gravitino](https://gravitino.apache.org/docs/latest/manage-statistics-in-gravitino)
 - [Manage Policies in 
Gravitino](https://gravitino.apache.org/docs/latest/manage-policies-in-gravitino)
 - [Manage Jobs in 
Gravitino](https://gravitino.apache.org/docs/latest/manage-jobs-in-gravitino)
 
-## NOTICE
+### Gravitino with LlamaIndex
+
+The Gravitino Playground also provides a simple RAG demo with LlamaIndex. The 
demo shows the
+ability to use Gravitino to manage both tabular and non-tabular datasets, 
connecting to
+LlamaIndex as a unified data source, then use LlamaIndex and LLM to query both 
tabular and
+non-tabular data with one natural language query.
+
+The demo notebook is `gravitino_llama_index_demo.ipynb` in Jupyter at 
[http://localhost:18888](http://localhost:18888).
+
+In the demo scenario, structured city statistics live in MySQL and detailed 
city introductions
+live in PDF files. A single natural language question needs answers drawn from 
both.
 
-If you want to clean cache files, you can delete the directory `data` of this 
repo.
+You will manage the MySQL table with a relational catalog and the PDF files 
with a fileset
+catalog, treating Gravitino as a unified data source for LlamaIndex to index 
both. An LLM then
+answers natural language queries over the combined data.
 
-## ASF Incubator disclaimer
+Note: the demo requires `OPENAI_API_KEY` to be set in 
`gravitino_llama_index_demo.ipynb` as shown
+below. `OPENAI_API_BASE` is optional.
+
+```python
+import os
+
+os.environ["OPENAI_API_KEY"] = ""
+os.environ["OPENAI_API_BASE"] = ""
+```
+
+## NOTICE
 
-Apache Gravitino is an effort undergoing incubation at The Apache Software 
Foundation (ASF), sponsored by the Apache Incubator. Incubation is required of 
all newly accepted projects until a further review indicates that the 
infrastructure, communications, and decision making process have stabilized in 
a manner consistent with other successful ASF projects. While incubation status 
is not necessarily a reflection of the completeness or stability of the code, 
it does indicate that the proje [...]
+The playground stores state in Docker volumes and in the `data` directory of 
this repo. See the Stop section above for how to reset the playground 
completely.
 
 <sub>Apache®, Apache Gravitino&trade;, Apache Hive&trade;, Apache 
Iceberg&trade;, and Apache Spark&trade; are either registered trademarks or 
trademarks of the Apache Software Foundation in the United States and/or other 
countries.</sub>
 
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 4df8f7d..603006f 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -30,6 +30,8 @@ services:
     entrypoint: /bin/bash /tmp/hive/init.sh
     volumes:
       - ./init/hive:/tmp/hive
+      - hive-hdfs:/tmp/hadoop-root
+      - hive-metastore-db:/var/lib/mysql
     healthcheck:
       test: ["CMD", "/tmp/check-status.sh"]
       interval: 10s
@@ -121,6 +123,7 @@ services:
       - "15432:5432"
     volumes:
       - ./init/postgres:/docker-entrypoint-initdb.d/
+      - postgres-data:/var/lib/postgresql/data
 
   mysql:
     image: mysql:${MYSQL_IMAGE_TAG}
@@ -133,6 +136,7 @@ services:
     ports:
       - "13306:3306"
     volumes:
+      - mysql-data:/var/lib/mysql
       - ./init/mysql:/docker-entrypoint-initdb.d/
       - ./healthcheck:/tmp/healthcheck
     command: 
@@ -201,3 +205,9 @@ services:
       - ./init/grafana/grafana.ini:/etc/grafana/grafana.ini
       - 
./init/grafana/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml
       - ./init/grafana/dashboards:/etc/grafana/provisioning/dashboards
+
+volumes:
+  mysql-data:
+  postgres-data:
+  hive-hdfs:
+  hive-metastore-db:
diff --git a/init/mysql/init.sql b/init/mysql/init.sql
index d0c09c9..09efa52 100644
--- a/init/mysql/init.sql
+++ b/init/mysql/init.sql
@@ -27,3 +27,23 @@ CREATE TABLE IF NOT EXISTS `demo_llamaindex`.`city_stats`  (
 INSERT INTO `demo_llamaindex`.`city_stats` (city_name, population, country) 
VALUES ("Toronto", 2930000, "Canada");
 INSERT INTO `demo_llamaindex`.`city_stats` (city_name, population, country) 
VALUES ("Tokyo", 13960000, "Japan");
 INSERT INTO `demo_llamaindex`.`city_stats` (city_name, population, country) 
VALUES ("Berlin", 3645000, "Germany");
+
+CREATE DATABASE IF NOT EXISTS `db`;
+
+CREATE TABLE IF NOT EXISTS `db`.`iceberg_tables` (
+  `catalog_name` VARCHAR(255) NOT NULL,
+  `table_namespace` VARCHAR(255) NOT NULL,
+  `table_name` VARCHAR(255) NOT NULL,
+  `metadata_location` VARCHAR(1000),
+  `previous_metadata_location` VARCHAR(1000),
+  `iceberg_type` VARCHAR(5),
+  PRIMARY KEY (`catalog_name`, `table_namespace`, `table_name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+
+CREATE TABLE IF NOT EXISTS `db`.`iceberg_namespace_properties` (
+  `catalog_name` VARCHAR(255) NOT NULL,
+  `namespace` VARCHAR(255) NOT NULL,
+  `property_key` VARCHAR(255) NOT NULL,
+  `property_value` VARCHAR(1000),
+  PRIMARY KEY (`catalog_name`, `namespace`, `property_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
diff --git a/init/spark/iceberg-seed.sh b/init/spark/iceberg-seed.sh
new file mode 100755
index 0000000..3fa6c5e
--- /dev/null
+++ b/init/spark/iceberg-seed.sh
@@ -0,0 +1,53 @@
+#!/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.
+#
+# Seed the Iceberg REST catalog with demo data (analytics.orders).
+# Idempotent: skips when the analytics namespace already exists.
+
+set -u
+
+SPARK_HOME=/opt/spark
+SEED_SQL=/tmp/spark/iceberg-seed.sql
+# The metalake and catalogs are created unauthenticated at startup, so that
+# identity owns them. The seed uses the same identity, which works both with
+# and without --enable-auth.
+AUTH_CONFS=""
+
+echo "[seed] Waiting for the Iceberg REST service on gravitino:9001..."
+for i in $(seq 1 60); do
+  if (echo > /dev/tcp/gravitino/9001) 2>/dev/null; then
+    break
+  fi
+  sleep 5
+done
+if ! (echo > /dev/tcp/gravitino/9001) 2>/dev/null; then
+  echo "[seed] Iceberg REST service not reachable after timeout; skipping 
seed."
+  exit 0
+fi
+
+cd "${SPARK_HOME}" || exit 0
+
+if bin/spark-sql ${AUTH_CONFS} -e "SHOW NAMESPACES IN catalog_rest" 
2>/dev/null | grep -q "^analytics$"; then
+  echo "[seed] analytics namespace already exists; skipping seed."
+  exit 0
+fi
+
+echo "[seed] Seeding Iceberg demo data..."
+bin/spark-sql ${AUTH_CONFS} -f "${SEED_SQL}" 2>&1 | sed 's/^/[seed] /'
+echo "[seed] Done."
diff --git a/init/spark/iceberg-seed.sql b/init/spark/iceberg-seed.sql
new file mode 100755
index 0000000..ddd85f1
--- /dev/null
+++ b/init/spark/iceberg-seed.sql
@@ -0,0 +1,35 @@
+-- Seed data for the Iceberg REST catalog (catalog_rest in Spark, 
catalog_iceberg in Trino).
+USE catalog_rest;
+
+-- Two inserts on purpose: the table gets two snapshots, so snapshot history 
and
+-- time travel queries are demonstrable out of the box.
+-- customer_id values overlap with the Hive sales.customers seed data, so
+-- cross-catalog joins between Hive and Iceberg work without setup.
+
+CREATE DATABASE IF NOT EXISTS analytics;
+
+CREATE TABLE IF NOT EXISTS analytics.orders (
+  order_id BIGINT,
+  customer_id INT,
+  order_date DATE,
+  region STRING,
+  amount DECIMAL(10,2),
+  status STRING
+) USING iceberg
+PARTITIONED BY (region);
+
+INSERT INTO analytics.orders VALUES
+  (1001, 11, DATE '2026-05-02', 'west',  245.50, 'shipped'),
+  (1002, 12, DATE '2026-05-03', 'east',   89.99, 'shipped'),
+  (1003, 11, DATE '2026-05-10', 'west',  512.00, 'returned'),
+  (1004, 14, DATE '2026-05-14', 'south', 133.25, 'shipped'),
+  (1005, 15, DATE '2026-05-21', 'east',  760.10, 'pending'),
+  (1006, 12, DATE '2026-06-01', 'east',   45.00, 'shipped'),
+  (1007, 16, DATE '2026-06-04', 'west',  310.75, 'shipped'),
+  (1008, 11, DATE '2026-06-09', 'west',   22.10, 'cancelled'),
+  (1009, 17, DATE '2026-06-15', 'south', 199.99, 'shipped'),
+  (1010, 15, DATE '2026-06-22', 'east',  405.60, 'shipped');
+
+INSERT INTO analytics.orders VALUES
+  (1011, 18, DATE '2026-07-01', 'west',  650.00, 'pending'),
+  (1012, 14, DATE '2026-07-03', 'south',  77.45, 'shipped');
diff --git a/init/spark/init.sh b/init/spark/init.sh
index 1a36307..b3de064 100644
--- a/init/spark/init.sh
+++ b/init/spark/init.sh
@@ -25,4 +25,5 @@ cp /tmp/spark/packages/${SPARK_CONNECTOR_JAR} 
/opt/spark/jars/${SPARK_CONNECTOR_
 cp /tmp/spark/packages/mysql-connector-java-8.0.27.jar 
/opt/spark/jars/mysql-connector-java-8.0.27.jar
 cp /tmp/spark/packages/kyuubi-spark-authz-shaded_2.12-1.9.2.jar 
/opt/spark/jars/kyuubi-spark-authz-shaded_2.12-1.9.2.jar
 sh /tmp/common/init_metalake_catalog.sh
+/bin/bash /tmp/spark/iceberg-seed.sh > /tmp/iceberg-seed.log 2>&1 &
 tail -f /dev/null
diff --git a/playground.sh b/playground.sh
index 1785a79..6e75b8a 100755
--- a/playground.sh
+++ b/playground.sh
@@ -26,9 +26,9 @@ playground_dir="$(
 
 playgroundRuntimeName="gravitino-playground"
 requiredDiskSpaceGB=25
-requiredRamGB=6
+requiredRamGB=8
 requiredCpuCores=2
-requiredPorts=(6080 8090 9001 3307 19000 19083 60070 13306 15342 18080 18888 
19090 13000)
+requiredPorts=(6080 8090 9001 3307 19000 19083 60070 13306 15432 14040 18080 
18888 19090 13000)
 dockerComposeCommand=""
 
 testDocker() {
@@ -43,11 +43,11 @@ testDocker() {
     exit 1
   fi
 
-  for containerId in $(docker ps -a | grep hello-world | awk '{print $1}'); do
-    docker rm $containerId
+  for containerId in $(docker ps -aq --filter ancestor=hello-world); do
+    docker rm "${containerId}" >/dev/null 2>&1
   done
-  for imageTag in $(docker images | grep hello-world | awk '{print $2}'); do
-    docker rmi hello-world:$imageTag
+  for imageId in $(docker images -q hello-world); do
+    docker rmi -f "${imageId}" >/dev/null 2>&1
   done
 }
 

Reply via email to