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

dimas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new 4ffb98af1 feat(metrics): Add schema-v4 with metrics tables support 
(#3523)
4ffb98af1 is described below

commit 4ffb98af1a8aeaea8a1647e62c2836b6541eb655
Author: Anand K Sankaran <[email protected]>
AuthorDate: Tue Feb 17 12:08:40 2026 -0800

    feat(metrics): Add schema-v4 with metrics tables support (#3523)
    
    This commit adds database schema support for metrics persistence tables
    as part of the JDBC persistence backend.
    
    Key changes:
    - schema-metrics-v1.sql for PostgreSQL and H2 with metrics tables
    - DatabaseType: Add metrics schema resource path support
    - JdbcBasePersistenceImpl: Add metrics schema bootstrap capability
    - JdbcBootstrapUtils: Support for metrics schema initialization
    - QueryGenerator: Add metrics-related query generation
    - SchemaOptions: Add metrics schema option for bootstrap command
    - BootstrapCommand: Support --metrics flag for schema bootstrap
    
    Testing:
    - MetricsPersistenceBootstrapValidationTest: Validates schema bootstrap
    - JdbcBootstrapUtilsTest: Tests for bootstrap utilities
    - QueryGeneratorTest: Tests for metrics query generation
    
    Co-authored-by: Anand Kumar Sankaran <[email protected]>
---
 .../persistence/relational/jdbc/DatabaseType.java  |  20 +++
 .../relational/jdbc/DatasourceOperations.java      |   8 +-
 .../relational/jdbc/JdbcBasePersistenceImpl.java   |  19 +++
 .../relational/jdbc/JdbcBootstrapUtils.java        |  14 ++
 .../jdbc/JdbcMetaStoreManagerFactory.java          |   7 +
 .../relational/jdbc/QueryGenerator.java            |   7 +
 .../src/main/resources/h2/schema-metrics-v1.sql    | 166 +++++++++++++++++++
 .../main/resources/postgres/schema-metrics-v1.sql  | 177 +++++++++++++++++++++
 .../relational/jdbc/JdbcBootstrapUtilsTest.java    |  31 ++++
 .../MetricsPersistenceBootstrapValidationTest.java | 159 ++++++++++++++++++
 .../relational/jdbc/QueryGeneratorTest.java        |  10 ++
 .../core/persistence/bootstrap/SchemaOptions.java  |   7 +
 .../apache/polaris/admintool/BootstrapCommand.java |   9 ++
 .../jdbc/RelationalJdbcBootstrapCommandTest.java   |  23 +++
 14 files changed, 655 insertions(+), 2 deletions(-)

diff --git 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/DatabaseType.java
 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/DatabaseType.java
index 71a33db21..28b77ad8c 100644
--- 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/DatabaseType.java
+++ 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/DatabaseType.java
@@ -60,4 +60,24 @@ public enum DatabaseType {
     ClassLoader classLoader = DatasourceOperations.class.getClassLoader();
     return classLoader.getResourceAsStream(resourceName);
   }
+
+  /**
+   * Open an InputStream that contains data from the metrics schema init 
script. This stream should
+   * be closed by the caller.
+   *
+   * @param metricsSchemaVersion the metrics schema version (currently only 1 
is supported)
+   * @return an InputStream for the metrics schema SQL file
+   */
+  public InputStream openMetricsSchemaResource(int metricsSchemaVersion) {
+    if (metricsSchemaVersion != 1) {
+      throw new IllegalArgumentException(
+          "Unknown or invalid metrics schema version " + metricsSchemaVersion);
+    }
+
+    final String resourceName =
+        String.format("%s/schema-metrics-v%d.sql", this.getDisplayName(), 
metricsSchemaVersion);
+
+    ClassLoader classLoader = DatasourceOperations.class.getClassLoader();
+    return classLoader.getResourceAsStream(resourceName);
+  }
 }
diff --git 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/DatasourceOperations.java
 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/DatasourceOperations.java
index e44de3a94..5e1b2785d 100644
--- 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/DatasourceOperations.java
+++ 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/DatasourceOperations.java
@@ -57,7 +57,9 @@ public class DatasourceOperations {
   private static final String RELATION_DOES_NOT_EXIST = "42P01";
 
   // H2 STATUS CODES
-  private static final String H2_RELATION_DOES_NOT_EXIST = "90079";
+  // 90079 = Schema not found, 42S02 = Table or view not found
+  private static final String H2_SCHEMA_DOES_NOT_EXIST = "90079";
+  private static final String H2_TABLE_DOES_NOT_EXIST = "42S02";
 
   // POSTGRES RETRYABLE EXCEPTIONS
   private static final String SERIALIZATION_FAILURE_SQL_CODE = "40001";
@@ -402,7 +404,9 @@ public class DatasourceOperations {
   public boolean isRelationDoesNotExist(SQLException e) {
     return (RELATION_DOES_NOT_EXIST.equals(e.getSQLState())
             && databaseType == DatabaseType.POSTGRES)
-        || (H2_RELATION_DOES_NOT_EXIST.equals(e.getSQLState()) && databaseType 
== DatabaseType.H2);
+        || ((H2_SCHEMA_DOES_NOT_EXIST.equals(e.getSQLState())
+                || H2_TABLE_DOES_NOT_EXIST.equals(e.getSQLState()))
+            && databaseType == DatabaseType.H2);
   }
 
   private Connection borrowConnection() throws SQLException {
diff --git 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java
 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java
index 9401df2dd..f3a83cbf0 100644
--- 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java
+++ 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java
@@ -777,6 +777,25 @@ public class JdbcBasePersistenceImpl implements 
BasePersistence, IntegrationPers
     }
   }
 
+  /**
+   * Checks if the metrics tables have been bootstrapped by querying the 
metrics_version table.
+   *
+   * @param datasourceOperations the datasource operations to use for the check
+   * @return true if the metrics_version table exists and contains data, false 
otherwise
+   */
+  public static boolean metricsTableExists(DatasourceOperations 
datasourceOperations) {
+    PreparedQuery query = QueryGenerator.generateMetricsVersionQuery();
+    try {
+      List<SchemaVersion> versions = datasourceOperations.executeSelect(query, 
new SchemaVersion());
+      return versions != null && !versions.isEmpty();
+    } catch (SQLException e) {
+      if (datasourceOperations.isRelationDoesNotExist(e)) {
+        return false;
+      }
+      throw new IllegalStateException("Failed to check if metrics tables 
exist", e);
+    }
+  }
+
   /** {@inheritDoc} */
   @Override
   public <T extends PolarisEntity & LocationBasedEntity>
diff --git 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBootstrapUtils.java
 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBootstrapUtils.java
index 814417d1b..a6e691cd0 100644
--- 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBootstrapUtils.java
+++ 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBootstrapUtils.java
@@ -88,4 +88,18 @@ public class JdbcBootstrapUtils {
     }
     return -1;
   }
+
+  /**
+   * Determines whether the metrics schema should be included during bootstrap.
+   *
+   * @param bootstrapOptions The bootstrap options containing schema 
information.
+   * @return true if the metrics schema should be included, false otherwise.
+   */
+  public static boolean shouldIncludeMetrics(BootstrapOptions 
bootstrapOptions) {
+    SchemaOptions schemaOptions = bootstrapOptions.schemaOptions();
+    if (schemaOptions != null) {
+      return schemaOptions.includeMetrics();
+    }
+    return false;
+  }
 }
diff --git 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcMetaStoreManagerFactory.java
 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcMetaStoreManagerFactory.java
index 26f38fc31..24c1d025c 100644
--- 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcMetaStoreManagerFactory.java
+++ 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcMetaStoreManagerFactory.java
@@ -172,6 +172,13 @@ public class JdbcMetaStoreManagerFactory implements 
MetaStoreManagerFactory {
               datasourceOperations
                   .getDatabaseType()
                   .openInitScriptResource(effectiveSchemaVersion));
+
+          // Run the metrics schema script if requested
+          if (JdbcBootstrapUtils.shouldIncludeMetrics(bootstrapOptions)) {
+            LOGGER.info("Including metrics schema for realm: {}", realm);
+            datasourceOperations.executeScript(
+                
datasourceOperations.getDatabaseType().openMetricsSchemaResource(1));
+          }
         } catch (SQLException e) {
           throw new RuntimeException(
               String.format("Error executing sql script: %s", e.getMessage()), 
e);
diff --git 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/QueryGenerator.java
 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/QueryGenerator.java
index a8720fda5..044625276 100644
--- 
a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/QueryGenerator.java
+++ 
b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/QueryGenerator.java
@@ -369,6 +369,13 @@ public class QueryGenerator {
         List.of());
   }
 
+  @VisibleForTesting
+  static PreparedQuery generateMetricsVersionQuery() {
+    return new PreparedQuery(
+        "SELECT version_value FROM POLARIS_SCHEMA.metrics_version WHERE 
version_key = 'metrics_version'",
+        List.of());
+  }
+
   /**
    * Generate a SELECT query to find any entities that have a given realm 
&amp; parent and that may
    * overlap with a given location. The check is performed without 
consideration for the scheme, so
diff --git 
a/persistence/relational-jdbc/src/main/resources/h2/schema-metrics-v1.sql 
b/persistence/relational-jdbc/src/main/resources/h2/schema-metrics-v1.sql
new file mode 100644
index 000000000..9f73a567b
--- /dev/null
+++ b/persistence/relational-jdbc/src/main/resources/h2/schema-metrics-v1.sql
@@ -0,0 +1,166 @@
+--
+-- 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.
+--
+
+-- This schema is SEPARATE from the entity schema and can evolve independently.
+-- It contains tables for storing Iceberg metrics reports.
+--
+-- Tables:
+--   * `metrics_version` - Version tracking for the metrics schema
+--   * `scan_metrics_report` - Scan metrics reports
+--   * `commit_metrics_report` - Commit metrics reports
+-- ============================================================================
+
+CREATE SCHEMA IF NOT EXISTS POLARIS_SCHEMA;
+SET SCHEMA POLARIS_SCHEMA;
+
+-- Metrics schema version tracking (separate from entity schema version)
+CREATE TABLE IF NOT EXISTS metrics_version (
+    version_key VARCHAR PRIMARY KEY,
+    version_value INTEGER NOT NULL
+);
+
+MERGE INTO metrics_version (version_key, version_value)
+    KEY (version_key)
+    VALUES ('metrics_version', 1);
+
+COMMENT ON TABLE metrics_version IS 'the version of the metrics schema in use';
+
+-- ============================================================================
+-- SCAN METRICS REPORT TABLE
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS scan_metrics_report (
+    report_id TEXT NOT NULL,
+    realm_id TEXT NOT NULL,
+    catalog_id BIGINT NOT NULL,
+    table_id BIGINT NOT NULL,
+
+    -- Report metadata
+    timestamp_ms BIGINT NOT NULL,
+    principal_name TEXT,
+    request_id TEXT,
+
+    -- Trace correlation
+    otel_trace_id TEXT,
+    otel_span_id TEXT,
+    report_trace_id TEXT,
+
+    -- Scan context
+    snapshot_id BIGINT,
+    schema_id INTEGER,
+    filter_expression TEXT,
+    projected_field_ids TEXT,
+    projected_field_names TEXT,
+
+    -- Scan metrics
+    result_data_files BIGINT DEFAULT 0,
+    result_delete_files BIGINT DEFAULT 0,
+    total_file_size_bytes BIGINT DEFAULT 0,
+    total_data_manifests BIGINT DEFAULT 0,
+    total_delete_manifests BIGINT DEFAULT 0,
+    scanned_data_manifests BIGINT DEFAULT 0,
+    scanned_delete_manifests BIGINT DEFAULT 0,
+    skipped_data_manifests BIGINT DEFAULT 0,
+    skipped_delete_manifests BIGINT DEFAULT 0,
+    skipped_data_files BIGINT DEFAULT 0,
+    skipped_delete_files BIGINT DEFAULT 0,
+    total_planning_duration_ms BIGINT DEFAULT 0,
+
+    -- Equality/positional delete metrics
+    equality_delete_files BIGINT DEFAULT 0,
+    positional_delete_files BIGINT DEFAULT 0,
+    indexed_delete_files BIGINT DEFAULT 0,
+    total_delete_file_size_bytes BIGINT DEFAULT 0,
+
+    -- Additional metadata (for extensibility)
+    metadata TEXT DEFAULT '{}',
+
+    PRIMARY KEY (realm_id, report_id)
+);
+
+COMMENT ON TABLE scan_metrics_report IS 'Scan metrics reports as first-class 
entities';
+
+-- Index for retention cleanup by timestamp
+CREATE INDEX IF NOT EXISTS idx_scan_report_timestamp ON 
scan_metrics_report(realm_id, timestamp_ms);
+
+-- ============================================================================
+-- COMMIT METRICS REPORT TABLE
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS commit_metrics_report (
+    report_id TEXT NOT NULL,
+    realm_id TEXT NOT NULL,
+    catalog_id BIGINT NOT NULL,
+    table_id BIGINT NOT NULL,
+
+    -- Report metadata
+    timestamp_ms BIGINT NOT NULL,
+    principal_name TEXT,
+    request_id TEXT,
+
+    -- Trace correlation
+    otel_trace_id TEXT,
+    otel_span_id TEXT,
+    report_trace_id TEXT,
+
+    -- Commit context
+    snapshot_id BIGINT NOT NULL,
+    sequence_number BIGINT,
+    operation TEXT NOT NULL,
+
+    -- File metrics
+    added_data_files BIGINT DEFAULT 0,
+    removed_data_files BIGINT DEFAULT 0,
+    total_data_files BIGINT DEFAULT 0,
+    added_delete_files BIGINT DEFAULT 0,
+    removed_delete_files BIGINT DEFAULT 0,
+    total_delete_files BIGINT DEFAULT 0,
+
+    -- Equality delete files
+    added_equality_delete_files BIGINT DEFAULT 0,
+    removed_equality_delete_files BIGINT DEFAULT 0,
+
+    -- Positional delete files
+    added_positional_delete_files BIGINT DEFAULT 0,
+    removed_positional_delete_files BIGINT DEFAULT 0,
+
+    -- Record metrics
+    added_records BIGINT DEFAULT 0,
+    removed_records BIGINT DEFAULT 0,
+    total_records BIGINT DEFAULT 0,
+
+    -- Size metrics
+    added_file_size_bytes BIGINT DEFAULT 0,
+    removed_file_size_bytes BIGINT DEFAULT 0,
+    total_file_size_bytes BIGINT DEFAULT 0,
+
+    -- Duration and attempts
+    total_duration_ms BIGINT DEFAULT 0,
+    attempts INTEGER DEFAULT 1,
+
+    -- Additional metadata (for extensibility)
+    metadata TEXT DEFAULT '{}',
+
+    PRIMARY KEY (realm_id, report_id)
+);
+
+COMMENT ON TABLE commit_metrics_report IS 'Commit metrics reports as 
first-class entities';
+
+-- Index for retention cleanup by timestamp
+CREATE INDEX IF NOT EXISTS idx_commit_report_timestamp ON 
commit_metrics_report(realm_id, timestamp_ms);
diff --git 
a/persistence/relational-jdbc/src/main/resources/postgres/schema-metrics-v1.sql 
b/persistence/relational-jdbc/src/main/resources/postgres/schema-metrics-v1.sql
new file mode 100644
index 000000000..753f861cb
--- /dev/null
+++ 
b/persistence/relational-jdbc/src/main/resources/postgres/schema-metrics-v1.sql
@@ -0,0 +1,177 @@
+--
+-- 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.
+
+-- This schema is SEPARATE from the entity schema and can evolve independently.
+-- It contains tables for storing Iceberg metrics reports.
+--
+-- Tables:
+--   * `metrics_version` - Version tracking for the metrics schema
+--   * `scan_metrics_report` - Scan metrics reports
+--   * `commit_metrics_report` - Commit metrics reports
+-- ============================================================================
+
+CREATE SCHEMA IF NOT EXISTS POLARIS_SCHEMA;
+SET search_path TO POLARIS_SCHEMA;
+
+-- Metrics schema version tracking (separate from entity schema version)
+CREATE TABLE IF NOT EXISTS metrics_version (
+    version_key TEXT PRIMARY KEY,
+    version_value INTEGER NOT NULL
+);
+
+INSERT INTO metrics_version (version_key, version_value)
+VALUES ('metrics_version', 1)
+ON CONFLICT (version_key) DO UPDATE
+SET version_value = EXCLUDED.version_value;
+
+COMMENT ON TABLE metrics_version IS 'the version of the metrics schema in use';
+
+-- ============================================================================
+-- SCAN METRICS REPORT TABLE
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS scan_metrics_report (
+    report_id TEXT NOT NULL,
+    realm_id TEXT NOT NULL,
+    catalog_id BIGINT NOT NULL,
+    table_id BIGINT NOT NULL,
+
+    -- Report metadata
+    timestamp_ms BIGINT NOT NULL,
+    principal_name TEXT,
+    request_id TEXT,
+
+    -- Trace correlation
+    otel_trace_id TEXT,
+    otel_span_id TEXT,
+    report_trace_id TEXT,
+
+    -- Scan context
+    snapshot_id BIGINT,
+    schema_id INTEGER,
+    filter_expression TEXT,
+    projected_field_ids TEXT,
+    projected_field_names TEXT,
+
+    -- Scan metrics
+    result_data_files BIGINT DEFAULT 0,
+    result_delete_files BIGINT DEFAULT 0,
+    total_file_size_bytes BIGINT DEFAULT 0,
+    total_data_manifests BIGINT DEFAULT 0,
+    total_delete_manifests BIGINT DEFAULT 0,
+    scanned_data_manifests BIGINT DEFAULT 0,
+    scanned_delete_manifests BIGINT DEFAULT 0,
+    skipped_data_manifests BIGINT DEFAULT 0,
+    skipped_delete_manifests BIGINT DEFAULT 0,
+    skipped_data_files BIGINT DEFAULT 0,
+    skipped_delete_files BIGINT DEFAULT 0,
+    total_planning_duration_ms BIGINT DEFAULT 0,
+
+    -- Equality/positional delete metrics
+    equality_delete_files BIGINT DEFAULT 0,
+    positional_delete_files BIGINT DEFAULT 0,
+    indexed_delete_files BIGINT DEFAULT 0,
+    total_delete_file_size_bytes BIGINT DEFAULT 0,
+
+    -- Additional metadata (for extensibility)
+    metadata JSONB DEFAULT '{}'::JSONB,
+
+    PRIMARY KEY (realm_id, report_id)
+);
+
+COMMENT ON TABLE scan_metrics_report IS 'Scan metrics reports as first-class 
entities';
+COMMENT ON COLUMN scan_metrics_report.report_id IS 'Unique identifier for the 
report';
+COMMENT ON COLUMN scan_metrics_report.realm_id IS 'Realm ID for multi-tenancy';
+COMMENT ON COLUMN scan_metrics_report.catalog_id IS 'Catalog ID';
+COMMENT ON COLUMN scan_metrics_report.otel_trace_id IS 'OpenTelemetry trace ID 
from HTTP headers';
+COMMENT ON COLUMN scan_metrics_report.report_trace_id IS 'Trace ID from report 
metadata';
+
+-- Index for retention cleanup by timestamp
+CREATE INDEX IF NOT EXISTS idx_scan_report_timestamp
+    ON scan_metrics_report(realm_id, timestamp_ms DESC);
+
+-- ============================================================================
+-- COMMIT METRICS REPORT TABLE
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS commit_metrics_report (
+    report_id TEXT NOT NULL,
+    realm_id TEXT NOT NULL,
+    catalog_id BIGINT NOT NULL,
+    table_id BIGINT NOT NULL,
+
+    -- Report metadata
+    timestamp_ms BIGINT NOT NULL,
+    principal_name TEXT,
+    request_id TEXT,
+
+    -- Trace correlation
+    otel_trace_id TEXT,
+    otel_span_id TEXT,
+    report_trace_id TEXT,
+
+    -- Commit context
+    snapshot_id BIGINT NOT NULL,
+    sequence_number BIGINT,
+    operation TEXT NOT NULL,
+
+    -- File metrics
+    added_data_files BIGINT DEFAULT 0,
+    removed_data_files BIGINT DEFAULT 0,
+    total_data_files BIGINT DEFAULT 0,
+    added_delete_files BIGINT DEFAULT 0,
+    removed_delete_files BIGINT DEFAULT 0,
+    total_delete_files BIGINT DEFAULT 0,
+
+    -- Equality delete files
+    added_equality_delete_files BIGINT DEFAULT 0,
+    removed_equality_delete_files BIGINT DEFAULT 0,
+
+    -- Positional delete files
+    added_positional_delete_files BIGINT DEFAULT 0,
+    removed_positional_delete_files BIGINT DEFAULT 0,
+
+    -- Record metrics
+    added_records BIGINT DEFAULT 0,
+    removed_records BIGINT DEFAULT 0,
+    total_records BIGINT DEFAULT 0,
+
+    -- Size metrics
+    added_file_size_bytes BIGINT DEFAULT 0,
+    removed_file_size_bytes BIGINT DEFAULT 0,
+    total_file_size_bytes BIGINT DEFAULT 0,
+
+    -- Duration and attempts
+    total_duration_ms BIGINT DEFAULT 0,
+    attempts INTEGER DEFAULT 1,
+
+    -- Additional metadata (for extensibility)
+    metadata JSONB DEFAULT '{}'::JSONB,
+
+    PRIMARY KEY (realm_id, report_id)
+);
+
+COMMENT ON TABLE commit_metrics_report IS 'Commit metrics reports as 
first-class entities';
+COMMENT ON COLUMN commit_metrics_report.report_id IS 'Unique identifier for 
the report';
+COMMENT ON COLUMN commit_metrics_report.realm_id IS 'Realm ID for 
multi-tenancy';
+COMMENT ON COLUMN commit_metrics_report.operation IS 'Commit operation type: 
append, overwrite, delete, replace';
+COMMENT ON COLUMN commit_metrics_report.otel_trace_id IS 'OpenTelemetry trace 
ID from HTTP headers';
+
+-- Index for retention cleanup by timestamp
+CREATE INDEX IF NOT EXISTS idx_commit_report_timestamp
+    ON commit_metrics_report(realm_id, timestamp_ms DESC);
diff --git 
a/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/JdbcBootstrapUtilsTest.java
 
b/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/JdbcBootstrapUtilsTest.java
index 6a9eb9552..f36ab6848 100644
--- 
a/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/JdbcBootstrapUtilsTest.java
+++ 
b/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/JdbcBootstrapUtilsTest.java
@@ -150,4 +150,35 @@ class JdbcBootstrapUtilsTest {
       assertEquals(-1, result);
     }
   }
+
+  @Nested
+  @ExtendWith(MockitoExtension.class)
+  class ShouldIncludeMetricsTests {
+
+    @Mock private BootstrapOptions mockBootstrapOptions;
+    @Mock private SchemaOptions mockSchemaOptions;
+
+    @Test
+    void whenSchemaOptionsIsNull_shouldReturnFalse() {
+      when(mockBootstrapOptions.schemaOptions()).thenReturn(null);
+      boolean result = 
JdbcBootstrapUtils.shouldIncludeMetrics(mockBootstrapOptions);
+      assertEquals(false, result);
+    }
+
+    @Test
+    void whenIncludeMetricsIsTrue_shouldReturnTrue() {
+      when(mockBootstrapOptions.schemaOptions()).thenReturn(mockSchemaOptions);
+      when(mockSchemaOptions.includeMetrics()).thenReturn(true);
+      boolean result = 
JdbcBootstrapUtils.shouldIncludeMetrics(mockBootstrapOptions);
+      assertEquals(true, result);
+    }
+
+    @Test
+    void whenIncludeMetricsIsFalse_shouldReturnFalse() {
+      when(mockBootstrapOptions.schemaOptions()).thenReturn(mockSchemaOptions);
+      when(mockSchemaOptions.includeMetrics()).thenReturn(false);
+      boolean result = 
JdbcBootstrapUtils.shouldIncludeMetrics(mockBootstrapOptions);
+      assertEquals(false, result);
+    }
+  }
 }
diff --git 
a/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/MetricsPersistenceBootstrapValidationTest.java
 
b/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/MetricsPersistenceBootstrapValidationTest.java
new file mode 100644
index 000000000..f92f77f7f
--- /dev/null
+++ 
b/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/MetricsPersistenceBootstrapValidationTest.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.polaris.persistence.relational.jdbc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.InputStream;
+import java.sql.SQLException;
+import java.util.Optional;
+import javax.sql.DataSource;
+import org.h2.jdbcx.JdbcConnectionPool;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for metrics persistence bootstrap validation functionality.
+ *
+ * <p>These tests verify that the system correctly detects whether the metrics 
tables have been
+ * bootstrapped and provides appropriate error messages when they are missing.
+ */
+class MetricsPersistenceBootstrapValidationTest {
+
+  private DataSource dataSource;
+  private DatasourceOperations datasourceOperations;
+
+  @BeforeEach
+  void setUp() throws SQLException {
+    // Create a fresh H2 in-memory database for each test
+    dataSource =
+        JdbcConnectionPool.create(
+            "jdbc:h2:mem:test_metrics_validation_" + System.nanoTime() + 
";DB_CLOSE_DELAY=-1",
+            "sa",
+            "");
+    datasourceOperations = new DatasourceOperations(dataSource, new 
TestJdbcConfiguration());
+  }
+
+  /** Test configuration for H2 database. */
+  private static class TestJdbcConfiguration implements 
RelationalJdbcConfiguration {
+    @Override
+    public Optional<Integer> maxRetries() {
+      return Optional.of(2);
+    }
+
+    @Override
+    public Optional<Long> maxDurationInMs() {
+      return Optional.of(100L);
+    }
+
+    @Override
+    public Optional<Long> initialDelayInMs() {
+      return Optional.of(100L);
+    }
+  }
+
+  @AfterEach
+  void tearDown() {
+    if (dataSource instanceof JdbcConnectionPool) {
+      ((JdbcConnectionPool) dataSource).dispose();
+    }
+  }
+
+  @Nested
+  class MetricsTableExistsTests {
+
+    @Test
+    void whenMetricsTableDoesNotExist_shouldReturnFalse() {
+      // No schema loaded - metrics table doesn't exist
+      boolean result = 
JdbcBasePersistenceImpl.metricsTableExists(datasourceOperations);
+      assertThat(result).isFalse();
+    }
+
+    @Test
+    void whenOnlyEntitySchemaLoaded_shouldReturnFalse() throws SQLException {
+      // Load only the entity schema (v4), not the metrics schema
+      loadSchema("h2/schema-v4.sql");
+
+      boolean result = 
JdbcBasePersistenceImpl.metricsTableExists(datasourceOperations);
+      assertThat(result).isFalse();
+    }
+
+    @Test
+    void whenMetricsSchemaLoaded_shouldReturnTrue() throws SQLException {
+      // Load the metrics schema
+      loadSchema("h2/schema-metrics-v1.sql");
+
+      boolean result = 
JdbcBasePersistenceImpl.metricsTableExists(datasourceOperations);
+      assertThat(result).isTrue();
+    }
+
+    @Test
+    void whenBothSchemasLoaded_shouldReturnTrue() throws SQLException {
+      // Load both entity and metrics schemas
+      loadSchema("h2/schema-v4.sql");
+      loadSchema("h2/schema-metrics-v1.sql");
+
+      boolean result = 
JdbcBasePersistenceImpl.metricsTableExists(datasourceOperations);
+      assertThat(result).isTrue();
+    }
+  }
+
+  @Nested
+  class CheckMetricsPersistenceBootstrappedTests {
+
+    @Test
+    void whenMetricsTableDoesNotExist_shouldThrowIllegalStateException() {
+      // No schema loaded - metrics table doesn't exist
+      assertThatThrownBy(
+              () -> {
+                if 
(!JdbcBasePersistenceImpl.metricsTableExists(datasourceOperations)) {
+                  throw new IllegalStateException(
+                      "Metrics persistence is enabled but the metrics tables 
have not been bootstrapped. "
+                          + "Please run the bootstrap command with the 
--include-metrics flag to create "
+                          + "the required schema before enabling this 
feature.");
+                }
+              })
+          .isInstanceOf(IllegalStateException.class)
+          .hasMessageContaining("metrics tables have not been bootstrapped")
+          .hasMessageContaining("--include-metrics");
+    }
+
+    @Test
+    void whenMetricsSchemaLoaded_shouldNotThrow() throws SQLException {
+      // Load the metrics schema
+      loadSchema("h2/schema-metrics-v1.sql");
+
+      // Should not throw
+      boolean exists = 
JdbcBasePersistenceImpl.metricsTableExists(datasourceOperations);
+      assertThat(exists).isTrue();
+    }
+  }
+
+  private void loadSchema(String schemaPath) throws SQLException {
+    ClassLoader classLoader = getClass().getClassLoader();
+    InputStream scriptStream = classLoader.getResourceAsStream(schemaPath);
+    if (scriptStream == null) {
+      throw new IllegalStateException("Schema file not found: " + schemaPath);
+    }
+    datasourceOperations.executeScript(scriptStream);
+  }
+}
diff --git 
a/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/QueryGeneratorTest.java
 
b/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/QueryGeneratorTest.java
index 38a06f2b5..2f6d481d1 100644
--- 
a/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/QueryGeneratorTest.java
+++ 
b/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/QueryGeneratorTest.java
@@ -33,6 +33,7 @@ import java.util.Map;
 import java.util.Set;
 import org.apache.polaris.core.entity.PolarisEntityCore;
 import org.apache.polaris.core.entity.PolarisEntityId;
+import 
org.apache.polaris.persistence.relational.jdbc.QueryGenerator.PreparedQuery;
 import org.apache.polaris.persistence.relational.jdbc.models.ModelEntity;
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -395,4 +396,13 @@ public class QueryGeneratorTest {
         .containsExactly(
             "realmId", -123L, "/", "//", "//バケツ/", "//バケツ/\"loc.ation\"/", 
"//バケツ/\"loc.ation\"/%");
   }
+
+  @Test
+  void testGenerateMetricsVersionQuery() {
+    PreparedQuery query = QueryGenerator.generateMetricsVersionQuery();
+    assertEquals(
+        "SELECT version_value FROM POLARIS_SCHEMA.metrics_version WHERE 
version_key = 'metrics_version'",
+        query.sql());
+    Assertions.assertThat(query.parameters()).isEmpty();
+  }
 }
diff --git 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/SchemaOptions.java
 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/SchemaOptions.java
index 5cfc20a88..8798a66f9 100644
--- 
a/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/SchemaOptions.java
+++ 
b/polaris-core/src/main/java/org/apache/polaris/core/persistence/bootstrap/SchemaOptions.java
@@ -21,8 +21,15 @@ package org.apache.polaris.core.persistence.bootstrap;
 
 import java.util.Optional;
 import org.apache.polaris.immutables.PolarisImmutable;
+import org.immutables.value.Value;
 
 @PolarisImmutable
 public interface SchemaOptions {
   Optional<Integer> schemaVersion();
+
+  /** Whether to include the metrics schema during bootstrap. Defaults to 
false. */
+  @Value.Default
+  default boolean includeMetrics() {
+    return false;
+  }
 }
diff --git 
a/runtime/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java
 
b/runtime/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java
index 82d92f4e1..53c89ddd5 100644
--- 
a/runtime/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java
+++ 
b/runtime/admin/src/main/java/org/apache/polaris/admintool/BootstrapCommand.java
@@ -93,6 +93,11 @@ public class BootstrapCommand extends BaseMetaStoreCommand {
           paramLabel = "<schema version>",
           description = "The version of the schema to load in [1, 2, 3, 
LATEST].")
       Integer schemaVersion;
+
+      @CommandLine.Option(
+          names = {"--include-metrics"},
+          description = "Include metrics schema tables during bootstrap.")
+      boolean includeMetrics;
     }
   }
 
@@ -136,6 +141,10 @@ public class BootstrapCommand extends BaseMetaStoreCommand 
{
           builder.schemaVersion(inputOptions.schemaInputOptions.schemaVersion);
         }
 
+        if (inputOptions.schemaInputOptions.includeMetrics) {
+          builder.includeMetrics(true);
+        }
+
         schemaOptions = builder.build();
       } else {
         schemaOptions = ImmutableSchemaOptions.builder().build();
diff --git 
a/runtime/admin/src/test/java/org/apache/polaris/admintool/relational/jdbc/RelationalJdbcBootstrapCommandTest.java
 
b/runtime/admin/src/test/java/org/apache/polaris/admintool/relational/jdbc/RelationalJdbcBootstrapCommandTest.java
index 31f3a9eea..73abd2cbd 100644
--- 
a/runtime/admin/src/test/java/org/apache/polaris/admintool/relational/jdbc/RelationalJdbcBootstrapCommandTest.java
+++ 
b/runtime/admin/src/test/java/org/apache/polaris/admintool/relational/jdbc/RelationalJdbcBootstrapCommandTest.java
@@ -44,4 +44,27 @@ public class RelationalJdbcBootstrapCommandTest extends 
BootstrapCommandTestBase
     // assertThat(result2.exitCode()).isEqualTo(EXIT_CODE_BOOTSTRAP_ERROR);
     // assertThat(result2.getOutput()).contains("Cannot bootstrap due to 
schema version mismatch.");
   }
+
+  @Test
+  public void testBootstrapWithIncludeMetrics(QuarkusMainLauncher launcher) {
+    // Test that --include-metrics option is accepted and bootstrap completes 
successfully.
+    // The metrics tables are created during bootstrap when this flag is set.
+    LaunchResult result =
+        launcher.launch(
+            "bootstrap", "-r", "realm1", "-c", "realm1,root,s3cr3t", 
"--include-metrics");
+    assertThat(result.exitCode()).isEqualTo(0);
+    assertThat(result.getOutput())
+        .contains("Realm 'realm1' successfully bootstrapped.")
+        .contains("Bootstrap completed successfully.");
+  }
+
+  @Test
+  public void testBootstrapWithoutIncludeMetrics(QuarkusMainLauncher launcher) 
{
+    // Test that bootstrap works without --include-metrics (default behavior)
+    LaunchResult result = launcher.launch("bootstrap", "-r", "realm1", "-c", 
"realm1,root,s3cr3t");
+    assertThat(result.exitCode()).isEqualTo(0);
+    assertThat(result.getOutput())
+        .contains("Realm 'realm1' successfully bootstrapped.")
+        .contains("Bootstrap completed successfully.");
+  }
 }


Reply via email to