Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SQE: Sovereign Query Engine

SQE is a Rust-based distributed SQL query engine for Apache Iceberg tables. It replaces a patched Trino fork with a purpose-built engine based on Apache DataFusion and iceberg-rust.

graph LR
    Client["JDBC / Flight SQL Client"] --> Coordinator
    Coordinator --> Worker1["Worker 1"]
    Coordinator --> Worker2["Worker 2"]
    Coordinator --> WorkerN["Worker N"]
    Worker1 --> S3["S3 / MinIO"]
    Worker2 --> S3
    WorkerN --> S3
    Coordinator --> Polaris["Polaris Catalog"]
    Coordinator --> Keycloak["Keycloak OIDC"]

Key Properties

  • No service account. Every query runs as the authenticated user. The user’s bearer token passes through to the Polaris catalog (metadata) and on the write path. Read-path S3 access currently uses the configured [storage] credentials; per-user read-credential vending is on the roadmap (see S3 credential vending).
  • Arrow-native — columnar data flows from Parquet files through the entire query pipeline to the client. No row-based serialization anywhere.
  • Iceberg-native — built on iceberg-rust, not a connector bolted onto a generic engine. Partition pruning, metadata caching, and Iceberg v3 support are first-class.
  • Fine-grained security — row filters and column masks enforced at the logical plan level, before the optimizer runs. Invisible columns, transparent row filtering, no information leakage.
  • Rust performance — single binary, no JVM, no GC pauses, predictable memory usage, fast startup.

Quick Start (embedded, no server)

cargo install --path crates/sqe-cli
sqe-cli --embedded                    # ~/.sqe/warehouse persistent Iceberg catalog

sqe> SELECT * FROM '/data/sales.parquet' LIMIT 5;
sqe> SELECT * FROM read_csv('s3://bucket/orders.tsv.gz');
sqe> SELECT * FROM 'hf://datasets/squad/plain_text/train-00000-of-00001.parquet';
sqe> SELECT * FROM read_delta('/data/delta/sales', version => '5');

Full embedded reference: Using the CLI. DuckDB comparison: getsqe.com/compare/duckdb.

Quick Start (cluster mode)

# Build
cargo build --release --bin sqe-server --bin sqe-cli

# Start coordinator (sqe-server runs as coordinator by default)
SQE_CONFIG=sqe.toml ./target/release/sqe-server

# Connect
./target/release/sqe-cli --host localhost --port 50051

Project Status

SQE is production-ready for the data path against Apache Iceberg; the coordinator currently runs as a single replica (a single point of failure), and coordinator HA is on the roadmap (see Kubernetes & Helm). The cluster mode runs distributed (coordinator + stateless workers) with OIDC bearer-token passthrough, Polaris / Nessie / Glue / HMS / S3 Tables / JDBC / Hadoop catalogs, and 167/189 (88.4%) on the public Iceberg matrix scoreboard. The embedded mode (V8 through V12.1) adds DuckDB-style file-format TVFs (read_csv, read_json, read_delta), HuggingFace hf:// URLs, and a single-binary CLI for laptop analytics.

Why SQE

The Problem

Our data platform runs on Apache Iceberg tables stored in S3, cataloged by Apache Polaris (Iceberg REST Catalog), with authentication through Keycloak OIDC. We need a SQL query engine that can:

  1. Authenticate users through Keycloak
  2. Pass the user’s bearer token through to Polaris and on the write path (no service account). Read-path S3 access currently uses the configured [storage] credentials; per-user read vending is on the roadmap (see S3 credential vending)
  3. Enforce fine-grained security (row filters, column masks) per user
  4. Support full SQL for analytics, dbt transformations, and ad-hoc queries
  5. Run on Kubernetes with minimal operational overhead

Why Not Trino?

We started with Trino, the industry-standard SQL engine for data lakehouses. It works, but:

ChallengeDetail
Service account modelTrino authenticates to the catalog and storage with a single service identity. It can’t pass per-user bearer tokens through to Polaris. We forked Trino (DCAF branch) to add token passthrough, but maintaining a JVM fork is expensive.
Security enforcementTrino’s security model (system/catalog access control) doesn’t natively support Iceberg-level row filters and column masks applied at the query plan level.
JVM overheadTrino requires significant heap memory, has GC pauses, and takes 10-30 seconds to start. Not ideal for auto-scaling Kubernetes pods.
Maintenance burdenOur Trino fork drifts from upstream with every release. Rebasing is a multi-week effort. Security patches are delayed.

Why DataFusion + Rust?

graph TB
    subgraph "Old: Trino Fork"
        T[Trino JVM] -->|service account| P1[Polaris]
        T -->|service account| S1[S3]
        T -.- Fork[DCAF Fork<br/>maintenance burden]
    end

    subgraph "New: SQE"
        C[SQE Coordinator<br/>Rust binary] -->|user bearer token| P2[Polaris]
        W[SQE Workers] -->|"writes: user creds, reads: storage key"| S2[S3]
        C --> W
    end

    style Fork fill:#f96,stroke:#333
    style C fill:#6f9,stroke:#333
    style W fill:#6f9,stroke:#333

Apache DataFusion is a Rust-native query engine that gives us:

  • Extensible query planning: we can inject security filters into the LogicalPlan before optimization, which is exactly where row filters and column masks need to go
  • iceberg-rust integration: native Rust Iceberg library, no JNI bridge, no serialization overhead
  • Per-query context: each query gets its own SessionContext with the user’s bearer token. No shared service account.
  • Single binary: the coordinator and worker ship as one ~50MB binary. Starts in milliseconds.
  • No GC: predictable latency, no stop-the-world pauses during large scans

The Name

Sovereign — because every query runs with the identity and permissions of the user who submitted it. No service account intermediary. No privilege escalation. The user’s token is sovereign.

The scope is precise. The user’s bearer token reaches the Polaris catalog (metadata) and the write path per user. The read path currently reads S3 with the configured [storage] credentials; per-user read-credential vending is on the roadmap (see S3 credential vending). Catalog permissions and the plan-rewriting policy engine still gate what each user can see, so identity drives authorization even where the read path shares a storage key.

From Trino to DataFusion

Architecture Comparison

graph LR
    subgraph Trino
        TC[Coordinator<br/>JVM ~2GB heap] --> TW1[Worker<br/>JVM ~8GB heap]
        TC --> TW2[Worker<br/>JVM ~8GB heap]
        TC -->|Hive Metastore<br/>protocol| HMS[Hive Metastore<br/>or Polaris]
        TW1 -->|service account| TS3[S3]
        TW2 -->|service account| TS3
    end

    subgraph SQE
        SC[sqe-server<br/>coordinator<br/>~50MB binary] --> SW1[sqe-server<br/>worker]
        SC --> SW2[sqe-server<br/>worker]
        SC -->|user bearer token<br/>Iceberg REST| POL[Polaris]
        SW1 -->|user credentials| SS3[S3]
        SW2 -->|user credentials| SS3
    end

What Changes

AspectTrino (DCAF fork)SQE
LanguageJava 21Rust
Binary size~1.2GB (with plugins)~50MB
Startup time10-30 seconds< 1 second
Memory modelJVM heap + GCDirect allocation, no GC
Catalog protocolHive Metastore / Iceberg RESTIceberg REST (native)
Auth to catalogService accountUser bearer token passthrough
Auth to storageService account IAM roleUser credentials from catalog vending
Wire protocolTrino HTTP (custom)Arrow Flight SQL (gRPC)
Data format in-flightRow-based JSON pagesArrow columnar batches
Security modelSystem/catalog access controlLogicalPlan rewriting (row filters, column masks)
Query engineCustom cost-based optimizerApache DataFusion
Table formatIceberg connectoriceberg-rust (native)
MaintenanceFork of 2M+ LOC Java projectPurpose-built ~5K LOC Rust

What Stays the Same

  • Apache Iceberg as the table format
  • Apache Polaris as the REST catalog
  • Keycloak as the identity provider
  • S3 as the storage layer
  • dbt as the transformation framework (new native adapter instead of Trino adapter)
  • JDBC connectivity (via Arrow Flight SQL JDBC driver instead of Trino JDBC)

Migration Path

SQE includes an optional Trino-compatible HTTP endpoint (/v1/statement) that speaks enough of the Trino wire protocol to support existing dashboards and tools during the migration period. This is not a full Trino emulation. It covers SELECT, SHOW, and basic DDL, enough to keep things running while teams migrate to Flight SQL.

timeline
    title Migration Timeline
    Phase 1 : SQE single-node : Flight SQL : CLI
    Phase 2 : Write path : dbt-sqe adapter : Views
    Phase 3 : Distributed execution : Workers
    Phase 4 : Trino compat layer : Dashboard migration
    Phase 5 : Security policies : Row filters : Column masks
    Phase 6 : Decommission Trino fork

The Auth Challenge

The core design constraint that drove us to build SQE: no service account.

The Problem with Service Accounts

In a typical data platform, the query engine authenticates to the catalog and storage with a service account, a single identity with broad permissions. The engine then enforces per-user access control internally.

sequenceDiagram
    participant User
    participant Trino
    participant Polaris
    participant S3

    User->>Trino: Query (user token)
    Note over Trino: Validates user token
    Trino->>Polaris: List tables (SERVICE ACCOUNT)
    Polaris-->>Trino: Table metadata
    Trino->>S3: Read Parquet (SERVICE ACCOUNT IAM role)
    S3-->>Trino: Data
    Trino-->>User: Results

This means:

  • Polaris sees one identity for all queries: audit logs show the service account, not the actual user
  • S3 access is all-or-nothing: the service account can read everything, security depends entirely on the engine enforcing it correctly
  • Credential rotation is a blast-radius event: rotating the service account key affects all users simultaneously
  • Compliance gap: auditors want to see that Alice read table X, not that sqe-service-account did

SQE’s Approach: Bearer Token Passthrough

SQE never stores or uses a service account for data access. Instead, the user’s Keycloak bearer token flows through the entire stack:

sequenceDiagram
    participant User
    participant SQE as SQE Coordinator
    participant KC as Keycloak
    participant Polaris
    participant S3

    User->>SQE: Handshake (username, password)
    SQE->>KC: OIDC Password Grant
    KC-->>SQE: Access token + refresh token
    SQE-->>User: Session (bearer token)

    User->>SQE: Query (bearer token)
    SQE->>Polaris: List tables (USER's bearer token)
    Polaris-->>SQE: Table metadata + S3 credentials
    Note over Polaris: Polaris vends scoped S3<br/>credentials for THIS user
    SQE->>S3: Read Parquet (user-scoped credentials)
    S3-->>SQE: Data
    SQE-->>User: Arrow Flight results

Key Implications

PropertyService Account ModelSQE Token Passthrough
Polaris audit trailService accountActual user
S3 access scopeEverythingUser-scoped (credential vending)
Credential rotationBlast radius: all usersPer-user: transparent refresh
Security enforcementEngine-internal onlyCatalog + storage + engine
ComplianceRequires mapping logsNative user identity

Per-Session Catalog

Each user session gets its own SessionCatalog instance, initialized with the user’s bearer token:

graph TB
    subgraph "Session: alice"
        SC1[SessionCatalog<br/>token: alice_jwt] --> P[Polaris REST]
    end

    subgraph "Session: bob"
        SC2[SessionCatalog<br/>token: bob_jwt] --> P
    end

    P -->|alice's token| S3A[S3: alice sees<br/>tables A, B, C]
    P -->|bob's token| S3B[S3: bob sees<br/>tables A, B only]

Polaris enforces catalog-level access control based on the token. If Alice has access to tables A, B, C but Bob only has access to A and B, this is enforced at the catalog level. SQE doesn’t need to duplicate this logic.

Token Lifecycle

SQE manages token refresh transparently. A background task checks all active sessions every 10 seconds and refreshes tokens that are about to expire:

stateDiagram-v2
    [*] --> Active: Handshake (ROPC grant)
    Active --> Refreshing: Token expiry - 60s buffer
    Refreshing --> Active: New token from Keycloak
    Refreshing --> Expired: Refresh fails
    Active --> Expired: Session timeout
    Expired --> [*]: Session removed

    note right of Active: Queries use current token
    note right of Refreshing: Background task (10s interval)

The token fingerprint (last 8 characters of the access token) is used to invalidate iceberg-rust’s internal catalog session cache when a token is refreshed, ensuring the catalog client always uses the current token.

System Overview

Components

graph TB
    subgraph Clients
        JDBC["JDBC / ODBC<br/>(Flight SQL driver)"]
        CLI["sqe-cli"]
        DBT["dbt-sqe adapter"]
        DASH["Dashboards<br/>(Trino compat)"]
    end

    subgraph "SQE Cluster"
        subgraph "Coordinator (sqe-server --mode coordinator)"
            FLS["Flight SQL Server<br/>:50051"]
            TH["Trino HTTP<br/>:8080"]
            SM["Session Manager"]
            QH["Query Handler"]
            PE["Policy Enforcer"]
            SCHED["Scheduler"]
        end

        subgraph "Workers (sqe-server --mode worker)"
            W1["Worker 1<br/>DataFusion executor"]
            W2["Worker 2<br/>DataFusion executor"]
            WN["Worker N<br/>DataFusion executor"]
        end
    end

    subgraph "External Services"
        KC["OIDC provider<br/>(Keycloak / Auth0 / Entra)"]
        CAT["Catalog backend<br/>(Polaris / Nessie / Glue REST /<br/>S3 Tables / Unity / HMS / JDBC)"]
        S3["S3-compatible storage<br/>(AWS / Ceph / R2 / rustfs)"]
    end

    JDBC --> FLS
    CLI --> FLS
    DBT --> FLS
    DASH --> TH

    SM --> KC
    QH --> PE
    QH --> SCHED
    SCHED --> W1
    SCHED --> W2
    SCHED --> WN

    QH --> CAT
    W1 --> S3
    W2 --> S3
    WN --> S3

The catalog backend is selectable at runtime. Polaris is the primary target and the only one verified end-to-end for production write paths today. Nessie, AWS Glue, AWS S3 Tables, Unity Catalog OSS, Hive Metastore, JDBC (Postgres), and Hadoop storage-only are all reachable through the same iceberg::Catalog trait, with live integration tests in sqe-catalog/tests/backends_integration.rs. AWS endpoints share the OSS Iceberg REST code path through the aws-sigv4 cargo feature on the vendored iceberg-catalog-rest crate. See features/iceberg.md for the catalog-by-catalog state.

The coordinator currently runs as a single replica. It is a single point of failure: a restart drops in-flight queries and session state, which is process-local. Workers are stateless and scale horizontally. Coordinator high availability is on the roadmap. See Kubernetes & Helm for the deployment topology.

Request Flow

A query flows through SQE in these stages:

sequenceDiagram
    participant C as Client
    participant F as Flight SQL Server
    participant SM as Session Manager
    participant QH as Query Handler
    participant PE as Policy Enforcer
    participant DF as DataFusion
    participant CAT as Polaris Catalog
    participant S3 as S3 Storage

    C->>F: do_handshake(user, pass)
    F->>SM: authenticate(user, pass)
    SM->>SM: Keycloak OIDC → session
    F-->>C: bearer token

    C->>F: execute(SQL, token)
    F->>SM: get_session(token)
    F->>QH: execute(session, SQL)

    QH->>QH: parse & classify SQL
    QH->>CAT: create SessionCatalog(user_token)
    QH->>DF: plan SQL → LogicalPlan
    QH->>PE: enforce(user, plan)
    PE-->>QH: secured plan (row filters, column masks)
    QH->>DF: optimize & execute
    DF->>S3: read Parquet files
    S3-->>DF: Arrow RecordBatches
    DF-->>QH: results
    QH-->>F: RecordBatches
    F-->>C: Arrow Flight stream

Single-Node vs Distributed

SQE starts in single-node mode by default. The coordinator executes queries locally using DataFusion. No workers needed.

For larger deployments, enable workers:

graph LR
    subgraph "Single-Node (default)"
        C1[sqe-server] -->|local DataFusion| S1[S3]
    end

    subgraph "Distributed"
        C2[Coordinator] -->|plan fragments| W1[Worker 1]
        C2 --> W2[Worker 2]
        W1 --> S2[S3]
        W2 --> S2
    end
ModeWhen to useConfig
Single-nodeDev, small datasets, < 100GBsqe-server (default)
DistributedProduction, large scans, parallel I/Oworker.enabled=true in Helm

Ports

PortProtocolPurpose
50051gRPC (Flight SQL)Primary query interface
50052gRPC (Flight)Worker data exchange
8080HTTPTrino-compatible endpoint
9090HTTPPrometheus metrics
9091HTTPHealth probes (/healthz, /readyz)

Caching

SQE caches at five layers, each falling through to the next on a miss. The first two layers (session and catalog) live in memory and are short-lived; the last three (table metadata, manifest, footer) hold immutable or near-immutable Iceberg data.

graph TD
    SC["Layer 1: SessionContext cache<br/>moka, SHA-256 token fingerprint<br/>TTL 5 min, max 100 entries"]
    RC["Layer 2: RestCatalog cache<br/>moka, per-warehouse<br/>TTL 5 min"]
    TMC["Layer 3: Table metadata cache<br/>moka, ETag validation<br/>TTL configurable (default 30s)"]
    MC["Layer 4: Manifest file cache<br/>moka, content-addressed<br/>no TTL, default 512 MB"]
    FC["Layer 5: Parquet footer cache<br/>object_store, byte-range<br/>default 256 MB"]

    SC -->|miss| RC
    RC -->|miss| TMC
    TMC -->|miss| MC
    MC -->|miss| FC
    FC -->|miss| S3["S3 storage"]

    DDL["DDL (CREATE / DROP / ALTER)"] -.->|invalidates| SC

Invalidation follows the Iceberg data model:

  • The session cache is invalidated after any DDL statement (CREATE TABLE, DROP TABLE, ALTER TABLE).
  • Table metadata uses ETag-based conditional requests. Polaris returns 304 Not Modified when metadata has not changed, so a cache hit costs one cheap round-trip rather than a full metadata fetch.
  • Manifest files are immutable by Iceberg specification, so they need no TTL-based expiry. They evict only under memory pressure.
  • The footer cache evicts on an LRU basis within its configured memory budget.

Coordinator

The coordinator is the brain of SQE. It handles SQL parsing, query planning, security enforcement, and result delivery. In single-node mode, it also executes queries directly.

Responsibilities

graph TB
    subgraph Coordinator
        FLS["Flight SQL Server"] --> SM["Session Manager"]
        FLS --> QH["Query Handler"]

        QH --> PARSE["SQL Parser<br/>(sqlparser-rs)"]
        QH --> CLASS["Statement Classifier"]
        QH --> PLAN["Query Planner<br/>(DataFusion)"]
        QH --> PE["Policy Enforcer"]
        QH --> CAT["Catalog Ops<br/>(DDL)"]
        QH --> WH["Write Handler<br/>(CTAS, INSERT,<br/>DELETE, UPDATE,<br/>MERGE — CoW)"]

        SM --> AUTH["Authenticator<br/>(Keycloak OIDC)"]
        SM --> TC["Token Cache"]

        PLAN --> DF["DataFusion<br/>SessionContext"]
        DF --> SC["SessionCatalog<br/>(per-user token)"]
    end

Statement Routing

The coordinator classifies every SQL statement and routes it to the appropriate handler:

StatementHandlerDescription
SELECTexecute_queryPlan, then policy enforce, then execute, then stream results
SHOW CATALOGShandle_show_catalogsReturns warehouse name
SHOW SCHEMAShandle_show_schemasLists namespaces from Polaris
SHOW TABLEShandle_show_tablesLists tables in namespace(s)
CREATE TABLE AS SELECThandle_ctasExecute SELECT, then write Parquet, then commit to Iceberg
INSERT INTOhandle_insertExecute SELECT, then append Parquet, then commit
CREATE VIEWhandle_create_viewPlan SELECT for schema validation, then store in catalog
DROP TABLEcatalog_ops.drop_tableForward to Polaris REST
CREATE SCHEMAcatalog_ops.create_schemaCreate namespace in Polaris
DROP SCHEMAcatalog_ops.drop_schemaDrop namespace from Polaris
EXPLAINhandle_explainShow query plan
DELETE FROMhandle_deleteCoW: scan affected files, filter, rewrite via rewrite_files()
UPDATEhandle_updateCoW: scan affected files, apply SET, rewrite via rewrite_files()
MERGE INTOhandle_mergeCoW: full outer join, classify rows, rewrite via rewrite_files()
GRANT/REVOKEPolicy backendParsed always; enforced when an access-control backend is configured (default none). See GRANT and REVOKE.

Session Context

Each query gets a fresh DataFusion SessionContext with the user’s catalog:

sequenceDiagram
    participant QH as Query Handler
    participant SC as SessionCatalog
    participant POL as Polaris
    participant DF as DataFusion

    QH->>SC: new(catalog_url, warehouse, user_token)
    SC->>POL: list_namespaces() [user_token]
    POL-->>SC: [ns1, ns2, ns3]
    QH->>DF: register_catalog(warehouse, CatalogProvider)
    QH->>DF: sql("SELECT * FROM ns1.table1")
    DF->>SC: get_table("ns1", "table1")
    SC->>POL: load_table("ns1.table1") [user_token]
    POL-->>SC: table metadata + S3 location
    SC-->>DF: TableProvider (Iceberg scan)

This means two users running the same query may see different tables, schemas, or data, depending on what Polaris grants them.

Worker

Workers are stateless DataFusion executors. They receive plan fragments (scan tasks) from the coordinator, read Parquet files from S3, and stream Arrow results back.

Architecture

graph TB
    subgraph Worker["sqe-server --mode worker"]
        FS["Flight Service<br/>:50052"]
        EX["Executor"]
        PR["Parquet Reader"]
    end

    COORD["Coordinator"] -->|ScanTask ticket| FS
    FS --> EX
    EX --> PR
    PR -->|read| S3["S3 / MinIO"]
    PR -->|Arrow RecordBatches| FS
    FS -->|Flight stream| COORD

Scan Task

The coordinator sends workers a ScanTask, a lightweight JSON message containing everything the worker needs:

{
  "fragment_id": "frag-001",
  "data_file_paths": [
    "s3://warehouse/ns/table/data/00001.parquet",
    "s3://warehouse/ns/table/data/00002.parquet"
  ],
  "projected_columns": ["id", "name", "amount"],
  "s3_endpoint": "http://s3:9000",
  "s3_region": "us-east-1",
  "s3_access_key": "...",
  "s3_secret_key": "...",
  "s3_path_style": true
}

Workers don’t need access to Polaris or Keycloak. The coordinator resolves table metadata, applies security, and provides the worker with direct S3 credentials and file paths.

Health Checking

The coordinator monitors workers with a background health check task:

stateDiagram-v2
    [*] --> Unhealthy: Registered
    Unhealthy --> Healthy: Health check OK
    Healthy --> Healthy: Health check OK
    Healthy --> Degraded: 1-2 consecutive failures
    Degraded --> Unhealthy: 3 consecutive failures
    Degraded --> Healthy: Health check OK
    Unhealthy --> Healthy: Health check OK
  • Health checks run every 5 seconds via Flight Action("health_check")
  • A worker is marked unhealthy after 3 consecutive failures
  • Unhealthy workers are excluded from query scheduling
  • Recovery is automatic: a healthy response resets the failure counter

Scaling

Workers are stateless, so scaling is just changing the replica count:

# Helm
helm upgrade sqe deploy/helm/sqe/ --set worker.replicas=5

# kubectl
kubectl scale deployment sqe-worker --replicas=5

The coordinator discovers workers from the config or service discovery. No re-registration needed.

Authentication Flow

SQE supports two OAuth2 flows for initial authentication, then manages token lifecycle transparently:

  • OIDC Password Grant (ROPC) – for user-interactive authentication where Flight SQL sends username and password. Works with Keycloak or any OIDC provider that supports the Resource Owner Password Credentials grant.
  • OAuth2 Client Credentials – for service-to-service auth, test environments, or OIDC providers that do not support ROPC. Configured by setting token_endpoint directly instead of keycloak_url.

The mode is selected automatically based on configuration: if keycloak_url is set, SQE uses ROPC; if token_endpoint is set (and keycloak_url is empty), SQE uses client credentials.

Why ROPC?

Flight SQL’s handshake sends username and password directly. There’s no browser redirect flow possible over gRPC. ROPC is the standard mechanism for non-interactive clients (JDBC drivers, CLI tools, dbt adapters).

Client Credentials Mode

When token_endpoint is set (and keycloak_url is empty), SQE uses the client_credentials grant instead of ROPC. In this mode:

  • The coordinator obtains a service token using client_id + client_secret posted directly to the configured token endpoint.
  • The username from the Flight SQL handshake is informational only – it is used for session labeling and audit logs, but is not sent to the token endpoint.
  • There is no refresh_token in client credentials responses. When a token nears expiry, SQE re-fetches a new token via another client_credentials request.
  • This is the mode used by the lightweight test stack (Polaris built-in OAuth), where Polaris itself acts as the token issuer.

Example Configuration

[auth]
token_endpoint = "http://polaris:8181/api/catalog/v1/oauth/tokens"
client_id = "root"
client_secret = "s3cr3t"

Client Credentials Sequence

sequenceDiagram
    participant Client
    participant SQE as SQE Coordinator
    participant TE as Token Endpoint
    participant POL as Polaris
    participant S3

    Note over Client,TE: Authentication
    Client->>SQE: Flight Handshake<br/>Basic auth (user:pass)
    SQE->>TE: POST /token<br/>grant_type=client_credentials<br/>client_id, client_secret
    TE-->>SQE: access_token, expires_in
    SQE->>SQE: Create Session<br/>(username from handshake,<br/>token from endpoint)
    SQE-->>Client: Bearer token (session_id)

    Note over Client,S3: Query Execution
    Client->>SQE: execute(SQL)<br/>Authorization: Bearer session_id
    SQE->>SQE: Lookup session → get access_token
    SQE->>POL: GET /namespaces<br/>Authorization: Bearer access_token
    POL-->>SQE: [namespace list]
    SQE->>POL: POST /tables/load<br/>Authorization: Bearer access_token
    POL-->>SQE: table metadata + S3 credentials
    SQE->>S3: GetObject (vended credentials)
    S3-->>SQE: Parquet data
    SQE-->>Client: Arrow Flight stream

    Note over SQE,TE: Background Token Re-fetch
    SQE->>TE: POST /token<br/>grant_type=client_credentials<br/>client_id, client_secret
    TE-->>SQE: new access_token, expires_in
    SQE->>SQE: Update session token

ROPC Flow

The following sections describe the ROPC (password grant) flow in detail.

Complete Flow

sequenceDiagram
    participant Client
    participant SQE as SQE Coordinator
    participant KC as Keycloak
    participant POL as Polaris
    participant S3

    Note over Client,KC: Authentication
    Client->>SQE: Flight Handshake<br/>Basic auth (user:pass)
    SQE->>KC: POST /token<br/>grant_type=password<br/>username, password, client_id
    KC-->>SQE: access_token, refresh_token, expires_in
    SQE->>SQE: Create Session<br/>(id, user, roles, tokens)
    SQE-->>Client: Bearer token (session_id)

    Note over Client,S3: Query Execution
    Client->>SQE: execute(SQL)<br/>Authorization: Bearer session_id
    SQE->>SQE: Lookup session → get access_token
    SQE->>POL: GET /namespaces<br/>Authorization: Bearer access_token
    POL-->>SQE: [namespace list]
    SQE->>POL: POST /tables/load<br/>Authorization: Bearer access_token
    POL-->>SQE: table metadata + S3 credentials
    Note over POL: Polaris vends S3<br/>credentials scoped to<br/>this user + table
    SQE->>S3: GetObject (vended credentials)
    S3-->>SQE: Parquet data
    SQE-->>Client: Arrow Flight stream

    Note over SQE,KC: Background Token Refresh
    SQE->>KC: POST /token<br/>grant_type=refresh_token
    KC-->>SQE: new access_token, new refresh_token
    SQE->>SQE: Update session tokens

Token Refresh

A background task runs every 10 seconds, scanning all active sessions:

#![allow(unused)]
fn main() {
// Pseudocode
loop {
    sleep(10 seconds);
    for session in sessions_expiring_within(60 seconds) {
        match keycloak.refresh_token(session.refresh_token) {
            Ok(new_tokens) => session.update(new_tokens),
            Err(_) => session.mark_expired(),
        }
    }
}
}

The 60-second buffer ensures tokens are refreshed well before expiry, avoiding mid-query auth failures.

Client Credentials mode: There is no refresh_token in client credentials responses. The background task detects this and re-fetches a fresh token via a new client_credentials request to the token endpoint when the current token is near expiry. The same 60-second buffer applies.

Token Fingerprinting

When a token is refreshed, the iceberg-rust catalog client’s internal HTTP session cache still holds the old token. SQE uses a token fingerprint (last 8 characters of the access token) as part of the catalog session key. When the fingerprint changes, a new catalog session is created with the fresh token.

graph LR
    T1["Token: ...abc12345<br/>fingerprint: abc12345"] -->|refresh| T2["Token: ...xyz98765<br/>fingerprint: xyz98765"]
    T1 --> CS1["CatalogSession 1"]
    T2 --> CS2["CatalogSession 2<br/>(new, fresh token)"]

Role Extraction

SQE extracts user roles from the JWT realm_access.roles claim. These roles are stored in the session and used for policy evaluation:

{
  "realm_access": {
    "roles": ["data-analyst", "finance-reader", "admin"]
  }
}

Roles flow through to the Policy Enforcer, which uses them to determine row filters and column masks for each query.

Client Credentials mode: Role extraction only applies in OIDC (ROPC) mode, where the JWT contains user-specific claims. In client credentials mode, the token represents the service itself and typically does not carry realm_access.roles. The session’s role list is empty, and all authorization decisions are delegated to Polaris (which enforces access based on the service principal’s catalog grants).

Security & Policy

SQE enforces fine-grained security through LogicalPlan rewriting, injecting row filters and column masks into the query plan before DataFusion’s optimizer runs.

Status: The plan-rewriting policy enforcer is implemented and pluggable, and it is off by default. The default policy engine is passthrough (PassthroughEnforcer), which returns plans unmodified, so enforcement is opt-in. The one enforcement backend is Apache Ranger, with an in-memory store for dev and tests. A default open-source deployment runs without any of this. See GRANT and REVOKE for the SQL surface and the Chameleon / SBP note.

Design Principle

Security enforcement happens at the logical plan level, not at the data level:

graph TB
    SQL["SQL: SELECT * FROM sales"] --> PARSE["Parse"]
    PARSE --> PLAN["LogicalPlan<br/>Projection → TableScan(sales)"]
    PLAN --> POLICY["Policy Enforcer<br/>inject row filter + column mask"]
    POLICY --> SECURED["Secured LogicalPlan<br/>Projection → Filter(region='EU') → TableScan(sales)<br/>+ mask(ssn)"]
    SECURED --> OPT["DataFusion Optimizer"]
    OPT --> EXEC["Execute"]

    style POLICY fill:#f96,stroke:#333

This approach means:

  • Row filters are transparent. The user doesn’t know they exist
  • Column masks block predicate pushdown on raw values. You can’t WHERE ssn = '123-45-6789' to probe masked data
  • Denied columns are invisible. They don’t appear in SELECT *, not as errors
  • The optimizer can push user predicates through row filters but not through column masks

Policy Enforcer Trait

#![allow(unused)]
fn main() {
#[async_trait]
pub trait PolicyEnforcer: Send + Sync {
    async fn evaluate(
        &self,
        user: &SessionUser,
        plan: LogicalPlan,
    ) -> Result<LogicalPlan>;
}
}

Implementations:

  • PassthroughEnforcer: returns plan unchanged (default; enforcement opt-in)
  • Ranger: reads row-filter and column-mask policies from Apache Ranger and feeds the plan rewriter (shipped, wired)
  • InMemory: grants stored in a hash map for dev and tests (shipped, wired)

The Ranger enforcer reads the same hive service-def that Apache Spark reads through its Kyuubi authorization plugin, so one policy written once in Ranger enforces byte-identically in SQE and in Spark. See Fine-grained access control for the how-to and Spark / Ranger Parity for the validated result.

SQL Extensions

-- Grant row filter
GRANT SELECT ON sales TO ROLE analyst
  ROWS WHERE region = 'EU';

-- Grant column mask
GRANT SELECT ON customers TO ROLE support
  MASKED WITH (ssn AS '***-**-' || RIGHT(ssn, 4));

-- View effective grants
SHOW EFFECTIVE GRANTS FOR USER "alice";

-- View grants
SHOW GRANTS ON sales;

No Information Leakage

Following the PostgreSQL RLS model:

ScenarioBehavior
User queries a denied columnColumn is invisible in SELECT *, error on explicit reference
User queries filtered rowsRows silently excluded, no indication they exist
User applies predicate on masked columnPredicate evaluated on masked value, not raw value
User runs EXPLAINShows secured plan (filters visible, mask functions visible)
User runs SHOW TABLESOnly shows tables the user has access to (Polaris enforced)

Runtime Security Controls

SQE includes several runtime security mechanisms that are active by default or can be enabled via configuration.

Rate Limiting

Throttles query submission to prevent abuse or runaway clients. Uses a token-bucket algorithm (via the governor crate).

[rate_limit]
enabled = true
per_user_queries_per_minute = 60
global_queries_per_minute = 1000

When a limit is exceeded, the client receives a RESOURCE_EXHAUSTED Flight error. Rate limiting is disabled by default.

Query Timeouts

Every query is subject to an execution timeout. If the query exceeds the limit, it is cancelled and the client receives an error.

[query]
timeout_secs = 300              # Default: 5 minutes

[query.role_overrides]
admin = 3600                    # Admins get 1 hour
analyst = 600                   # Analysts get 10 minutes

Role overrides allow different timeout limits per role. The user’s longest-matching role timeout wins.

Session Lifecycle

Sessions have both idle and absolute timeouts. A background sweeper runs every 60 seconds to clean up expired sessions.

[session]
idle_timeout_secs = 900         # 15 min idle timeout
absolute_timeout_secs = 28800   # 8 hour hard cap
  • Idle timeout: sessions with no query activity for this long are expired
  • Absolute timeout: sessions older than this are expired regardless of activity

Query Cancellation

SQE supports Arrow Flight’s native cancellation mechanism. When a client cancels a query (or disconnects), the CancellationToken is triggered and propagated to workers, stopping execution promptly.

Error Sanitization

In production mode (debug = false, the default), error messages returned to clients are sanitized:

  • Internal details (stack traces, file paths, internal error types) are stripped
  • Clients receive a short error message and a request ID for correlation
  • Full details are logged server-side for debugging

Enable debug = true during development to see full error details:

[coordinator]
debug = true

TLS Encryption

Flight SQL connections can be encrypted with TLS. Optional mTLS adds client certificate verification.

[coordinator.tls]
cert_file = "/etc/sqe/server.crt"
key_file  = "/etc/sqe/server.key"
ca_file   = "/etc/sqe/ca.crt"    # Optional: mTLS

See Configuration for details.

Streaming Execution

SQE’s streaming execution engine enables 1TB-scale queries on memory-constrained servers. The implementation is split into two phases: Phase A (safe) handles single-node memory management and scan optimization, while Phase B (fast) distributes computation across workers via Arrow Flight DoExchange.

The Problem

A coordinator-centric query engine hits a hard wall: every intermediate result flows through one process. An ORDER BY on 1TB of data requires 1TB of memory (or spill space) on the coordinator, regardless of how many workers scanned the data. A four-way hash join between large tables can exhaust coordinator memory long before the result set is assembled.

The fundamental tension is between sovereignty (run on your own hardware, which may be small) and scale (query datasets that don’t fit in memory). SQE solves this in two stages: first, make the coordinator survive large queries through spill-to-disk and scan optimization (Phase A); then, push computation to workers so the coordinator handles only final aggregation (Phase B).

Phase A: Safe (Single-Node)

Phase A ensures that a single coordinator with limited memory (e.g., 512MB) can execute large analytical queries without OOM kills.

Coordinator Spill-to-Disk

DataFusion’s FairSpillPool divides available memory across all active operators. When an operator (sort, hash aggregate, hash join) exceeds its share, it spills intermediate results to disk as sorted runs.

Key components:

  • FairSpillPool – configured via memory_limit in sqe.toml. Divides memory equally among registered MemoryConsumer instances. Triggers spill when any consumer exceeds its fair share.
  • Watermark system – four levels (green/yellow/orange/red) based on pool utilization percentage. Green (<60%) allows normal execution. Yellow (60-75%) triggers advisory warnings. Orange (75-90%) forces spillable operators to spill. Red (>90%) activates admission control, queueing new queries until memory drops below the orange threshold.
  • Admission control – when the pool is in the red zone, new queries wait in a bounded queue rather than competing for memory. This prevents cascade failures where N concurrent queries each grab 1/N of memory and all spill simultaneously.
  • External merge sort – when a SortExec spills, it writes sorted runs to spill_dir (default: /tmp/sqe-spill). On final output, a k-way merge reads all runs simultaneously, producing a globally sorted stream with constant memory overhead.

Configuration in sqe.toml:

[coordinator]
memory_limit = "512MB"
spill_dir = "/tmp/sqe-spill"
spill_compression = "zstd"  # lz4, zstd, or none

Late Materialization

Standard Parquet scans read all projected columns from every row group. Late materialization splits this into two phases:

  1. Predicate phase – read only the columns referenced in WHERE clauses. Apply filters. Produce a set of surviving row indices.
  2. Projection phase – for surviving rows only, read the remaining projected columns.

This is implemented as a two-phase RowFilter scan in the Iceberg scan planning layer. For queries with selective predicates (e.g., WHERE status = 'CLOSED' on a table where 5% of rows match), late materialization reduces I/O by up to 95% on the non-predicate columns.

The optimization is transparent to the rest of the plan – the TableScan still produces the same Arrow schema. The difference is entirely in how many bytes are read from Parquet.

Iceberg Scan Planning

Three optimizations happen before any Parquet data is read:

  • File-level min/max pruning – Iceberg manifest files contain per-column min/max statistics for each data file. SQE reads these statistics and skips files where the predicate cannot match. For example, WHERE order_date > '2025-01-01' skips any file whose order_date max is before 2025.
  • Sort-order detection – Iceberg metadata records the sort order of each data file. When a query includes ORDER BY on the sort column, SQE can skip the sort operator entirely and produce output directly from the pre-sorted scan. When multiple sorted files need merging, a merge-sort is cheaper than a full re-sort.
  • PageIndex pruning – for Parquet files with page-level statistics (column index), SQE prunes individual pages within a row group, further reducing I/O for selective predicates.
  • TopK optimizationORDER BY ... LIMIT N queries use a heap-based TopK operator that maintains only N rows in memory, avoiding a full sort and spill.

S3 I/O Pipeline

Reading Parquet files from S3 involves many small HTTP GET requests (one per column chunk per row group). SQE optimizes this with:

  • Request coalescing – adjacent byte ranges within coalesce_threshold (default: 1MB) are merged into a single GET request. This reduces the number of HTTP round-trips, which dominate latency on high-latency S3 endpoints.
  • Footer cache – Parquet file footers (schema, row group metadata, column chunk offsets) are cached in a footer_cache_size-bounded LRU cache. Repeated queries against the same table skip the footer read entirely.
  • Prefetch – while the executor processes the current row group, the next row group’s column chunks are fetched in the background, hiding S3 latency behind compute.

Configuration:

[storage]
coalesce_threshold = "1MB"
footer_cache_size = 256  # number of footers

SortMergeJoin Fallback

DataFusion’s default join strategy is hash join, which builds a hash table from the build side in memory. For large joins, this hash table can exceed the memory limit. DataFusion does not yet support hash join spill-to-disk upstream.

SQE registers a SortMergeJoin fallback: when the estimated build-side size exceeds hash_join_memory_threshold, the optimizer rewrites the join as a sort-merge join. Both sides are sorted (spilling to disk if needed via the external merge sort) and then merged with constant memory. This is slower than an in-memory hash join but avoids OOM on large joins.

[optimizer]
hash_join_memory_threshold = "256MB"

Phase B: Fast (Distributed)

Phase B pushes computation past the scan boundary. Instead of workers sending raw Arrow batches to the coordinator for all processing, workers perform filters, partial aggregations, partial sorts, and join probes locally.

DoExchange Shuffle

Arrow Flight’s DoExchange RPC enables bidirectional streaming between workers. SQE uses this to implement a hash-partitioned shuffle:

  1. The coordinator decomposes the physical plan into stages separated by shuffle boundaries (e.g., a hash join requires both sides to be hash-partitioned on the join key).
  2. Each stage runs on a set of workers. When a stage completes, its output is hash-partitioned by the shuffle key and streamed to the next stage’s workers via DoExchange.
  3. The partitioning function uses the same hash(key) % num_partitions scheme as DataFusion’s RepartitionExec, ensuring compatibility with the existing hash join and hash aggregate operators.

Distributed Sort (Range-Partition)

A distributed ORDER BY proceeds in three steps:

  1. Sample – each worker samples its local partition and sends the sample to the coordinator.
  2. Range boundaries – the coordinator computes quantile boundaries from the samples, producing N-1 split points for N workers.
  3. Range-partition and merge – each worker range-partitions its data and sends each range to the designated worker. Each receiving worker sorts its range locally (spilling if needed). The coordinator merges the sorted ranges via a k-way merge.

This distributes both the memory cost and the CPU cost of sorting. A 1TB ORDER BY with 8 workers requires roughly 125GB of spill per worker instead of 1TB on the coordinator.

Two-Phase Aggregation

Aggregation queries (GROUP BY) use a two-phase approach:

  1. Partial aggregation – each worker computes partial aggregates on its local data. For SUM(amount) GROUP BY region, each worker produces a partial sum per region from its partition.
  2. Final aggregation – partial results are shuffled by the grouping key to a set of finalizer workers (or the coordinator for small result sets). Each finalizer merges the partial aggregates into the final result.

This solves the q18 problem: TPC-H query 18 has a high-cardinality GROUP BY that produces millions of groups. On a single coordinator with 512MB, the GroupedHashAggregate exceeds memory. With two-phase aggregation, each worker handles a fraction of the groups, and memory pressure is distributed.

Distributed Joins

SQE supports four join strategies in distributed mode:

  • Broadcast join – when one side of the join is small (below broadcast_threshold, default 10MB), it is broadcast to all workers. Each worker probes its local partition of the large side against the broadcast table. No shuffle required.
  • Shuffle hash join – both sides are hash-partitioned on the join key and shuffled to matching workers. Each worker performs a local hash join on its partition.
  • Pre-sorted merge join – when both sides are already sorted on the join key (detected via Iceberg sort-order metadata), workers perform a merge join without re-sorting. This avoids the sort cost entirely.
  • Predicate transfer – before executing a join, the build side’s distinct join keys are collected and pushed as an IN-list filter to the probe side’s scan. This skips probe-side files that contain no matching keys, reducing I/O by 90%+ for selective joins. Based on the predicate transfer technique from Yang et al. (SIGMOD 2025).
[optimizer]
broadcast_threshold = "10MB"

Multi-Endpoint Flight SQL

In Phase A, all results flow through the coordinator’s single Flight SQL endpoint. Phase B adds multi-endpoint support: get_flight_info can return multiple FlightEndpoint entries, each pointing to a different worker. The client fetches results directly from workers, bypassing the coordinator for data transfer.

This eliminates the coordinator NIC bottleneck for large result sets. A query returning 4GB across 4 workers streams 1GB directly from each worker to the client, achieving 4x the effective bandwidth.

Stage Decomposition

The coordinator decomposes the physical plan into stages:

  1. Scan stage – workers read Parquet files, apply predicates and projections.
  2. Shuffle stage – workers hash-partition or range-partition output for the next stage.
  3. Join/Aggregate stage – workers perform local joins or aggregations on shuffled data.
  4. Final stage – coordinator (or a designated worker) performs final aggregation, sort, or limit.

Each stage boundary is a shuffle point. The coordinator tracks stage completion and triggers the next stage when all workers in the current stage have finished.

Memory Model

SQE uses a four-level watermark system to manage memory pressure:

LevelPool UtilizationBehavior
Green< 60%Normal execution, no restrictions
Yellow60-75%Advisory: log warnings, increment metrics
Orange75-90%Spillable operators forced to spill
Red> 90%Admission control: new queries queued

The FairSpillPool divides the total memory_limit equally among all registered MemoryConsumer instances. When a consumer’s try_grow call would push the pool past the orange threshold, the pool asks other spillable consumers to spill first. If spilling frees enough memory, the allocation succeeds. If not, the allocation fails with ResourceExhausted.

Per-operator behavior:

  • SortExec – spills sorted runs to disk, later merged via k-way merge.
  • HashAggregateExec – spills partition groups to disk (when supported by DataFusion).
  • HashJoinExec – not spillable upstream; SQE rewrites to SortMergeJoin when estimated size exceeds threshold.
  • SortMergeJoinExec – both sides sort-and-spill independently, then merge with constant memory.

Configuration Reference

FieldSectionDefaultDescription
memory_limit[coordinator] / [worker]8GBMaximum memory for the DataFusion runtime
spill_dir[coordinator] / [worker]/tmp/sqe-spillDirectory for spill files
spill_compression[coordinator] / [worker]zstdCompression for spill files (lz4, zstd, none)
hash_join_memory_threshold[optimizer]256MBBuild-side size above which hash join is rewritten to sort-merge join
broadcast_threshold[optimizer]10MBJoin side size below which broadcast join is used
coalesce_threshold[storage]1MBMaximum gap between byte ranges to coalesce into one S3 GET
footer_cache_size[storage]256Number of Parquet footers to cache

Benchmark Results

TPC-H at scale factor 1 (approximately 1GB of data) on a coordinator with 512MB memory and spill-to-disk enabled:

  • 21 of 22 queries pass. All queries produce correct results within the memory budget.
  • 1 failure: q18. TPC-H query 18 uses a high-cardinality GROUP BY with HAVING that produces millions of intermediate groups. DataFusion’s GroupedHashAggregate does not yet support spill-to-disk for hash aggregation, so the operator exceeds the 512MB limit. This is a known upstream limitation. With Phase B’s two-phase aggregation (distributing the groups across workers), q18 passes.

These results demonstrate that SQE can run analytical workloads on hardware that would be considered undersized for traditional query engines. The combination of spill-to-disk, late materialization, and scan planning keeps memory usage bounded regardless of data size.

Security and trust model

This page states SQE’s trust boundaries plainly: what is gated per user, what is not, and what a compromised component can reach. It describes the model as documented and shipping today, and marks the gaps. Where a control is designed but not yet built, it says so and links the limitation. For the SQL surface of access control, see GRANT and REVOKE. For the runtime controls (TLS, rate limiting, timeouts, error sanitization), see Security & Policy.

Identity: the user’s token is the credential

SQE has no service account. A client authenticates with a username and password (or a pre-minted JWT, or one of the other configured providers), and the coordinator exchanges that for an OIDC token. The token becomes the credential for the rest of the query. There is no second, broader identity that the engine falls back to for the catalog. See Authentication Flow.

In client-credentials mode (the lightweight test stack, where the catalog itself issues tokens) the username from the handshake is informational, used for session labelling and audit. The catalog requests still carry the service token. In OIDC password-grant mode, the token is the user’s own.

The metadata boundary: gated per user

Catalog operations carry the user’s bearer token. Listing namespaces, loading a table, committing a snapshot: all go to the catalog as Authorization: Bearer <user token>, and the catalog enforces what that user may see and do. SHOW TABLES returns only tables the user can access. A write commits as the user. The metadata path is gated per user.

The read data path: a known gap

Here the boundary is weaker than the metadata path, and it is a documented limitation, not a hidden one.

Writes use per-table credentials vended by the catalog. INSERT, MERGE, and DELETE go through the loaded table’s file IO, which carries the credentials the catalog returned for that table.

Reads do not. The coordinator reads data files with the static S3 key configured in the [storage] section. That key is the same for every user, and it is not scoped per table or per query. Per-user read credential vending (the catalog returns short-lived, table-scoped credentials and SQE reads with those) is designed but not yet built. See S3 Credential Vending and Limitations.

The consequence to hold in mind: a user who passes the catalog’s permission check for a table can have its data files read on their behalf with the static storage key. The catalog gates which tables a user can address; the read data path is not separately gated per user. Scope the [storage] key to the minimum the engine needs to function.

Policy enforcement: off by default

SQE parses a fine-grained access-control SQL surface: column masks (GRANT ... MASKED WITH), row filters (GRANT ... ROWS WHERE), SHOW EFFECTIVE GRANTS, and CHECK ACCESS. The enforcement model is plan rewriting: filters and masks are injected into the logical plan before DataFusion’s optimizer runs, so the optimizer cannot push a user predicate through a mask to probe raw values. The design follows the PostgreSQL row-level-security model, with no information leakage.

The enforcement is off by default: the default [policy] engine = "passthrough" returns plans unmodified. Two enforcers are shipped and wired, and you turn enforcement on by selecting one. ranger reads row-filter and column-mask policies from an Apache Ranger query service and feeds the plan rewriter; in-memory keeps grants in a hash map for dev and tests. The opa and cedar engines are defined in config but not yet wired (selecting them errors today). Because the Ranger backend reads the same query service-def that Apache Spark reads through its Kyuubi authorization plugin, one policy enforces byte-identically in SQE and in Spark. See Fine-grained access control for the how-to, Spark / Ranger Parity for the cross-engine result, and GRANT and REVOKE for the engine config.

The TVF object-store boundary

The file-format table-valued functions (read_parquet, read_csv, read_json, read_delta) read external files directly. They can address local filesystem paths and arbitrary object-store URLs. When inline credentials are omitted, they fall back to the configured [storage] credentials. Inline credentials are passed as SQL literals; SQE’s audit logger redacts values matching access_key, secret_key, and session_token patterns. See read_parquet TVF.

The boundary to reason about: a SQL-capable user can direct these functions at whatever the engine’s storage identity can reach. The trust boundary is the engine’s storage credentials and host filesystem access, not the user’s. Scope the engine’s storage credentials minimally and run it with least-privilege filesystem access, the same posture you would take for any service that reads paths supplied at query time.

What a compromised worker can reach

Workers are stateless. They hold no catalog state and no persistent credentials. The coordinator ships a worker a secured plan fragment plus the user’s bearer token for the query, and the worker executes against storage. A worker reads data files for the queries it is assigned. It does not hold the long-lived OIDC client secret, and it does not have its own standing identity to the catalog.

In distributed mode the coordinator and workers share a secret that authenticates worker registration and the credential push. The engine refuses to start when the coordinator URL or worker URLs are set with an empty worker secret. See Worker Secret.

A compromised worker can therefore reach the storage reachable with the credentials pushed to it for the queries it runs. The mitigation tracks the read-path gap above: today reads use the static [storage] key, so a compromised worker that observes that key sees the same broad storage reach the coordinator has. Per-user, per-table vended credentials would narrow that blast radius; they are not yet built for the read path.

Transport

Flight SQL connections can run with TLS, and optional mTLS adds client-certificate verification. TLS is off unless both a certificate and key are configured. See TLS in Configuration and Security & Policy. Run with TLS in any deployment where the network between client and coordinator, or coordinator and workers, is not already trusted.

Summary of boundaries

PathGated per user todayNotes
Catalog metadata (list, load, commit)YesUser bearer token to the catalog
Write data path (INSERT / MERGE / DELETE)YesPer-table vended credentials
Read data pathNoStatic [storage] key; per-user vending not yet built
Row filters / column masksOff by default; availableRanger + in-memory enforcers shipped; enable with [policy] engine. Ranger shares the policy with Spark/Kyuubi
TVF file accessEngine identity, not userScope storage creds and filesystem access minimally
Worker reachLimited by pushed credsToday bounded by the static read key

Research Papers

The streaming execution engine draws on decades of database systems research. This chapter lists the papers that most directly influenced SQE’s design, with notes on how each idea is implemented.

Papers and Their Influence

PaperVenueHow It Influenced SQE
Graefe, “Volcano – An Extensible and Parallel Query Evaluation System”IEEE TKDE 1994Exchange operator model – foundation for DoExchange shuffle
Shapiro, “Join Processing in Database Systems with Large Main Memories”VLDB 1986Partition-based spill – why SortMergeJoin is the safe fallback until hash join spill lands
Sethi et al., “Presto: SQL on Everything”ICDE 2019Coordinator/worker architecture SQE mirrors; no-spill-then-spill evolution
Leis et al., “Morsel-Driven Parallelism: A NUMA-Aware Query Evaluation Framework”SIGMOD 2014NUMA-aware execution; informs batch sizing and DataFusion’s pull model
Pedreira et al., “Velox: Meta’s Unified Execution Engine”VLDB 2022Cooperative memory arbitration – future direction for SQE
Raasveldt & Muhleisen, “DuckDB: An Embeddable Analytical Database”SIGMOD 2019Single-node out-of-core proof that 1TB works on 16GB with proper buffer management
Pang et al., “Memory-Adaptive External Sorting”VLDB 1993Dynamic sort splitting – how FairSpillPool divides memory
Abadi et al., “Materialization Strategies in a Column-Oriented DBMS”ICDE 2007Late materialization – predicate columns first, projection for survivors
Yang et al., “Predicate Transfer: Efficient Pre-Filtering for Joins”SIGMOD 2025Push join keys as IN-list to probe side – skip 90%+ of probe files

Detailed Notes

Graefe, “Volcano” (1994)

Volcano introduced the exchange operator as the universal mechanism for parallelism in query evaluation. An exchange operator sits between two plan tree segments and handles data redistribution – hash partitioning, round-robin, or broadcast – without the operators above or below knowing about parallelism. SQE’s DoExchange shuffle is a direct implementation of this model over Arrow Flight’s bidirectional streaming RPC. Each shuffle boundary in the stage decomposition corresponds to an exchange operator. The key insight from Volcano that SQE preserves: the operators themselves are single-threaded and unaware of distribution; all parallelism is encapsulated in the exchange.

Shapiro, “Hybrid Hash Join” (1986)

Shapiro’s hybrid hash join partitions the build side into buckets, keeping one bucket in memory and spilling the rest to disk. During the probe phase, the in-memory bucket is probed immediately; the spilled buckets are read back and probed sequentially. This is the standard approach for hash join spill in systems like PostgreSQL and Trino. DataFusion does not yet implement hash join spill upstream, so SQE cannot use this technique directly. Instead, SQE falls back to SortMergeJoin for large joins – both sides are sorted (spilling via external merge sort) and then merged with constant memory. The SortMergeJoin fallback is the safe path; when DataFusion adds hash join spill, SQE can adopt the hybrid approach from Shapiro for better performance on unsorted inputs.

Sethi et al., “Presto: SQL on Everything” (2019)

Presto’s architecture – a stateless coordinator that plans and schedules, stateless workers that execute, no shared storage between them – is the direct model for SQE’s coordinator/worker split. The paper describes Presto’s evolution from a no-spill engine (all intermediate data in memory) to one that supports spill-to-disk under memory pressure. SQE followed the same evolution: Phase A added coordinator spill (the “safe” path), and Phase B added distributed computation (pushing work to workers so the coordinator handles less data). The paper’s observation that “most queries fit in memory; spill is for the tail” matches SQE’s experience – 20 of 22 TPC-H queries run without spill on 512MB; only the largest sorts and aggregations trigger it.

Leis et al., “Morsel-Driven Parallelism” (2014)

The morsel-driven model assigns small, fixed-size chunks of work (morsels) to worker threads, enabling NUMA-aware scheduling without explicit thread pinning. DataFusion’s pull-based execution model, where each operator produces batches on demand, is conceptually similar. SQE’s batch sizing (default 8192 rows per RecordBatch) is informed by this paper’s finding that small, uniform work units lead to better load balancing and cache utilization. The paper also highlights the importance of avoiding global synchronization in the hot path – a principle SQE follows by using per-operator memory consumers and tokio::sync::watch channels for credential refresh rather than shared mutexes.

Pedreira et al., “Velox” (2022)

Velox introduces cooperative memory arbitration: operators register with a central arbitrator and respond to memory pressure by spilling or shrinking their buffers. This is more sophisticated than DataFusion’s FairSpillPool, which divides memory equally and triggers spill when any consumer exceeds its share. Velox’s arbitrator can make global decisions – asking a low-priority operator to spill so a high-priority one can proceed. SQE does not implement priority-based arbitration today, but the FairSpillPool’s watermark system (green/yellow/orange/red) provides a simpler version of the same concept. The Velox paper’s cooperative model is the planned future direction for SQE’s memory management, particularly for mixed workloads where interactive queries should preempt batch jobs.

Raasveldt & Muhleisen, “DuckDB” (2019)

DuckDB proves that a single-node engine with proper buffer management can process datasets far larger than available memory. Its out-of-core hash join and sort implementations use disk-backed buffers that transparently page data in and out. SQE’s Phase A is built on the same principle: spill-to-disk is not an error path, it is the normal execution path for large queries on small machines. The difference is that SQE operates over remote storage (S3) rather than local files, so the I/O pipeline (coalescing, footer cache, prefetch) is more critical. DuckDB’s benchmark results – 1TB queries on 16GB machines – provided the confidence that SQE’s 512MB target was achievable with the right memory management.

Pang et al., “Memory-Adaptive External Sorting” (1993)

This paper addresses the problem of external sorting when available memory fluctuates during execution (due to other concurrent operators). The key technique is dynamic run splitting: instead of committing to a fixed run size at the start of the sort, the algorithm adapts the run size based on currently available memory. SQE’s FairSpillPool implements a version of this: as other operators allocate and release memory, the pool available to a SortExec changes. When memory shrinks (another query starts), the sort produces smaller runs and spills more frequently. When memory grows (another query finishes), the sort can produce larger runs. The k-way merge at the end adapts to whatever set of runs was produced.

Abadi et al., “Materialization Strategies” (2007)

Abadi’s paper compares early materialization (read all columns, then filter) with late materialization (read predicate columns, filter, then read remaining columns for survivors) in column-oriented databases. Late materialization wins when predicates are selective because it avoids reading non-predicate columns for filtered-out rows. SQE implements late materialization in the Iceberg scan layer: the scan planner splits the column set into predicate columns and projection-only columns, reads the predicate columns first, applies the RowFilter, and then reads the projection columns only for rows that survived the filter. For a query like SELECT * FROM orders WHERE status = 'CLOSED' on a table where 5% of rows match, this reduces column-chunk reads by up to 19x (for a 20-column table where status is one column).

Yang et al., “Predicate Transfer” (2025)

Predicate transfer pushes join key values from the build side of a join to the probe side’s scan, filtering probe-side data before it enters the join operator. In the simplest form, the distinct join keys from the build side are collected into an IN-list and injected as a predicate on the probe side’s table scan. SQE implements this for distributed joins: after the build side is scanned and its distinct keys are known, the coordinator pushes the key set to probe-side workers as a scan predicate. Combined with Iceberg’s file-level min/max statistics, this can skip entire data files on the probe side. For selective joins (e.g., a dimension table join where only 100 of 10,000 distinct key values appear), predicate transfer skips 90%+ of probe-side files, dramatically reducing I/O. This is particularly effective for star-schema queries common in analytical workloads.

SQL Support

SQE inherits DataFusion’s broad SQL support and adds Iceberg-specific operations.

Query Language

SELECT & Expressions

-- Full ANSI SQL
SELECT customer_id, SUM(amount) AS total
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY customer_id
HAVING SUM(amount) > 1000
ORDER BY total DESC
LIMIT 10;

-- CTEs
WITH monthly AS (
    SELECT DATE_TRUNC('month', order_date) AS month, SUM(amount) AS total
    FROM orders GROUP BY 1
)
SELECT month, total, LAG(total) OVER (ORDER BY month) AS prev_month
FROM monthly;

-- Subqueries, EXISTS, IN
SELECT * FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders WHERE amount > 500);

Higher-order array functions

SQE parses SQL with the DuckDB dialect, so Trino-style lambda syntax (x -> expr) is accepted. The DuckDB dialect keeps the same identifier quoting as the default parser; it just adds lambda support. Two higher-order array functions are aliased onto DataFusion 54’s built-ins:

SELECT filter(ARRAY[1, 2, 3, 4], x -> x > 2);                  -- [3, 4]        (array_filter)
SELECT transform(ARRAY[1, 2, 3], x -> x * 10);                 -- [10, 20, 30]  (array_transform)
SELECT any_match(ARRAY[1, 2, 3], x -> x > 2);                  -- true          (array_any_match)
SELECT all_match(ARRAY[2, 4, 6], x -> x % 2 = 0);              -- true          (SQE UDF)
SELECT none_match(ARRAY[1, 3, 5], x -> x % 2 = 0);             -- true          (SQE UDF)
SELECT reduce(ARRAY[1, 2, 3, 4], 0, (s, x) -> s + x, s -> s);  -- 10            (SQE UDF)

All six Trino array higher-order functions are covered. See the array and map reference.

Window Functions

SELECT
    employee_id,
    department,
    salary,
    ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank,
    AVG(salary) OVER (PARTITION BY department) AS dept_avg,
    salary - LAG(salary) OVER (ORDER BY hire_date) AS salary_diff
FROM employees;

Supported: ROW_NUMBER, RANK, DENSE_RANK, NTILE, LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE, CUME_DIST, PERCENT_RANK, with PARTITION BY, ORDER BY, and frame clauses (ROWS BETWEEN, RANGE BETWEEN).

Joins

-- All join types
SELECT * FROM a INNER JOIN b ON a.id = b.id;
SELECT * FROM a LEFT JOIN b ON a.id = b.id;
SELECT * FROM a RIGHT JOIN b ON a.id = b.id;
SELECT * FROM a FULL OUTER JOIN b ON a.id = b.id;
SELECT * FROM a CROSS JOIN b;

-- Anti and semi joins (via EXISTS/NOT EXISTS)
SELECT * FROM a WHERE NOT EXISTS (SELECT 1 FROM b WHERE b.id = a.id);

Set Operations

SELECT id FROM a UNION ALL SELECT id FROM b;
SELECT id FROM a INTERSECT SELECT id FROM b;
SELECT id FROM a EXCEPT SELECT id FROM b;

DDL

-- Schemas
CREATE SCHEMA analytics;
DROP SCHEMA staging;

-- Tables (via CTAS)
CREATE TABLE analytics.summary AS
SELECT region, SUM(revenue) AS total FROM sales GROUP BY region;

-- CREATE OR REPLACE
CREATE OR REPLACE TABLE analytics.summary AS
SELECT region, SUM(revenue) AS total FROM sales GROUP BY region;

-- Views
CREATE VIEW active_customers AS
SELECT * FROM customers WHERE status = 'active';

DROP VIEW active_customers;

-- Drop
DROP TABLE analytics.summary;
DROP TABLE IF EXISTS analytics.summary;

DML

-- Insert from query
INSERT INTO target_table
SELECT * FROM source_table WHERE condition;

-- CTAS
CREATE TABLE new_table AS SELECT * FROM existing_table;

-- DELETE (Copy-on-Write by default)
DELETE FROM orders WHERE status = 'cancelled';
DELETE FROM orders WHERE customer_id IN (SELECT id FROM blacklist);

-- DELETE (Merge-on-Read; opt in via table property)
ALTER TABLE orders SET TBLPROPERTIES ('write.delete.mode' = 'merge-on-read');
DELETE FROM orders WHERE status = 'cancelled';  -- writes a position delete file

-- UPDATE (Copy-on-Write)
UPDATE orders SET status = 'shipped' WHERE tracking_id IS NOT NULL;
UPDATE orders SET amount = CASE WHEN amount > 1000 THEN amount * 0.9 ELSE amount END;

-- MERGE INTO (Copy-on-Write)
MERGE INTO target USING source ON target.id = source.id
WHEN MATCHED THEN UPDATE SET value = source.value
WHEN NOT MATCHED THEN INSERT (id, value) VALUES (source.id, source.value);

All row-level write operations (DELETE, UPDATE, MERGE INTO) default to Copy-on-Write via the RisingWave iceberg-rust fork’s rewrite_files() transaction API. Affected data files are read, filtered/transformed, and rewritten as new files in a single atomic commit.

DELETE also supports Merge-on-Read when write.delete.mode = 'merge-on-read' is set on the table. SQE writes a position-delete file (or an equality-delete file when the table declares an identifier-field-id) and commits via FastAppendAction / RowDeltaAction. MoR avoids rewriting whole data files for small deletes against large tables.

Data Types

SQE accepts the standard ANSI SQL type set plus a few Iceberg-specific extensions:

CREATE TABLE events (
    id              BIGINT,
    payload         JSON,                       -- Aliases to Utf8 underneath
    occurred_at     TIMESTAMP(6),
    occurred_time   TIME(6),                    -- Time-of-day, microseconds
    occurred_at_tz  TIMESTAMP(6) WITH TIME ZONE,
    occurred_ns     TIMESTAMP_NS,               -- V3-only: nanosecond precision
    region_id       INTEGER,
    amount          DECIMAL(18, 2)
);
  • JSON columns store as Utf8. CAST(json_col AS BIGINT|VARCHAR|DOUBLE) rides DataFusion’s built-in coercion. JSON-shaped extraction works through json_extract, json_extract_scalar, json_array_length, json_parse, json_get_str, json_get_int, json_get_float, json_get_bool.
  • TIME / TIME(p) maps to Arrow Time64(Microsecond) since Iceberg’s time primitive is microsecond-only across V2 and V3. Precisions 0..=6 collapse to microsecond. TIME(p > 6) rejects with a clear NotImplemented; use TIMESTAMP(9) for sub-microsecond resolution.
  • TIME WITH TIME ZONE rejects at CREATE TABLE: Arrow has no equivalent. Use TIMESTAMP WITH TIME ZONE instead.
  • TIMESTAMP_NS (and TIMESTAMP_NS WITH TIME ZONE) is a V3-only nanosecond timestamp. SQE auto-upgrades the table to format-version 3 when one of these types appears in a CREATE.
  • localtime() returns Time64. EXTRACT(HOUR|MINUTE|SECOND FROM time_col) works through the Trino-aliased hour() / minute() / second() UDFs. year() / month() / day() on a TIME column raise a clear plan error per Trino spec.

Metadata Queries

SHOW CATALOGS;
SHOW SCHEMAS;
SHOW TABLES;
SHOW TABLES IN schema_name;

-- information_schema
SELECT * FROM information_schema.tables;
SELECT * FROM information_schema.schemata;
SELECT * FROM information_schema.columns WHERE table_name = 'orders';

-- Query plan (logical + physical)
EXPLAIN SELECT * FROM orders WHERE amount > 100;

-- With actual execution metrics
EXPLAIN ANALYZE SELECT * FROM orders WHERE amount > 100;

-- With Iceberg file/row estimates (no execution)
EXPLAIN FULL SELECT * FROM orders WHERE amount > 100;

Feature Comparison

CategorySQETrinoSpark SQL
Window functionsFullFullFull
CTEsFullFullFull
Joins (all types)FullFullFull
Set operationsFullFullFull
CTASYesYesYes
INSERT INTO SELECTYesYesYes
MERGE INTOYes (CoW)YesYes
DELETE FROMYes (CoW)YesYes
UPDATEYes (CoW)YesYes
ViewsYesYesYes
Arrow-native wire formatYesNo (JSON)No (Thrift)
Row-level securityYes (plan-rewritten, pluggable, off by default)PluginRanger
Bearer token passthroughYesNoNo

Query Plan Inspection (EXPLAIN)

SQE provides three variants of EXPLAIN for inspecting how queries are planned and executed.

EXPLAIN

Returns the logical and physical query plan without executing the query.

EXPLAIN SELECT * FROM orders WHERE amount > 100;

Output: Two rows, logical_plan and physical_plan, each containing a text representation of the plan tree. The plan shown is the policy-enforced plan: any row filters or column masks applied by the security layer are visible.

EXPLAIN ANALYZE

Executes the query and returns per-operator timing and row counts.

EXPLAIN ANALYZE
SELECT dept_id, COUNT(*), AVG(salary)
FROM employees
GROUP BY dept_id;

Output columns: step, operation, output_rows, elapsed_ms

Rows are ordered leaf-to-root (execution order). output_rows and elapsed_ms are NULL for operators that do not expose DataFusion metrics.

EXPLAIN FULL

Returns the plan enriched with Iceberg table statistics, without executing the query.

EXPLAIN FULL SELECT * FROM large_table WHERE region = 'EU';

Output columns: step, operation, estimated_rows, estimated_bytes, files_scanned, files_total

For IcebergScanExec nodes, statistics come from the Iceberg snapshot summary (fast, no data file reads). estimated_rows reflects the total rows in the snapshot at plan time. files_scanned equals files_total because predicate-pushdown to file level is not yet implemented.

For other operators (Filter, Aggregate, Sort) estimated_rows comes from DataFusion’s cardinality analysis where available; file columns are NULL.

Notes

  • All three variants apply policy enforcement. The plan reflects what will actually execute for the authenticated user.
  • EXPLAIN FULL on non-Iceberg tables (e.g., information_schema) returns NULL for all statistics columns without error.

Custom SQL Extensions

SQE extends standard SQL with statements for security policy management. These are parsed by wrapping sqlparser-rs. We don’t fork the parser.

Status: Shipped. The parser handles the custom ROWS WHERE and MASKED WITH clauses and the policy engine enforces them via plan rewriting. Enforcement is off by default (policy.engine = passthrough, access_control.backend = none), so a default open-source deployment runs without it. See GRANT and REVOKE for the full SQL surface, backends, and known gaps.

Policy Statements

GRANT with Row Filter

GRANT SELECT ON schema.table TO ROLE role_name
  ROWS WHERE condition;

Example:

-- Analysts can only see European data
GRANT SELECT ON sales.orders TO ROLE eu_analyst
  ROWS WHERE region = 'EU';

-- Finance team sees only their cost center
GRANT SELECT ON hr.expenses TO ROLE finance
  ROWS WHERE cost_center = current_user_attr('cost_center');

GRANT with Column Mask

GRANT SELECT ON schema.table TO ROLE role_name
  MASKED WITH (column AS expression);

Example:

-- Support sees masked SSN (last 4 digits only)
GRANT SELECT ON customers TO ROLE support
  MASKED WITH (ssn AS '***-**-' || RIGHT(ssn, 4));

-- Partial email masking
GRANT SELECT ON users TO ROLE viewer
  MASKED WITH (email AS CONCAT(LEFT(email, 2), '***@', SPLIT_PART(email, '@', 2)));

REVOKE

REVOKE SELECT ON schema.table FROM ROLE role_name;

SHOW Statements

-- All grants on a table
SHOW GRANTS ON schema.table;

-- Effective grants for a user (combines all grants, resolves role inheritance)
SHOW EFFECTIVE GRANTS FOR USER "alice";

Parser Strategy

SQE wraps sqlparser-rs rather than forking it:

graph LR
    SQL["SQL Input"] --> SP["sqlparser-rs<br/>(standard parse)"]
    SP --> AST["Standard AST"]
    AST --> PP["Post-Parse Transform"]
    PP -->|Standard SQL| STD["Standard Statement"]
    PP -->|GRANT with ROWS WHERE| PS["PolicyStatement::GrantRowFilter"]
    PP -->|GRANT with MASKED WITH| PS2["PolicyStatement::GrantColumnMask"]
    PP -->|SHOW GRANTS| PS3["PolicyStatement::ShowGrants"]

The post-parse transform detects GRANT/REVOKE statements with the custom extensions and converts them to PolicyStatement AST nodes. Standard GRANT/REVOKE (without extensions) passes through unchanged.

Statement Classification

Every SQL statement is classified for routing, metrics, and audit:

#![allow(unused)]
fn main() {
pub enum StatementKind {
    Query,          // SELECT
    Ctas,           // CREATE TABLE AS SELECT
    Insert,         // INSERT INTO
    Merge,          // MERGE INTO
    Delete,         // DELETE FROM
    Drop,           // DROP TABLE/VIEW
    Rename,         // ALTER TABLE RENAME
    CreateView,     // CREATE VIEW
    DropView,       // DROP VIEW
    CreateSchema,   // CREATE SCHEMA
    DropSchema,     // DROP SCHEMA
    ShowCatalogs,   // SHOW CATALOGS
    ShowSchemas,    // SHOW SCHEMAS
    ShowTables,     // SHOW TABLES
    Policy,         // GRANT, REVOKE
    Utility,        // EXPLAIN, SET, etc.
}
}

Each kind maps to a stable lowercase label ("query", "ctas", "insert") used in Prometheus metrics and audit logs.

Iceberg Integration

SQE is built on the iceberg-rust library (vendored fork) and speaks the Iceberg REST Catalog protocol natively. Iceberg is the only table format SQE supports.

Iceberg version

  • iceberg-rust: SQE-rebased fork of risingwavelabs/iceberg-rust dev_rebase_main_20260303 at commit c034b19105fa, vendored at vendor/iceberg-rust/. Provides RewriteFilesAction / OverwriteFilesAction (Copy-on-Write DELETE/UPDATE), PositionDeleteFileWriter (Merge-on-Read position deletes), and DeletionVectorWriter (Iceberg V3) on top of upstream v0.9.0.
  • DataFusion: 54.0
  • Arrow: 58
  • Parquet: 58
  • Iceberg table format: V2 and V3. V3 features verified end-to-end (TIMESTAMP_NS, column defaults, equality-delete UPDATE on identifier-fields, partition evolution).

The matrix score against icebergmatrix.org is 167/189 (88.4%), placing SQE fifth on the public scoreboard behind only Spark distributions (EMR, AWS Glue, OSS Spark, Dataproc). See getsqe.com/compare/iceberg for the per-cell breakdown and the side-by-side V2/V3 comparison against every other engine on the public scoreboard.

Architecture

graph TB
    subgraph SQE
        SC["SessionCatalog<br/>per-user token"]
        TP["IcebergTableProvider<br/>DataFusion TableProvider"]
        WR["Writer<br/>Parquet output"]
    end

    SC -->|REST API + bearer or SigV4| CAT["Catalog backend<br/>Polaris, Nessie, Glue REST,<br/>S3 Tables, Unity, HMS, JDBC, Hadoop"]
    CAT -->|table metadata| SC
    CAT -->|S3 credentials<br/>credential vending| SC

    TP -->|read Parquet| OS["Object storage<br/>AWS S3, GCS, ADLS Gen2, R2,<br/>Ceph, MinIO, rustfs"]
    WR -->|write Parquet| OS

    SC -->|commit| CAT

Supported catalogs

SQE keeps the catalog choice as a runtime configuration concern. Every catalog below ships compiled in by default; pick one in [catalog.backend]. See Catalog backends for the full per-backend recipe with TOML examples and verification queries. For where the data files live (S3, R2, GCS, ADLS Gen2, HTTPS, hf://), see Storage backends.

CatalogtypeTransportAuthStatus
Apache PolarisrestIceberg RESTOIDC bearer + credential vendingprimary
Project Nessie 0.107+restIceberg RESTbearer / anonymouslive verified
AWS Glue (Iceberg REST)restIceberg REST + AWS SigV4AWS provider chainlive verified
AWS S3 Tables (REST)restIceberg REST + AWS SigV4 (s3tables signing)AWS provider chainlive verified
Unity Catalog OSSrestIceberg RESTbearer (Databricks) / anonymous (OSS)live verified, read-only on OSS
AWS Glue (native SDK)glueaws-sdk-glueAWS provider chainlive verified eu-example-1
AWS S3 Tables (native SDK)s3tablesaws-sdk-s3tablesAWS provider chainlive verified eu-example-2
Hive MetastorehmsThriftnone / Kerberoslive verified
JDBC (Postgres / MySQL / SQLite)jdbciceberg-catalog-sqlDB credentialslive verified (Postgres)
Hadoop / storage-onlyhadoopobject_store path scannonelive verified, read-only

There are two ways into AWS-managed Iceberg. The REST path (type = "rest" with the Glue REST or S3 Tables REST endpoint as catalog_url) speaks the Iceberg REST protocol that AWS exposes on top of these services. The native SDK path (type = "glue" or type = "s3tables") goes directly through aws-sdk-glue or aws-sdk-s3tables without REST. Both work; the SDK path is currently more maintained upstream and avoids the SigV4 signing patches.

The five non-Hadoop backends share one dispatch path through the upstream iceberg-catalog-loader crate. SQE’s for_session_other_backend translates the typed CatalogBackend enum into a uniform (catalog_type, name, props) tuple and hands it to iceberg_catalog_loader::load(type). The loader picks the right CatalogBuilder (REST / Glue / S3 Tables / HMS / SQL) and returns an Arc<dyn iceberg::Catalog>. Hadoop is the lone outlier because it is filesystem-only; its dispatch stays in sqe-catalog/src/backends/hadoop.rs.

Two SQE-only patches sit on top of the vendored loader. The first feature-gates each backend so a slim build does not transitively pull every backend’s AWS SDK / Thrift / sqlx weight. The second adds Send + Sync to BoxedCatalogBuilder so the boxed builder can cross await points in async contexts. Both are documented in vendor/iceberg-rust/README.md and forward-compatible with upstream.

Live integration tests for HMS, Nessie, JDBC Postgres, AWS Glue, AWS S3 Tables, and Unity OSS live in sqe-catalog/tests/backends_integration.rs. Each is #[ignore] and runs against a docker-compose overlay or a real cloud account configured via .env. Glue and S3 Tables are also live-verified (2026-05-05) against AWS account 123456789012 in eu-example-1 and eu-example-2.

The AWS REST endpoints share the OSS Iceberg REST code path. Phase P added an aws-sigv4 cargo feature to the vendored iceberg-catalog-rest crate that swaps the OAuth/Bearer authenticator for an AWS SigV4 signer when rest.sigv4-enabled=true lands in the catalog properties (or in the server’s /v1/config defaults). The signer reads credentials from the standard AWS provider chain.

Catalog REST surface

For Polaris, Nessie, Unity OSS, AWS Glue, and AWS S3 Tables, SQE talks to the catalog via the Iceberg REST API. Key interactions:

OperationREST endpointSQE use
List namespacesGET /v1/{prefix}/namespacesSHOW SCHEMAS
List tablesGET /v1/{prefix}/namespaces/{ns}/tablesSHOW TABLES
Load tableGET /v1/{prefix}/namespaces/{ns}/tables/{t}Query planning
Create tablePOST /v1/{prefix}/namespaces/{ns}/tablesCREATE TABLE
Drop tableDELETE /v1/{prefix}/namespaces/{ns}/tables/{t}DROP TABLE
Create namespacePOST /v1/{prefix}/namespacesCREATE SCHEMA
Drop namespaceDELETE /v1/{prefix}/namespaces/{ns}DROP SCHEMA
Commit tablePOST /v1/{prefix}/namespaces/{ns}/tables/{t}After write
Server configGET /v1/config?warehouse=...Discovery / signing hints

Every request includes the user’s bearer token (Polaris, Unity, Nessie, anything OIDC) or is signed with AWS SigV4 (Glue, S3 Tables). The catalog enforces access control.

Credential vending

When SQE loads a table, the catalog returns the table metadata and scoped storage credentials for accessing the data files:

sequenceDiagram
    participant SQE
    participant Catalog
    participant Storage as S3 / Object store

    SQE->>Catalog: Load table (user token / SigV4)
    Catalog-->>SQE: Table metadata<br/>+ scoped storage credentials<br/>(STS / table-scoped key)
    Note over SQE: Credentials are per-user,<br/>per-table, time-limited
    SQE->>Storage: Read Parquet (scoped credentials)
    Storage-->>SQE: Data (allowed prefix only)

This means:

  • No service account with broad storage access.
  • Each user’s storage access is scoped to exactly the tables they are querying.
  • Credentials are short-lived (STS or equivalent).

Read path

graph LR
    PLAN["LogicalPlan<br/>TableScan"] --> META["Load table metadata<br/>from catalog"]
    META --> PRUNE["Partition pruning<br/>(manifest filtering)"]
    PRUNE --> FILES["Data file list"]
    FILES --> READ["Read Parquet<br/>(columnar, predicate pushdown,<br/>row-group skipping, RowFilter)"]
    READ --> BATCH["Arrow RecordBatches"]

Read-side optimizations:

  • Partition pruning: Iceberg manifest stats skip whole partitions that cannot match the query predicate.
  • Column projection: only requested columns leave Parquet.
  • Predicate pushdown: filters land at the row group level, the page-index level, and the Parquet RowFilter.
  • Runtime filter pushdown: Phase P shipped a DynamicPredicate API that absorbs DataFusion 54 hash-join build-side runtime filters into the same pruning surface. SF10 TPC-H lineitem-heavy queries saw q06 -51%, q07 -31%, q14 -33%. Engineering log at Runtime filter pushdown.
  • Bloom filter consultation: write.parquet.bloom-filter-columns lands bloom offsets in the file footer; DataFusion consults them automatically for literal equality predicates at scan time.
  • 5-layer caching: REST catalog cache, table metadata cache, manifest cache, SessionContext cache, OAuth token cache. Warm queries hit sub-millisecond planning.

Write path

graph LR
    SQL["CTAS / INSERT / DELETE / UPDATE / MERGE"] --> EXEC["Execute SELECT"]
    EXEC --> BATCH["RecordBatches"]
    BATCH --> WRITE["Write Parquet<br/>(WriterProperties from table props)"]
    WRITE --> COMMIT["Commit to Iceberg<br/>(append / row-delta / rewrite)"]

Supported DML, both V2 and V3 verified:

  • CREATE TABLE AS SELECT: Apache Iceberg V2 and V3, including TIMESTAMP_NS columns and DEFAULT literals.
  • INSERT INTO: streaming, with proper schema validation against the catalog.
  • DELETE FROM: Copy-on-Write via RewriteFilesAction, or Merge-on-Read via PositionDeleteFileWriter when write.delete.mode=merge-on-read.
  • UPDATE: CoW or MoR (equality deletes when the table declares an identifier-field-id).
  • MERGE INTO: full WHEN MATCHED / WHEN NOT MATCHED semantics, dispatching to CoW or MoR based on the table’s write.merge.mode (default copy-on-write).
  • ALTER TABLE: ADD/DROP/RENAME COLUMN, SET/DROP NOT NULL, type promotion, ADD/DROP/REPLACE PARTITION FIELD (partition evolution), CREATE/DROP BRANCH/TAG, SET WRITE BRANCH.

DELETE reads write.delete.mode, UPDATE reads write.update.mode, and MERGE reads write.merge.mode. Each defaults to copy-on-write. CTAS and INSERT stream RecordBatches straight to Parquet at constant memory. Partitioned writes use an unbounded writer by default. Set fanout_max_open_writers or fanout_buffer_budget to opt into a bounded fanout writer that caps the number of open per-partition writers and flushes the least-recently-written one when a limit is hit.

The writer respects write.parquet.bloom-filter-columns and write.parquet.bloom-filter-fpp for any column the schema knows about. The footer-inspection test in sqe-catalog/src/parquet_writer_config.rs proves bloom offsets land in the resulting Parquet file.

Maintenance procedures

SQE runs table maintenance through CALL system.* procedures, each committing through the vendored iceberg-rust transaction actions:

  • rewrite_data_files: bin-packs small files into larger ones, with target_file_size_bytes, min_input_files, and max_concurrent_file_group_rewrites options.
  • expire_snapshots: drops snapshots older than a cutoff, honouring older_than and retain_last.
  • remove_orphan_files: deletes data files no snapshot references.
  • rewrite_manifests: compacts manifest files.
  • register_table and drop_table (without purge): catalog-pointer operations for migration, disaster recovery, and the golden-dataset workflow.

Snapshot-pointer procedures (set_current_snapshot, rollback_to_snapshot) and drop_table(purge => true) are not yet implemented and return an explicit error.

V3 features verified

  • TIMESTAMP_NS / TIMESTAMPTZ_NS: V3 nanosecond timestamps round-trip end-to-end.
  • Column defaults: CREATE TABLE ... DEFAULT <literal> applies write_default; ALTER TABLE ADD COLUMN ... DEFAULT applies initial_default.
  • Position deletes (V3): MoR DELETE on a V3 table writes position-delete files.
  • Equality deletes (V3): UPDATE with a declared identifier-field-id commits a single RowDelta with new data file plus equality-delete row.
  • Partition evolution (V3): ALTER TABLE ADD/DROP/REPLACE PARTITION FIELD evolves the spec on V3 tables, including with day(ts) on TIMESTAMP_NS columns.
  • Time travel (V3): FOR SYSTEM_TIME AS OF and FOR VERSION AS OF work against V3 tables through the same snapshot walk as V2.
  • Schema evolution (V3): ADD COLUMN, DROP COLUMN, RENAME COLUMN, SET DATA TYPE all work on V3.

V3 features still blocked upstream:

  • Variant: pending iceberg-rust #2188.
  • Geometry: pending DataFusion UDT #12644.
  • Vector / Embedding: V3 spec not finalised.

The deferred list is tracked in docs/evidence/iceberg-matrix-state.json under caveats for each cell.

Write Path

SQE supports writing data to Iceberg tables through SQL. Writes go through the coordinator, which executes the SELECT portion, writes Parquet files to S3, and commits to the Iceberg catalog.

Supported Operations

CREATE TABLE AS SELECT (CTAS)

CREATE TABLE analytics.monthly_sales AS
SELECT
    DATE_TRUNC('month', order_date) AS month,
    region,
    SUM(amount) AS total
FROM raw.orders
GROUP BY 1, 2;

Flow:

  1. Parse SQL, extract target table name and SELECT query
  2. Execute SELECT to get Arrow RecordBatches
  3. Convert Arrow schema to Iceberg schema
  4. Create table in the Iceberg catalog
  5. Stream RecordBatches to Parquet files at constant memory
  6. Commit data files to Iceberg via AppendAction

Add PARTITIONED BY (year(ts), bucket(16, id), ...) to partition on write. The standard Iceberg transforms (year, month, day, hour, bucket, truncate, identity) are parsed and applied. Partitioned writes use an unbounded writer by default; set fanout_max_open_writers or fanout_buffer_budget to opt into a bounded fanout writer that caps open per-partition writers and flushes the least-recently-written one when a limit is hit.

CREATE OR REPLACE TABLE

CREATE OR REPLACE TABLE analytics.monthly_sales AS
SELECT ... ;

Drops the existing table (if it exists) and creates a new one. Useful for dbt table materializations.

INSERT INTO

INSERT INTO analytics.monthly_sales
SELECT
    DATE_TRUNC('month', order_date) AS month,
    region,
    SUM(amount) AS total
FROM raw.orders
WHERE order_date >= '2024-06-01'
GROUP BY 1, 2;

Flow:

  1. Parse SQL, extract target table and SELECT query
  2. Execute SELECT to get Arrow RecordBatches
  3. Stream RecordBatches to Parquet files at constant memory
  4. Commit data files to Iceberg via AppendAction (new snapshot)

Write Architecture

sequenceDiagram
    participant QH as Query Handler
    participant DF as DataFusion
    participant WH as Write Handler
    participant S3
    participant POL as Polaris

    QH->>DF: Execute SELECT query
    DF-->>QH: RecordBatches

    QH->>WH: handle_ctas(table_name, schema, batches)
    WH->>POL: Create table (schema)
    POL-->>WH: Table created

    WH->>S3: Write Parquet data files
    S3-->>WH: Written (paths + sizes)

    WH->>POL: Commit AppendAction<br/>(data file list → new snapshot)
    POL-->>WH: Committed
    WH-->>QH: Success

Row-Level Operations

DELETE, UPDATE, and MERGE each choose Copy-on-Write or Merge-on-Read per operation, read from a table property: write.delete.mode, write.update.mode, and write.merge.mode. Each defaults to copy-on-write.

Copy-on-Write reads the affected data files, filters or transforms the rows, and rewrites them as new files in a single atomic commit through RewriteFilesAction. Merge-on-Read leaves the data files in place and writes delete files instead: position deletes for DELETE, equality deletes for UPDATE (when the table declares an identifier-field-id), and a RowDeltaAction for MERGE. Copy-on-Write keeps reads fast; Merge-on-Read keeps writes cheap.

DELETE FROM

DELETE FROM sales.orders WHERE status = 'cancelled';

-- Cross-table subqueries in WHERE
DELETE FROM sales.orders
WHERE customer_id IN (SELECT id FROM blacklist);

-- DELETE without WHERE = truncate
DELETE FROM sales.orders;

Flow:

  1. Scan table metadata to identify affected data files
  2. Read each affected file, apply the WHERE filter
  3. If all rows match: mark file for removal
  4. If partial match: rewrite file without matching rows
  5. Commit via RewriteFilesAction (remove old files, add rewritten files)

Under write.delete.mode=merge-on-read, DELETE instead writes position-delete files and commits a RowDeltaAction, leaving the data files untouched.

UPDATE

UPDATE sales.orders SET status = 'shipped' WHERE tracking_id IS NOT NULL;

-- CASE WHEN transformations
UPDATE sales.orders SET amount = CASE
    WHEN amount > 1000 THEN amount * 0.9
    ELSE amount
END;

Flow:

  1. Scan table metadata to identify affected data files
  2. Read each affected file, apply the WHERE filter
  3. For matching rows: apply SET expressions
  4. Rewrite file with modified rows
  5. Commit via RewriteFilesAction

Under write.update.mode=merge-on-read, UPDATE writes a new data file plus an equality-delete file in one RowDeltaAction, provided the table declares an identifier-field-id.

MERGE INTO

MERGE INTO target USING source ON target.id = source.id
WHEN MATCHED THEN UPDATE SET value = source.value
WHEN NOT MATCHED THEN INSERT (id, value) VALUES (source.id, source.value);

Flow:

  1. Execute a full outer join of source and target via DataFusion
  2. Classify each result row: matched (UPDATE/DELETE) or not matched (INSERT)
  3. Rewrite affected target data files with modifications applied
  4. Add new data files for INSERT rows
  5. Commit via RewriteFilesAction (remove old files, add new + rewritten files)

Under write.merge.mode=merge-on-read, MERGE routes matched and unmatched rows through a RowDeltaAction (position/equality deletes plus new data files) instead of rewriting the target files.

Iceberg Dependency

Row-level writes depend on the SQE-rebased fork of risingwavelabs/iceberg-rust, vendored at vendor/iceberg-rust/. It provides RewriteFilesAction / OverwriteFilesAction for Copy-on-Write, PositionDeleteFileWriter and EqualityDeleteFileWriter with RowDeltaAction for Merge-on-Read, and DeletionVectorWriter for Iceberg V3, none of which are available upstream yet. SQE also carries its own vendor patches (feature-gated catalog backends, Send + Sync catalog builders, ADD COLUMN scan projection) and owns write-path components outside the vendor tree, such as the bounded fanout writer. See vendor/iceberg-rust/README.md for the patch inventory.

Memory Safety

Large writes used to be a way to run the coordinator out of memory. The write path now bounds its own memory in three ways.

The straight-line paths stream at constant memory. CTAS and INSERT write Parquet in a streaming loop rather than collecting the full result set first. Flight DoPut ingest streams the upload instead of buffering it. Row group by row group, the peak stays flat regardless of how many rows the SELECT produces.

Some row-level paths still have to buffer. Copy-on-Write MERGE reads the target rows, and UPDATE, DELETE, and Merge-on-Read decode each affected file before rewriting it. Those buffers register against the DataFusion memory pool (Layer A). When a buffer would exceed the pool, the write fails with a typed ResourceExhausted error instead of OOM-killing the process. The query dies, the coordinator lives, and the caller gets a clear reason.

Partitioned writes can open one Parquet writer per partition, and a high-cardinality partition key multiplies that into memory pressure. The bounded fanout writer caps the number of concurrently open per-partition writers. When a batch arrives for a new partition and the map is full, the least-recently-written writer closes and flushes first, then the new one opens. Cutover trades bounded memory for small-file debt, which CALL system.rewrite_data_files repairs. The bounded writer is opt-in: set fanout_max_open_writers or fanout_buffer_budget (see Configuration) to turn it on. Left unset, partitioned writes use the unbounded writer.

Two knobs stay off by default until validated against a live catalog. merge_target_streaming streams a Copy-on-Write MERGE target from the pinned data files through the merge join as governed operator memory, instead of buffering the whole target. write_buffer_tracking is a diagnostic escape hatch: it defaults on, and setting it false disables the Layer A reservations (never the streaming paths) if a deployment ever hits an accounting false positive.

Maintenance

Table maintenance runs through CALL system.* procedures: rewrite_data_files (bin-packs small files), expire_snapshots, remove_orphan_files, rewrite_manifests, plus register_table and drop_table. See Iceberg Integration for the full surface.

dbt Compatibility

The write path is designed to support dbt Core via a native dbt-sqe adapter:

dbt MaterializationSQLStatus
tableCREATE OR REPLACE TABLE AS SELECTSupported
incremental (append)INSERT INTO SELECTSupported
incremental (merge)MERGE INTOSupported (CoW or MoR)
viewCREATE VIEW AS SELECTSupported
seedINSERT INTO (from CSV)Supported

Autonomous Compaction

Iceberg tables accumulate small files under normal write traffic: frequent appends, streaming ingest, and Merge-on-Read DELETE/UPDATE/MERGE statements that leave position and equality delete files behind. Left alone, small-file counts and delete debt both grow, and every scan pays for it: more files to open, more delete files to apply, worse pruning.

SQE compacts tables two ways. Run it on demand with CALL system.rewrite_data_files(...), the same procedure Trino and Spark expose under their own optimize/rewrite_data_files syntax. Or opt a table into the autonomous path: a background scheduler that watches opted-in tables, reports their compaction debt, and, once you trust it, compacts them on a cron schedule with no human in the loop.

This page is the orienting map. The full sizing knobs live in Configuration, the procedure arguments live in CALL procedures, and the distributed dispatch mechanics live in Distributed compaction.

What it does

A compaction rewrite reads a group of small data files, applies any Merge-on-Read deletes that cover them so deleted rows never reappear, and writes new, larger Parquet files in their place. The whole operation is one Iceberg commit: old files out, new files in, atomically. A strategy argument picks the layout, and one more argument targets delete-heavy files regardless of strategy:

  • strategy => 'binpack' (default). Groups small files by partition and rolls them up to target_file_size_bytes (512 MiB by default). Cheapest option, no reordering.
  • strategy => 'sort' with sort_order. Gathers a whole partition into one stream, sorts it by the given column list through a spillable DataFusion sort, and rolls the output at the target size. Sorting the partition as a single stream is what makes the result prunable: output files land with disjoint key ranges instead of each file spanning the full domain, so predicate pushdown skips more files at scan time.
  • sort_order => 'zorder(col_a, col_b)'. Z-order clustering for queries that filter on different subsets of several columns at once. Iceberg’s sort-order metadata cannot express z-order, so SQE stamps none for this case, matching Spark’s behavior.
  • delete_file_threshold => N. Rewrites any data file with at least N delete files applying to it, even if the file is already at or above the target size. The threshold is the lever for delete-heavy Merge-on-Read tables, where the file itself is fine but every scan pays to apply a stack of deletes against it.
  • rewrite_all => true. Forces a rewrite of every data file, including files already at or above the target size and partitions below min_input_files. It applies all deletes and re-encodes at the target size. Use it to force a clean pass after a schema or partition-spec change, or to apply accumulated deletes across a whole table in one commit. Off by default; subsumed by strategy => 'sort', which already rewrites the whole partition.

CALL system.table_health('ns', 't') is the read-only companion: it reuses the same file-collection and bin-pack logic as a rewrite, but never writes anything, so a SELECT-only session can check compaction debt before deciding whether to run one. It reports live and small file counts, delete and delete-heavy file counts, eligible bin-pack groups, and estimated rewrite bytes. See CALL procedures for the full argument list and column reference for both procedures.

Autonomy: advisory before active

The [maintenance] config block gates the background path with a mode ladder, and every step up that ladder is a decision an operator makes explicitly, never a default:

  • mode = "off" (the default). No maintenance principal is built, no scheduler task runs. Total absence, not a loop that declines to fire.
  • mode = "advisory". The scheduler discovers opted-in tables and publishes the same health report CALL system.table_health returns. Nothing is rewritten.
  • mode = "active". The scheduler commits real rewrites against tables that are both due and opted in, through the same handler CALL system.rewrite_data_files uses interactively.

Run advisory mode first. Let it report real compaction debt against your actual write pattern before opting a table into active mode. Active mode mutates data files and commits snapshots; treat the switch from advisory to active as a reviewed, per-table decision, not a rollout.

A table only ever gets touched by the active scheduler when three things line up: the global mode is advisory or active, the table owner has set sqe.maintenance.enabled = true via ALTER TABLE ... SET TBLPROPERTIES, and the maintenance principal holds a TABLE_WRITE_DATA grant on that table’s namespace in Polaris. Miss any one of the three and nothing happens to that table. Full detail on the gates, the cron schedule, and per-table overrides for the sizing knobs is in Configuration.

A dedicated, isolated principal

The background scheduler never runs as you, and it never runs as the service account behind interactive queries either. [maintenance.principal] configures a separate OAuth2 client-credentials identity, used solely by the maintenance path. It is not added to the interactive auth chain, so there is no code path by which a query session could authenticate as it, even by accident.

Give this principal the least privilege it needs: TABLE_READ_DATA for advisory analysis, TABLE_WRITE_DATA added only for tables opted into active mode, never CREATE or DROP or admin grants. Polaris enforces that boundary server-side, on top of SQE’s own opt-in and mode gates.

Distribution: coordinator-local or the worker fleet

[maintenance.distribution] mode decides whether an active-mode rewrite runs on the coordinator alone or fans its file groups out across the worker fleet:

  • auto (default). Coordinator-local below min_workers healthy workers, fans out once the fleet reaches that floor.
  • local. Always coordinator-local, even with a healthy fleet.
  • require. Always fans out. Below min_workers it does not fall back quietly: a scheduled tick skips the job loudly, and a manual CALL ... distributed => 'require' errors outright.

In the distributed path, workers read data files, apply deletes, sort if requested, and write new Parquet directly against S3, using their own S3 credentials and no catalog token at all. Commit authority never leaves the coordinator: it validates every worker’s output, re-checks the row-count invariant across the whole job, and commits one atomic snapshot that swaps every old file for every new file at once. By default a job either commits everything or nothing. Opting into [maintenance.distribution] partial_progress trades that guarantee for incremental batch commits on very large tables, at the cost of a larger commit-conflict surface. See Configuration for the knob, and Distributed compaction for the full planning, dispatch, and commit flow, including the partial-progress commit model.

High availability

Running the scheduler on more than one coordinator needs a way to stop two of them compacting the same table in the same window. [maintenance.scheduler] lease picks the guard:

  • none. No lease. Only safe for a single-coordinator deployment, and validation requires an explicit single_scheduler_acknowledged = true before it will accept this setting with the scheduler enabled.
  • catalog (default). Before dispatching a rewrite, the scheduler claims a lease row in the state table. A coordinator that finds the lease already held skips its tick for that table; a crashed holder’s claim expires after lease_ttl_secs.

The lease is an efficiency mechanism, not the source of correctness. Correctness comes from Iceberg’s optimistic-concurrency commit: if a lease operation fails, or two coordinators somehow race to compact the same table, exactly one of them wins the commit and the other re-plans against the new snapshot and finds nothing left to do. The lease only saves the loser the cost of a redundant scan and rewrite. The alternative HA shape, running with the in-process scheduler disabled everywhere and driving timing from an external Kubernetes CronJob with concurrencyPolicy: Forbid, needs no lease at all, because there is only ever one caller in flight.

Audited and reversible

Every advisory-mode analysis and every active-mode commit emits an AuditKind::Maintenance audit event. Job history, per-table last-run state, and lease rows live in sqe_system.maintenance_log, a normal SQL table an operator creates once and queries like any other: filter it by status, table, or time range to see what the scheduler has actually done.

A compaction commit is an ordinary Iceberg snapshot. The files it superseded stay in place until expire_snapshots removes them, which means an autonomous compaction is reversible within the snapshot-retention window. Read the table’s snapshot history, find the snapshot before the compaction landed, and run:

CALL system.rollback_to_snapshot(table => 'analytics.events', snapshot_id => 8472810294831234567);

That single call points the table back at the prior snapshot and undoes the compaction, as long as that snapshot has not aged out.

Getting started: advisory to active

  1. Check debt, no config changes required. CALL system.table_health(table => 'ns.t') works regardless of maintenance.mode, on any session with SELECT on the table. Use it to see whether a table is worth compacting at all.
  2. Turn on advisory mode. Set [maintenance] mode = "advisory", add a [maintenance.principal] block, and set scheduler.enabled = true. mode = "active" comes later, in step 4. Let advisory mode run against your real tables long enough to see debt accumulate and validate the cron schedule.
  3. Opt one table in. ALTER TABLE ns.t SET TBLPROPERTIES ('sqe.maintenance.enabled' = 'true'), and grant the maintenance principal TABLE_WRITE_DATA on that table’s namespace in Polaris.
  4. Flip the global mode to active. Only opted-in tables with the write grant are ever touched; every other table keeps behaving exactly as it did under advisory.
  5. Watch sqe_system.maintenance_log. Confirm jobs land as success, not skipped or failed, before opting in more tables.

Run manual CALL system.rewrite_data_files(...) calls at any point in this progression: the manual path and the autonomous path are the same handler, and neither depends on the other being configured.

Access control tutorial

A working walkthrough of both halves of SQE’s access control, in the order you would actually build them: first decide who may open a table at all, then decide which rows and columns they see inside it.

Every statement here runs against quickstart/polaris-ranger-keycloak. The same ground is covered as an executable transcript by scripts/access-control-demo.sh (32 steps, exits non-zero on any mismatch) and asserted on decoded Arrow values by make test-access-control (23 cases). If something in this page disagrees with those, they are right and this is stale.

The two gates

        GRANT / REVOKE / DENY                 Ranger query service
                 |                          (row filters, masks, tags)
                 v                                    |
  ranger "polaris" service                            v
                 |                            SQE plan rewriter
                 v                                    |
  Polaris embedded authorizer  --> table --> DataFusion --> rows
       "may you open it"                  "what do you see"

Gate one is Polaris. GRANT and REVOKE in SQE become policies on the Ranger polaris service, and Polaris’s embedded Ranger authorizer enforces them when SQE asks to load a table. This answers may this user open this object at all. SQE does no filtering here. A denial arrives as “table not found”, because Polaris hides rather than forbids.

Gate two is SQE. Row filters, column masks and column restriction are applied by rewriting the logical plan before DataFusion optimizes it, from policies on a Ranger query service. This answers which rows and columns may this user see.

They are independent, they use different Ranger services, and a query must pass both. Revoking the coarse SELECT denies the query at Polaris before any mask is ever computed, which is why the order in this tutorial matters: a mask you cannot observe is a mask you cannot debug.

Gate oneGate two
Enforced byPolarisSQE
Ranger servicepolarisquery
Authored withGRANT / REVOKE / DENY in SQLCREATE OR REPLACE POLICY / DROP POLICY in SQL (or Ranger UI/REST)
Granularitycatalog, namespace, table, viewrow, column, tag
Denial looks liketable not foundfewer rows, or masked values

Part 1: the Polaris gate

1.1 One grant, three policies

Start with the thing that trips up every first attempt, because the mechanism explains most of what follows. A table grant is not one policy. Reaching sales_wh.acdemo.orders needs three things to succeed, and only one of them is about the table:

  • LIST_NAMESPACES, authorized at the catalog level with namespace-list. Polaris does not use Ranger’s SELF_OR_DESCENDANTS matching, so a namespace-scoped namespace-list will not do: listing is denied outright, not filtered.
  • a per-namespace visibility probe (LOAD_NAMESPACE_METADATA) needing namespace-level namespace-properties-read. A 403 hides the namespace, deliberately, so ungranted namespace names do not leak.
  • the table’s own access types, at the table level.

Either of the first two failing gives an empty schema list, and planning stops at “table not found” without ever attempting LOAD_TABLE. The table exists and Polaris would serve it; SQE never asks. Nothing in the log shows a 403, because there is no denial to report.

You write one statement. SQE writes all three policies:

GRANT SELECT ON sales_wh.acdemo.orders TO ROLE "analyst";

That is the same plan grant-profile.json v4 specifies, which is what the data-platform control plane generates its policies from. Matching it is deliberate: both write to the same Ranger service, and a SQL grant that produced different policies from the equivalent API call would make “who granted this” unanswerable.

Know what the catalog level costs. Its holder can enumerate every namespace NAME in the catalog, including namespaces unrelated to the table you granted. A namespace called pii_customer_health becomes visible even though not one of its rows is. That is a real widening and it happens on every table grant, so treat SHOW SCHEMAS as a leak surface and keep namespace names free of anything you would not put in a ticket title. If a catalog holds namespaces whose very existence is sensitive, separate catalogs are the boundary that works.

MANAGE and ALL are the exception. They bind at the catalog level already and carry catalog-content-manage, so there is nothing above them to add and the plan is a single policy.

Revoke touches the deepest level only, on purpose. REVOKE SELECT on the table removes the table policy and leaves the catalog and namespace policies alone. Those are shared with every other grant anyone holds in that catalog, so walking the whole plan backwards would strip discovery out from under unrelated grants: an outage dressed up as a narrow revoke. The cost is that traversal policies accumulate and nobody cleans them up. That is the right trade. An orphaned namespace-list is discovery on a catalog the grantee could already reach; over-revoking is an outage.

To take discovery back, do it explicitly and in this order:

REVOKE USAGE ON DATABASE sales_wh        FROM ROLE "analyst";
REVOKE USAGE ON SCHEMA   sales_wh.acdemo FROM ROLE "analyst";

Revoking discovery first is the tidier order, and it also avoids a known defect. A principal left holding catalog discovery while every namespace under it is invisible gets an empty schema list, and on a current-thread tokio runtime SQE’s catalog provider blocks in its sync-to-async bridge instead of reporting “table not found”. A deployed coordinator runs a multi-thread runtime and denies normally, so this is not something a served query hits; it shows up in tests and would affect an embedded single-threaded host. Recorded in docs/internal/research/2026-08-02-catalog-traversal-gate.md.

1.2 A grant is what enables a read

With the traversal in place, the grant is observable. Before:

-- as alice
SELECT id FROM sales_wh.acdemo.orders;
-- table 'sales_wh.acdemo.orders' not found

Grant it, wait for the Polaris plugin to poll (5 to 30 seconds; it is not instant), and the same statement returns rows:

-- as carol, an admin
GRANT SELECT ON sales_wh.acdemo.orders TO ROLE "analyst";

-- as alice, a member of analyst
SELECT id, region FROM sales_wh.acdemo.orders ORDER BY id;
--  id | region
-- ----+--------
--   1 | EU
--   2 | US
--   3 | EU

REVOKE puts it back:

REVOKE SELECT ON sales_wh.acdemo.orders FROM ROLE "analyst";

Revoking one privilege does not disturb another. Ranger permits a single policy per resource, so every grant on a table shares one item and their access types union. INSERT requires everything SELECT does, which means a literal REVOKE INSERT would strip the read access too. SQE labels each grant (chm:<GRANTEE_TYPE>:<name>:<PRIVILEGE>) and holds back the access types another labelled privilege still needs, so narrowing a user from read-write to read-only does what it says.

The chm prefix is not SQE’s own. The data-platform control plane writes to the same Ranger service and reads the same labels, so both tools have to agree on the format or each is blind to the other’s grants and cascades over them. A group grantee is labelled ROLE, because a Keycloak group is materialised as a Ranger role of the identical name.

Two properties worth internalising:

A role grant reaches only role members. dave is in no role, so the grant above does nothing for him. Role membership lives in Ranger, not in the token: Polaris ignores the token’s realm roles because they lack the PRINCIPAL_ROLE: prefix it expects.

Read does not imply write. SELECT and INSERT are separate privileges and map to disjoint access-type sets. An analyst holding SELECT gets a denial on INSERT until GRANT INSERT is issued too.

1.3 Privileges and the level each binds to

SQL privilegeRanger access typesLevel
SELECTtable-data-read, table-properties-read, table-listtable
INSERT / UPDATE / DELETE / MODIFYtable-data-write plus the full snapshot, schema, sort-order, partition-spec and properties commit settable
DROPtable-droptable
CREATE TABLEtable-createnamespace
USAGEnamespace-list, namespace-properties-readnamespace
DROP SCHEMAnamespace-dropnamespace
CREATE SCHEMAnamespace-createcatalog
ALL PRIVILEGEScatalog-content-managecatalog

Two things follow from that right-hand column.

One privilege expands to many access types. The Polaris embedded authorizer does not honour service-def implied-grants, so SQE lists every access type the operation will check. INSERT is 22 of them, because committing an Iceberg snapshot fans out into many fine-grained Polaris operations.

The level is not advisory. Naming an object deeper than a privilege’s level used to silently widen the grant. GRANT ALL ON sales_wh.acdemo.orders dropped the namespace and table and wrote catalog-content-manage on sales_wh: one table named, success reported, the whole catalog conferred. SQE now refuses it and names the scope that would have been written:

Privilege 'ALL PRIVILEGES' binds to the catalog level, but the statement names
a namespace or table. The policy would apply to 'sales_wh' and everything under
it, which is wider than the object named. Re-issue the statement against
'sales_wh', or name a privilege that binds to the object you meant.

USAGE on a table and CREATE SCHEMA on a namespace widen through the same path and are refused the same way.

1.4 Wildcards: all and future

GRANT SELECT ON ALL TABLES IN SCHEMA sales_wh.acdemo TO ROLE "analyst";
GRANT SELECT ON FUTURE TABLES IN SCHEMA sales_wh.acdemo TO ROLE "analyst";

Both write the same policy, with table = "*", so both cover existing and future tables. Ranger has no future-only resource. Snowflake distinguishes the two; SQE cannot, and treats ON FUTURE as a superset rather than rejecting it. Use a table-specific grant when you mean one existing table.

Do not confuse either with GRANT ... ON SCHEMA, which stays a namespace resource and does not reach the tables inside it. Namespace USAGE is namespace-list plus namespace-properties-read and deliberately carries no table-data-read.

1.5 Views

A view has no resource level of its own. Its NAME goes in the table slot and the access types are the view-* set:

CREATE OR REPLACE VIEW sales_wh.acdemo.orders_eu AS
  SELECT id, region FROM sales_wh.acdemo.orders WHERE region = 'EU';

GRANT SELECT ON VIEW sales_wh.acdemo.orders_eu TO ROLE "analyst";

SHOW GRANTS ON sales_wh.acdemo.orders_eu;
-- view-properties-read | sales_wh.acdemo.orders_eu | ROLE | analyst | ALLOW
-- view-list            | sales_wh.acdemo.orders_eu | ROLE | analyst | ALLOW

Note what is absent: no table-data-read.

A view is not a privilege boundary. SQE expands the view and plans against its base tables, so the reader needs a grant on orders as well. This is the opposite of a Snowflake secure view, where the view owner’s privileges stand in for the reader’s. Never use a view to hand out indirect access to a table.

What a view does give you is masking and filtering that cannot be dodged, which is Part 2.

1.6 DENY

DENY SELECT ON sales_wh.acdemo.orders TO USER dave;

Deny beats allow in Ranger, so this overrides any grant dave holds directly or through a role. It is idempotent (re-issuing updates the same policy rather than stacking), reversible with REVOKE, and audited as a privilege change.

One caveat, deliberate: DENY goes through Ranger’s policy API, which authorizes the authenticated REST user rather than a named grantor. Unlike GRANT it is therefore not resource-scoped to the caller, and the admin_roles config gate is the only check. Ranger offers no grantor-scoped deny, so DENY stays admin-only even under the ranger-delegate setting below.

1.7 Who may grant

By default a session needs a role from [auth] admin_roles before it may issue GRANT or REVOKE at all, and then Ranger checks delegateAdmin on the resource. Both have to pass, which means WITH GRANT OPTION on its own does nothing for a user without an engine-wide admin role.

To let table owners manage their own tables, hand the decision to Ranger:

[access_control]
grant_authority = "ranger-delegate"

Then this works, as dave, holding no admin role:

-- as an admin, once: dave owns the table, bob can see the catalog and namespace
GRANT SELECT ON sales_wh.acdemo.orders TO USER dave WITH GRANT OPTION;
GRANT USAGE ON DATABASE sales_wh TO USER bob;
GRANT USAGE ON SCHEMA sales_wh.acdemo TO USER bob;

-- as dave, no admin role:
GRANT SELECT ON sales_wh.acdemo.orders TO USER bob;

The two USAGE statements are not decoration. A table grant writes three policies (catalog, namespace, table) because reaching a table needs discovery above it, and Ranger’s delegateAdmin does not cascade upward: dave owns the table and is refused 403 on the catalog. SQE skips a traversal level the grantee already holds, so once bob has discovery, dave’s grant only has to write the level he owns. A grantee with no discovery yet gets an error naming the level and these statements.

Before switching, read the Ranger policies. ranger-delegate widens who may grant to everyone holding delegateAdmin, and the quickstart’s wildcard discovery policy gives roles analyst and engineer exactly that for the discovery access types.

1.8 Introspection

SHOW GRANTS ON sales_wh.acdemo.orders;

Reads the policies back out of Ranger, one row per (access type, grantee).

CHECK ACCESS SELECT ON sales_wh.acdemo.orders FOR USER "alice";
--  allowed | reason
-- ---------+---------------------------
--  true    | Allowed via ROLE 'analyst'

CHECK ACCESS resolves the target user’s Ranger roles, including nested roles, and applies deny-overrides-allow. It is best-effort introspection, not the enforcement path: it does not account for tag policies, conditions, or wildcard resource matching beyond exact match and bare *. Polaris remains authoritative.

It does not resolve groups, because Ranger only learns a user’s groups when usersync runs. A grant reachable only through a group will not show up here.


Part 2: the SQE data gate

Everything in Part 2 is enforced by SQE, from a Ranger query service, and is invisible to gate one. A user must already hold SELECT for any of it to be observable.

Policies here are authored with SQE’s Databricks-inspired CREATE POLICY SQL. SQE translates the statement to Ranger, which remains the shared source of truth for SQE and Spark/Kyuubi. Ranger’s console and REST API remain available for external administration.

Resolved policies are cached. A mask tightened in the console is not honoured until the cached entry expires, up to [policy.ranger] cache-ttl-secs. Grants issued through SQE flush the cache on commit, so only console-authored changes have that window.

2.1 Column masks

A column mask scoped to a table and column:

CREATE OR REPLACE POLICY acdemo_mask_ssn
ON TABLE sales_wh.acdemo.orders
COLUMN MASK MASK_SHOW_LAST_4 TO ROLE engineer ON COLUMN ssn;

The database value is the namespace, not the catalog. bob (an engineer) then sees the masked value while alice (analyst only) sees the raw one, from the same statement against the same table. That contrast is the point: a mask is per-principal, not per-column.

The full Ranger built-in vocabulary is implemented:

dataMaskType111-11-1111 becomesNotes
MASK_NULLNULLtyped NULL, row count unchanged
MASK_SHOW_LAST_4xxx-xx-1111
MASK_SHOW_FIRST_4111-xx-xxxx
MASKnnn-nn-nnnnX / x / n per character class, punctuation kept. EU becomes XX
MASK_HASH64 hex charsHMAC-SHA256 keyed by policy.mask_key. Set the key: without it SQE warns and hashes unkeyed, which is brute-forceable on low-entropy columns like SSN
MASK_DATE_SHOW_YEAR2021-05-04 becomes 2021-01-01dates only
CUSTOMwhatever you writearbitrary SQL with {col} as the placeholder
MASK_NONEunchangedexplicit exemption, depends on policy evaluation order

Masks also block predicate pushdown on the raw value. WHERE ssn = '111-11-1111' evaluates against the masked value, never the underlying one, so a mask cannot be peeled off with a filter.

2.2 Row filters

The row-filter expression is ordinary SQL:

CREATE OR REPLACE POLICY acdemo_rowfilter_eu
ON TABLE sales_wh.acdemo.orders
ROW FILTER TO ROLE engineer USING (region = 'EU');

The filter is injected above the TableScan, before optimization, so the user’s own predicates can be pushed through it but not around it. Multiple applicable filters AND together.

Session functions are const-folded per session, which is how one policy serves many principals:

region = current_user() OR is_role_in_session('auditor')

current_user(), current_role() and is_role_in_session() are available.

One caveat. A row filter referencing a column the view does not project fails the query when read through that view:

Plan rewrite failed: Internal error: Failed to create policy filter:
Schema error: No field named region.

Fail-closed, so nothing leaks, but the message names neither the policy nor the view. The same filter with the same narrow projection in a direct query works. Until this is fixed, a row filter and a narrow view over the same table are mutually exclusive.

2.3 Column restriction

A mask SQE cannot build is not returned raw. The column is nullified in place and stays in the schema, so SELECT that_column still plans rather than erroring on an unknown field. This is the fail-closed path, and it is what you get from a CUSTOM mask with no expression, or a mask type carrying another component’s prefix such as trino:MASK_NULL.

2.4 Tags

Tags let one rule protect a column wherever it appears, instead of one policy per table. There are two halves, and they live in different places.

Association: which columns carry which tag. In SQE, on the Iceberg table property sqe.column-tags, written with SQL:

ALTER TABLE sales_wh.acdemo.orders SET TAGS (ssn = ('PII'), region = ('GEO'));
SHOW TAGS ON sales_wh.acdemo.orders;
ALTER TABLE sales_wh.acdemo.orders UNSET TAGS (region);

SET TAGS merges rather than replaces, so a previous tag on another column survives. Flushing the policy cache is part of the statement.

The rule: what the tag means. SQL writes a policy to Ranger’s linked tag service (SQE discovers the service and component-qualified vocabulary):

CREATE OR REPLACE POLICY acdemo_tag_pii
ON TAG PII
COLUMN MASK MASK_SHOW_LAST_4 TO ROLE engineer;

Two details that will cost you an afternoon each:

Mask types must be component-qualified. hive:MASK_SHOW_LAST_4, never the bare name. The tag service definition does not define bare names.

Tag row filters need a Ranger Admin property. Tag masks work out of the box. Tag row filters need this in ranger-admin-site.xml:

<property>
  <name>ranger.servicedef.autopropagate.rowfilterdef.to.tag</name>
  <value>true</value>
</property>

Ranger copies each component’s dataMaskDef into the tag service definition unconditionally, but copies its rowFilterDef only when that property is true, and it defaults to false. No Ranger upgrade changes this. Without it the POST is rejected with “tag policy can specify values for one of the following resource sets: does not have any resource hierarchies”, which names resource hierarchies rather than the missing capability.

A tag is not a protection

This is the one thing people get backwards.

SituationResult
Column tagged, no rule anywherecolumn returned raw
Column tagged, rule SQE cannot mapcolumn restricted
Tag state unknown (Ranger unreachable)all rows denied

A tag with no policy is not a protection, so there is nothing to fail closed about. A tag whose policy names a mask SQE cannot build IS a protection SQE cannot honour, so the column is restricted. Tagging a column does not protect it. The rule in Ranger is what protects it.

Spark parity

Masks are shared with Spark through the same query service. Associations are not: Spark reads tag associations from the Ranger or Atlas tag store, while SQE reads them from Iceberg table properties. One mask rule, two association sources.

One enforcement detail does not carry over, and it is pinned by scripts/access-control-parity-demo.sh: Kyuubi places its masking projection below its row-filter marker, so a row filter that reads a tag-masked column compares the mask value and matches nothing, where SQE matches on the stored value. No data leaks either way. Mask precedence used to be a second difference and is not any more, because policy.mask-precedence now defaults to tag.

2.5 Precedence

  1. Restriction beats mask. A column SQE cannot safely return is nullified, whatever the mask says.
  2. A tag mask beats a resource mask, by default. This matches the Ranger plugin order Spark/Kyuubi uses, so the same policy set renders the same value in both engines. Set policy.mask-precedence = "resource" for the most-specific-rule-wins reading instead.
  3. Row filters AND together.
  4. Row filters read stored values. A filter is evaluated before masks are applied, so masking a filtered column does not change which rows survive.
  5. Deny beats allow, on gate one.

2.6 What happens when things break

ConditionResult
Ranger unreachableall rows denied; enforcement resumes on recovery
Tag state unknownall rows denied. Unknown is not “untagged”
Unmappable mask typecolumn restricted, never returned raw
Unparseable row filterbecomes lit(false), all rows denied
Table not mappable to a policy keyall rows denied
Tag carrying no rulecolumn returned raw (see above)
Policy cache not yet expiredfail-stale, up to cache-ttl-secs

Everything fails closed except the cache, which is deliberately fail-stale and bounded by its TTL.


Putting both gates together

An analyst who may read European orders, without ever seeing an SSN:

-- Gate one: may they open it
GRANT USAGE  ON DATABASE sales_wh        TO ROLE "analyst";
GRANT USAGE  ON SCHEMA   sales_wh.acdemo TO ROLE "analyst";
GRANT SELECT ON sales_wh.acdemo.orders   TO ROLE "analyst";

-- Gate two: what they see (author on the query service)
--   policyType 2, filterExpr "region = 'EU'",       roles ["analyst"]
--   policyType 1, column ssn, MASK_SHOW_LAST_4,     roles ["analyst"]

-- Verify gate one
CHECK ACCESS SELECT ON sales_wh.acdemo.orders FOR USER "alice";
-- true | Allowed via ROLE 'analyst'

-- Verify gate two by reading as alice
SELECT id, region, ssn FROM sales_wh.acdemo.orders ORDER BY id;
--  id | region | ssn
-- ----+--------+-------------
--   1 | EU     | xxx-xx-1111
--   3 | EU     | xxx-xx-3333

Two rows, not three, and no raw SSN. If you see three rows the filter has not landed yet; if you see the raw SSN the mask has not. Check the TTL before changing anything.

Where to go next

Fine-grained access control

SQE enforces row filters and column masks by rewriting the query’s logical plan before DataFusion optimizes it. Filters and masks are injected above the table scan, so the optimizer cannot push a user predicate through a mask to probe raw values. The model follows PostgreSQL row-level security: denied rows are invisible, masked columns return transformed values, and there is no information leakage.

The headline is where the policy comes from. SQE reads a Ranger service of servicedef type hive, named query in the quickstarts, the same instance Apache Spark reads through its Kyuubi authorization plugin. One policy, written once in the Ranger console, enforces byte-identically in SQE and in Spark: an SSN masked to xxx-xx-1111 reads the same no matter which engine ran the query. See Spark / Ranger Parity for the validated cross-engine result and its edges.

Enforcement is off by default

The default [policy] engine = "passthrough" returns plans unmodified. Turn enforcement on by selecting an engine:

  • ranger reads row-filter and column-mask policies from that Ranger instance and feeds the plan rewriter. This is the production path, and the one shared with Spark/Kyuubi.
  • in-memory keeps grants in a hash map, for development and tests.
  • opa and cedar are defined in config but not yet wired (selecting them errors today).

Configure the Ranger backend

[policy]
engine = "ranger"
mask-precedence = "tag"        # which mask wins on a column covered by both (default: "tag")

[policy.ranger]
url = "http://ranger-admin:6080"
service-name = "query"         # the Ranger instance to read; shared with Spark/Kyuubi (default: "hive")
admin-user = "admin"
# Set the password via SQE_POLICY__RANGER__ADMIN_PASSWORD, not in the file.
admin-password = ""
cache-ttl-secs = 30            # resolved-policy cache TTL

mask-precedence settles the one case where a column is covered twice, by a resource policy naming it and by a tag policy matching its classification. tag is the default and matches the plugin order Hive and Spark/Kyuubi implement, so a rule authored once in Ranger renders the same value whichever engine reads it. resource restores the narrower most-specific-rule-wins reading. The column is masked either way; only which mask applies changes.

[policy.ranger] is distinct from [access_control.ranger]. The [policy] block points at the frontend-query service for SQE-side fine-grained enforcement (row filters and masks that SQE applies). The [access_control] block points at the polaris service for the coarse GRANT-to-catalog path where Polaris enforces. They can target the same Ranger Admin host and read different services. See GRANT and REVOKE for the two-axis model.

Column masks

SQE realizes the full Ranger hive-servicedef built-in mask set. Each Ranger dataMaskType maps to a mask SQE applies in the rewritten plan:

Ranger dataMaskTypeEffect
MASK_NULLReplace the value with a typed NULL.
MASK_HASHHMAC-SHA256 hex digest (plain SHA-256 when no mask key is set).
MASKFull redact: uppercase to X, lowercase to x, digit to n; punctuation kept.
MASK_SHOW_LAST_4Show the last 4 characters, mask the rest. 111-11-1111 becomes xxx-xx-1111.
MASK_SHOW_FIRST_4Show the first 4 characters, mask the rest.
MASK_DATE_SHOW_YEARTruncate a date to its year.
CUSTOMAn arbitrary SQL expression with {col} as the column placeholder.
MASK_NONEExplicit exemption. Place it first in Ranger to carve an exception.

Character counting is by Unicode scalar, matching Hive, which is what makes the output byte-identical to Spark. Anything SQE cannot map, including a CUSTOM expression that fails to parse, restricts the column instead of leaking it. Masking is fail-closed.

Row filters

A Ranger row-filter policy attaches a boolean SQL expression to a table for a user or role. SQE parses it and injects it as a filter above the scan, so a user sees only the rows the expression admits. Row-filter expressions can reference session context.

Role-conditional masking

Row filters and CUSTOM masks can call session-context functions: current_user(), current_role(), and is_role_in_session(). SQE const-folds them per session before the plan is distributed, so a fragment running on a worker carries the resolved value rather than re-evaluating identity. That is how a single policy masks a column for an analyst but shows it to an auditor, the way Snowflake conditional masking does.

Tag-based masking

A mask can apply to every column carrying a tag rather than to a named column. The mask-per-tag rule lives in Ranger as a tag-service policy (returned in the Ranger download bundle’s tagPolicies block, shared with Spark). The tag-to-column association is authored in SQL and stored in the Iceberg sqe.column-tags table property:

ALTER TABLE sales.orders SET TAGS (ssn = ('PII'), amount = ('FINANCIAL'));
ALTER TABLE sales.orders UNSET TAGS (amount);
SHOW TAGS ON sales.orders;

The Snowflake form works too: ALTER TABLE ... MODIFY COLUMN ssn SET TAG PII = 'true'. SET TAGS merges, changing only the columns you name. Storing the association as a table property means it travels with the data through clone, rename, and replicate, and covers federated catalogs Polaris cannot gate. Tag parity with Spark stops at the association: Spark reads it from the Ranger or Atlas tag store, so full tag parity would need an Iceberg-to-Ranger tag sync, which is optional and not built.

Author the mask type under the hive: prefix (hive:MASK_SHOW_LAST_4). Ranger’s tag service definition never defines bare mask names: it aggregates the mask types of every component it can decorate, so the entries are hive:MASK_SHOW_LAST_4, trino:MASK_NULL, and so on. SQE accepts the bare and hive: forms and deliberately leaves another component’s prefix unmatched, which restricts the tagged column rather than applying another engine’s policy.

Tag row filters need one Ranger Admin property

A tag-service policy can carry a row filter as well as a mask, so every table with a column tagged PII is filtered by one rule. Ranger ships this switched off, and it is not a version limitation:

<property>
  <name>ranger.servicedef.autopropagate.rowfilterdef.to.tag</name>
  <value>true</value>
</property>

Ranger copies each component’s dataMaskDef into the tag service definition unconditionally, but copies its rowFilterDef only when that property is set in ranger-admin-site.xml. Without it, tag masks work and tag row filters cannot be authored at all: the POST is rejected with

tag policy can specify values for one of the following resource sets:
 does not have any resource hierarchies

which names resources rather than the missing capability, so it reads like a malformed policy. See Ranger tag storage for the source-level detail.

What happens when policy lookup fails

Every failure mode denies rather than falling back to unfiltered data:

ConditionBehaviour
Ranger unreachableDeny all rows. The circuit breaker opens, and enforcement resumes on its own once Ranger returns.
Tag state unknown (the table’s metadata is not in cache)Deny all rows. Unknown is not the same as untagged: a mask might exist that SQE cannot see.
Mask type SQE cannot map, including a CUSTOM expression that fails to parseRestrict the column. It is nullified in place, not dropped, so the query still plans.

Each of these is pinned by a test in crates/sqe-coordinator/tests/it/access_control_e2e.rs against a live Ranger, including one that stops the ranger-admin container mid-suite.

Admin-side edits are honored on a delay

cache-ttl-secs bounds an over-permissive window. SQE caches the resolved policy per user and table, so a mask or row filter authored directly in the Ranger console is not applied until that entry expires, up to cache-ttl-secs later. A query that ran before the edit keeps its old decision for the rest of the TTL.

Grants issued through SQE (GRANT, REVOKE) are not affected, since those flush the cache. Lower the TTL if prompt propagation of console-authored edits matters more than fetch load against Ranger Admin. The tag path re-reads the column-to-tag map on every call and has no such window.

Walkthrough

For a worked example that sets up both gates in order, with the SQL and the output at each step, see the access control tutorial. It covers the Polaris catalog gate and this fine-grained path separately, then together.

The in-engine SQL surface

Independent of Ranger, SQE parses a native grant surface (GRANT ... ROWS WHERE, GRANT ... MASKED WITH, SHOW EFFECTIVE GRANTS, CHECK ACCESS) that the in-memory engine enforces. See Security & Policy and GRANT and REVOKE.

How it fits the trust model

Fine-grained enforcement is one layer. Catalog metadata and the write path are gated per user through the caller’s bearer token; the read data path uses the engine’s storage credentials. See Security and trust model for the full boundary map, and Fine-grained Enforcement for the rewrite internals and the precedence contract.

Access control: what is supported, and what is proven

Two independent gates run on every query.

Polaris gates the catalog. GRANT and REVOKE in SQE become Apache Ranger policies on the polaris service, and Polaris’s embedded Ranger authorizer enforces them. This answers “may this user load this object at all”. SQE does no filtering on this axis.

SQE gates the data. Row filters, column masks, and column restriction are applied by rewriting the logical plan before DataFusion optimizes it, from policies read out of a Ranger query service. This answers “which rows and columns may this user see”.

A query must pass both. Revoking the coarse SELECT denies it at Polaris before any mask is computed.

Everything marked Proven below has an executable assertion in crates/sqe-coordinator/tests/it/access_control_e2e.rs, running against a live Polaris, Ranger 2.8 and Keycloak, asserting decoded Arrow values. Run it with make test-access-control. scripts/access-control-demo.sh walks the same ground as a readable SQL transcript.

Catalog gate (Polaris)

The polaris service-def resource hierarchy is root -> catalog -> {namespace -> table, principal, policy}.

LevelSQLSupportedProven
TableGRANT SELECT ON cat.ns.tbl TO USER uYesYes
Table, via roleGRANT SELECT ON cat.ns.tbl TO ROLE rYesYes
Table writeGRANT INSERT ON cat.ns.tbl TO ROLE rYesYes
Table dropGRANT DROP ON cat.ns.tbl TO ROLE rYesYes
NamespaceGRANT USAGE ON SCHEMA cat.ns TO ROLE rYesNo
Namespace createGRANT CREATE TABLE ON SCHEMA cat.nsYesNo
CatalogGRANT CREATE SCHEMA ON catYesNo
All tables in schemaGRANT SELECT ON ALL TABLES IN SCHEMA cat.nsYes, as a table wildcardYes, including a table created after the grant
Future tables in schemaGRANT SELECT ON FUTURE TABLES IN SCHEMA cat.nsYes, same policy as ALLUnit-tested (shape); the ALL case proves the behaviour
Deny precedenceRanger deny item overrides an allowYesYes
RevokeREVOKE SELECT ON cat.ns.tbl FROM ROLE rYesYes
IntrospectionSHOW GRANTS, CHECK ACCESSYesYes
ViewGRANT SELECT ON VIEW cat.ns.v TO ROLE rYesYes
All / future views in schemaGRANT SELECT ON ALL VIEWS IN SCHEMA cat.nsYes, as a wildcardYes
DenyDENY SELECT ON cat.ns.tbl TO USER uYesYes
Group granteeGRANT SELECT ON cat.ns.tbl TO GROUP gYes, as a Ranger role of the same nameYes, for enforcement

One grant, three policies: the traversal is load-bearing

GRANT SELECT ON cat.ns.tbl TO USER alice writes THREE Ranger policies, not one, and the reason is on SQE’s side rather than Polaris’s.

Polaris will serve the table: a direct LOAD_TABLE with only the table-level grant returns 200. But SQE resolves a table through its catalog provider, which answers only for a namespace present in its cached namespace list, and building that list takes two calls that must both succeed:

  1. LIST_NAMESPACES, authorized at the catalog level. A namespace-scoped namespace-list does not satisfy it, because Polaris does not use Ranger’s SELF_OR_DESCENDANTS matching. Listing is denied outright, never filtered.
  2. A per-namespace visibility probe (LOAD_NAMESPACE_METADATA), needing namespace-level namespace-properties-read. On 403 the namespace is hidden, deliberately, so ungranted namespace names do not leak.

Either failure yields an empty schema list, and planning ends at table 'cat.ns.tbl' not found with LOAD_TABLE never attempted.

So one statement produces a three-level plan, written outermost first:

LevelAccess typeWhy
catalognamespace-listLIST_NAMESPACES is catalog-scoped and unfiltered
namespacenamespace-properties-readthe per-namespace visibility probe
tablethe privilege’s own setthe data

The shape matches grant-profile.json v5, which the data-platform control plane generates from. That is the point: both write to the same Ranger service, and a SQL grant producing different policies from the equivalent API call makes “who granted this” unanswerable. Pinned by a_table_grant_writes_v4s_three_level_plan and, live, by one_table_grant_writes_the_namespace_it_needs.

MANAGE and ALL bind at the catalog level already and carry catalog-content-manage, so their plan is a single policy.

The catalog level is a real widening, accepted rather than hidden. Any grantee who holds it can enumerate every namespace name in the catalog, so a name like pii_customer_health is visible even though its rows are not, and this now happens on every table grant. Separate catalogs are the boundary if namespace names are themselves sensitive. Verified on Polaris 1.7 with a clean database; recorded in docs/internal/research/2026-08-02-catalog-traversal-gate.md.

REVOKE touches the deepest level only. The catalog and namespace policies are shared with every other grant in that catalog, so walking the plan backwards would strip discovery from unrelated grants. Traversal policies therefore accumulate and are not cleaned up, which is the correct trade: an orphaned namespace-list is discovery on a catalog the grantee could already reach, whereas over-revoking is an outage. Clear it explicitly with REVOKE USAGE ON DATABASE before REVOKE USAGE ON SCHEMA (see the hang in the gap table below).

A grant must be scoped at the privilege’s own level

Each privilege binds to exactly one resource level, shown in the mapping table in Ranger access control. Naming an object deeper than that level is refused rather than widened:

GRANT ALL PRIVILEGES ON wh.sales.orders TO USER alice;

ALL binds to the catalog, so this used to drop the namespace and table and write catalog-content-manage on wh. One table was named, success was reported, and alice got every table in the catalog. SQE now errors and names the scope that would have been written. USAGE on a table and CREATE SCHEMA on a namespace widen through the same path and are refused the same way.

Views work, and are not a privilege boundary

A view has no resource level of its own. Its NAME goes in the table slot and the access types are the view-* set, which is what GRANT ... ON VIEW writes. Verified live: the resulting policy carries view-properties-read and view-list on the view coordinate and no table-data-read.

A view is not a security boundary. SQE expands the view and plans against its base tables, so the reader needs a grant there too. That is the opposite of a Snowflake secure view, where the view’s owner privileges stand in for the reader’s. Do not use a view to grant indirect access to a table.

What a view DOES give you is masking and filtering that cannot be dodged.

Column masks survive a view. There is no bypass. A view that projects a masked column returns the MASKED value, because the view expands to a TableScan of the base table and the plan rewriter runs on that scan. Verified with a user who is both an admin (so the view loads) and a member of the masked role: xxx-xx-1111 reading the base table, xxx-xx-1111 reading the view. Creating a view over a protected table is not a way around masking.

Row filters break on views when the filter references an unprojected column. This is a real defect, and it is view-specific. A row filter on region against a view declared as SELECT id, ssn FROM orders fails the whole query:

Plan rewrite failed: Internal error: Failed to create policy filter:
Schema error: No field named region.
Valid fields are ...orders.id, ...orders.ssn

The same filter on a DIRECT query with the same narrow projection (SELECT id, ssn FROM orders) works and returns the filtered rows, so this is not the general case: SQE injects the filter below the user projection and it resolves fine. Only the view path fails.

Behaviour is fail-closed (a hard error, no rows, nothing leaked) but the message is a DataFusion internal error that names neither the policy nor the view. This is the same class as Kyuubi’s #6889, which the quickstart bootstrap already cites as the reason no row-filter policy is seeded for the Spark cross-compare. Until it is fixed, a row filter and a narrow view over the same table are mutually exclusive.

Data gate (SQE plan rewriting)

Column masks

The full Ranger hive built-in vocabulary is implemented.

Ranger dataMaskTypeResultProven
MASK_NULLtyped NULL, row count unchangedYes
MASK_SHOW_LAST_4111-11-1111 becomes xxx-xx-1111Yes
MASK_SHOW_FIRST_4111-11-1111 becomes 111-xx-xxxxYes
MASKX / x / n per char class, punctuation kept. EU becomes XXYes
MASK_HASHHMAC-SHA256 hex, keyed by policy.mask_keyYes, against an out-of-band digest
MASK_DATE_SHOW_YEAR2021-05-04 becomes 2021-01-01Yes
CUSTOMarbitrary SQL with {col}Yes
MASK_NONEexplicit exemptionUnit-tested. It depends on Ranger policy EVALUATION ORDER, which is a property of the policy set rather than one policy, so an e2e case needs explicit priorities

The hash case is asserted against a digest computed outside the engine (openssl dgst -sha256 -hmac), so the implementation is not checking itself. A plain SHA-256 of the same input is a different value, which is what proves the mask key reached the UDF.

Row filters, restriction, tags

CapabilityResultProven
Resource row filteronly admitted rows returned; other users unaffectedYes
Column restrictioncolumn nullified in place, stays in the schema so SELECT col still plansYes
Tag column maskmask applies to every column carrying the tag, association from the Iceberg sqe.column-tags propertyYes
Tag row filterone rule filters every table holding a tagged columnYes, with the Ranger property below
Precedencerestriction beats mask; tag mask beats resource mask by default (policy.mask-precedence, set resource to invert); row filters AND togetherBoth precedence modes proven live and unit-tested; the rest unit-tested
Role-conditional maskingcurrent_user(), current_role(), is_role_in_session() const-folded per sessionUnit-tested
Masks block predicate pushdownWHERE ssn = '...' evaluates the masked value, never the raw oneUnit-tested

Tag row filters need one Ranger Admin property

Tag masks work out of the box. Tag row filters need

<property>
  <name>ranger.servicedef.autopropagate.rowfilterdef.to.tag</name>
  <value>true</value>
</property>

in ranger-admin-site.xml. Ranger copies each component’s dataMaskDef into the tag service definition unconditionally but copies its rowFilterDef only when that property is true, and it defaults to false. No Ranger upgrade changes this. Without it the policy POST is rejected with “tag policy can specify values for one of the following resource sets: does not have any resource hierarchies”, which names resource hierarchies rather than the missing capability.

Also author tag mask types component-qualified (hive:MASK_SHOW_LAST_4). The tag service definition never defines bare names.

Failure behaviour

ConditionResultProven
Ranger unreachabledeny all rows; enforcement resumes after recoveryYes, by stopping the container mid-test
Tag state unknowndeny all rows. Unknown is not “untagged”Yes
Unmappable mask type, resource or tagcolumn restricted, never returned rawYes
Tag carrying NO ruleinert: the column is returned rawYes

The last two rows are easy to conflate and they behave differently, so it is worth being explicit. A tag with no policy anywhere is not a protection, so there is nothing to fail closed about and the column reads normally. A tag whose policy names a mask SQE cannot build (a CUSTOM with no expression, or another component’s prefix such as trino:MASK_NULL) IS a protection SQE cannot honour, so the column is restricted. Tagging a column does not protect it by itself; the rule in Ranger is what protects it. | Unparseable row filter | becomes lit(false), deny all | Unit-tested | | Table not mappable to a policy key | deny all rows | Unit-tested |

One default is deliberately not fail-closed. The resolved-policy cache is fail-stale: a mask tightened in the Ranger console is not honored until the cached entry expires, up to [policy.ranger] cache-ttl-secs. Grants issued through SQE do not have this window, because GRANT, REVOKE and SET TAGS flush the cache on commit. The window is asserted at both edges by cache_ttl_bounds_policy_staleness.

Cross-engine parity (SQE and Spark)

Tag associations need one extra thing beyond a shared policy. They are authored into the Iceberg property sqe.column-tags, which only SQE reads, so with project-tags = true SQE ALSO writes the association into Ranger’s tag store where Kyuubi looks. If that write fails the Iceberg property is rolled back and the statement fails, because keeping it would mask the column in SQE while Spark returned it raw, and the statement would have reported success.

Tag mask types on the tag service must be component-qualified (hive:CUSTOM, not CUSTOM): Ranger’s tag servicedef aggregates each component’s mask vocabulary rather than defining bare names.

One policy in the shared frontend service, two engines, output compared directly. Per-engine checks are not enough: they pass while the engines disagree, which is the failure that matters. Both engines are pointed at the same service, and the suite is make test-access-control-spark.

PropertyProven
A portable CUSTOM column mask renders byte-identicallyYes, column_mask_is_byte_identical_across_engines
A role outside the masked role sees the raw value in bothYes, an_unmasked_role_is_unmasked_in_both_engines
A row filter selects the same rows in bothYes, row_filter_returns_identical_rows_across_engines
A named mask type does NOT render identicallyYes, asserted as a divergence
Tag-based masksYes with project-tags = true, tag_column_mask_is_byte_identical_across_engines
A failed projection does not leave a half-applied tagYes, a_failed_projection_rolls_back_the_tag

Object-level parity is covered separately by spark_access_control_e2e: grants written through SQE’s GRANT statement, asserted through Spark, for read and write.

Known gaps

GapDetail
Scope must match the privilegeA privilege binds to one resource level. Naming an object deeper than that level is refused rather than widened: GRANT ALL ON wh.sales.orders errors instead of writing a catalog-wide policy. Re-issue it at the level the error names. Pinned by all_privileges_on_a_table_is_refused_rather_than_widened_to_the_catalog.
Revoke narrows, it does not cascadeRanger allows one policy per resource, so grants share an item and WRITE_ACCESS contains all of READ_ACCESS. REVOKE INSERT used to strip the grantee’s independent SELECT too. SQE now labels each grant (chm:<TYPE>:<name>:<PRIVILEGE>) and holds back access types another labelled privilege still needs. The chm prefix is shared with the data-platform control plane deliberately: both write to the same Ranger service and both read these labels, so a private prefix would leave each blind to the other’s grants and cascading over them. A label naming a privilege SQE does not map is dropped and logged rather than trusted, because an under-revoke is worse than the cascade. Grants written before labels existed fall back to the old behaviour, logged. Pinned by revoking_write_leaves_an_independent_read_grant_intact.
Catalog discovery with nothing visible stalls a current-thread runtimeA principal who can list a catalog’s namespaces while every per-namespace probe 403s takes the slow path instead of getting “table not found”: contains_namespace bridges to async through runtime_bridge::block_on_compat, and on a current-thread runtime that bridge blocks the calling runtime while it waits. A deployed coordinator runs a multi-thread runtime and denies normally; this affects tests (#[tokio::test] defaults to current-thread) and any single-threaded embedding. It no longer hangs: the bridge waits on an OS-level deadline (60s) and returns an error naming the cause, because a tokio timer cannot fire on a runtime whose thread is blocked. What remains unfixable there is the underlying stall, since a resource registered with the parked runtime’s IO driver cannot make progress from anywhere else. Background in docs/internal/research/2026-08-02-catalog-traversal-gate.md.
Traversal policies accumulateREVOKE releases the deepest level only, because the catalog and namespace policies a grant writes are shared with every other grant in that catalog. Orphaned namespace-list / namespace-properties-read are left behind and nothing cleans them up. Deliberate: over-revoking would strip discovery from unrelated grants. Clear them with REVOKE USAGE ON DATABASE then REVOKE USAGE ON SCHEMA, in that order.
Narrowing a privilege does not narrow past grantsRanger’s grant endpoint MERGES access types into the policy for a resource, and REVOKE removes only the types it names. So when SQE narrows what a privilege confers (as adopting grant-profile v4 narrowed INSERT), policies written by the earlier version keep the wider set, and a REVOKE from the new version cannot clear the residue. New grants get the narrower set; existing ones need a one-off cleanup.
Delegate admin does not cascade upwardA table grant writes catalog, namespace and table policies, and Ranger authorizes each against the grantor. Measured on 2.8: a grantor holding delegateAdmin on cat.ns.tbl gets 200 there and 403 on both cat.ns and cat, for grant and revoke alike, and 403 for an access type outside their delegate set. SQE skips a traversal level the grantee already holds at that exact resource (Ranger merges, so it is a no-op write), which is what makes WITH GRANT OPTION usable. A grantee with no discovery yet still needs an admin to seed it, and the error names the level and the statements. Pinned by a_delegated_owner_grants_on_their_own_table_without_an_admin_role.
WITH GRANT OPTION needs grant_authorityIt maps to delegateAdmin, but the default [access_control] grant_authority = "admin-role" also requires an [auth] admin_roles role, so a table owner without one cannot use it. Set grant_authority = "ranger-delegate" to make Ranger’s per-resource check the only one. Read the Ranger policies first: it widens grant authority to everyone holding delegateAdmin, and a wildcard catalog = * policy written with delegateAdmin: true covers its roles service-wide. DENY ignores the setting and stays admin-only. Pinned by a_non_admin_cannot_grant_under_the_default_gate and deny_still_requires_an_admin_role_under_ranger_delegate.
Views are not a boundaryGRANT ... ON VIEW works, but SQE expands the view and plans against its base tables, so the reader needs a grant there too. Not a Snowflake secure view.
Group grantees are Ranger rolesGRANT ... TO GROUP g writes to the Ranger ROLES field, not groups. The control plane materialises every Keycloak group as a Ranger role of the identical name, so a group grant and the same-named role grant are the same write. SQE does NOT auto-create the role: a typo would otherwise become an empty role and a grant conferring nothing, so an unknown grantee is refused by Ranger instead.
Row filters work through narrow viewsA filter on a column the view does not project is enforced: the scan’s projection is widened internally, the filter applied, then the original output columns restored, so the extra column never reaches the result. It previously failed the query with a DataFusion No field named error, making a row filter and a narrow view over one table mutually exclusive. Pinned by row_filter_on_an_unprojected_column_is_enforced_not_an_error.
Ranger wildcardsSupported: * matches any run, ? exactly one, and comparison folds case, per the query servicedef’s matcherOptions: {wildCard: "true", ignoreCase: "true"}. Previously only exact match and a bare * fired, so a policy written orders* or on Orders was silently inert. Pinned by ranger_wildcards_and_case_folding_match_the_servicedef.
Namespace keys are the full pathresolve_policy_key passes the whole dotted namespace, so a.b.sales and sales no longer collide on one Ranger database. A policy naming only the last component still matches, and logs that it did, so policies written against the old key keep working while operators rewrite them. Pinned by namespaces_sharing_a_last_component_no_longer_collide.
Tag parity with SparkCLOSED by the tag projector. Spark reads associations from Ranger’s tag store, not from Iceberg properties, so a tag-masked column used to be protected in SQE and returned RAW by Spark. With [policy.ranger] project-tags = true, SET TAG also writes the association into Ranger’s tag store and both engines mask identically. Pinned by tag_column_mask_is_byte_identical_across_engines. Projection is OFF by default: a deployment with no second engine reading Ranger gains nothing and would acquire a hard dependency on the Ranger tag API in its DDL path. Left off, tag masks remain SQE-only.
A SQL grant authorizes the Spark path only with the defer policySQE writes only the polaris Ranger service. Kyuubi’s RangerSparkExtension runs in ACTIVE mode against the query service and checks its own privilege FIRST, so without a matching policyType-0 item it default-denies before Polaris is consulted, and a GRANT issued in SQE is not sufficient for Spark. Measured: AccessControlException: Permission denied: user [bob] does not have [select] privilege on [acdemo/orders/id] on a table Polaris permitted. The query service therefore carries a deliberate blanket allow so Kyuubi defers and Polaris decides object level: an item for group public on Ranger’s auto-created all - database, table, column policy, written by the grant API because that auto policy owns the resource signature and a separately named policy is refused with error 3010. Pinned by object_denial_survives_the_frontend_defer_policy, which proves the blanket allow grants no data access of its own. A Spark path that connects as a service principal bypasses the polaris plane entirely and is subject to neither.
ALL vs FUTURE tablesRanger has no future-only resource, so both collapse to one wildcard policy. Snowflake distinguishes them.
Tag propagationA column derived from a tagged column in a CTAS starts untagged.
A leftover service-account catalog defeats per-user identity on SparkHanding Spark a per-user token governs ONLY that catalog. Any other catalog configured for the same warehouse is a separate identity the caller can name instead. Measured: a user denied on a table through his own catalog read it through a credential-configured alias in the same session. Overriding that alias’s token does not help, because Iceberg prefers credential when both are set. The fix is to remove the service-account catalog, not to shadow it. The quickstart no longer ships one, and two guards fail if it returns: no_service_account_catalog_can_defeat_per_user_identity, and the identity check in parity-test.sh that a tokenless spark-sql cannot load the table. Still open for any deployment that configures a service-account credential.
Identity assurance differs by tier on the Spark pathThe object tier verifies a JWT signature: Spark presents a per-user Keycloak token to the Iceberg REST catalog and Polaris authorizes that user. The fine-grained tier trusts HADOOP_USER_NAME, an unauthenticated string the client picks. A mismatched pair gets one user’s OBJECT rights and another’s MASKS, which mismatched_identity_reveals_the_two_tier_trust_split demonstrates rather than fixes. In a deployment the platform controls spark-submit; closing the split means running Spark behind a Kyuubi server with real authentication. SQE has no equivalent gap, because it validates the token and derives both tiers from it.
Polaris denial messages name the principal and the operationPrincipal 'dave' is not authorized for op 'LIST_TABLES', where SQE hides a denied object as “not found”. A Spark user therefore learns that an object exists and which operation was refused.
A refused write is refused at COMMITPolaris denies ADD_TABLE_SNAPSHOT rather than LOAD_TABLE, so an unauthorized INSERT can leave staged data files in object storage even though the table is untouched. Authorization holds and the row count does not move, which spark_write_privileges_are_separate_from_read asserts; storage hygiene does not. A denied writer can generate orphan files at will, and cleanup is the existing maintenance procedure’s job.
Kyuubi’s policy view lags its poll intervalThe Spark plugin caches the policy bundle on disk and refreshes on a 10s poll, so a short-lived spark-sql JVM started seconds after a policy change can still enforce the previous bundle. Object-level tests are unaffected, because the only frontend policy in play is the static defer item. Anything that changes frontend policy mid-run needs settling time, and a passing assertion taken too soon proves nothing.
Spark row filters need the filter column projectedKyuubi on Spark 3.5 throws MISSING_ATTRIBUTES (Kyuubi #6889) when a row filter references a column the query does not select. SQE has no such restriction, so a filter that is transparent in SQE breaks the query in Spark.
Named Ranger mask types render differently per engineMASK_SHOW_LAST_4 gives xxx-xx-1111 in SQE and nnnUnnU1111 in Kyuubi, because Kyuubi ignores the servicedef transformer and applies its own mask characters. The semantics agree (raw hidden, last four visible); only the rendering differs. Only a CUSTOM transformer written in portable standard SQL (concat('xxx-xx-', substr({col},8,4))) is byte-equal. Pinned in both directions by a_named_mask_type_is_not_byte_portable and column_mask_is_byte_identical_across_engines, which are each other’s control: if the comparison ever reported equal regardless, the first would fail.

Where to go next

Access control: evaluation order, engine by engine

A reference for reviewers, auditors and data engineers who need to answer one question precisely: for this column, in this engine, who sees what, and which component decided?

Everything under “Measured” was observed on a live Apache Polaris 1.7, Apache Ranger 2.8, Keycloak 26.5 and Spark 3.5.9 with Kyuubi Authz 1.11.1, and has an executable assertion behind it. The comparison to Databricks and Snowflake at the end is from their product documentation, NOT measured here, and is marked as such. Treat the two kinds of claim differently.

Where the ACLs live

Five stores, and knowing which one answers a question is most of the work.

StoreContainsWritten byRead by
Ranger service polaris (custom servicedef, 69 access types)object-level allow/deny per operation: catalog, namespace, table, view, principal, policySQE GRANT/REVOKE/DENY, or the platform control planePolaris, through its embedded Ranger authorizer
Ranger service query (servicedef type hive: database/table/column)row filters (policyType 2), column masks (policyType 1), and one deliberate blanket allow (policyType 0)Ranger Admin console, or the SQE test fixturesSQE’s plan rewriter AND Spark’s Kyuubi plugin
Ranger service tag (attached to query via its tagService field)mask and filter rules PER TAGRanger Admin consoleboth engines
Iceberg table property sqe.column-tagswhich column carries which tagALTER TABLE ... SET TAGSQE only
Ranger tag store (/service/tags/...)the same associations, projectedSQE’s tag projector when project-tags = trueKyuubi, and any other Ranger-plugin engine

The last two hold the same information in two places. That is deliberate: the Iceberg property is the source of truth so tags travel with the table, and the Ranger tag store is a projection so foreign engines can read them. It is also the single most fragile part of the design, for reasons the gap table gives.

Order of evaluation: SQE

Numbered in the order a failure surfaces, which is not the order the documentation usually implies.

1. Namespace resolution (SQE-side, before any authorization of the table). SQE resolves a table through its catalog provider, which answers only for a namespace present in its cached namespace list. Building that list takes two calls, and BOTH must succeed:

  • LIST_NAMESPACES, authorized at the catalog level via namespace-list. A namespace-scoped grant does not satisfy it, because Polaris does not use Ranger’s SELF_OR_DESCENDANTS matching. Listing is denied outright, never filtered.
  • LOAD_NAMESPACE_METADATA per namespace, needing namespace-level namespace-properties-read. A 403 hides that namespace, deliberately, so ungranted names do not leak into SHOW SCHEMAS.

Either failure yields an empty schema list and the query ends at table 'cat.ns.tbl' not found, with LOAD_TABLE never attempted. Nothing in the log shows a 403, because there was no denial to report. For an auditor this is the most misleading state in the system: a permission problem that presents as a missing object.

2. Polaris authorizes LOAD_TABLE against the polaris service, using the caller’s bearer token. Needs table-properties-read and table-data-read. A denial is a 403 that SQE surfaces as “table not found”, matching the Polaris information-hiding model.

3. Polaris authorizes the write, at COMMIT. An INSERT is refused at ADD_TABLE_SNAPSHOT, not before the data is staged.

4. SQE’s plan rewriter applies the fine-grained tier, reading the query and tag services. Row filters inject as Filter nodes above the TableScan; column masks replace column references with masking expressions, before DataFusion optimizes, so the optimizer cannot push a user predicate through a mask onto raw values.

Because a single GRANT SELECT ON cat.ns.tbl has to satisfy step 1 as well as step 2, it writes three Ranger policies, outermost first:

LevelAccess typeWhy
catalognamespace-listLIST_NAMESPACES is catalog-scoped and unfiltered
namespacenamespace-properties-readthe per-namespace visibility probe
tablethe privilege’s own setthe data

That catalog-level policy is a real widening: any grantee holding it can enumerate every namespace name in the catalog. A name like pii_customer_health is visible even though its rows are not. Separate catalogs are the boundary when namespace names are themselves sensitive.

Order of evaluation: Spark

Different order, and the first step surprises people.

1. Kyuubi checks ITS OWN privilege first, before Polaris is consulted at all. Running in ACTIVE mode against the query service, it default-denies without a matching policyType-0 item:

org.apache.kyuubi.plugin.spark.authz.AccessControlException:
  Permission denied: user [bob] does not have [select] privilege on [ac/orders/id]

SQE ignores policyType-0 policies entirely. So the same grant that works in SQE fails in Spark, and the failure reads like a Polaris bug. The query service therefore carries one deliberate blanket allow for group public that makes Kyuubi defer, leaving object level to Polaris. It grants no data access, because Polaris still decides.

2. Polaris authorizes LOAD_TABLE, exactly as for SQE, provided Spark presented a per-user token. This is the step that is trivially skipped: with a service-account credential, Polaris authorizes the service account and the entire object tier is bypassed. Per-user identity requires

spark.sql.catalog.<c>.token=<the user's OIDC JWT>
spark.sql.catalog.<c>.token-refresh-enabled=false

The second line is load-bearing. Left at its default, Iceberg exchanges the external JWT against Polaris’s own token endpoint and the identity silently reverts to the service account.

A per-user token governs ONLY the catalog it is attached to. Any other catalog configured for the same warehouse in that session is a SEPARATE identity, and the caller chooses which one by naming it. Measured: with spark.sql.catalog.sales_wh.credential set to a service account, a user denied on a table through his own catalog reads the same table through that alias in the same session.

The session cannot defend itself. Overriding the alias’s token with the user’s JWT does not help, because Iceberg prefers credential when both are set (measured). The fix is a deployment one: remove the service-account catalog, do not shadow it.

The quickstart used to ship exactly that hazard and no longer does: no credential and no oauth2-server-uri, token-refresh-enabled pinned off, and the caller passes its own token per invocation. Two guards keep it that way, and both assert the property rather than inspecting the config file, because a config file is easy to regress:

  • no_service_account_catalog_can_defeat_per_user_identity fails if naming another alias reads a table the caller was just denied.
  • parity-test.sh fails if a spark-sql with no caller token can load the table at all.

A refusal without a credential is NOT the same refusal as a denied grant, and the guard asserts which one it got. With a credential the alias was a second IDENTITY. With none it has no identity, so the request never becomes an authorization question: Polaris rejects it before it can answer and Iceberg reports Unable to parse error response rather than a ForbiddenException. A test accepting either could not tell a revoked grant from a catalog nobody gave a token.

3. Kyuubi injects masks and row filters from the query and tag services.

Side by side

PropertySQESpark (Kyuubi)
Object-level authorityPolaris, on the polaris servicePolaris, same service, IF a per-user token is presented
Identity for object levelOIDC JWT, signature verifiedOIDC JWT, signature verified
Identity for fine-grainedderived from the same verified JWTHADOOP_USER_NAME, an unauthenticated string the client picks
policyType-0 access policiesignoredREQUIRED, else default-deny
Row filters and masksplan rewriter, before optimizationKyuubi extension, at analysis
Tag associations read fromIceberg property sqe.column-tagsRanger tag store (needs the projector)
Resource mask vs tag maskRESOURCE winsTAG wins
Row filter on an unprojected columnworksMISSING_ATTRIBUTES (Kyuubi #6889)
Named mask types (MASK_SHOW_LAST_4)honors the servicedef transformer, xxx-xx-1111applies its own characters, nnnUnnU1111
Policy freshnessown cache TTLon-disk bundle, 10s poll

Precedence rules

Deny beats allow, in both engines, on both tiers. A Ranger deny item on a policy overrides an allow item on the same policy. Ranger keeps one policy per exact resource, so deny precedence is expressed by editing that policy rather than adding a second one.

A deny hits every member of the named role, including admins. Denying engineer denies anyone in engineer, and that includes an operator who holds an admin role as well. Verified the hard way: a deny on engineer locked the fixture admin out of the table and cascaded into nine unrelated test failures.

Resource masks and tag masks disagree between engines. SQE applies the resource mask; Kyuubi applies the tag mask, because stock RangerBasePlugin evaluates tag policies before resource policies. Neither leaks, since both mask. But the WEAKER of the two becomes effective for whoever picks that engine.

What to read to answer an audit question

For “who can read cat.ns.tbl”:

  1. GET /service/plugins/policies/service/name/polaris, and look for policies whose resources match the table, its namespace, AND its catalog. Remember the three levels: a table-level allow with no catalog-level namespace-list is inert in SQE.
  2. Check denyPolicyItems on those policies before policyItems. Deny wins.
  3. Resolve Ranger ROLE membership, not OIDC claims. Polaris ignores the token’s realm roles (they lack the PRINCIPAL_ROLE: prefix), so role-based access works through Ranger role membership.

For “what does this user SEE in that table”:

  1. GET /service/plugins/policies/service/name/query for policyType 1 and 2 items matching database (the last dotted namespace component), table, and column.
  2. GET /service/plugins/policies/service/name/tag for tag rules, then find which columns carry those tags: SHOW TAGS ON cat.ns.tbl for SQE’s view, and GET /service/tags/download/query for the projection Spark reads. If those two disagree, the engines disagree.
  3. Decide which engine the question is about, and apply the precedence row above.

For “was this refused, and by what”:

  1. AccessControlException: Permission denied: user [...] means Kyuubi refused, before Polaris was consulted.
  2. ForbiddenException: ... not authorized for op '...' means Polaris refused, and names the operation.
  3. table not found from SQE may be a denial at step 1, with no 403 anywhere.

Gaps, and which way each one fails

Direction matters more than severity. A fail-closed gap is an outage; a fail-open gap is an incident.

GapDirectionDetail
Spark’s fine-grained tier trusts an asserted usernameopenA mismatched pair gets one user’s object rights and another’s masks. The object tier follows the token, so it cannot be widened this way, but mask selection can be steered. Closing it means Spark behind a Kyuubi server with real authentication.
A service-principal Spark bypasses the object tieropen by configuration, guarded in the quickstartPolaris authorizes the service account, and nothing in the query service compensates because object level is not its job. Unchanged as a hazard: any deployment that gives Spark a service-account credential gets this. What changed is that the quickstart no longer does, and two guards fail if it comes back (no_service_account_catalog_can_defeat_per_user_identity and the identity check in parity-test.sh). The mitigation is per-user tokens with token-refresh-enabled=false, not a policy.
A leftover service-account catalog defeats per-user identityopenAdding a per-user catalog does not remove an existing one. Both identities are live and the caller picks by naming the catalog. Measured: denied through the per-user catalog, allowed through the service-account alias, same session, same table. A per-user token cannot shadow a credential; Iceberg prefers the credential. Remove the other catalog.
Renaming a tagged column silently UNMASKS it, in both enginesopen in bothsqe.column-tags is keyed by column NAME and no schema-change path rewrites it, so after RENAME COLUMN ssn TO tax_id the association names a column that is gone, no tag matches, and the mask stops applying. Measured, both engines: SELECT id, tax_id returns the column RAW. A routine rename unmasks a governed column and nothing reports it. This row previously said the engines broke DIFFERENTLY, with SQE dropping the column entirely. That was real but it was a SCAN defect, not access control: the small-file read path resolved the projection against each data file’s parquet names, so a renamed column matched nothing and was discarded. Fixed by resolving Iceberg field ids; the engines now agree and this one shared gap is what remains.
A column added after a grant is readable and unmaskedopen, by designThe object tier has no column level, so table-data-read covers columns that did not exist when it was granted. No policy names the new column, so nothing masks it.
Adding a column to a MASKED tableclosed (fixed)Was: ALTER TABLE ADD COLUMN nickname then SELECT id, ssn, nickname failed with PhysicalExpr Column references column 'nickname' at index 2 ... but input schema only has 2 columns, making a governed table unqueryable after a routine schema change. Not a plan-rewriter defect: the identical query failed with NO policy at all. The small-file scan path dropped nickname (absent from files written before the ALTER) and the mask projection above the scan then indexed past the end of a narrower batch. Fixed by field-id resolution plus a NULL backfill. Regression-guarded: the mask still applies and the new column reads NULL, never another column’s values.
Tag masks without the projectoropenAssociations live only in the Iceberg property, which Kyuubi cannot read, so a column masked in SQE comes back raw in Spark. Closed by project-tags = true.
A CTAS-derived column starts untaggedopenTags do not propagate through a projection.
Catalog-level widening on every table grantopen, acceptedEvery grantee can enumerate all namespace names in the catalog.
Polaris denial messages name principal and operationopen, minorA Spark user learns an object exists and which operation was refused. SQE hides denied objects instead.
Mask precedence differs between engineseitherWhichever mask is weaker becomes effective for that engine’s users.
A refused write is refused at commitclosed, but messyAuthorization holds and the table is untouched; staged files can be left in object storage. A denied writer can generate orphan files at will.
Kyuubi’s policy view lags 10sclosedA revoke is not instant for a short-lived spark-sql JVM. Over-permissive for up to one poll interval.
Row filter on an unprojected column in SparkclosedQuery fails outright (Kyuubi #6889). Transparent in SQE, breaking in Spark.
REVOKE leaves traversal policies behindclosed, deliberateAn orphaned namespace-list is discovery on a catalog the grantee could already reach. Walking the plan backwards would strip discovery from unrelated grants.
Views are not a privilege boundaryopen, by designSQE expands a view and plans against its base tables, so the reader needs a grant there too. Masks still apply through the view and cannot be dodged.
Ranger 2.8 ships no tag row filtersclosedThe tag servicedef has an empty rowFilterDef unless ranger.servicedef.autopropagate.rowfilterdef.to.tag=true is set on Ranger Admin.

How this differs from Databricks and Snowflake

From product documentation, not measured here. The architectural contrast is the part worth internalizing; treat specific feature claims as a starting point for your own check.

The structural difference

In Unity Catalog and in Snowflake, the engine is the policy authority. One system of record holds the grants, evaluates them, and enforces them. There is exactly one answer to “who can see this column”, and no possibility of two engines disagreeing, because there is only one engine.

SQE splits the roles three ways. Polaris is the object authority, Ranger is the policy store, and each engine enforces the fine-grained tier itself. That is what makes the same policy set govern SQE and Spark at once, which neither Databricks nor Snowflake offers for a foreign engine. Every divergence in the gap table above is the price of that property. If you do not need multiple engines on one policy set, you are paying for something you will not use.

Object level

SQE + Polaris + RangerUnity CatalogSnowflake
Grant modelRanger policies per resource, allow and deny itemsGRANT/REVOKE in the metastore, hierarchical inheritanceRBAC, role hierarchy, every privilege a role grant
Traversalcatalog namespace-list plus namespace namespace-properties-read, written automatically by one GRANTinherited from catalog and schemaexplicit USAGE on database and schema
Denyfirst-class Ranger deny items, precedence over allowno deny; absence of grant is the only negativeno deny; REVOKE only
Ownershipnot a grant source; the polaris servicedef has an owner concept SQE does not lean onowner has full rights, drives inheritanceowner role has full rights, MANAGED ACCESS schemas centralize it
Future objectsALL and FUTURE collapse to one wildcard policy, because Ranger has no future-only resourceinheritance covers new objectsFUTURE GRANTS, a distinct first-class concept

Snowflake’s USAGE-on-the-path requirement is the closest analogue to SQE’s three-level expansion, and for the same underlying reason: reaching an object requires traversing to it. Snowflake makes the operator write it; SQE writes it for you and accepts the resulting namespace-name visibility.

Deny items are where SQE is genuinely ahead. Neither Databricks nor Snowflake offers a negative grant that overrides a positive one, so “everyone in analytics except contractors” is a role-modelling exercise there and a single policy item here.

Fine-grained

SQEUnity CatalogSnowflake
Column maskingRanger mask policy, applied by rewriting the plancolumn mask, a SQL UDF attached to the columnmasking policy object, attached with ALTER TABLE ... SET MASKING POLICY
Row filteringRanger row-filter policy, injected above the scanrow filter, a SQL UDF returning a booleanrow access policy attached to the table
Policy reusepolicy names a resource pattern, so one policy covers many columns by wildcardfunction reused across tablespolicy is a schema-level object referenced by name, reused explicitly
Tag-driven maskingmask rule on a tag in the tag service, association in the Iceberg propertygoverned tags with ABAC policies (newer capability; verify current status)masking policy assigned to a tag, tag applied to the column
Enforcement pointbefore optimization, in the enginein the enginein the engine
Cross-enginethe same policy governs SQE and SparkUnity Catalog onlySnowflake only

Snowflake’s tag-based masking is the direct analogue of SQE’s tag masks, and the comparison is instructive: Snowflake stores the tag association in its own metadata, so there is one place to look. SQE stores it in the Iceberg property so it travels with the table, then has to project it into Ranger for Spark. The sovereignty property and the consistency risk are the same design decision seen from two sides.

Views

Snowflake secure views hide the definition and evaluate with the view owner’s privileges, so a view is a genuine privilege boundary and the standard way to grant narrowed access. SQE views are not a privilege boundary. SQE expands the view and plans against the base tables, so the reader needs a grant there too. Do not use an SQE view to grant indirect access to a table.

What SQE views do give you is masking that cannot be dodged: a view projecting a masked column returns the masked value, because the rewriter runs on the base-table scan.

What to take from the comparison

If your governance model is single-engine, Unity Catalog and Snowflake give you one authority, one answer, and no projection to keep in step. That is a real advantage and this document is largely a catalogue of what it costs not to have it.

If you need Spark and SQE reading one policy set, or you need the policy store to be something you run yourself, the split model is the reason to accept the gap table above. Read it before deciding, not after.

See also

read_parquet TVF

read_parquet() is a table-valued function registered on every SQE SessionContext. It reads Parquet files from local disk or S3-compatible storage and returns them as a DataFusion table scan, making Parquet files directly queryable without first loading data into Iceberg.

Syntax

SELECT * FROM read_parquet(
  '<path>',
  [access_key => '<key>',]
  [secret_key => '<secret>',]
  [endpoint => '<url>',]
  [region => '<region>']
)

The first argument is the file path or glob pattern. All other arguments are named (keyword) parameters for S3 credentials. Named parameters are optional and fall back to the engine’s configured storage defaults when omitted.

Local files

Absolute paths and glob patterns both work:

-- Single file
SELECT * FROM read_parquet('/data/tpch/sf1/lineitem/part-0000.parquet');

-- All files in a directory
SELECT * FROM read_parquet('/data/tpch/sf1/lineitem/*.parquet');

-- Recursive glob
SELECT * FROM read_parquet('/data/tpch/sf1/lineitem/**/*.parquet');

The schema is inferred from the Parquet metadata of the first matched file. All matched files must share the same schema.

S3 with inline credentials

Pass credentials directly in the SQL statement. This is the primary mechanism used by sqe-bench load to inject credentials at load time without relying on environment variables or configuration files.

SELECT * FROM read_parquet(
  's3://bench-data/tpch/sf1/lineitem/*.parquet',
  access_key => 'AKIAIOSFODNN7EXAMPLE',
  secret_key => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
  endpoint   => 'http://localhost:9000',
  region     => 'us-east-1'
);

All four named parameters are optional independently. Omit endpoint for AWS S3 (uses the default AWS endpoint for the given region). Omit region to default to us-east-1.

S3 with default credentials

When no inline credentials are provided, read_parquet() falls back to the storage configuration in sqe.toml:

-- Uses [storage] section from sqe.toml
SELECT * FROM read_parquet('s3://bench-data/tpch/sf1/lineitem/*.parquet');

This is convenient for internal workloads where the engine already has ambient S3 credentials configured.

Glob patterns

read_parquet() supports the same glob syntax as object_store:

PatternMatches
*.parquetAll .parquet files in the named directory
**/*.parquetAll .parquet files in any subdirectory
part-00[0-9][0-9].parquetFiles matching the character class

For S3 paths, globbing is applied to the key prefix after the bucket name.

Using with CTAS for data loading

The primary use case for read_parquet() is ingesting external Parquet data into Iceberg tables via CTAS. This avoids an intermediate format conversion step. The Parquet files are read directly and written as Iceberg data files in one operation.

-- Load TPC-H lineitem from local disk
CREATE TABLE tpch_sf1.lineitem AS
SELECT * FROM read_parquet('/data/tpch/sf1/lineitem/*.parquet');

-- Load from S3 with inline credentials
CREATE TABLE tpch_sf1.lineitem AS
SELECT * FROM read_parquet(
  's3://bench-data/tpch/sf1/lineitem/*.parquet',
  access_key => 'AKIA...',
  secret_key => '...',
  endpoint   => 'http://localhost:9000',
  region     => 'us-east-1'
);

-- Transform during load
CREATE TABLE analytics.orders_summary AS
SELECT
  o_orderdate,
  o_orderstatus,
  COUNT(*) AS order_count,
  SUM(o_totalprice) AS total_revenue
FROM read_parquet('/data/tpch/sf1/orders/*.parquet')
GROUP BY o_orderdate, o_orderstatus;

Because read_parquet() returns a standard DataFusion table scan, it participates in the full optimizer pipeline: predicate pushdown, projection pruning, and partition pruning all apply.

Querying without loading

read_parquet() can also be used as a one-off query target, without creating an Iceberg table:

-- Inspect schema
DESCRIBE SELECT * FROM read_parquet('/data/tpch/sf1/orders/*.parquet') LIMIT 0;

-- Quick aggregation over raw Parquet
SELECT o_orderstatus, COUNT(*) AS cnt
FROM read_parquet('/data/tpch/sf1/orders/*.parquet')
GROUP BY o_orderstatus;

-- Join Parquet with an Iceberg table
SELECT p.p_name, l.l_quantity
FROM read_parquet('/data/tpch/sf1/lineitem/*.parquet') AS l
JOIN warehouse.tpch_sf1.part AS p ON l.l_partkey = p.p_partkey
LIMIT 20;

Implementation

read_parquet() is registered in sqe-catalog as a DataFusion TableFunction. On each invocation:

  1. The path argument is parsed to detect s3:// vs local (/ or file://) paths.
  2. For S3: an AmazonS3Builder is constructed from the inline named parameters, with fallback to the StorageConfig from sqe-core for any omitted fields.
  3. For local paths: the built-in DataFusion local filesystem ObjectStore is used.
  4. Glob patterns are expanded against the chosen ObjectStore.
  5. A ListingTable is constructed over the matched files and returned as the table scan node.

The function is registered on every SessionContext at startup, so it is always available without any special configuration.

Limitations

  • All matched Parquet files must share an identical Arrow schema. Schema evolution across files in the same glob is not supported.
  • read_parquet() is read-only. It cannot be used as the target of an INSERT INTO.
  • Credential parameters are passed as SQL literals. Avoid logging or displaying these queries in audit logs without redaction. SQE’s audit logger redacts named parameter values that match access_key, secret_key, and session_token patterns.
  • Very large numbers of matched files (>10,000) may cause slow planning due to the object listing step.

File-format TVFs

The four TVFs read_parquet, read_csv, read_json, and read_delta query files directly without registering an external table. They share a uniform calling convention and a uniform path-resolution layer (local filesystem, S3, HTTPS, HuggingFace hf://).

This chapter covers read_csv, read_json, and read_delta. The dedicated read_parquet chapter covers Parquet specifically.

Common path forms

All four TVFs accept the same path shapes:

-- Local
SELECT * FROM read_csv('/data/sales.csv');

-- S3 (anywhere object_store understands)
SELECT * FROM read_csv('s3://bucket/key.csv',
    access_key => 'AKIA...', secret_key => '...',
    endpoint => 'http://localhost:9000', region => 'us-east-1');

-- HTTP / HTTPS (V10)
SELECT * FROM read_csv('https://raw.githubusercontent.com/.../data.csv');

-- HuggingFace (V10)
SELECT * FROM read_csv('hf://datasets/squad/plain_text/train.csv');

-- HuggingFace with revision (V12.1)
SELECT * FROM read_parquet('hf://datasets/foo/[email protected]/data.parquet');

-- HuggingFace auto-generated parquet view
SELECT * FROM read_parquet('hf://datasets/foo/bar@~parquet/default/train/0.parquet');

S3 credentials default to the engine’s [storage] block when not supplied inline. HTTPS and hf:// paths flow through V10’s LazyHttpObjectStoreRegistry, which constructs an HttpStore for the host on first request.

Quoted-string auto-detect

V8 introduced a shortcut. With the embedded CLI, the engine recognises a quoted string in a FROM clause as a file URL and dispatches to the right TVF based on extension:

SELECT * FROM '/data/sales.parquet';
SELECT * FROM 's3://bucket/orders.csv';
SELECT * FROM 'hf://datasets/foo/bar/data.csv';

Format dispatch happens by extension. .parquet -> read_parquet, .csv / .tsv / .psv / .ssv -> read_csv, .json / .jsonl / .ndjson -> read_json, .avro -> the Avro reader. Compressed extensions are recognised: .csv.gz, .tsv.zst, .json.bz2 all dispatch to the right reader with the right codec.

read_csv

SELECT * FROM read_csv(
    '<path>',
    [delimiter | delim | sep => '<byte>',]
    [has_header | header => '<bool>',]
    [quote => '<byte>',]
    [escape => '<byte>',]
    [comment => '<byte>',]
    [null_regex | nullstr => '<regex>',]
    [compression | compress => 'auto|none|gzip|bz2|xz|zstd',]
    [file_extension => '<.ext>']
);

Smart defaults:

  • Delimiter detected from the path extension. .csv is ,, .tsv is tab, .psv is |, .ssv is ;. Compression suffixes are stripped first, so .tsv.gz still picks tab.
  • Compression detected from the path extension. .gz, .bz2, .xz, .zst are recognised.
  • has_header defaults to true (DataFusion default).

DuckDB-style aliases: sep, delim for delimiter; header for has_header; nullstr for null_regex; compress for compression.

-- All three are equivalent
SELECT * FROM read_csv('events.tsv');
SELECT * FROM read_csv('events.tsv', sep => '\t');
SELECT * FROM read_csv('events.tsv', delimiter => '\t', has_header => 'true');

-- Compressed, with explicit override
SELECT * FROM read_csv('events.tsv.zst', compression => 'auto');

-- Semicolon-separated file
SELECT * FROM read_csv('financial.ssv', sep => ';');

read_json

SELECT * FROM read_json(
    '<path>',
    [access_key | secret_key | endpoint | region | file_extension,]
    [format => 'auto|newline_delimited|array',]
    [compression | compress => 'auto|none|gzip|zip|zstd|bz2|xz']
);
ArgValuesDefaultNotes
formatauto, newline_delimited (aliases ndjson, nd), array (alias json)autoauto on a plain file resolves to NDJSON. A top-level JSON array is only read as an array when format => 'array' is passed explicitly (or the source is a .zip, see below). newline_delimited => 'false' is a legacy alias for format => 'array'.
compression (alias compress)auto, none, gzip, zip, zstd, bz2, xzauto (detected from the path extension).json.gz, .json.zip, .json.zst, .json.bz2, .json.xz all dispatch automatically.
file_extensionany extension stringderived from <path>, codec suffix included (e.g. .jsonl.gz)Overrides the listing extension when the path doesn’t carry one.

Two execution paths back this TVF:

  • Streaming path (NDJSON, any supported compression except zip): DataFusion’s built-in JSON listing table reads the file as a stream, the same way read_csv does. This is the default, and it handles arbitrarily large NDJSON files without buffering the whole object in memory.
  • Buffer path (format => 'array', or any .zip source): the object is fetched whole, decompressed, and reshaped into NDJSON in memory before decoding. A top-level JSON array can’t be streamed line-by-line, and a zip archive isn’t a single compressed byte stream DataFusion’s codecs understand, so both cases route here. A size guard rejects inputs larger than the buffer cap with a clear error instead of risking OOM on a mislabeled file.
-- Plain NDJSON, local or remote
SELECT * FROM read_json('/var/log/events.jsonl');
SELECT * FROM read_json('hf://datasets/nyu-mll/glue/cola/train.jsonl');

-- gzip NDJSON (streaming; auto-detected from .gz)
SELECT * FROM read_json('s3://logs/2026-05-07/events.json.gz');

-- full JSON array (buffer path; format must be explicit)
SELECT * FROM read_json('/data/events.json', format => 'array');
SELECT * FROM read_json('/data/events.json.gz', format => 'array');

-- zip archive (buffer path; every JSON/NDJSON entry is concatenated,
-- directory entries are skipped, array and NDJSON entries can mix)
SELECT * FROM read_json('/data/export.json.zip');

read_delta

SELECT * FROM read_delta(
    '<path>',
    [access_key | secret_key | endpoint | region,]
    [version => '<u64>',]
    [timestamp => '<RFC3339>']
);

Read-only Delta Lake reader, V11. Wraps deltalake-core 0.32.1. Time travel via version (snapshot id) or timestamp (RFC3339); the two are mutually exclusive.

SELECT * FROM read_delta('/data/delta/sales');

SELECT * FROM read_delta('s3://bucket/delta/orders',
    access_key => 'AKIA...');

-- Time travel
SELECT * FROM read_delta('/data/delta/sales', version => '5');
SELECT * FROM read_delta('/data/delta/sales',
    timestamp => '2026-04-01T00:00:00Z');

Writes are not exposed. The Delta transaction pipeline is substantial; the read path covers the most common ad-hoc query case.

HuggingFace specifics

The hf:// path uses a slightly different shape than S3 or HTTPS because HuggingFace expects a revision in the URL.

Two revision spellings work:

  1. Inline @<rev> (DuckDB-style):

    SELECT * FROM read_parquet('hf://datasets/foo/[email protected]/train.parquet');
    
  2. Query parameter ?revision=<rev>:

    SELECT * FROM read_parquet('hf://datasets/foo/bar/train.parquet?revision=v1.0');
    

Default is main when neither is specified. Specifying both rejects with a clear error.

@~parquet is special. HuggingFace auto-generates a Parquet conversion of every dataset on the refs/convert/parquet branch. The TVF translates this:

-- Equivalent to https://huggingface.co/datasets/foo/bar/resolve/refs%2Fconvert%2Fparquet/data.parquet
SELECT * FROM read_parquet('hf://datasets/foo/bar@~parquet/default/train/0.parquet');

Glob expansion (**/*.parquet) is on the V12.2 roadmap; today the path must point to a specific file.

When to use which

  • read_parquet: ad-hoc queries against Parquet on disk, S3, HTTPS, or hf://. Anything Iceberg-aware that does not need the catalog.
  • read_csv: ETL ingestion, log analysis, dataset preview before deciding to load into Iceberg.
  • read_json: NDJSON logs, HuggingFace train.jsonl style splits.
  • read_delta: query a Delta Lake table without converting to Iceberg.

For tables with metadata that you want to write back to, register them in a catalog. The TVFs are reads only.

Implementation references

  • sqe-catalog/src/read_parquet.rs
  • sqe-catalog/src/read_csv.rs
  • sqe-catalog/src/read_json.rs
  • sqe-catalog/src/read_delta.rs
  • sqe-catalog/src/file_tvf_common.rs: shared parsing + S3 / HTTPS / hf:// resolver
  • sqe-catalog/src/lazy_object_store.rs: V10’s lazy HTTPS object-store registry

Observability

SQE provides full observability through Prometheus metrics, OpenTelemetry traces/logs, and structured audit logging.

Metrics (Prometheus)

Available at http://coordinator:9090/metrics in Prometheus text format.

MetricTypeLabelsDescription
sqe_query_count_totalCounterstatus, statement_typeTotal queries by status and type
sqe_query_duration_secondsHistogramstatement_typeQuery duration distribution
sqe_rows_returned_totalCounterCumulative rows returned
sqe_active_queriesGaugenoneQueries that have not reached a terminal state
sqe_active_sessionsGaugeCurrent active sessions
sqe_healthy_workersGaugeWorkers passing health checks
sqe_scan_files_totalCounteroutcomeIceberg files planned, read, or pruned
sqe_scan_bytes_totalCounterstageIceberg bytes planned and read
sqe_scan_rows_totalCounterstageIceberg rows before filters, decoded, and returned by scans
sqe_scan_row_groups_pruned_totalCounternoneParquet row groups skipped by bloom pruning
sqe_s3_requests_totalCounteroperation, statusS3 request outcomes
sqe_s3_bytes_read_totalCounternoneBytes read from S3, including local Iceberg scans
sqe_s3_bytes_written_totalCounternoneBytes written to S3
sqe_coordinator_memory_used_bytesGaugenoneDataFusion coordinator memory in use
sqe_coordinator_memory_limit_bytesGaugenoneConfigured coordinator memory limit
sqe_coordinator_memory_pressureGaugenoneMemory pressure level from 0 to 3

Histogram buckets: 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s, 10s, 30s, 60s.

Statement types: query, ctas, insert, merge, delete, drop, create_view, create_schema, show_catalogs, show_schemas, show_tables, policy, utility.

Example Queries (PromQL)

# Query rate (queries per second)
rate(sqe_query_count_total[5m])

# Error rate
rate(sqe_query_count_total{status="error"}[5m])

# P99 query duration
histogram_quantile(0.99, rate(sqe_query_duration_seconds_bucket[5m]))

# Active sessions
sqe_active_sessions

Local observability stack

For a self-contained metrics view alongside the test stack, SQE ships a Docker Compose overlay using VictoriaMetrics (Prometheus-compatible, around 30 MB RAM) and Grafana:

docker compose -f docker-compose.test.yml -f docker-compose.observability.yml up -d
open http://localhost:13000    # Grafana, admin / admin

The overlay auto-scrapes the single-node coordinator (localhost:19090), the distributed coordinator (localhost:29090), and workers (localhost:29091-29094). A pre-built dashboard lives at deploy/observability/sqe-benchmark-dashboard.json and is auto-provisioned by the overlay. To import it manually, copy the JSON into your Grafana instance and point it at a Prometheus or VictoriaMetrics data source.

Health Endpoints

Available on port 9091 (metrics port + 1) for both coordinator and workers.

Kubernetes Probes

EndpointPurposeResponse
GET /healthzLiveness probeAlways returns 200 ok
GET /readyzReadiness probe200 when ready, 503 during init

Cluster Status (Ballista/DataFusion-style)

GET /api/v1/status returns a JSON snapshot of the node and cluster:

{
  "status": "ACTIVE",
  "node": {
    "role": "coordinator",
    "version": "0.1.0",
    "datafusionVersion": "51",
    "uptimeSeconds": 3600
  },
  "workers": {
    "total": 2,
    "healthy": 2,
    "healthyUrls": ["http://worker-0:50052", "http://worker-1:50052"]
  }
}

For worker nodes, the workers field is null.

Trino-Compatible Info (port 8080)

When the Trino compat layer is enabled, standard Trino info endpoints are available on the Trino HTTP port:

EndpointResponse
GET /v1/infoJSON: nodeVersion, environment, coordinator, starting, uptime
GET /v1/info/statePlain text: ACTIVE or STARTING

These endpoints are compatible with Trino JDBC drivers, DBeaver, and other Trino-aware tools for auto-detecting node state.

OpenTelemetry

otlp_endpoint exports traces, metrics, and logs via OTLP/gRPC. A collector with only a traces pipeline should use traces_otlp_endpoint; Prometheus scraping and structured stdout logging then remain independent.

graph LR
    SQE["sqe-server"] -->|OTLP gRPC| COLL["OTel Collector"]
    COLL --> JAEGER["Jaeger<br/>(traces)"]
    COLL --> PROM["Prometheus<br/>(metrics)"]
    COLL --> LOKI["Loki<br/>(logs)"]

Configuration:

[metrics]
# Trace-only collector. Recommended when /metrics is scraped and stdout logs
# are collected separately.
traces_otlp_endpoint = "http://otel-collector:4317"
trace_sample_rate = 1.0

# Legacy all-signals endpoint. Leave empty for the trace-only setup above.
otlp_endpoint = ""

When the endpoint is empty (default), SQE falls back to structured JSON logs on stdout, no external dependency required.

Trace Spans

Key spans emitted:

  • sqe.query: full query and result-stream lifecycle
  • sqe.plan: SQL parsing and planning
  • sqe.policy_rewrite: policy enforcement
  • iceberg_scan: Iceberg planning, read, decode, and filter work
  • dispatch_to_worker: coordinator fragment dispatch
  • sqe.worker.scan: worker scan execution
  • iceberg.rest.request: outbound Polaris or Iceberg REST catalog request

W3C trace and request correlation

The Trino HTTP and Flight SQL endpoints accept the standard W3C traceparent and tracestate headers. Flight clients send the same values as ASCII gRPC metadata. SQE extracts them before authentication or planning and uses the configured global W3C propagator on coordinator to worker Flight calls and outbound Iceberg REST catalog calls.

The optional x-request-id and x-session-id values are correlation metadata, not credentials. SQE accepts only 1 to 128 characters from A-Z, a-z, 0-9, ., _, :, and -. Invalid values are omitted. These headers never replace the W3C trace ID and are never used for authentication.

Correlation fields have distinct meanings:

FieldMeaning
trace_id32-character lowercase hexadecimal W3C trace shared across services
span_id16-character lowercase hexadecimal identifier for one operation
request_idBFF request correlation value, when supplied and valid
session_idSafe caller correlation value or SQE session identifier, depending on the boundary
query_idSQE query lifecycle identifier used for submission, polling, cancellation, and profiles

Trino polling and cancellation are independent HTTP requests. Their spans use their own incoming remote parent and record the original query_id; SQE does not invent parent relationships between separate BFF requests. The durable sqe.query span remains active while the result stream is executing.

VictoriaLogs and VictoriaTraces investigation

Start in VictoriaLogs with the BFF request value, for example request_id:="req-01JABC". Open an SQE event and copy its trace_id. Search VictoriaLogs for that exact trace_id to see the coordinator, policy and auth, worker, and Polaris events. Open the same ID in VictoriaTraces to inspect the flight_sql.request or trino.* server span, sqe.query, Iceberg scan, dispatch_to_worker, sqe.worker.scan, and iceberg.rest.request chain. Use query_id to join later Trino polling or cancellation requests to the durable query lifecycle and to compare the trace with EXPLAIN FULL scan counters.

Audit Log

SQE writes a JSONL audit log capturing every query:

{
  "timestamp": "2025-03-15T10:30:00Z",
  "username": "alice",
  "session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "query_text": "SELECT * FROM sales.orders WHERE region = 'EU'",
  "query_hash": "sha256:e3b0c44298fc1c149afb...",
  "statement_type": "query",
  "client_ip": "10.0.1.42",
  "duration_ms": 142,
  "rows_returned": 1583,
  "status": "ok"
}

The query_hash field is a SHA-256 hash of the SQL text, useful for correlating repeated queries without storing the full text. When audit logging is enabled, all fields are always present.

Configuration:

[metrics]
audit_log_path = "/var/log/sqe/audit.jsonl"

When the path is empty (default), audit logging is disabled (no-op).

Structured Logging

All SQE components use tracing with JSON output:

{
  "timestamp": "2025-03-15T10:30:00.142Z",
  "level": "INFO",
  "target": "sqe_coordinator::query_handler",
  "message": "Query executed",
  "trace_id": "0af7651916cd43dd8448eb211c80319c",
  "span_id": "00f067aa0ba902b7",
  "request_id": "req-01JABC",
  "session_id": "session-42",
  "query_id": "0190f4d5-ec1a-7b22-9f86-55a89dce7777",
  "user": "alice",
  "statement_type": "query",
  "duration_ms": 142,
  "rows": 1583
}

Log level controlled via RUST_LOG environment variable:

RUST_LOG=info             # Default
RUST_LOG=sqe=debug        # Debug SQE crates only
RUST_LOG=sqe=trace        # Everything

Kubernetes Integration

The Helm chart includes optional ServiceMonitor for Prometheus Operator:

serviceMonitor:
  enabled: true
  interval: 30s
  labels:
    release: prometheus

Trino Compatibility

SQE includes a Trino-compatible HTTP endpoint that allows existing Trino clients (JDBC drivers, CLI tools, DBeaver) and Trino-speaking BI tools (Metabase, Superset) to connect without modification.

Enabling

The Trino HTTP endpoint is enabled by default on port 8080. Set port to 0 to disable:

[coordinator]
trino_http_port = 8080    # 0 to disable

Endpoints

EndpointMethodDescription
/v1/infoGETNode info (version, uptime, coordinator status)
/v1/info/stateGETPlain text: ACTIVE or STARTING
/v1/statementPOSTSubmit a SQL query
/v1/statement/queued/{id}/{token}GETPoll a queued/running query until results are ready
/v1/statement/{id}/{token}GETFetch paginated results
/v1/statement/{id}DELETECancel a running query

Authentication

The Trino endpoint supports two authentication methods:

Bearer Token

Pass an existing access token directly:

curl -H "Authorization: Bearer eyJhbG..." \
     -H "X-Trino-User: alice" \
     -d "SELECT 1" \
     http://localhost:8080/v1/statement

Basic Auth

Username and password are exchanged for a token via the configured OIDC/OAuth2 backend:

curl -u alice:password \
     -d "SELECT 1" \
     http://localhost:8080/v1/statement

Client Headers

SQE respects standard Trino client headers:

HeaderPurpose
X-Trino-UserOverride username (used with Bearer auth)
X-Trino-CatalogSet default catalog for the session
X-Trino-SchemaSet default schema for the session
X-Trino-SourceClient identifier (logged for audit)

Result Pagination

Query results are paginated. The initial response includes a nextUri field. Follow nextUri links to retrieve subsequent pages:

{
  "id": "query-uuid",
  "stats": { "state": "FINISHED" },
  "columns": [{"name": "result", "type": "integer"}],
  "data": [[1]],
  "nextUri": "http://localhost:8080/v1/statement/query-uuid/1"
}

When nextUri is absent, all results have been consumed.

Async Submission

Submission is async, matching Trino’s own protocol. POST /v1/statement spawns the query on a background task with a bounded initial wait. If the query does not finish in that window, the first response carries state: QUEUED, no data, and a nextUri pointing at /v1/statement/queued/{id}/{token}:

{
  "id": "query-uuid",
  "stats": { "state": "QUEUED" },
  "nextUri": "http://localhost:8080/v1/statement/queued/query-uuid/0"
}

The client follows the queued links (state stays QUEUED or RUNNING) until the query finishes, at which point nextUri redirects to the results route at token 0 and the response starts carrying columns and data. Clients that only ever poll nextUri need no special handling: the queued and results routes chain transparently.

Using with the CLI

sqe-cli --protocol http --host localhost --port 8080 --user alice

Connecting External Tools

DBeaver

  1. Create a new Trino connection
  2. Host: localhost, Port: 8080
  3. Authentication: Username/Password
  4. Driver properties: no special settings needed

JDBC (Java)

String url = "jdbc:trino://localhost:8080";
Properties props = new Properties();
props.setProperty("user", "alice");
props.setProperty("password", "secret");
Connection conn = DriverManager.getConnection(url, props);

Metabase and Superset

BI tools that speak Trino connect through the same endpoint. Metabase uses the Trino JDBC driver, Superset uses the Trino SQLAlchemy dialect (trino://user@host:8080/catalog). Both drive a metadata handshake on connect (prepare a statement, enumerate catalogs and schemas, list tables, describe columns) before running a chart, and SQE now matches Trino’s exact response shape at each step:

  • PREPARE and DEALLOCATE PREPARE are handled as session-control via the X-Trino-Added-Prepare / X-Trino-Deallocated-Prepare headers, so the JDBC connection test succeeds.
  • SHOW TABLES returns a single Table column, SHOW SCHEMAS a Schema column, and SHOW CATALOGS a Catalog column, so schema sync reads the right values.
  • SHOW CATALOGS and the system.jdbc.* / system.metadata.* tables enumerate every reachable catalog and skip the ones the caller is not authorized to list, and SHOW TABLES / SHOW SCHEMAS honor the session catalog (X-Trino-Catalog).
  • DESCRIBE and SHOW COLUMNS resolve double-quoted identifiers ("catalog"."schema"."table"), so field discovery works.
  • Types map to their Trino equivalents: timestamp(6) carries its precision in the type signature (so date bucketing over JDBC works), and computed unsigned-64 aggregates like count(*) map to bigint rather than decimal.

Limitations

  • The Trino endpoint returns results as JSON (Trino wire format), not Arrow. For maximum performance, use Flight SQL.
  • Transaction control (START TRANSACTION, COMMIT) is not supported. Queries execute in auto-commit mode.
  • Type mapping covers common types; complex nested types may differ from native Trino behavior.
  • Iceberg hidden columns ($path, $file_modified_time, $partition) are not exposed on table scans. They need a per-row, per-source-file column that is resolvable by name but excluded from SELECT *. DataFusion has no such metadata-column mechanism yet (tracked upstream at apache/datafusion#20135, not in any release), and adding the column to the scan schema would make every SELECT * return it. For file-level introspection use the table_files('ns', 't') table function, which lists file_path, record_count, and file_size_in_bytes per data file.
  • Materialized views are not supported. CREATE MATERIALIZED VIEW returns a clear “not supported” error rather than creating a plain view. DROP MATERIALIZED VIEW IF EXISTS is treated as a no-op so client tooling that issues it on teardown can proceed.

Flight SQL vs Trino HTTP

AspectFlight SQL (default)Trino HTTP
Port500518080
Wire formatArrow IPC (binary, columnar)JSON
PerformanceHigh (zero-copy)Lower (serialization overhead)
Client supportADBC, JDBC (Flight SQL), dbtTrino JDBC, DBeaver, Metabase, Superset
PaginationArrow Flight streamingnextUri polling

Use Flight SQL for performance-sensitive workloads. Use Trino HTTP for compatibility with existing tools.

Benchmark Suite

SQE ships with sqe-bench, a Rust CLI tool that generates benchmark data, loads it into SQE via the read_parquet() TVF, and runs query suites to validate SQL correctness and measure query performance.

For the longitudinal view (every benchmark JSON in benchmarks/results/ plotted across time, per-suite, per-scale, per-query heatmaps), see getsqe.com/performance. Charts auto-regenerate from the committed JSONs via make benchmark-charts.

Available Benchmarks

BenchmarkQueriesTablesFocus
tpch228Star/snowflake schema, pure analytical reads
tpcds9924Complex SQL, correlated subqueries, window functions
ssb135Denormalized star schema, fast smoke testing
tpcc179OLTP read + write queries (DELETE/UPDATE via CoW)
tpce1133Brokerage OLTP, complex demographics and trade schema
tpcbb10~25SQL-only subset over TPC-DS data + web logs

Why these benchmarks? Each covers a different slice of SQL correctness:

  • TPC-H and SSB validate the analytical core: joins, aggregates, GROUP BY, ORDER BY, date arithmetic. TPC-H is the standard first check for any SQL engine.
  • TPC-DS is the hardest. Its 99 queries exercise correlated subqueries, CTEs, window functions, GROUPING SETS, and complex multi-table joins. Passing TPC-DS well means the engine handles real analytical workloads.
  • TPC-C and TPC-E cover OLTP patterns: point lookups, small aggregates, indexed access by key ranges, plus write operations (DELETE, UPDATE) exercised via Copy-on-Write.
  • TPC-BB exercises semi-structured data alongside the TPC-DS schema, useful for validating string functions and JSON handling.

Results (SF1, vs Trino 465)

The numbers below are the latest SF1 run, as of 2026-06-12, against Trino 465 on identical Iceberg tables and S3 storage. All 222 queries pass (222/222). SQE wins six of seven suites at SF1.

SuiteSQETrinoSpeedupPass
TPC-E (11)9.3s172.0s18.5x11/11
TPC-BB (10)28.0s255.7s9.1x10/10
TPC-C (8 read)0.41s2.65s6.5x8/8
TPC-DS (99)13.4s45.6s3.4x93/99
ClickBench (43)1.3s4.46s3.4x43/43
TPC-H (22)16.8s26.7s1.6x22/22
SSB (13)8.3s5.8s0.70x13/13

Run-to-run variance is real, so treat each figure as approximate. The rank order is stable across the last month of runs.

Where SQE trails

SSB is the one suite SQE loses at SF1: 8.3s against Trino’s 5.8s, a 0.70x result. SSB is a denormalized star schema built for fast star-join filtering. Trino ships build-side key sets (bloom filters) into its scans, which prunes the lineorder fact table before it is read. SQE’s equivalent, shipping build-side key sets to distributed workers, is in progress. The lineorder fact has a uniform foreign-key distribution that defeats row-group min/max pruning, so the runtime filter only helps at row level today.

TPC-DS has the most misses at SF1 (93/99). The six gaps are GROUPING SETS edge cases around grand-total row presence; they are the same six since March, not new regressions. TPC-E passes 11/11 but is the suite that historically needed the most work: it joins across 33 tables and uses IN-subquery patterns that DataFusion cannot always decorrelate, so deep-join queries dominate its run time.

The SF1 wins are decisive. At SF10 the picture narrows. On the level rig (Trino 481, totals across runs, single-node / distributed-2-worker / Trino range):

SuiteSQE single-nodeSQE distributed 2wTrino 481
TPC-H130.5s95.5s106.4s - 138.6s
SSB42.0s53.6s28.0s - 41.1s
TPC-DS543.9s338.3s328.4s - 468.0s

At SF10, TPC-H distributed (95.5s) lands inside Trino’s range, roughly par to ahead. TPC-DS distributed (338.3s) sits inside Trino’s range, close. SSB still trails at SF10, the same pattern as SF1. These are SF10 figures on a single rig, not the canonical SF1 results above.

How it is validated

Timing data is only as good as the result data behind it. Two layers of validation run before any number is trusted.

The first layer is differential validation against Trino. sqe-bench compare <suite> runs every query against SQE (Flight SQL) and Trino (HTTP) on the same Iceberg tables and diffs the result rows. A row-count or value mismatch fails the query. A query that returns zero rows on both engines is reported as vacuous, not a match: agreement on nothing validates nothing.

The second layer is an independent data oracle. DuckDB’s official dsdgen output loads side by side with the generated parquet, with per-table row counts and per-column null fractions checked, and all queries run against both datasets inside DuckDB. A query that returns rows on official data and none on ours is a generator-fidelity bug, found without either SQL engine in the loop.

The oracle earned its keep. It flagged 16 vacuous TPC-DS queries as generator gaps rather than engine bugs, and it settled the one genuine engine disagreement in SQE’s favor: TPC-DS q75 differs by two rows because Trino’s DECIMAL(17,2) division rounds two ratios up past a < 0.9 filter and drops them. DuckDB matches SQE exactly. The benchmark that looked like an SQE failure was a Trino rounding bug.

For the longitudinal view across every committed run, see the benchmark history on getsqe.com.

Generating Data

The generate command produces Parquet files on local disk or S3. Data is deterministic (seeded) so results are reproducible.

# Generate TPC-H at scale factor 1 (~1 GB, 8 tables)
cargo run -p sqe-bench -- generate tpch --scale 1 --output ./data

# Scale factor 10 (~10 GB)
cargo run -p sqe-bench -- generate tpch --scale 10 --output ./data

# Write directly to S3
cargo run -p sqe-bench -- generate tpch --scale 1 \
  --output s3://bench-data/ \
  --s3-access-key AKIA... \
  --s3-secret-key ... \
  --s3-endpoint http://localhost:9000 \
  --s3-region us-east-1

# Generate all benchmarks at once
./scripts/benchmark-generate-all.sh

Scale factors explained

The scale factor controls dataset size. Scale factor 1 produces roughly 1 GB for TPC-H and TPC-DS; SSB is ~600 MB at SF1.

Scale factorTPC-H sizeTPC-DS sizeUse case
1~1 GB~1 GBDevelopment, CI, correctness checks
10~10 GB~10 GBPerformance testing
100~100 GB~100 GBNear-production load
1000~1 TB~1 TBFull-scale benchmarking

Files are split at 128 MB for parallelism. Output is structured as:

./data/
└── tpch/
    └── sf1/
        ├── lineitem/
        │   ├── part-0000.parquet
        │   └── part-0001.parquet
        ├── orders/
        │   └── part-0000.parquet
        └── ... (8 tables total)

Direct-to-Iceberg Sink

generate --sink iceberg skips the staging Parquet step entirely. Instead of writing files to --output for a later load run, it connects straight to the Iceberg REST catalog, creates the tables, and commits data files with one fast_append per table. The sink works for every generator benchmark (TPC-H, TPC-DS, SSB, TPC-C, TPC-E, TPC-BB, ClickBench, and bank), not just the bank demo schema that introduced the sink.

cargo run -p sqe-bench -- generate tpch --scale 10 --sink iceberg \
  --catalog-uri http://localhost:8181/api/catalog \
  --warehouse bench --namespace tpch \
  --client-id ... --client-secret ...

The command creates the namespace if it does not exist, then generates and commits each table in turn. Data is written in parallel shards for TPC-H and bank; the other generators write one shard per table.

The sink buffers one full generation shard in memory before writing it, so peak memory scales with shard size: per-table for the serial generators, per rows / --threads for TPC-H and bank. At large scales, raise --threads to shrink each shard, or fall back to the parquet staging path (generate then load) if memory stays tight.

Two flags control repeated runs against the same namespace:

  • --resume skips a table that already carries a sqe-bench.table.<name>=done property, so an interrupted or repeated generate run does not redo work or duplicate rows. The marker is set on the table itself, not derived from snapshot history, because some catalogs serve a trimmed snapshot list.
  • --clean drops and recreates every table first, so the run is idempotent regardless of what state the namespace was in. --clean and --resume are mutually exclusive.

Without --resume or --clean, re-running generate against a table that already holds data commits another fast_append on top of it, duplicating every row. The done marker is only consulted when you pass --resume.

Catalog and storage settings match the load command’s --s3-* flags, plus the catalog’s OAuth2 client-credentials pair (--client-id/--client-secret) or a pre-acquired --bearer-token. See sqe-bench generate --help for the full flag list.

Loading Data

The load command connects to SQE and creates Iceberg tables using read_parquet() + CTAS. No intermediate format conversion is needed. Parquet files are read directly and written as Iceberg.

# Load TPC-H from local disk
cargo run -p sqe-bench -- load tpch \
  --scale 1 \
  --data ./data \
  --host localhost \
  --port 60051 \
  --username root \
  --password ""

# Load from S3
cargo run -p sqe-bench -- load tpch \
  --scale 1 \
  --data s3://bench-data/ \
  --s3-access-key AKIA... \
  --s3-secret-key ... \
  --s3-endpoint http://localhost:9000 \
  --s3-region us-east-1 \
  --host localhost \
  --port 60051 \
  --username root \
  --password ""

# Drop and recreate tables before loading
cargo run -p sqe-bench -- load tpch --scale 1 --data ./data --clean \
  --host localhost --port 60051 --username root --password ""

# Use the Trino HTTP protocol instead of Flight SQL
cargo run -p sqe-bench -- load tpch --scale 1 --data ./data \
  --protocol trino \
  --host localhost \
  --port 8080 \
  --username root \
  --password ""

The loader creates a namespace named <benchmark>_sf<N> (e.g., tpch_sf1) and sends one CTAS statement per table:

CREATE TABLE tpch_sf1.lineitem AS
SELECT * FROM read_parquet('/data/tpch/sf1/lineitem/*.parquet');

For S3 sources, inline credentials are injected:

CREATE TABLE tpch_sf1.lineitem AS
SELECT * FROM read_parquet(
  's3://bench-data/tpch/sf1/lineitem/*.parquet',
  access_key => 'AKIA...',
  secret_key => '...',
  endpoint => 'http://localhost:9000',
  region => 'us-east-1'
);

See read_parquet TVF for full syntax documentation.

Fast benchmark runs via attached golden tables

For the read-only suites (tpch, ssb, tpcds, clickbench), the load step dominates wall-clock time and adds nothing to the query measurement: every run rewrites the same parquet into fresh Iceberg tables before a single query runs. Publish those tables once into a persistent Polaris, then attach them read-only on every subsequent run instead of reloading.

Publish once:

BENCH_GOLDEN_POLARIS_URL=https://polaris.example.com/api/catalog \
BENCH_GOLDEN_WAREHOUSE=golden_warehouse \
BENCH_GOLDEN_TOKEN=<bearer> \
BENCH_S3_ENDPOINT=https://s3.example.com \
BENCH_GOLDEN_S3_ACCESS_KEY=... BENCH_GOLDEN_S3_SECRET_KEY=... \
BENCH_SCALE=1 ./scripts/benchmark-publish-iceberg.sh

Then run any read-only suite with BENCH_DATA_SOURCE=attach:

BENCH_GOLDEN_POLARIS_URL=https://polaris.example.com/api/catalog \
BENCH_GOLDEN_WAREHOUSE=golden_warehouse \
BENCH_GOLDEN_TOKEN=<bearer> \
BENCH_S3_ENDPOINT=https://s3.example.com \
BENCH_GOLDEN_S3_ACCESS_KEY=... BENCH_GOLDEN_S3_SECRET_KEY=... \
BENCH_DATA_SOURCE=attach BENCH_SCALE=1 ./scripts/benchmark-test.sh tpch

benchmark-test.sh attaches the golden catalog once (scripts/benchmark-attach-golden.sh) and queries each table as golden.<namespace>.<table> instead of generating and loading data. tpcc and tpce are write suites (their queries include DELETE/UPDATE via CoW), so they still generate and load normally even in attach mode; a shallow-clone path that would let them run against golden tables too, without mutating the shared copy, is planned but not yet built. bank and tpcbb also still generate and load normally in attach mode: bank is published to golden by benchmark-publish-iceberg.sh, but attach mode does not yet query it from there, and tpcbb’s own tables (web_clickstreams, product_reviews) are not published at all, since tpcbb shares tpcds’s namespace and only needs the two tables it adds. Wiring bank/tpcbb into attach mode is the deferred follow-up alongside the tpcc/tpce shallow-clone path. Attach is loud by design: a missing or unreachable golden catalog fails the run with the ATTACH error rather than silently falling back to a full load, which would mask the exact cost this path removes.

Unified harness (benchmark.sh + sqe-bench run)

For the read-only suites (tpch, ssb, tpcds, clickbench), the unified harness combines data attachment with query execution in one command:

BENCH_PROFILE=local BENCH_SCALE=1 scripts/benchmark.sh tpch ssb tpcds clickbench

The scripts/benchmark.sh orchestrates two steps. First, it attaches the golden Iceberg catalog (published once via benchmark-publish-iceberg.sh). Second, it runs the suites through sqe-bench run, which executes all queries against the attached tables and emits JSON reports to benchmarks/results/.

Configuration profiles live in benchmarks/profiles/<name>.toml. Profiles define the coordinator config, catalog credentials, and suite-specific settings. Credentials come from environment variables or AWS profiles, never stored in the TOML file itself. The profile loader redacts secrets on error and checks for committed keys at startup.

Each read-only suite attaches golden (zero load); write suites (tpcc, tpce) are a follow-up plan. The old benchmark-*.sh scripts remain: the migration to unified is a later commit after parity is confirmed.

Running Tests

The test command executes all queries in the benchmark suite against the loaded data and reports correctness and timing.

# Run all TPC-H queries (Flight SQL, default)
cargo run -p sqe-bench -- test tpch \
  --scale 1 \
  --host localhost \
  --port 60051 \
  --username root \
  --password ""

# Run a single query
cargo run -p sqe-bench -- test tpch --scale 1 --query q03 \
  --host localhost --port 60051 --username root --password ""

# Use Trino HTTP protocol
cargo run -p sqe-bench -- test tpch --scale 1 \
  --protocol trino \
  --host localhost \
  --port 8080 \
  --username root \
  --password ""

# Run all benchmarks end-to-end
./scripts/benchmark-test.sh tpch
./scripts/benchmark-test.sh tpcds
./scripts/benchmark-test.sh ssb

Query result statuses

StatusMeaning
PASSResult matches expected output exactly (within numeric tolerance)
DIFFResult matches in shape but has minor differences (e.g., decimal precision)
FAILResult is wrong: wrong rows, wrong values, wrong schema
SKIPQuery requires an unimplemented feature; counted but not failed
ERRORQuery failed to execute (engine error, timeout, crash)

DIFF is not treated as a failure in CI. It is a signal for investigation. Decimal precision differences are expected when comparing float-heavy aggregates across different engines.

Queries can declare their requirements in a header comment:

-- name: Revenue by nation
-- requires: delete, merge
-- timeout: 30s
SELECT ...

Any query with -- requires: will be SKIPped if SQE does not support that feature, rather than FAILing the suite.

Understanding Results

Terminal output

TPC-H SF1 - Flight SQL (localhost:50051)
─────────────────────────────────────────
q01  PASS   1.23s   6001215 rows
q02  PASS   0.45s       460 rows
q03  PASS   0.89s     11620 rows
...
q17  DIFF   2.10s         1 rows  (decimal precision)
q22  PASS   0.33s         7 rows

Results: 20/22 PASS, 1 DIFF, 1 SKIP
Total time: 28.4s
Report: benchmarks/results/tpch-sf1-flight-2026-03-24T14:30:00.json

JSON report format

Reports are written to benchmarks/results/<benchmark>-sf<N>-<protocol>-<timestamp>.json:

{
  "benchmark": "tpch",
  "scale_factor": 1,
  "protocol": "flight",
  "host": "localhost:50051",
  "timestamp": "2026-03-24T14:30:00Z",
  "summary": {
    "total": 22,
    "pass": 20,
    "fail": 0,
    "diff": 1,
    "skip": 1,
    "error": 0,
    "total_duration_ms": 28400
  },
  "queries": [
    {
      "id": "q01",
      "status": "pass",
      "duration_ms": 1230,
      "rows": 6001215
    },
    {
      "id": "q17",
      "status": "diff",
      "duration_ms": 2100,
      "rows": 1,
      "diff_detail": "decimal precision mismatch: expected 1.0000, got 0.9999"
    }
  ]
}

JSON reports are machine-readable and suitable for tracking regressions over time in CI.

Historical Performance Tracking

Benchmark JSON results are committed to benchmarks/results/ for historical comparison. This enables tracking performance regressions and improvements across releases. The committed JSONs feed the per-suite, per-scale, per-query timeline on getsqe.com/performance; refer there for the longitudinal view of how each suite moved across the optimization work.

Comparing against Trino

The benchmark harness can run the same suite against a real Trino on the same data, so you can compare SQE and Trino directly. The Results section above is the output of exactly this run at SF1. There are two modes:

  • Correctness parity: --compare-trino diffs SQE’s results against Trino’s row-for-row. This is how SQL correctness is validated at scale, not just timing. A row-count or value mismatch fails the query. A query that returns zero rows on both engines is reported as vacuous, not a match, because agreement on nothing validates nothing. Small decimal differences on float-heavy aggregates are flagged for investigation rather than treated as failures.
  • Timing: the same run records per-query wall-clock for both engines, so a head-to-head speed comparison falls out of the parity run.

Run a comparison yourself and see the captured numbers in the benchmark quickstart: Benchmarks: TPC-H / TPC-DS / SSB, or in the repo under benchmarks/.

CI/CD Integration

All three scripts support automated use without a TTY:

# Generate data once (idempotent - skip if files exist)
./scripts/benchmark-generate-all.sh

# Load all benchmarks
./scripts/benchmark-load.sh

# Run and report
./scripts/benchmark-test.sh tpch
./scripts/benchmark-test.sh tpcds
./scripts/benchmark-test.sh ssb

# Exit code is 0 if all queries are PASS or SKIP
# Exit code is 1 if any query is FAIL or ERROR

A typical CI pipeline runs TPC-H at SF1 as a smoke test on every PR, and the full suite (TPC-H + TPC-DS + SSB) nightly.

Query Files

Query SQL files are stored under benchmarks/queries/<benchmark>/:

benchmarks/
├── queries/
│   ├── tpch/     q01.sql ... q22.sql
│   ├── tpcds/    q01.sql ... q99.sql
│   ├── ssb/      q1.1.sql ... q4.3.sql
│   ├── tpcc/     order_status.sql, stock_level.sql, ...
│   ├── tpce/     trade_lookup.sql, customer_position.sql, ...
│   └── tpcbb/    q01.sql ... q10.sql
├── expected/
│   ├── tpch/sf1/    q01.csv ... q22.csv
│   └── ...
└── schemas/
    ├── tpch.sql
    ├── tpcds.sql
    └── ...

Expected results under benchmarks/expected/ are CSV files containing the correct output at the given scale factor. They are committed to the repository and used for regression checking.

Adding New Benchmarks

Implement the BenchmarkGenerator trait in sqe-bench/src/generate/:

#![allow(unused)]
fn main() {
pub trait BenchmarkGenerator {
    fn name(&self) -> &str;
    fn tables(&self) -> Vec<TableDef>;
    fn generate_table(
        &self,
        table: &str,
        scale: f64,
        output: &dyn ObjectStore,
        prefix: &str,
    ) -> Result<GenerateStats>;
}

pub struct TableDef {
    pub name: String,
    pub schema: Arc<Schema>,             // Arrow schema
    pub row_count_fn: fn(f64) -> usize,  // scale factor to row count
}
}

Steps to add a benchmark:

  1. Create sqe-bench/src/generate/<name>.rs implementing BenchmarkGenerator.
  2. Add SQL query files under benchmarks/queries/<name>/.
  3. Add expected result CSVs under benchmarks/expected/<name>/sf1/.
  4. Register the generator in sqe-bench/src/generate/mod.rs.
  5. Add the benchmark name to the CLI subcommand list in sqe-bench/src/cli.rs.
  6. Add a schema DDL file under benchmarks/schemas/<name>.sql for reference.

Supported catalog backends

SQE supports multiple Iceberg catalog backends and wire-protocol client adapters. The same binary works with every option below; choose by setting the [catalog.backend] block in your TOML. Catalog weight is opt-in through cargo features: a default REST-only build pulls no AWS SDK, no Thrift, no sqlx. Add glue, s3tables, hms, or sql as needed; rest and hadoop are always available.

For per-backend TOML configuration, credential setup, and troubleshooting, see Catalog backends.


Polaris (and any Iceberg REST catalog)

Apache Polaris exposes the Iceberg REST catalog specification. SQE uses this as its default backend. Any Iceberg REST-compatible service (Polaris, Lakeformation REST, custom) works with the same config block.

[catalog]
polaris_url = "http://localhost:18181/api/catalog"
warehouse   = "test_warehouse"

See the quickstart: Polaris + Keycloak (client credentials).


AWS Glue Data Catalog

Native AWS SDK integration against the regional Glue Data Catalog. Credentials come from the standard provider chain (AWS_PROFILE, instance profile, SSO).

[catalog.backend]
type      = "glue"
region    = "eu-example-1"
warehouse = "s3://my-bucket/warehouse"

See the quickstart: AWS Glue Data Catalog.


AWS S3 Tables

Managed Iceberg via the federated Glue Iceberg REST endpoint with AWS SigV4 authentication on every request. Backed by the vendored iceberg-catalog-rest crate with the aws-sigv4 feature.

[catalog.backend]
type             = "s3tables"
table_bucket_arn = "arn:aws:s3tables:eu-example-1:ACCOUNT:bucket/NAME"

See the quickstart: AWS S3 Tables (managed Iceberg).


Unity Catalog OSS

Unity Catalog OSS exposes an Iceberg REST adapter at /api/2.1/unity-catalog/iceberg/. The OSS image is read-only on create/drop; use for query workloads.

docker compose -f docker-compose.unity.yml up -d
set -a; source .env; set +a
cargo test -p sqe-catalog --test backends_integration -- --ignored unity_catalog::

See the quickstart: Unity Catalog OSS (Iceberg REST, read-only).


Hive Metastore

Thrift metastore protocol. Requires the hms cargo feature.

docker compose -f docker-compose.hms.yml up -d
set -a; source .env; set +a
cargo test -p sqe-catalog --features hms --test backends_integration -- --ignored hms::

Covered by the suite (hms::live_hms_namespace_round_trip, a create / list / drop round-trip against the Thrift metastore).


Project Nessie

Git-like Iceberg REST catalog with branch/tag semantics.

docker compose -f docker-compose.nessie.yml up -d
set -a; source .env; set +a
cargo test -p sqe-catalog --test backends_integration -- --ignored nessie::

See the quickstart: Project Nessie (Iceberg REST catalog).


Hadoop (filesystem warehouse, no catalog service)

No metadata service. SQE walks the warehouse prefix for metadata.json files. See Embedded mode for the catalog-free embedded flow.

[catalog.backend]
type      = "hadoop"
warehouse = "s3://my-bucket/warehouse"

Quack (DuckDB wire protocol)

Quack is not an Iceberg catalog; it is a client/server wire protocol that lets DuckDB clients (and other Quack-compatible tools) issue SQL to SQE and receive Arrow-serialised results. It sits alongside the Iceberg catalog backends, not in competition with them.

See Quack.


How catalog backends are tested

  • sqe-catalog/tests/backends_integration.rs: live round-trips per backend (create / list / drop namespace, or read smoke), gated on #[ignore] plus the .env warehouse variables.
  • sqe-catalog/tests/mount_*_test.rs: mount-time validation per backend (rejects bad secrets, requires a warehouse, and so on).

Embedded mode

SQE can run the full query engine in-process, with no server and no network. sqe-cli in embedded mode starts DataFusion, the Iceberg reader, and the same SQL planner locally, inside the CLI process. This is the fastest path for querying a warehouse from a laptop, a CI job, or a script.

Four warehouse modes:

  • In-memory (--memory): a transient DataFusion catalog. Nothing is persisted. Good for ad-hoc SQL and testing SQL functions.
  • Filesystem warehouse (--warehouse PATH): an Iceberg warehouse on local disk or object storage with no catalog service. SQE walks the path for metadata.json files and treats the prefix as the catalog. The “Iceberg without a catalog” case.
  • Persistent SQLite catalog (--catalog-backend sqlite): a durable single-node catalog backed by a local SQLite file. Survives restarts.
  • Cloud catalogs embedded: Glue and S3 Tables can be attached directly, with no coordinator, using the standard AWS credential chain.

See the quickstarts:


In-memory

sqe-cli --embedded --memory -e "SELECT 1 AS one"
+-----+
| one |
+-----+
| 1   |
+-----+

Filesystem warehouse (no catalog service)

Point at a directory; SQE reads the Iceberg metadata directly. No Polaris, no Glue, no metastore.

sqe-cli --embedded --warehouse /data/warehouse \
    -e "SELECT COUNT(*) FROM sales.orders"

This is the catalog-free Hadoop mode. Writes need atomic rename, which object stores do not all provide, so this mode is read-oriented; for writes use a real catalog. The same backend powers the [catalog.backend] type = "hadoop" server config.

Cloud catalogs embedded

The embedded engine can attach a Glue or S3 Tables catalog directly, with no coordinator. Pass --catalog-backend plus the cloud warehouse; credentials come from the standard AWS provider chain (AWS_PROFILE, instance profile, SSO). These catalogs attach read-only (query, not write); use the server for writes. Requires the aws cargo feature, which is off by default to keep the AWS SDK out of standard builds: cargo install --path crates/sqe-cli --features aws.

# AWS Glue Data Catalog (warehouse is an s3:// prefix)
AWS_PROFILE=analytics sqe-cli --embedded \
    --catalog-backend glue \
    --catalog-warehouse s3://my-bucket/warehouse --region eu-example-1 \
    -e "SELECT * FROM glue.analytics.events LIMIT 10"

# AWS S3 Tables (warehouse is the table-bucket ARN)
AWS_PROFILE=analytics sqe-cli --embedded \
    --catalog-backend s3tables \
    --catalog-warehouse arn:aws:s3tables:eu-example-1:ACCOUNT:bucket/NAME \
    --region eu-example-1 \
    -e "SHOW SCHEMAS"

The catalog mounts under the backend name by default (glue. / s3tables.); override with --catalog-name.

Writing data

The embedded engine ships the same Iceberg write path as the cluster: DDL, CTAS, INSERT, UPDATE, DELETE, MERGE INTO, all against the local SQLite-backed catalog. A laptop session can build real Iceberg tables, not just read them.

CREATE SCHEMA iceberg.sales;

CREATE TABLE iceberg.sales.orders (
    id     BIGINT,
    region VARCHAR,
    ts     TIMESTAMP,
    total  DECIMAL(18,2)
);

-- Land external data straight into Iceberg. CTAS streams, so a file
-- larger than memory still loads without OOM.
CREATE TABLE iceberg.sales.orders_2026 AS
SELECT id, region, ts, total
FROM read_parquet('s3://bucket/2026/*.parquet')
WHERE total > 0;

INSERT INTO iceberg.sales.orders VALUES (1, 'eu', NOW(), 99.95);

UPDATE iceberg.sales.orders SET total = total * 1.21 WHERE region = 'eu';

DELETE FROM iceberg.sales.orders WHERE id < 100;

MERGE INTO iceberg.sales.orders t
USING iceberg.sales.orders_2026 s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET total = s.total
WHEN NOT MATCHED THEN INSERT (id, region, ts, total)
                    VALUES (s.id, s.region, s.ts, s.total);

Default DML mode is Copy-on-Write. Opt a table into Merge-on-Read with the standard Iceberg properties:

ALTER TABLE iceberg.sales.orders
SET TBLPROPERTIES ('write.delete.mode' = 'merge-on-read');

Branching, tagging, and time travel work from the embedded prompt too; see DML for SET WRITE_BRANCH and FOR VERSION AS OF, and DDL for CREATE BRANCH / CREATE TAG. Exporting results to plain files goes through COPY ... TO; see Using the CLI.

Tables in the warehouse are valid Iceberg. Point a cluster deployment (or any other Iceberg reader) at the same path later and they come along unchanged.

Cookbook

Common embedded patterns in one place. Details on each TVF live in read_parquet and the file-format TVFs; path and credential forms in Storage backends.

Inspect an unknown file:

SELECT * FROM '/tmp/unknown.parquet' LIMIT 10;

Pull a public dataset into a local Iceberg table:

CREATE TABLE iceberg.demo.titanic AS
SELECT * FROM read_csv(
    'https://raw.githubusercontent.com/datasets/titanic/main/data/titanic.csv'
);

Materialize a HuggingFace dataset locally:

CREATE TABLE iceberg.demo.squad AS
SELECT * FROM read_parquet(
    'hf://datasets/squad/plain_text/train-00000-of-00001.parquet'
);

Join an Iceberg table against a raw file without loading it first:

SELECT i.region, sum(d.amount)
FROM iceberg.sales.orders i
JOIN read_parquet('/exports/transactions.parquet') d ON i.id = d.order_id
GROUP BY i.region;

Read a semicolon-separated European CSV:

SELECT * FROM read_csv('data/financial.ssv', sep => ';');

One-shot query from a shell script, machine-readable output:

sqe-cli --embedded \
    -e "SELECT count(*) FROM read_parquet('s3://bucket/sales/*.parquet')" \
    --format csv

Run a SQL script, abort on the first error:

sqe-cli --embedded --file daily-report.sql --stop-on-error

Differences from cluster mode

The embedded prompt speaks the same SQL surface as the coordinator’s Flight SQL endpoint: same parser, same planner, same Trino-compat function set, same Iceberg V2/V3 readers and writers. What changes is the deployment shape, and a few things fall away with it:

  • No auth. No OIDC, no bearer tokens, no per-user identity, no policy enforcement. The process runs as the Unix user.
  • Local catalogs plus read-only Glue / S3 Tables. Shared REST catalogs (Polaris, Nessie, Unity) and writes to cloud catalogs need the cluster path.
  • Single-node execution. No worker fan-out, no distributed shuffle. The query memory pool is capped by --memory-limit (default 1GB).
  • Single writer. The SQLite catalog is a single-process catalog. Two embedded sessions writing the same warehouse at once will conflict.
  • No observability endpoints. Prometheus metrics, OpenTelemetry, and the audit log live in the server.

How it is tested

  • crates/sqe-cli/tests/cli_smoke.rs: binary-level flag parsing, exit codes, mutually-exclusive flag rejection, and the --embedded --memory happy path.
  • The catalog spec parser (NAME=PATH) is validated for empty names, missing separators, and dotted names.

Notes

  • --memory and --warehouse are mutually exclusive.
  • Local-path TVFs (read_csv and friends) work in embedded mode; the embedded engine enables allow_local_paths so a laptop user can read local files.
  • Embedded mode authenticates the OS user against the configured catalog’s credential source, not OIDC; there is no server to pass tokens through.

Flight SQL

Arrow Flight SQL is SQE’s primary wire protocol. It is columnar end to end: results come back as Arrow record batches over gRPC, with no row-by-row serialization tax. Every official client (the sqe-cli, ADBC drivers, the dbt-sqe adapter) speaks it. This page covers both topologies against Apache Polaris.

Single server

One coordinator parses, plans, and executes. Good for development, small deployments, and the default Docker image.

Prerequisites

docker compose -f docker-compose.test.yml up -d
./scripts/bootstrap-test.sh

This brings up Polaris (http://localhost:18181) and an S3-compatible store, then creates the test_warehouse warehouse and the default / test_ns namespaces.

Configuration

The coordinator reads one TOML file. The Flight SQL listener is on 50051:

[auth]
token_endpoint = "http://localhost:18181/api/catalog/v1/oauth/tokens"
client_id      = "root"

[catalog]
catalog_url = "http://localhost:18181/api/catalog"
warehouse   = "test_warehouse"

[storage]
s3_endpoint   = "http://localhost:19000"
s3_region     = "us-east-1"
s3_path_style = true

Run

# Start the coordinator (Flight SQL on 50051).
./target/release/sqe-server --config sqe.toml

# Connect with the CLI over Flight.
./target/release/sqe-cli --protocol flight --host localhost --port 50051 \
    -u root -e "SELECT 1 AS one"

Expected output

 one
-----
 1
(1 row)

The in-process equivalent is exercised by integration_test.rs: test_authentication (OIDC client-credentials against Polaris), test_simple_select, and the file-format TVF tests all run the full Flight SQL query path against this stack.

How it is tested

  • crates/sqe-coordinator/tests/integration_test.rs (run with ./scripts/integration-test.sh): authentication, SELECT, CTAS round-trip, information_schema, and read_parquet/read_csv/read_json.

Distributed (coordinator + workers)

The coordinator parses and plans, then ships secured plan fragments to stateless workers over Arrow Flight. Workers hold no catalog state; they receive the plan and the user’s bearer token and execute.

Prerequisites

docker compose -f docker-compose.test.yml -f docker-compose.distributed.yml up --build -d
./scripts/bootstrap-test.sh

This adds a coordinator (Flight SQL on 60051) and two workers (internal 50052, exposed as 60061 / 60062) on the shared Polaris and storage.

Run

scripts/test.sh scenario distributed

The scenario builds sqe-cli, runs SQL over Flight on 60051, and verifies worker dispatch through system.runtime.tasks (proving fragments actually reach the workers rather than silently falling back to local execution).

Expected output

The scenario runs a sequence of SQL statements over Flight on 60051 and prints a pass line per check, ending with the worker-dispatch verification. The single-node Flight path (auth, CTAS, SELECT against live Polaris) was re-run this round through the TVF integration tests (3/3, 23.6s); the distributed harness is covered by the suite and the committed benchmark baselines (TPC-H SF1 distributed 22/22 in 12.0s, 3.1x over single node).

How it is tested

  • crates/sqe-coordinator/tests/integration_test.rs::test_distributed_select (ignored by default; needs workers listening on :50052).
  • quickstart/distributed (run via scripts/test.sh scenario distributed): full coordinator + worker harness.
  • scripts/concurrent-test.sh: N parallel Flight clients, cache behaviour.

Notes

  • Auth is bearer-token passthrough: the CLI authenticates the user via OIDC, and the token rides to Polaris and S3. There is no service account.
  • The internal Flight port is 50051; compose maps it to 60051 to avoid colliding with a local coordinator.
  • Workers are stateless. Scale by adding worker replicas; the coordinator load-balances fragments across registered workers.

Trino HTTP compatibility

SQE speaks enough of the Trino HTTP protocol that Trino clients, JDBC drivers, and BI tools can point at it unchanged. The coordinator exposes the /v1/statement endpoint with nextUri pagination, /v1/info, and /v1/info/state. This is a compatibility surface, not a re-implementation of Trino; it covers the query-submission path that clients actually use.

Single server

Prerequisites

docker compose -f docker-compose.test.yml up -d
./scripts/bootstrap-test.sh

Configuration

The Trino HTTP listener runs alongside Flight SQL. The default port is 8080 (the test/distributed compose files map it to 28080):

[coordinator]
trino_http_port = 8080    # 0 to disable

Run

# Submit a query. Basic auth carries the user; the password is the OIDC secret
# (empty for the local root client).
curl -s -u root: \
  -H "X-Trino-User: root" \
  -d "SELECT 1 AS one" \
  http://localhost:28080/v1/statement

Trino clients follow the nextUri field until results are exhausted. A JDBC client connects with the Trino driver against http://localhost:28080.

Expected output

The first response carries a nextUri; following it returns the data:

{
  "columns": [{"name": "one", "type": "bigint"}],
  "data": [[1]],
  "stats": {"state": "FINISHED"}
}

How it is tested

  • crates/sqe-coordinator/tests/integration_test.rs::test_trino_http_query: server startup, Basic auth, /v1/statement POST, pagination.
  • test_trino_type_mapping, test_trino_batches_to_json: Arrow to Trino JSON.
  • scripts/trino-parity-test.sh and scripts/trino-compat-test.sh: run the same SQL against SQE and a real Trino and diff the results.

Distributed

The Trino HTTP endpoint lives on the coordinator. Distribution across workers is identical to the Flight path: the coordinator plans, workers execute. The client sees a single Trino-compatible endpoint regardless of worker count.

Prerequisites

docker compose -f docker-compose.test.yml -f docker-compose.distributed.yml up --build -d
./scripts/bootstrap-test.sh

Run

scripts/test.sh scenario distributed

The scenario exercises the Trino HTTP endpoint on 28080 alongside the Flight path and confirms worker dispatch.

Expected output

The distributed scenario’s Trino check submits a query to the HTTP endpoint on the coordinator (28080) and follows nextUri to completion, alongside the Flight path on the same cluster. This is covered by the suite; the docker-dependent re-run was not repeated this round (see the validation matrix note on local Docker capacity).

Trino SQL parity

SQE adds a Trino-compatibility function layer (date/time helpers like year(), month(), day_of_week(), JSON casts, and more) so dbt models and Trino SQL run with fewer rewrites. The current parity surface is tracked in Trino Compatibility. The parity scripts above are the regression guard.

Notes

  • Authentication needs Basic auth (-u user:password) to populate the session, not just the X-Trino-User header. For the local root client the password is empty (-u root:).
  • The Trino HTTP endpoint is enabled by default on [coordinator] trino_http_port = 8080; set the port to 0 to disable it. Flight SQL is the recommended protocol for SQE-native clients.

SQL Reference

A function-by-function and statement-by-statement reference for everything SQE can parse and execute. Every entry lists where the implementation lives so you can jump from “what does this do” to “where do I read the code”.

The reference focuses on what ships in the running engine, not the SQL standard in the abstract. If a function is not listed here, it is not registered in our SessionContext.

How to read these tables

Every page uses the same column shape. The first three columns describe the function in SQE; the four right columns describe how the same idea looks in other engines.

ColumnMeaning
FunctionThe name SQE accepts in SQL. Case-insensitive on the surface, lower-case canonical name.
OriginWhere the implementation comes from. See origins below.
NotesOne-line summary, return type, gotchas, link to source line.
TrinoThe Trino-equivalent function name, or - if Trino has none.
SnowflakeThe Snowflake-equivalent function name, or - if Snowflake has none.
Spark SQLThe Spark-equivalent function name, or - if Spark has none.
DuckDBThe DuckDB-equivalent function name, or - if DuckDB has none.

Origins

Every function in SQE has exactly one origin. Eight values appear:

Origin tagWhat it meansWhere it lives
datafusion-builtinShipped automatically with SessionContext::new(). No SQE registration.Upstream datafusion-functions-* crates.
datafusion-functions-jsonDataFusion JSON helper crate, registered explicitly.datafusion_functions_json::register_all() in session_context.rs:361.
sqe-trino-functionsOur Trino-compatibility crate. Adds Trino names for things DataFusion calls differently.sqe-trino-functions/src/trino_functions.rs and trino_functions_ext.rs.
sqe-trino-functions (ext)Extended Trino aliases. Same crate, separate registration call.register_extended_trino_functions() in the same crate.
sqe-policySecurity crate. Currently exposes one UDF (sha256) used by column masks.sqe-policy/src/sha256_udf.rs.
sqe-catalogIceberg catalog and TVF crate. Provides read_* and table_* table functions.sqe-catalog/src/.
sqe-sqlParser extension. Statements pre-parsed before DataFusion sees them.sqe-sql/src/.
sqe-coordinatorStatement router. Handles statements that need catalog calls or auth before execution.crates/sqe-coordinator/src/query_handler.rs, catalog_ops.rs.

The two registration entry points are crates/sqe-coordinator/src/session_context.rs (cluster mode) and crates/sqe-cli/src/embedded.rs (single-binary mode). Both register the same UDFs / UDTFs in the same order, so a function works the same way in both personas.

Pages

Scalar functions

  • Conditional and null-handling: if, iff, case, coalesce, nullif, greatest, least, nvl, nvl2, typeof, try.
  • String: concat, substring, trim, lower, upper, regex, normalisation, split, format, hash digests.
  • Math: trig, rounding, logs, exponents, sign, modular, base conversion.
  • Date and time: timestamp construction, extraction, formatting, parsing, arithmetic, time-zone handling.
  • Array, map, struct: the 40+ functions from datafusion-functions-nested plus Trino aggregate constructors (map_agg, histogram).
  • JSON: two layered surfaces: Trino-named (json_extract, json_parse) and the datafusion-functions-json json_get_* family.
  • Encoding, hashing, URL: base64, hex, md5, sha224..512, url_extract_*, url_encode, url_decode.

Aggregate and window

  • Aggregate functions: count, sum, avg, statistical, regression, array_agg, string_agg / listagg, histogram, map_agg, approximation.
  • Window functions: row_number, rank, lag, lead, first_value, frame syntax (ROWS BETWEEN, RANGE BETWEEN, GROUPS BETWEEN).

Table-valued functions

  • Table-valued functions: file format (read_parquet, read_csv, read_json, read_delta), Iceberg metadata (table_snapshots, table_history, table_files, table_partitions, table_manifests, table_refs), generators (generate_series, unnest).

Statements

  • DDL: CREATE, ALTER, DROP for tables, schemas, views; partition evolution; branches and tags; column defaults.
  • DML: SELECT, INSERT, UPDATE, DELETE, MERGE, COPY TO, TRUNCATE, time travel (FOR VERSION AS OF, FOR SYSTEM_TIME AS OF, FOR INCREMENTAL BETWEEN), SET WRITE_BRANCH.
  • CALL procedures: system.rewrite_data_files, expire_snapshots, remove_orphan_files, rewrite_manifests, suggest_bloom_filter_columns.
  • GRANT and REVOKE: SQE-specific security extensions. GRANT MASKED WITH, GRANT ROWS WHERE, SHOW GRANTS, SHOW EFFECTIVE GRANTS, CHECK ACCESS.
  • SHOW and EXPLAIN: metadata queries and plan inspection. SHOW CATALOGS, SHOW STATS, EXPLAIN FULL.
  • Operators: arithmetic, string, comparison, null tests, casting (::), set membership.

Embedded CLI

  • Dot-commands: .help, .tables, .schema, .describe, .summarize, .timer, .read, .format. Embedded CLI only.

What is intentionally not in SQE

Some functions appear in the dialect comparison columns as missing. The reasoning:

  • PIVOT, UNPIVOT, QUALIFY, ASOF JOIN, FROM-first syntax: DataFusion’s parser does not accept them. Tracked upstream.
  • Lambda expressions, list comprehensions: DataFusion has no AST node for closures.
  • Oracle / Snowflake DECODE: name collides with DataFusion’s binary decode(input, encoding) helper. CASE WHEN covers the use case.
  • IIF (T-SQL): covered by if (Trino) and iff (Snowflake), both registered.
  • postgres_table_scanner, mysql_table_scanner, sqlite_scanner: out of scope. SQE is Iceberg-first; if you need a non-Iceberg engine, query it where it lives.
  • spatial, vss, fts, excel: niche. Use a tool built for the job (PostGIS, a vector DB, an FTS engine).

The full DuckDB-comparison audit lives at getsqe.com/compare/duckdb. The Trino-comparison audit lives at getsqe.com/compare/trino.

Conditional and null-handling

Functions for choosing between values, replacing nulls, and inspecting types. Most are scalar UDFs; CASE WHEN is a SQL expression handled by the planner.

Function table

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
if(cond, then, else)sqe-trino-functions3-arg conditional. NULL condition returns else. Result type is the type of then. trino_functions.rs:955if-ifif
iff(cond, then, else)sqe-trino-functionsIdentical semantics to if. Snowflake spelling. NULL condition returns else. trino_functions.rs:1039-iff--
case when ... then ... [else ...] enddatafusion-builtinSearched form. Walks branches in order; first true when wins.casecasecasecase
case <expr> when <val> then ... enddatafusion-builtinSimple form. Compares expr to each when val; first equal wins. NULL never matches.casecasecasecase
coalesce(a, b, ...)datafusion-builtinFirst non-NULL argument. Variadic. Returns NULL if every arg is NULL.coalescecoalescecoalescecoalesce
nullif(a, b)datafusion-builtinReturns NULL when a = b, else returns a. Inverse of coalesce(nullif(...), default) for “blank to null” patterns.nullifnullifnullifnullif
nvl(a, b)datafusion-builtinTwo-arg coalesce shape. Returns a if non-NULL, else b.-nvlnvl-
nvl2(expr, when_not_null, when_null)datafusion-builtinThree-arg form: branches on whether expr IS NULL.-nvl2nvl2-
greatest(a, b, ...)datafusion-builtinMax of the arguments. NULLs ignored unless every argument is NULL. Variadic.greatestgreatestgreatestgreatest
least(a, b, ...)datafusion-builtinMin of the arguments. NULLs ignored unless every argument is NULL. Variadic.leastleastleastleast
typeof(expr)sqe-trino-functionsReturns the Arrow type as text ("Int64", "Utf8", "Timestamp(Microsecond, None)"). Trino spells the same way; result string differs by engine. trino_functions.rs:1031typeof--typeof
try(expr)sqe-trino-functions (ext)Catches errors from expr and returns NULL on failure. Handy for casting strings of unknown shape. trino_functions_ext.rs:76trytry_cast (different shape)--
arbitrary(col)sqe-trino-functions (ext)Aggregate that returns one non-deterministic non-NULL value. Trino name. Equivalent to any_value. trino_functions_ext.rs:68arbitraryany_valueany_valueany_value

Patterns

Replace NULL with a default

SELECT coalesce(comment, 'no comment') FROM orders;
SELECT nvl(comment, 'no comment') FROM orders;          -- two-arg shorthand

Treat empty strings as NULL

SELECT coalesce(nullif(name, ''), 'unknown') FROM users;

nullif(name, '') returns NULL when name is the empty string, then coalesce substitutes the default.

Branch on a boolean

SELECT
    iff(amount > 1000, 'large', 'small') AS bucket,    -- Snowflake
    if(amount > 1000, 'large', 'small') AS bucket_t    -- Trino
FROM orders;

Both calls produce the same result. Use whichever matches your team’s existing dbt models. dbt-snowflake projects ported to SQE keep iff() working unmodified.

Complex branching: prefer CASE

SELECT
    CASE
        WHEN amount < 100 THEN 'small'
        WHEN amount < 1000 THEN 'medium'
        ELSE 'large'
    END AS bucket
FROM orders;

Reach for CASE when there are more than two branches or the condition is not a single boolean expression.

Take the safer cast

SELECT try(CAST(payload AS BIGINT)) AS amount FROM events;

try() swallows the conversion error and returns NULL for rows that fail. Without it, one bad row aborts the query.

Type promotion

coalesce, greatest, least, if, iff all coerce arguments to a common supertype. The rules follow SQL standard widening: integer + decimal -> decimal; integer + double -> double; date + timestamp -> timestamp. If the arguments have no common supertype the planner returns an error before execution.

case is stricter: every branch must produce the same type, or the planner adds explicit casts when it can. Mixed types without an obvious supertype fail at plan time.

NULL handling cheat sheet

ConstructNULL inputResult
if(NULL, x, y)NULL conditiony (NULL treated as false)
iff(NULL, x, y)NULL conditiony (NULL treated as false)
case when NULL then x else y endNULL conditiony
coalesce(NULL, NULL, x)All but x are NULLx
nullif(NULL, x)First arg NULLNULL
nullif(x, NULL)Second arg NULLx (NULL is not equal to anything)
greatest(NULL, 1, 2)One NULL2 (NULLs skipped)
greatest(NULL, NULL)All NULLNULL

Why no IIF (T-SQL)

T-SQL’s IIF(cond, then, else) is the same shape as iff. SQE registers iff (Snowflake) and if (Trino), both pointing at the same implementation, so a T-SQL IIF rename is the only change needed. We deliberately did not register a third name to keep the function table tight.

Why no Oracle / Snowflake DECODE

Snowflake’s DECODE(expr, search1, result1, ..., default) is a multi-way conditional with NULL = NULL match semantics. Two reasons it is not in SQE:

  1. The name collides with DataFusion’s built-in decode(input, encoding), which decodes base64 / hex strings to binary. Registering a Snowflake-style DECODE under the same name would shadow the encoding helper and break any existing callsite.
  2. CASE WHEN expr IS NOT DISTINCT FROM s1 THEN r1 ... END covers the same ground in standard SQL. (IS NOT DISTINCT FROM treats NULL = NULL as true.)

The audit row lives in the feature comparison so the conflict is visible.

String functions

DataFusion contributes ~35 string functions plus a unicode submodule and a regex submodule. SQE adds Trino-name aliases and a few extras.

Concatenation

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
concat(a, b, ...)datafusion-builtinVariadic. NULL inputs become empty string.yesyesyesyes
concat_ws(sep, a, b, ...)datafusion-builtinConcat with separator; skips NULL args.yesyesyesyes
a || bdatafusion-builtinSQL standard concat. NULL propagates.yesyesyesyes

Length and offsets

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
length(s) / char_length(s) / character_length(s)datafusion-builtinNumber of characters. UTF-8-aware.yesyesyesyes
octet_length(s)datafusion-builtinNumber of bytes.yesyesyesyes
bit_length(s)datafusion-builtinoctet_length * 8.yesyesyesyes
position(needle in haystack) / strpos(haystack, needle)datafusion-builtin1-based offset of needle. 0 if not found.yesyesyesyes
find_in_set(needle, comma_list)datafusion-builtin1-based offset in a comma-separated list.--yes-
codepoint(s)sqe-trino-functionsUnicode codepoint of the first character. trino_functions.rs:101yes---
chr(n) / char(n)datafusion-builtinCodepoint -> character.yesyesyesyes
ascii(s)datafusion-builtinCodepoint of the first character.yesyesyesyes

Slicing

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
substring(s from start [for length])datafusion-builtinSQL standard. 1-based.yesyesyesyes
substr(s, start [, length])datafusion-builtinFunction form.yesyesyesyes
left(s, n)datafusion-builtinLeftmost n chars.yesyesyesyes
right(s, n)datafusion-builtinRightmost n chars.yesyesyesyes
split_part(s, delim, n)datafusion-builtinNth part after splitting by delim.yesyesyesyes
split(s, delim)sqe-trino-functionsReturns array of parts. trino_functions.rs:118yesyesyesyes
split_part(s, delim, n)datafusion-builtinSingle part by index.yesyesyesyes

Trimming

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
trim(s) / trim(both ' ' from s)datafusion-builtinDefault trims whitespace.yesyesyesyes
ltrim(s [, chars])datafusion-builtinTrim from left.yesyesyesyes
rtrim(s [, chars])datafusion-builtinTrim from right.yesyesyesyes
btrim(s [, chars])datafusion-builtinTrim both sides; explicit alias of trim.-yesyesyes

Case and padding

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
lower(s) / upper(s)datafusion-builtinCase conversion.yesyesyesyes
initcap(s)datafusion-builtinTitle-case each word.yesyesyesyes
lpad(s, n [, fill])datafusion-builtinPad on left to length n.yesyesyesyes
rpad(s, n [, fill])datafusion-builtinPad on right.yesyesyesyes
repeat(s, n)datafusion-builtinRepeat n times.yesyesyesyes
reverse(s)datafusion-builtinReverse the string.yesyesyesyes
replace(s, from, to)datafusion-builtinReplace all occurrences.yesyesyesyes
translate(s, from, to)datafusion-builtinPer-character substitution.yesyesyesyes
overlay(s placing rep from start [for length])datafusion-builtinSplice substring.yesyesyesyes
format(pattern, args...)sqe-trino-functions (ext)C-style printf. trino_functions_ext.rs:79yes-yes-

Predicates

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
starts_with(s, prefix)datafusion-builtinTrue if s begins with prefix.yesyesyesyes
ends_with(s, suffix)datafusion-builtinTrue if s ends with suffix.yesyesyesyes
contains(s, sub)datafusion-builtinTrue if s contains sub.-yesyesyes
s LIKE patterndatafusion-builtinSQL standard pattern. _ and %.yesyesyesyes
s ILIKE patterndatafusion-builtinCase-insensitive LIKE.yesyespartialyes
s SIMILAR TO patterndatafusion-builtinSQL/POSIX-light regex.yesyes-yes

Regular expressions

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
regexp_like(s, pattern)datafusion-builtinTrue if pattern matches anywhere.yesyesyesyes
regexp_match(s, pattern)datafusion-builtinReturns array of capture groups.yesyesyesyes
regexp_replace(s, pattern, repl [, flags])datafusion-builtinReplace matches. Flags g (global), i (insensitive).yesyesyesyes
regexp_count(s, pattern)datafusion-builtinCount of non-overlapping matches.yesyesyesyes
regexp_extract(s, pattern [, group])sqe-trino-functions (ext)Extract first match (or capture group N). trino_functions_ext.rs:46yesyesyesyes
regexp_extract_all(s, pattern [, group])sqe-trino-functions (ext)All matches as array. trino_functions_ext.rs:47yes-partial-
regexp_split(s, pattern)sqe-trino-functions (ext)Split by regex. trino_functions_ext.rs:48yes---

Hashing

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
md5(s)datafusion-builtin128-bit hex.yesyesyesyes
sha224(s)datafusion-builtinSHA-224 hex.yes-yes-
sha256(s)datafusion-builtin + sqe-policySHA-256 hex. SQE registers an alias used by column masks.yesyesyesyes
sha384(s)datafusion-builtinSHA-384 hex.yes-yes-
sha512(s)datafusion-builtinSHA-512 hex.yesyesyesyes
digest(s, algorithm)datafusion-builtinGeneric digest. Algos: md5, sha224, sha256, sha384, sha512, blake2s, blake2b, blake3.----

Distance / phonetic

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
levenshtein(a, b)datafusion-builtinEdit distance.yesyesyesyes
hamming_distance(a, b)sqe-trino-functions (ext)Hamming distance for equal-length strings. trino_functions_ext.rs:35yes---
soundex(s)sqe-trino-functions (ext)Soundex code. trino_functions_ext.rs:34yesyesyes-
word_stem(s [, lang])sqe-trino-functions (ext)Stemmer. Default English. trino_functions_ext.rs:61yes---

Encoding

See Encoding, hashing, URL for from_base64, to_base64, from_hex, to_hex, encode, decode.

Unicode normalization

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
normalize(s [, form])sqe-trino-functions (ext)Unicode normalization. Forms: NFC (default), NFD, NFKC, NFKD. trino_functions_ext.rs:49yes---
from_utf8(bytes)sqe-trino-functionsConvert binary to UTF-8 string. trino_functions.rs:85yes---
to_utf8(s)sqe-trino-functionsConvert string to UTF-8 binary. trino_functions.rs:86yes---

Examples

Cleanse user input

SELECT
    initcap(trim(lower(name))) AS name_clean,
    regexp_replace(email, '\\s+', '') AS email_clean,
    coalesce(nullif(trim(comment), ''), 'no comment') AS comment_clean
FROM users;

Extract domain from URL

SELECT
    url,
    regexp_extract(url, 'https?://([^/]+)', 1) AS host_via_regex,
    url_extract_host(url) AS host_via_helper
FROM access_logs;

url_extract_host is dramatically faster than the regex version. Prefer it whenever the input is well-formed URLs.

Tokenise a sentence

SELECT id, token
FROM articles, UNNEST(split(body, ' ')) AS t(token)
WHERE length(token) > 3;

Mask sensitive data

SELECT
    user_id,
    sha256(email) AS email_hash,
    concat('***', right(phone, 4)) AS phone_masked
FROM users;

(For declarative masking via grants, see GRANT and REVOKE.)

What is NOT supported

  • SOUNDEX_DIFFERENCE (T-SQL). use levenshtein(soundex(a), soundex(b)).
  • PARSE_URL family with named parts. Use the url_extract_* family in Encoding, hashing, URL.
  • STRING_SPLIT_TO_ARRAY. use split(s, delim).

Math functions

DataFusion contributes ~30 math functions. SQE adds a small set of Trino-named extras (e(), mod(), truncate(), sign()) plus base conversion and IEEE specials (infinity, nan).

Sign and rounding

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
abs(x)datafusion-builtinAbsolute value.yesyesyesyes
sign(x)sqe-trino-functions-1 / 0 / +1 (and NaN -> NaN). trino_functions.rs:100yesyesyesyes
ceil(x) / ceiling(x)datafusion-builtinRound up to integer.yesyesyesyes
floor(x)datafusion-builtinRound down.yesyesyesyes
round(x [, n])datafusion-builtinRound to N decimal places. Banker’s rounding by default.yesyesyesyes
trunc(x [, n])datafusion-builtinRound toward zero.yesyesyesyes
truncate(x [, n])sqe-trino-functionsTrino-named alias of trunc. trino_functions.rs:99yesyespartial-

Powers, logs, roots

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
pow(x, y) / power(x, y)datafusion-builtinx to the y.yesyesyesyes
sqrt(x)datafusion-builtinSquare root.yesyesyesyes
cbrt(x)datafusion-builtinCube root.yesyesyesyes
exp(x)datafusion-builtine^x.yesyesyesyes
ln(x)datafusion-builtinNatural log.yesyesyesyes
log(x [, base])datafusion-builtinLog base 10 by default; or specified base.yesyesyesyes
log2(x) / log10(x)datafusion-builtinSpecific bases.yesyesyesyes
e()sqe-trino-functionsEuler’s number. trino_functions.rs:97yes---
pi()datafusion-builtinPi as a constant.yesyesyesyes

Trigonometry

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
sin(x) / cos(x) / tan(x)datafusion-builtinStandard trig. Radians.yesyesyesyes
asin(x) / acos(x) / atan(x)datafusion-builtinInverse trig.yesyesyesyes
atan2(y, x)datafusion-builtinTwo-arg arctangent, full quadrant.yesyesyesyes
sinh(x) / cosh(x) / tanh(x)datafusion-builtinHyperbolic.yesyespartialyes
asinh(x) / acosh(x) / atanh(x)datafusion-builtinInverse hyperbolic.yes--yes
degrees(x)datafusion-builtinRadians -> degrees.yesyesyesyes
radians(x)datafusion-builtinDegrees -> radians.yesyesyesyes

Modular and bit / base

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
mod(n, m)sqe-trino-functionsModulo. Trino-named alias. trino_functions.rs:98yesyesyesyes
n % mdatafusion-builtinSQL operator form.yesyesyesyes
gcd(a, b)datafusion-builtinGreatest common divisor.yes--yes
lcm(a, b)datafusion-builtinLeast common multiple.yes--yes
factorial(n)datafusion-builtinn!.--yesyes
from_base(s, radix)sqe-trino-functions (ext)Parse a base-N string to integer. trino_functions_ext.rs:36yes---
to_base(n, radix)sqe-trino-functions (ext)Convert integer to base-N string. trino_functions_ext.rs:37yes---
SELECT to_base(255, 16);     -- 'ff'
SELECT from_base('ff', 16);  -- 255
SELECT to_base(8, 2);        -- '1000'

Random

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
random()datafusion-builtinUniform [0, 1). Volatile (re-evaluated per call).yesyesyesyes
uuid()datafusion-builtinRFC 4122 v4 random UUID.yesyesyesyes

IEEE specials

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
nanvl(x, y)datafusion-builtinIf x is NaN, return y; else x.--yes-
isnan(x)datafusion-builtinTrue if x is NaN.yes-yesyes
isinf(x)datafusion-builtinTrue if x is infinite.---yes
iszero(x)datafusion-builtinTrue if x is exactly zero.----
infinity()sqe-trino-functions (ext)Positive infinity (Double). trino_functions_ext.rs:28yes---
nan()sqe-trino-functions (ext)NaN (Double). trino_functions_ext.rs:29yes---

Statistical helpers

For aggregates (stddev, variance, corr, covar_*, regr_*), see Aggregate functions. The math page covers scalars only.

Examples

Bucketing and binning

SELECT
    floor(amount / 100) * 100 AS bucket,
    count(*)
FROM orders
GROUP BY 1
ORDER BY 1;

Geometric mean via logs

SELECT exp(avg(ln(price))) AS geo_mean FROM products WHERE price > 0;

DataFusion has no built-in geo_mean; the log identity is the standard workaround.

Distance from a reference point (Pythagorean)

SELECT
    name,
    sqrt(pow(x - 100, 2) + pow(y - 200, 2)) AS distance
FROM points
ORDER BY distance
LIMIT 10;

Hex and binary representations

SELECT
    n,
    to_base(n, 16) AS hex,
    to_base(n, 2)  AS bin,
    to_base(n, 8)  AS oct
FROM generate_series(0, 255) AS t(n);

Numeric type promotion

pow, log, exp always return Double regardless of input. abs, floor, ceil, round preserve the input type. +, -, * follow SQL standard widening: integer + decimal -> decimal; integer + double -> double; decimal + decimal -> decimal with combined precision.

/ between two integers in DataFusion returns Double, not integer. For integer division use floor(a / b) or div(a, b).

Decimal precision

DECIMAL(p, s) arithmetic widens precision per SQL standard. Two DECIMAL(18, 2) values multiplied produce DECIMAL(36, 4). Going beyond DECIMAL(38, ...) overflows; CAST or use Double.

Date and time

The largest single category in SQE. Two layers stack:

  1. DataFusion native functions: date_part, date_trunc, date_bin, extract, now, the to_timestamp_* family, make_date. Powerful but uses DataFusion-specific names.
  2. Trino aliases registered by sqe-trino-functions: year(), month(), day(), date_add(), date_diff(), format_datetime(). These cover every Trino date function so dbt-trino models work unmodified.

Snowflake and Spark also map well via the Trino layer.

Construction and current value

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
now()sqe-trino-functionsReturns TIMESTAMP(6). Stable within one query. trino_functions.rs:55nowcurrent_timestampnow / current_timestampnow / current_timestamp
current_timestampdatafusion-builtinSame value as now(). SQL keyword, no parens.current_timestampcurrent_timestampcurrent_timestampcurrent_timestamp
current_datedatafusion-builtinToday’s date in session timezone.current_datecurrent_datecurrent_datecurrent_date
current_timedatafusion-builtinCurrent wall-clock time.current_timecurrent_timecurrent_timecurrent_time
localtime()sqe-trino-functionsLocal time-of-day. Returns TIME(6). trino_functions.rs:64localtime---
localtimestamp()sqe-trino-functionsLocal timestamp without offset. Returns TIMESTAMP(6). trino_functions.rs:65localtimestamp---
current_timezone()sqe-trino-functions (ext)Returns the session timezone string. trino_functions_ext.rs:41current_timezone-current_timezone-
make_date(year, month, day)datafusion-builtinConstruct a DATE.-date_from_partsmake_datemake_date

Extraction (year, month, day, …)

These return integer parts. SQE registers Trino names; DataFusion’s extract and date_part are also available for SQL standard syntax.

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
year(d)sqe-trino-functionstrino_functions.rs:28yearyearyearyear
month(d)sqe-trino-functionstrino_functions.rs:29monthmonthmonthmonth
day(d)sqe-trino-functionstrino_functions.rs:30day / day_of_monthdaydayday
hour(d)sqe-trino-functionstrino_functions.rs:31hourhourhourhour
minute(d)sqe-trino-functionstrino_functions.rs:32minuteminuteminuteminute
second(d)sqe-trino-functionstrino_functions.rs:33secondsecondsecondsecond
millisecond(d)sqe-trino-functions (ext)Sub-second component. trino_functions_ext.rs:27millisecond---
day_of_week(d)sqe-trino-functionsISO: 1=Mon..7=Sun. trino_functions.rs:34day_of_week / dowdayofweekdayofweekdayofweek
day_of_year(d)sqe-trino-functions1..366. trino_functions.rs:35day_of_year / doydayofyeardayofyeardayofyear
quarter(d)sqe-trino-functions1..4. trino_functions.rs:36quarterquarterquarterquarter
week(d)sqe-trino-functionsISO week number. trino_functions.rs:37week / week_of_yearweekofyearweekofyearweekofyear
extract(<part> from d)datafusion-builtinSQL standard. Parts: YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, DOW, DOY, EPOCH, QUARTER, WEEK.extractextractextractextract
date_part('year', d)datafusion-builtinFunction form of extract.date_partdate_part-date_part

Truncation and binning

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
date_trunc('month', d)datafusion-builtinRound down to a calendar boundary. Parts: year, quarter, month, week, day, hour, minute, second, millisecond, microsecond.date_truncdate_truncdate_truncdate_trunc
date_bin(stride, d)datafusion-builtinBin into fixed-width buckets, e.g. INTERVAL '15' minutes. SQE-relevant for time-series rollups.-time_slice-time_bucket
last_day_of_month(d)sqe-trino-functions (ext)Last calendar day of d’s month. trino_functions_ext.rs:43last_day_of_monthlast_daylast_daylast_day

Arithmetic

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
date_add(unit, n, d)sqe-trino-functionsunit is a string: year, quarter, month, week, day, hour, minute, second, millisecond. trino_functions.rs:40date_adddateadddate_add (different shape)date_add
date_diff(unit, d1, d2)sqe-trino-functionsDifference d2 - d1 in units. trino_functions.rs:41date_diffdatediffdatediffdate_diff
d + INTERVAL '5' DAYdatafusion-builtinSQL standard interval arithmetic.yesyesyesyes
d - INTERVAL '1' MONTHdatafusion-builtinSubtraction works the same way.yesyesyesyes

date_add example:

SELECT date_add('day', 7, DATE '2026-05-08');     -- 2026-05-15
SELECT date_add('month', -1, DATE '2026-05-08');  -- 2026-04-08
SELECT date_add('hour', 36, TIMESTAMP '2026-05-08 09:00:00');  -- 2026-05-09 21:00:00

date_diff example:

SELECT date_diff('day', DATE '2026-01-01', DATE '2026-01-06');   -- 5
SELECT date_diff('month', DATE '2026-01-01', DATE '2026-04-01'); -- 3
SELECT date_diff('year', DATE '2020-01-01', DATE '2026-01-01');  -- 6

Formatting and parsing

Two parallel formatting families:

  • Trino / MySQL %Y-%m-%d style via date_format / date_parse. Familiar to anyone who’s used strftime.
  • Java / Joda yyyy-MM-dd style via format_datetime / parse_datetime. The Trino default for new code.
FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
date_format(d, fmt)sqe-trino-functionsTrino / MySQL specifiers (%Y, %m, %d, %H, %i for minutes, %s for seconds). trino_functions.rs:51date_formatto_chardate_formatstrftime
date_parse(str, fmt)sqe-trino-functionsInverse of date_format. Returns TIMESTAMP(6). trino_functions.rs:52date_parseto_timestampto_timestampstrptime
format_datetime(d, fmt)sqe-trino-functions (ext)Java / Joda specifiers (yyyy-MM-dd HH:mm:ss). trino_functions_ext.rs:54format_datetime-date_format-
parse_datetime(str, fmt)sqe-trino-functions (ext)Inverse of format_datetime. trino_functions_ext.rs:55parse_datetime-to_timestamp-
to_iso8601(d)sqe-trino-functions (ext)RFC 3339 / ISO 8601 string. trino_functions_ext.rs:40to_iso8601---
from_iso8601_date(str)sqe-trino-functions (ext)ISO 8601 -> DATE. trino_functions_ext.rs:38from_iso8601_date---
from_iso8601_timestamp(str)sqe-trino-functions (ext)ISO 8601 -> TIMESTAMP(6). trino_functions_ext.rs:39from_iso8601_timestamp---
to_char(d, fmt)datafusion-builtinPostgres-style. Specifiers differ from Trino’s date_format.-to_chardate_formatstrftime
to_date(str [, fmt])datafusion-builtinParse to DATE.-to_dateto_datestrptime
to_timestamp(str [, fmt])datafusion-builtinParse to TIMESTAMP.parse_datetimeto_timestampto_timestampstrptime
to_timestamp_seconds(epoch)datafusion-builtinCast a unix epoch to TIMESTAMP(0).-to_timestamp-to_timestamp
to_timestamp_millis(ms)datafusion-builtinCast millis to TIMESTAMP(3).----
to_timestamp_micros(us)datafusion-builtinCast micros to TIMESTAMP(6).----
to_timestamp_nanos(ns)datafusion-builtinCast nanos to TIMESTAMP(9). V3 Iceberg ns timestamps.----

Trino-style example:

SELECT date_format(TIMESTAMP '2026-05-08 14:30:00', '%Y-%m-%d %H:%i:%s');
-- 2026-05-08 14:30:00

SELECT date_parse('2026-05-08 14:30:00', '%Y-%m-%d %H:%i:%s');
-- 2026-05-08 14:30:00 (TIMESTAMP)

Java-style example (preferred for new code):

SELECT format_datetime(TIMESTAMP '2026-05-08 14:30:00', 'yyyy-MM-dd HH:mm:ss');
-- 2026-05-08 14:30:00

Unix epoch

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
from_unixtime(epoch)sqe-trino-functionsSeconds since epoch -> TIMESTAMP(6). trino_functions.rs:42from_unixtime-from_unixtimeto_timestamp
to_unixtime(d)sqe-trino-functionsTIMESTAMP -> seconds since epoch (Double). trino_functions.rs:43to_unixtime-unix_timestampepoch
extract(epoch from d)datafusion-builtinSQL-standard alternative for to_unixtime.yes-yesyes

Time zones

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
with_timezone(d, tz)sqe-trino-functions (ext)Attach a timezone offset to a naive timestamp. trino_functions_ext.rs:50with_timezoneconvert_timezonefrom_utc_timestamp-
at_timezone(d, tz)sqe-trino-functions (ext)Convert a TIMESTAMP WITH TIME ZONE to another zone. trino_functions_ext.rs:51at_timezoneconvert_timezone--
timezone_hour(d)sqe-trino-functions (ext)Hour component of the offset. trino_functions_ext.rs:70timezone_hour---
timezone_minute(d)sqe-trino-functions (ext)Minute component of the offset. trino_functions_ext.rs:71timezone_minute---
expr AT TIME ZONE 'Europe/Amsterdam'datafusion-builtinSQL-standard syntax.yesyesyesyes

Misc

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
human_readable_seconds(n)sqe-trino-functions (ext)Format seconds as "1d 4h 30m". Useful in monitoring queries. trino_functions_ext.rs:42human_readable_seconds---

Worked example

A daily revenue rollup, time-zone aware, formatted for human reports:

SELECT
    date_format(
        with_timezone(date_trunc('day', order_ts), 'Europe/Amsterdam'),
        '%Y-%m-%d'
    ) AS day_local,
    quarter(order_ts) AS qtr,
    day_of_week(order_ts) AS dow,
    SUM(amount) AS revenue
FROM orders
WHERE order_ts >= TIMESTAMP '2026-01-01 00:00:00' AT TIME ZONE 'UTC'
GROUP BY 1, 2, 3
ORDER BY 1;

Iceberg V3 nanosecond timestamps

TIMESTAMP_NS and TIMESTAMP_NS WITH TIME ZONE only exist in Iceberg format-version 3. Adding such a column to a CREATE TABLE auto-bumps the table format version. All datetime functions operate on these the same way they operate on TIMESTAMP(6); the underlying Arrow type is Timestamp(Nanosecond, ...) instead of Timestamp(Microsecond, ...).

When you query a V3 ns column from a Trino client, Trino downscales to microseconds. SQE keeps the full precision when serving Arrow Flight SQL clients.

Array, map, struct

DataFusion’s datafusion-functions-nested crate ships ~40 array and map helpers. SQE adds Trino-named aggregate constructors (map_agg, histogram, multimap_agg).

Array construction

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
[1, 2, 3] (literal)datafusion-builtinElement type from common supertype.yesyesyesyes
make_array(a, b, ...)datafusion-builtinFunction form of literal.-yesyesyes
array(...)datafusion-builtinAlias for make_array.yesyesyesyes
range(start, stop)datafusion-builtinHalf-open integer array.---yes
range(start, stop, step)datafusion-builtinWith step.---yes
array_repeat(elem, n)datafusion-builtinArray of n copies.yes--yes

Array inspection

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
array_length(a) / cardinality(a)datafusion-builtinNumber of elements.yesyesyesyes
array_dims(a)datafusion-builtinArray of per-dimension sizes (for nested arrays).---yes
array_ndims(a)datafusion-builtinNesting depth.---yes
array_position(a, elem)datafusion-builtin1-based offset of first match; 0 if missing.yesyesyesyes
array_positions(a, elem)datafusion-builtinArray of all matching offsets.---yes
array_contains(a, elem) / array_has(a, elem)datafusion-builtinBoolean membership.yesyesyesyes
array_has_all(a, sub)datafusion-builtinAll of sub are in a.---yes
array_has_any(a, sub)datafusion-builtinAny of sub is in a.yes--yes

Array transformation

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
array_append(a, elem)datafusion-builtinAdd to end.yesyesyesyes
array_prepend(elem, a)datafusion-builtinAdd to start.yes-yesyes
array_concat(a1, a2, ...)datafusion-builtinVariadic concat.yesyesyesyes
array_remove(a, elem)datafusion-builtinRemove first occurrence.---yes
array_remove_all(a, elem)datafusion-builtinRemove all occurrences.yes--yes
array_replace(a, from, to)datafusion-builtinReplace first match.yes-yesyes
array_replace_all(a, from, to)datafusion-builtinReplace all matches.---yes
array_reverse(a)datafusion-builtinReverse order.yesyesyesyes
array_sort(a)datafusion-builtinAscending sort. NULLs last.yesyesyesyes
array_distinct(a)datafusion-builtinDeduplicate. Preserves first occurrence.yesyesyesyes
array_slice(a, start, end)datafusion-builtin1-based, inclusive. Negative indexes count from end.yesyesyesyes
array_pop_front(a) / array_pop_back(a)datafusion-builtinRemove first / last.yes--yes
array_resize(a, n [, fill])datafusion-builtinTruncate or pad to length n.---yes
array_flatten(a) / flatten(a)datafusion-builtinOne level of flattening.yesyesyesyes

Array set operations

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
array_intersect(a, b)datafusion-builtinCommon elements (set-style).yesyesyesyes
array_union(a, b)datafusion-builtinDistinct combination.yesyesyesyes
array_except(a, b)datafusion-builtinIn a but not in b.yes-yesyes

Array reductions

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
array_min(a)datafusion-builtinMinimum element.yesyesyesyes
array_max(a)datafusion-builtinMaximum.yesyesyesyes
array_sum(a)datafusion-builtinSum of numeric elements.yes--yes
array_mean(a)datafusion-builtinAverage.----
array_any_value(a)datafusion-builtinFirst non-NULL element.----

Array unnesting (lateral)

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
unnest(a)datafusion-builtinOne row per element. Used in FROM.yesyesyes (explode)yes
unnest(a) WITH ORDINALITYdatafusion-builtinAdds 1-based offset column.yes---
-- One row per (order, item) pair
SELECT order_id, item
FROM orders, UNNEST(items) AS t(item);

-- Numbered
SELECT order_id, item, idx
FROM orders, UNNEST(items) WITH ORDINALITY AS t(item, idx);

Map functions

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
map(keys_array, values_array)datafusion-builtinBuild a map from two parallel arrays.yes-yes (map_from_arrays)yes
map_keys(m)datafusion-builtinArray of keys.yesyesyesyes
map_values(m)datafusion-builtinArray of values.yesyesyesyes
map_extract(m, key)datafusion-builtinLookup. NULL if missing. Also accessible via m[key].yes (element_at)yes (get)yes (element_at)yes (element_at)
cardinality(m)datafusion-builtinNumber of keys.yesyesyesyes
m['key']datafusion-builtinSubscript syntax for map lookup.yesyesyesyes

Aggregates that build maps / arrays

See Aggregate functions for array_agg, map_agg, histogram, multimap_agg, map_union. The names differ slightly across engines:

SQETrinoSnowflakeSpark SQLDuckDB
array_agg(x)array_aggarray_aggcollect_listarray_agg / list
map_agg(k, v)map_aggobject_aggmap_from_arraysmap
histogram(x)histogram--histogram
multimap_agg(k, v)multimap_agg---

Struct / row

ConstructOriginNotesTrinoSnowflakeSpark SQLDuckDB
struct(a, b, ...)datafusion-builtinAnonymous record.-yes (object_construct)yesyes
named_struct('a', x, 'b', y)datafusion-builtinNamed-field record.yes (row(...))yes (object_construct)yesyes
s.fielddatafusion-builtinField access.yesyesyesyes
(a, b, ...) (row literal)datafusion-builtinAnonymous tuple.yes-yesyes
SELECT named_struct('host', host, 'port', port) AS endpoint
FROM servers;

SELECT endpoint.host, endpoint.port FROM ...;

Examples

Tag-set membership

-- Find products with both 'sale' and 'new' tags
SELECT * FROM products
WHERE array_has_all(tags, ARRAY['sale', 'new']);

-- Find products with any of the listed tags
SELECT * FROM products
WHERE array_has_any(tags, ARRAY['sale', 'clearance']);

Top-K frequencies via histogram

SELECT k, v
FROM events, UNNEST(map_keys(histogram(event_type)), map_values(histogram(event_type))) AS t(k, v)
ORDER BY v DESC
LIMIT 10;

Build a map from joined tables

SELECT
    user_id,
    map_agg(setting_key, setting_value) AS preferences
FROM user_settings
GROUP BY user_id;

map_agg errors on duplicate keys. For multimap-style behaviour use multimap_agg.

Lateral pattern: filter then unnest

SELECT order_id, tag
FROM orders, UNNEST(tags) AS t(tag)
WHERE order_id > 100 AND tag LIKE 'priority_%';

Lambda functions

SQE parses SQL with the DuckDB dialect, so lambda syntax (x -> expr) is accepted. All six Trino higher-order array functions work:

  • filter(array, x -> pred). Keeps the elements where the predicate holds.
  • transform(array, x -> expr). Applies the expression to each element.
  • any_match(array, x -> pred). True if any element matches.
  • all_match(array, x -> pred). True if every element matches (empty array is true).
  • none_match(array, x -> pred). True if no element matches.
  • reduce(array, init, (s, x) -> combine, s -> finish). Left fold: threads an accumulator through the elements, then maps it to the result.

filter and transform alias DataFusion 54’s array_filter and array_transform; any_match is DataFusion’s array_any_match. all_match, none_match, and reduce are SQE UDFs built on the same lambda machinery. Argument order, 1-based element binding, and NULL/empty-array semantics match Trino.

SELECT filter(ARRAY[1, 2, 3, 4], x -> x > 2);                     -- [3, 4]
SELECT transform(ARRAY[1, 2, 3], x -> x * 10);                    -- [10, 20, 30]
SELECT all_match(ARRAY[2, 4, 6], x -> x % 2 = 0);                 -- true
SELECT none_match(ARRAY[1, 3, 5], x -> x % 2 = 0);                -- true
SELECT reduce(ARRAY[1, 2, 3, 4], 0, (s, x) -> s + x, s -> s);     -- 10

What is NOT registered

  • zip(a, b) (parallel-iterate two arrays). Use unnest against an indexed pair instead.
  • Snowflake flatten table function (with PATH and OUTER options). Use UNNEST directly.

JSON

Two layered JSON surfaces. Both are registered in every session.

  1. Trino-named layer in sqe-trino-functions: json_extract, json_extract_scalar, json_array_length, json_parse, json_format, json_object, is_json_scalar, json_array_contains, json_size, json_array_get, to_json. Maps directly to dbt-trino model expectations.
  2. DataFusion JSON layer via datafusion-functions-json (registered in session_context.rs:361 and embedded.rs:172): json_get, json_get_str, json_get_int, json_get_float, json_get_bool, json_get_json, json_get_array, json_contains, json_as_text, json_length. Type-specific extractors that avoid an outer CAST.

JSON columns are stored as VARCHAR (Iceberg has no native JSON type yet). Both layers parse on every call, so for hot paths consider extracting once into a typed column.

Trino-named layer

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
json_parse(s)sqe-trino-functionsParse JSON text. Returns the same VARCHAR after validation; primarily used to fail fast on malformed input. trino_functions.rs:92yesparse_jsonfrom_jsonjson
json_format(j)sqe-trino-functionsFormat / re-emit JSON. trino_functions.rs:60yes-to_jsonto_json
json_object(k1, v1, k2, v2, ...)sqe-trino-functionsBuild a JSON object from key-value pairs. trino_functions.rs:59yesyesyesyes
json_array_length(j)sqe-trino-functionsNumber of elements in a JSON array. NULL if not an array. trino_functions.rs:91yesyesyesjson_array_length
json_extract(j, '$.path')sqe-trino-functionsJSONPath extraction. Returns JSON-encoded value. trino_functions.rs:89yesyesyesyes
json_extract_scalar(j, '$.path')sqe-trino-functionsSame path syntax; returns plain text scalar (or NULL). trino_functions.rs:90yes-get_json_objectjson_extract_string
is_json_scalar(j)sqe-trino-functions (ext)True for JSON null / boolean / number / string. False for objects and arrays. trino_functions_ext.rs:30yes---
json_array_contains(j, v)sqe-trino-functions (ext)True if JSON array contains the value. trino_functions_ext.rs:31yes---
json_size(j, '$.path')sqe-trino-functions (ext)Cardinality at the path. Object key count for objects; array length for arrays; 0 for primitives. trino_functions_ext.rs:72yes---
json_array_get(j, idx)sqe-trino-functions (ext)0-based element from a JSON array. trino_functions_ext.rs:73yes---
to_json(any)sqe-trino-functions (ext)Serialize a SQL value (struct, array, map) as JSON. trino_functions_ext.rs:80-yesyesyes

DataFusion JSON layer

These return typed scalars directly, which means no outer CAST and DataFusion can push predicates through.

FunctionOriginNotesReturns
json_get(j, key_or_index)datafusion-functions-jsonGeneric typed accessor. The argument can be a string key or integer index.Union of possible types
json_get_str(j, key)datafusion-functions-jsonForce string return.VARCHAR
json_get_int(j, key)datafusion-functions-jsonForce integer.BIGINT
json_get_float(j, key)datafusion-functions-jsonForce float.DOUBLE
json_get_bool(j, key)datafusion-functions-jsonForce boolean.BOOLEAN
json_get_json(j, key)datafusion-functions-jsonRe-emit nested JSON.VARCHAR (JSON)
json_get_array(j, key)datafusion-functions-jsonForce array.VARCHAR (JSON array)
json_contains(j, '$.path')datafusion-functions-jsonTrue if path exists.BOOLEAN
json_as_text(j)datafusion-functions-jsonRe-emit as JSON text (round-trip).VARCHAR
json_length(j)datafusion-functions-jsonNumber of object keys / array elements at root.BIGINT

Path syntax

The Trino layer uses JSONPath ($.foo.bar[0]).

The DataFusion JSON layer uses single-step keys: a string for object access or an integer for array access.

-- Trino-style: full JSONPath
SELECT json_extract_scalar(payload, '$.user.id')
FROM events;

-- DataFusion: chained single-step
SELECT json_get_str(json_get_json(payload, 'user'), 'id')
FROM events;

Both compose. Both work on the same VARCHAR column. Choose by ergonomics:

  • Trino-style is one call per path; cleaner SQL.
  • DataFusion JSON layer returns native types; no outer CAST.

For a deeply nested path used in a hot WHERE clause, the DataFusion layer wins on speed (no JSON re-encoding between steps).

Comparison

OperationSQETrinoSnowflakeSpark SQLDuckDB
Parse / validatejson_parsejson_parseparse_jsonfrom_json (typed)json() cast
Path extract (text)json_extract_scalarjson_extract_scalar: syntax (payload:user.id)get_json_objectjson_extract_string
Path extract (JSON)json_extractjson_extractpath (object navigation)nested from_jsonjson_extract
Typed extractjson_get_str/int/float/boolcast(... AS ...):value::TYPEfrom_json schemajson_extract_*
Build objectjson_object(k, v, ...)json_object(k, v, ...)object_constructto_json(struct(...))json_object
Build arrayjson_array(...) (Trino style; not registered yet)json_array(...)array_constructto_json(array(...))json_array
Lengthjson_array_length / json_lengthjson_array_lengtharray_sizejson_array_lengthjson_array_length
Contains key/valuejson_contains / json_array_containsjson_array_containsarray_containsarray_containsjson_contains
To JSON textto_jsoncast(x AS json)to_jsonto_jsonto_json

Examples

Extract a typed field for filtering

-- Slowpath: parse JSON, cast to int, compare
SELECT * FROM events
WHERE CAST(json_extract_scalar(payload, '$.user_id') AS BIGINT) = 12345;

-- Faster: typed extractor, no CAST
SELECT * FROM events
WHERE json_get_int(json_get_json(payload, 'user'), 'id') = 12345;

Project several fields at once

SELECT
    json_extract_scalar(payload, '$.user.id')        AS user_id,
    json_extract_scalar(payload, '$.user.email')     AS email,
    json_extract_scalar(payload, '$.session.locale') AS locale,
    CAST(json_extract_scalar(payload, '$.amount') AS DECIMAL(18, 2)) AS amount
FROM events;

Path-existence filter

SELECT * FROM events
WHERE json_contains(payload, '$.metadata.experimental_flag') = true;

Build a structured field for downstream consumers

SELECT json_object(
    'id', id,
    'host', url_extract_host(url),
    'path', url_extract_path(url),
    'received_at', cast(occurred_at AS VARCHAR)
) AS event_doc
FROM events;

Performance tips

  • Decode once at the boundary: when a JSON column is queried in many downstream views, consider materialising the relevant subfields into typed columns at ingest time. Repeated parsing is the biggest JSON cost.
  • Path index pruning: the DataFusion JSON layer can push json_get_str(payload, 'user_id') = 'X' through to a manifest min/max statistic when the underlying JSON has been pre-extracted. The Trino layer is opaque.
  • Avoid round-trip: json_format(json_parse(j)) is a no-op semantically but burns CPU. Skip the round-trip unless you need re-canonicalisation.

Iceberg JSON column type

Iceberg V3 has no native JSON primitive yet. SQE accepts JSON in CREATE TABLE and stores it as VARCHAR underneath. Predicates and projections work as text. When Iceberg adds a JSON primitive, the storage layout will change but the SQL surface stays the same.

What is not registered

  • json_array(...) (Trino name for build-an-array). Use cast(make_array(...) AS VARCHAR) followed by json_format, or to_json(make_array(...)).
  • json_size at the root: covered. With a path: covered.
  • JSONPath wildcards ($..foo, $[*]): not supported by DataFusion’s path parser. Use unnest(json_get_array(...)) for array-level wildcarding.

Encoding, hashing, URL

Three small, related families: binary encoding (base64 / hex), cryptographic hashes, URL parsing. SQE inherits the encoding and crypto helpers from DataFusion and adds Trino-named aliases plus eight URL extractors.

Binary encoding

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
encode(input, encoding)datafusion-builtinEncode binary to text. encoding: 'base64' or 'hex'.yesyesyesyes
decode(input, encoding)datafusion-builtinDecode text to binary. Same encoding set.yesyesyesyes
from_base64(s)sqe-trino-functionsTrino-named base64 decode. trino_functions.rs:78yesyes (base64_decode_string)yes (unbase64)yes
to_base64(b)sqe-trino-functionsTrino-named base64 encode. trino_functions.rs:79yesyes (base64_encode)yes (base64)yes
from_hex(s)sqe-trino-functionsTrino-named hex decode. trino_functions.rs:80yesyes (hex_decode_string)yes (unhex)yes
to_hex(n)datafusion-builtinEncode integer or binary as hex. NOT registered as Trino UDF: DataFusion already has it.yesyesyesyes
SELECT to_base64(CAST('hello' AS bytea));         -- 'aGVsbG8='
SELECT from_base64('aGVsbG8=');                   -- bytea -> 'hello'
SELECT encode(CAST('hi' AS bytea), 'hex');        -- '6869'
SELECT decode('6869', 'hex');                     -- bytea -> 'hi'

Cryptographic hashes

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
md5(s)datafusion-builtin128-bit hex string.yesyesyesyes
sha224(s)datafusion-builtinSHA-224 hex.yes-yes-
sha256(s)datafusion-builtin + sqe-policySHA-256 hex. SQE registers an additional UDF used by column masks.yesyesyesyes
sha384(s)datafusion-builtinSHA-384 hex.yes-yes-
sha512(s)datafusion-builtinSHA-512 hex.yesyesyesyes
digest(s, algo)datafusion-builtinGeneric. Algos: md5, sha224, sha256, sha384, sha512, blake2s, blake2b, blake3.----
checksum(b)sqe-trino-functions (ext)xxHash of bytes; cheaper than crypto hashes. trino_functions_ext.rs:69yes---
SELECT sha256(email) AS email_hash FROM users;
SELECT digest('hello', 'blake3');       -- 64-char blake3 hex
SELECT to_hex(checksum(payload)) FROM events;

URL parsing

All eight URL functions live in sqe-trino-functions. They wrap the url crate from crates.io for correct RFC 3986 handling.

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
url_extract_host(url)sqe-trino-functionsHostname only, no port. trino_functions.rs:67yesparse_url(..., 'HOST')parse_url(..., 'HOST')-
url_extract_path(url)sqe-trino-functionsPath component (after host, before query). trino_functions.rs:68yesparse_url(..., 'PATH')parse_url(..., 'PATH')-
url_extract_port(url)sqe-trino-functionsPort as INT. NULL when absent. trino_functions.rs:69yes---
url_extract_protocol(url)sqe-trino-functionsScheme (https, http, s3, etc). trino_functions.rs:70yesparse_url(..., 'PROTOCOL')parse_url(..., 'PROTOCOL')-
url_extract_query(url)sqe-trino-functionsQuery string (after ?, no leading ?). trino_functions.rs:71yesparse_url(..., 'QUERY')parse_url(..., 'QUERY')-
url_extract_parameter(url, name)sqe-trino-functionsFirst value of named parameter. trino_functions.rs:72yesparse_url(..., 'QUERY:name')parse_url(..., 'QUERY', 'name')-
url_encode(s)sqe-trino-functionsPercent-encode. trino_functions.rs:73yesyesyes-
url_decode(s)sqe-trino-functionsPercent-decode. trino_functions.rs:74yesyesyes-
SELECT
    url,
    url_extract_protocol(url) AS proto,
    url_extract_host(url)     AS host,
    url_extract_port(url)     AS port,
    url_extract_path(url)     AS path,
    url_extract_query(url)    AS query,
    url_extract_parameter(url, 'utm_source') AS utm
FROM events;

For example, on https://example.com:8443/api?utm_source=newsletter&page=2:

ComponentValue
url_extract_protocolhttps
url_extract_hostexample.com
url_extract_port8443
url_extract_path/api
url_extract_queryutm_source=newsletter&page=2
url_extract_parameter(..., 'utm_source')newsletter

When to use which hash

Use caseRecommendation
Equality lookups, deduplicationxxhash via checksum(). ~10x faster than crypto hashes.
Cryptographic integrity, signaturessha256 or sha512. Avoid md5 for new uses.
Column masking via grantssha256 (used in MASKED WITH clauses). Deterministic, no collisions in practice.
Hashing UTF-8 stringsAll accept VARCHAR or BINARY arguments. The result type is VARCHAR (hex) for crypto hashes, BINARY for digest().
SaltingConcatenate the salt: sha256(concat(salt, password)). SQE has no pbkdf2-style helpers.

When to use which URL parser

url_extract_host, url_extract_query, url_extract_parameter use a real RFC 3986 parser, so they handle internationalised domain names (IDN), unusual ports, percent-encoded query values, and trailing fragments correctly. The regex equivalent (regexp_extract(url, 'https?://([^/]+)', 1)) breaks on these edge cases.

Use the regex form only when the input is known to be a fixed shape that the regex covers.

Why no Snowflake-style PARSE_URL

Snowflake’s parse_url(url [, permissive]) returns an OBJECT with named keys. The eight separate url_extract_* functions are equivalent in expressive power and easier to compile statically. dbt-snowflake to dbt-sqe migrations need the rewrite, but it is mechanical: parse_url(u, 'HOST') becomes url_extract_host(u), etc.

Why DataFusion’s decode is not shadowed

A Snowflake-style DECODE(expr, search1, result1, ...) would conflict with DataFusion’s binary decode(input, encoding). SQE keeps the binary one and exposes the conditional logic via CASE WHEN. See Conditional for the trade-off.

Aggregate functions

Functions used in GROUP BY queries and OVER clauses. SQE inherits ~40 aggregates from DataFusion plus 12 Trino UDAFs from sqe-trino-functions.

Standard aggregates

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
count(expr)datafusion-builtinCounts non-NULL rows.countcountcountcount
count(*)datafusion-builtinCounts all rows.countcountcountcount
count(distinct expr)datafusion-builtinDistinct non-NULL count.count(distinct ...)count(distinct ...)count(distinct ...)count(distinct ...)
sum(expr)datafusion-builtinSum. NULL-skipping.sumsumsumsum
sum(distinct expr)datafusion-builtinDistinct sum.sum(distinct ...)sum(distinct ...)sum(distinct ...)sum(distinct ...)
avg(expr) / mean(expr)datafusion-builtinArithmetic mean. NULL-skipping.avgavgavg / meanavg / mean
min(expr)datafusion-builtinMinimum.minminminmin
max(expr)datafusion-builtinMaximum.maxmaxmaxmax
median(expr)datafusion-builtinExact median. Slower than approx_median on big inputs.-medianmedianmedian

Statistical / regression

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
variance(x) / var_samp(x)datafusion-builtinSample variance.variance / var_sampvariance_sampvariance / var_sampvariance / var_samp
var_pop(x)datafusion-builtinPopulation variance.var_popvariance_popvar_popvar_pop
stddev(x) / stddev_samp(x)datafusion-builtinSample stddev.stddev / stddev_sampstddev_sampstddev / stddev_sampstddev
stddev_pop(x)datafusion-builtinPopulation stddev.stddev_popstddev_popstddev_popstddev_pop
corr(y, x)datafusion-builtinPearson correlation.corrcorrcorrcorr
covar_samp(y, x) / covar_pop(y, x)datafusion-builtinSample / population covariance.covar_samp / covar_popcovar_samp / covar_popcovar_samp / covar_popcovar_samp / covar_pop
regr_slope(y, x)datafusion-builtinLinear regression slope.regr_sloperegr_slope-regr_slope
regr_intercept(y, x)datafusion-builtiny-intercept.regr_interceptregr_intercept-regr_intercept
regr_r2(y, x)datafusion-builtinR-squared.regr_r2regr_r2-regr_r2
regr_count, regr_sxx, regr_syy, regr_sxy, regr_avgx, regr_avgydatafusion-builtinRegression sums and counts.yesyes-yes

Distinct and approximation

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
approx_distinct(expr)datafusion-builtinHyperLogLog distinct count. ~1% error.approx_distinctapprox_count_distinctapprox_count_distinctapprox_count_distinct
approx_median(expr)datafusion-builtinMedian estimate via t-digest.--approx_percentile(0.5)approx_quantile(0.5)
approx_percentile_cont(expr, p)datafusion-builtinPercentile estimate via t-digest. p in [0, 1].approx_percentileapprox_percentileapprox_percentileapprox_quantile
approx_percentile(expr, p)sqe-trino-functionsTrino-named alias of approx_percentile_cont. trino_functions.rs:164approx_percentile---

Boolean and bitwise

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
bool_and(x)datafusion-builtinTrue only if every row is true.bool_andbooland_aggbool_and / everybool_and
bool_or(x)datafusion-builtinTrue if any row is true.bool_orboolor_aggbool_or / anybool_or
every(x)sqe-trino-functionsTrino-named alias of bool_and. trino_functions.rs:170every-every-
bit_and(x)datafusion-builtinBitwise AND of all values.bitwise_and_aggbitand_aggbit_and-
bit_or(x)datafusion-builtinBitwise OR.bitwise_or_aggbitor_aggbit_or-
bit_xor(x)datafusion-builtinBitwise XOR.bitwise_xor_aggbitxor_aggbit_xor-
bitwise_and_agg(x)sqe-trino-functionsTrino name for bit_and. trino_functions.rs:144bitwise_and_agg---
bitwise_or_agg(x)sqe-trino-functionsTrino name for bit_or. trino_functions.rs:150bitwise_or_agg---
bitwise_xor_agg(x)sqe-trino-functionsTrino name for bit_xor. trino_functions.rs:158bitwise_xor_agg---

Positional

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
first_value(expr [order by ...])datafusion-builtinFirst row’s value. Most useful with OVER ordering.first_valuefirst_valuefirst_valuefirst_value
last_value(expr [order by ...])datafusion-builtinLast row’s value.last_valuelast_valuelast_valuelast_value
nth_value(expr, n [order by ...])datafusion-builtinNth row’s value.nth_valuenth_valuenth_valuenth_value
max_by(value, key)sqe-trino-functionsvalue from the row with the max key. trino_functions.rs:177max_bymax_by-arg_max
min_by(value, key)sqe-trino-functionsvalue from the row with the min key. trino_functions.rs:178min_bymin_by-arg_min
arbitrary(expr)sqe-trino-functions (ext)Any one non-NULL value. Trino-named alias of any_value. trino_functions_ext.rs:68arbitraryany_valueany_valueany_value

Collection-building

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
array_agg(expr)datafusion-builtinCollect into an array. NULLs included.array_aggarray_aggcollect_listarray_agg / list
array_agg(distinct expr)datafusion-builtinDistinct array.array_agg(distinct)array_agg(distinct)collect_setlist_distinct
string_agg(expr, sep)datafusion-builtinConcatenate with separator. SQL standard.array_join/listagglistagg-string_agg
listagg(expr, sep)sqe-trino-functionsSame as string_agg; Snowflake / Trino name. trino_functions.rs:138listagglistagg--
histogram(expr)sqe-trino-functionsMap of value -> count. trino_functions.rs:188histogram--histogram
map_agg(key, value)sqe-trino-functionsBuild a map by aggregating key-value pairs. Last write wins. trino_functions.rs:189map_aggobject_aggmap_from_arraysmap
multimap_agg(key, value)sqe-trino-functionsMap where each value is an array (collects duplicates). trino_functions.rs:190multimap_agg---
map_union(map_col)sqe-trino-functionsAggregate already-built maps into one. trino_functions.rs:191map_union---

Modifiers

ModifierNotes
agg_func(expr) FILTER (WHERE pred)Filter rows before aggregation. Cleaner than agg_func(CASE WHEN pred THEN expr END).
agg_func(distinct expr)Distinct values only.
agg_func(expr) OVER (...)Window form. Uses PARTITION BY, ORDER BY, frame clauses. See Window functions.
agg_func(expr) WITHIN GROUP (ORDER BY ...)Ordered aggregate (e.g. listagg).

Example using FILTER:

SELECT
    region,
    count(*) AS total_orders,
    count(*) FILTER (WHERE status = 'cancelled') AS cancelled,
    sum(amount) FILTER (WHERE status = 'shipped') AS shipped_revenue
FROM orders
GROUP BY region;

GROUP BY extensions

ConstructOriginNotesTrinoSnowflakeSpark SQLDuckDB
GROUP BY GROUPING SETS ((a, b), (a), ())datafusion-builtinMultiple grouping levels in one query.yesyesyesyes
GROUP BY CUBE (a, b, c)datafusion-builtinAll 2^N grouping combinations.yesyesyesyes
GROUP BY ROLLUP (a, b, c)datafusion-builtinHierarchical: (), (a), (a, b), (a, b, c).yesyesyesyes
GROUPING(col)datafusion-builtinReturns 1 if col was rolled up in this row, else 0.yesyesyesyes
SELECT
    region,
    product,
    sum(amount) AS revenue,
    GROUPING(region) AS region_rolled_up,
    GROUPING(product) AS product_rolled_up
FROM orders
GROUP BY ROLLUP (region, product)
ORDER BY region, product;

Approximation vs exact: when to choose

  • count(distinct) exact. sub-second on millions of rows; avoid above ~1B distinct values.
  • approx_distinct HyperLogLog. order of magnitude faster on huge inputs. ~1% relative error.
  • median exact. sorts the entire group; expensive on big partitions.
  • approx_median / approx_percentile_cont t-digest. sub-percent error, much cheaper memory profile.

For dashboards over multi-billion-row tables, default to approximations. For audit queries that need exact counts, default to exact.

Window functions

Window functions compute a value per row using a “window” of related rows. Unlike aggregates, they do not collapse rows; the input row count is preserved.

All window functions in SQE come from datafusion-functions-window (DataFusion’s built-in window crate). No SQE-specific window functions exist; the SQL surface matches DataFusion exactly.

Syntax

window_function(args) OVER (
    [PARTITION BY col1, col2, ...]
    [ORDER BY col1 [ASC|DESC] [NULLS FIRST|LAST], ...]
    [frame_clause]
)

The frame clause has three forms:

ROWS BETWEEN <start> AND <end>
RANGE BETWEEN <start> AND <end>
GROUPS BETWEEN <start> AND <end>

Bounds:

UNBOUNDED PRECEDING
N PRECEDING
CURRENT ROW
N FOLLOWING
UNBOUNDED FOLLOWING

Default frame:

  • With ORDER BY: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
  • Without ORDER BY: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

Functions

Ranking

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
row_number()datafusion-builtin1-based unique rank.row_numberrow_numberrow_numberrow_number
rank()datafusion-builtinStandard rank with gaps after ties.rankrankrankrank
dense_rank()datafusion-builtinRank with no gaps.dense_rankdense_rankdense_rankdense_rank
percent_rank()datafusion-builtin(rank - 1) / (rows - 1) in [0, 1].percent_rankpercent_rankpercent_rankpercent_rank
cume_dist()datafusion-builtinCumulative distribution: rows <= current / total.cume_distcume_distcume_distcume_dist
ntile(n)datafusion-builtinBucket rows into N equal-size groups.ntilentilentilentile

Offset

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
lag(expr [, offset [, default]])datafusion-builtinValue offset rows back. Default offset 1, default value NULL.laglaglaglag
lead(expr [, offset [, default]])datafusion-builtinValue offset rows forward.leadleadleadlead
first_value(expr)datafusion-builtinFirst row’s value within frame.first_valuefirst_valuefirst_valuefirst_value
last_value(expr)datafusion-builtinLast row’s value within frame.last_valuelast_valuelast_valuelast_value
nth_value(expr, n)datafusion-builtinNth row’s value within frame.nth_valuenth_valuenth_valuenth_value

Aggregates as windows

Every aggregate function from Aggregate functions also works as a window function:

SELECT
    customer_id,
    order_date,
    amount,
    sum(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS running_total,
    avg(amount) OVER (PARTITION BY customer_id) AS customer_avg
FROM orders;

Frame examples

Running total

SELECT
    order_date,
    amount,
    sum(amount) OVER (
        ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_total
FROM orders;

Trailing 7-day average

SELECT
    order_date,
    amount,
    avg(amount) OVER (
        ORDER BY order_date
        RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW
    ) AS trailing_7d_avg
FROM orders;

RANGE with an INTERVAL works on date / timestamp ordering keys and respects time gaps. ROWS would just count rows regardless of time.

Top N per group via row_number

WITH ranked AS (
    SELECT
        category,
        product,
        revenue,
        row_number() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
    FROM products
)
SELECT * FROM ranked WHERE rn <= 5;

Rolling difference with LAG

SELECT
    order_date,
    amount,
    amount - lag(amount, 1, 0) OVER (ORDER BY order_date) AS day_over_day
FROM orders;

The , 0 argument fills the first row (where there is no predecessor) with zero instead of NULL.

Frame variants compared

FormWhat “between -1 and +1” means
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWINGThree rows by position: previous, current, next.
RANGE BETWEEN 1 PRECEDING AND 1 FOLLOWINGRows within [order_key - 1, order_key + 1] of the current order key value.
GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWINGThree peer groups: rows tied with the current key, plus the previous and next tied groups.

GROUPS is rare but useful when ordering on a low-cardinality key produces many ties and you want “the previous distinct value group” semantics.

What is NOT supported (DataFusion blocked)

  • QUALIFY clause (filtering on window-function output without a subquery). DataFusion’s parser does not accept QUALIFY. Workaround: wrap the SELECT and filter in an outer query, as in the “Top N per group” example above.

The audit row lives in the feature comparison. Tracked upstream as a parser enhancement.

Performance notes

  • PARTITION BY enables parallelism: each partition runs on its own thread / worker. Without partitioning, the window runs single-threaded against the global ordering.
  • ROWS frames are cheaper than RANGE frames when the ordering key has many ties; RANGE may need a binary search per row.
  • A unbounded preceding ... unbounded following frame on a sorted input lets DataFusion stream-compute aggregates without materialising the partition. Other frames require partition-buffering.

The EXPLAIN ANALYZE output shows partition counts and frame mode per WindowAgg node; use it when a window query is slower than expected.

Table-valued functions

A TVF returns a table you can use in a FROM clause. SQE ships two families:

  1. File format readers: read_parquet, read_csv, read_json, read_delta. Implemented in sqe-catalog/.
  2. Iceberg metadata readers: table_snapshots, table_history, table_files, table_partitions, table_manifests, table_refs. Implemented in sqe-catalog/src/iceberg_metadata_tvf.rs.

DataFusion contributes the generators (generate_series, unnest) and the URL-table auto-detect path (SELECT * FROM 'file.parquet').

File format TVFs

Detailed per-function docs: File-format TVFs, read_parquet TVF. Quick reference here.

TVFOriginNotesTrinoSnowflakeSpark SQLDuckDB
read_parquet(path, ...)sqe-catalogParquet on local FS / S3 / HTTPS / hf://. Inline auth args. read_parquet.rsHive table onlyinfer_schema+stageparquet sourceread_parquet
read_csv(path, ...)sqe-catalogDuckDB-style aliases (sep, delim, header, nullstr, compress). Smart defaults from extension. read_csv.rs-infer_schema+stagecsv sourceread_csv
read_json(path, ...)sqe-catalogNDJSON by default; format => 'array' for a top-level JSON array, .zip archives auto-route to the buffer path. read_json.rs--json sourceread_json
read_delta(path, ...)sqe-catalogRead-only Delta Lake reader. Time travel via version => N or timestamp => 'RFC3339'. read_delta.rsvia connector-nativevia extension
SELECT * FROM 'file.ext'datafusion-builtinQuoted-string auto-detect. Dispatches by extension to one of the readers above. Requires enable_url_table().---yes

Common arguments

All four file readers accept the same path scheme set: local, S3, HTTPS, hf://. Arguments are positional path + named keyword arguments:

SELECT * FROM read_parquet(
    's3://bucket/key.parquet',
    access_key => 'AKIA...',
    secret_key => '...',
    endpoint => 'http://localhost:9000',
    region => 'us-east-1'
);

The full keyword list per reader lives in File-format TVFs. The same shape works for read_csv, read_json, read_delta.

Path schemes

SchemeAuthExample
Localfilesystem perms/data/sales.parquet
s3://inline args, [storage] block, or AWS provider chain (V10)s3://bucket/key.parquet
https://session bearer for HF, otherwise publichttps://example.com/data.csv
hf://datasets/<org>/<name>/...HF_TOKEN env var, optional ?revision=hf://datasets/squad/plain_text/train.parquet
hf://[email protected]/...revision inline (V12.1)hf://datasets/foo/[email protected]/train.parquet
hf://...@~parquet/...auto-generated parquet view (V12.1)hf://datasets/foo/bar@~parquet/default/train/0.parquet

Iceberg metadata TVFs

Six TVFs that expose Iceberg internal state without leaving SQL. Useful for observability, audit, planning.

TVFOriginReturnsTrinoSnowflakeSpark SQL
table_snapshots(ns, table)sqe-catalogOne row per snapshot, in Trino’s $snapshots column shape. Columns: committed_at, snapshot_id, parent_id, operation, manifest_list, summary. iceberg_metadata_tvf.rs:93t$snapshots-t.snapshots
table_history(ns, table)sqe-catalogLinear snapshot history. Columns: made_current_at, snapshot_id, parent_id, is_current_ancestor. iceberg_metadata_tvf.rs:356t$history-t.history
table_files(ns, table)sqe-catalogOne row per data file in the current snapshot. Columns: file_path, file_format, record_count, file_size_in_bytes, column_sizes, value_counts, null_value_counts, partition, lower_bounds, upper_bounds. lower_bounds / upper_bounds are JSON maps of {field_id: value} and let you verify a compaction produced a prunable layout (after a sort compaction the output files carry disjoint ranges on the sort column). iceberg_metadata_tvf.rst$files-t.files
table_manifests(ns, table)sqe-catalogOne row per manifest in the current snapshot. iceberg_metadata_tvf.rs:217t$manifests-t.manifests
table_partitions(ns, table)sqe-catalogPer-partition aggregate. iceberg_metadata_tvf.rs:622t$partitions-t.partitions
table_refs(ns, table)sqe-catalogOne row per branch / tag. Columns: name, type, snapshot_id, max_ref_age_ms. iceberg_metadata_tvf.rs:768t$refs-t.refs

Trino’s table$snapshots syntax is also accepted; crates/sqe-coordinator/src/query_handler.rs rewrites it to the TVF call.

-- DuckDB / Trino-style $-syntax
SELECT * FROM analytics."events$snapshots";

-- Equivalent SQE TVF call
SELECT * FROM table_snapshots('analytics', 'events');

Examples

What’s the current snapshot’s row count?

SELECT SUM(record_count) AS rows
FROM table_files('analytics', 'events');

When did each branch fork?

SELECT name, type, snapshot_id
FROM table_refs('analytics', 'events')
WHERE type = 'branch';

How big are recent snapshots in megabytes?

SELECT
    snapshot_id,
    summary['added-files-size'] AS added_bytes,
    summary['total-files-size'] AS total_bytes
FROM table_snapshots('analytics', 'events')
ORDER BY committed_at DESC
LIMIT 10;

The summary column is a MAP<VARCHAR, VARCHAR>; cast values numerically when needed.

Generators (DataFusion built-ins)

FunctionOriginNotesTrinoSnowflakeSpark SQLDuckDB
generate_series(start, stop)datafusion-builtinInteger sequence, inclusive both ends.sequencesequencesequencegenerate_series
generate_series(start, stop, step)datafusion-builtinWith step. Negative step counts down.sequence--generate_series
range(start, stop) / range(start, stop, step)datafusion-builtinHalf-open: includes start, excludes stop.---range
unnest(array)datafusion-builtinLateral expansion: one input row -> N output rows.unnestflattenexplodeunnest

Examples:

SELECT * FROM generate_series(1, 5);
-- 1, 2, 3, 4, 5

SELECT day FROM generate_series(DATE '2026-05-01', DATE '2026-05-07') AS t(day);
-- 7 dates, May 1 through May 7

SELECT id, value FROM orders, UNNEST(items) AS t(value);
-- Lateral unnest: one row per (order, item)

Quoted-string auto-detect

SELECT * FROM '<path>' works as a shortcut when the path’s extension is recognised:

ExtensionDispatches to
.parquetread_parquet
.csv, .tsv, .psv, .ssv (with optional .gz / .bz2 / .xz / .zst)read_csv
.json, .jsonl, .ndjson (with optional codec suffix)read_json
.avroDataFusion’s avro reader

The mechanism is DataFusion’s enable_url_table() SessionConfig, called at crates/sqe-cli/src/embedded.rs:158. Auto-detect works in cluster mode too.

-- All three are equivalent (assuming the file is a CSV)
SELECT * FROM read_csv('/data/sales.csv');
SELECT * FROM '/data/sales.csv';
SELECT * FROM 'hf://datasets/squad/plain_text/train.csv';

When to register vs query directly

A read_* TVF call reads on every query. Two cases where registering as a table is better:

  1. Repeated queries: register once via CREATE TABLE foo AS SELECT * FROM read_parquet(...) so subsequent queries skip the URL fetch.
  2. You need writes: TVFs are read-only. Writes need a catalog-registered Iceberg table.

For ad-hoc analytics on a one-shot file, the TVF is faster: no schema decision, no commit, no metadata.

DDL

Data Definition Language: schemas, tables, views, columns, partitions, branches, tags. Most statements parse via sqlparser-rs; SQE adds branch / tag / partition-evolution syntax that sqlparser-rs does not natively understand.

Source: sqe-sql/src/classifier.rs, sqe-sql/src/ddl.rs, sqe-sql/src/partition.rs, sqe-sql/src/partition_evolution.rs. Coordinator handlers in crates/sqe-coordinator/src/catalog_ops.rs.

Schema

StatementOriginNotesTrinoSnowflakeSpark SQLDuckDB
CREATE SCHEMA [IF NOT EXISTS] cat.nssqlparser-rs + sqe-coordinatorCreates a namespace in the catalog.yesyesyesyes
CREATE SCHEMA [IF NOT EXISTS] cat.ns LOCATION 's3://...'sqlparser-rs + sqe-coordinatorOverride default location. Only on catalogs that accept location at namespace level (Polaris, S3 Tables).yesyesyes-
DROP SCHEMA [IF EXISTS] cat.ns [CASCADE|RESTRICT]sqlparser-rs + sqe-coordinatorCASCADE drops contained tables.yesyesyesyes
ALTER SCHEMA cat.ns RENAME TO new_namesqlparser-rs + sqe-coordinatorCatalog must support namespace rename.partialyesyesyes
CREATE SCHEMA IF NOT EXISTS analytics.staging;
CREATE SCHEMA marketing LOCATION 's3://my-warehouse/marketing/';
DROP SCHEMA staging CASCADE;

Table creation

StatementOriginNotesTrinoSnowflakeSpark SQLDuckDB
CREATE TABLE t (col TYPE [DEFAULT expr], ...)sqlparser-rs + sqe-sql + sqe-coordinatorIceberg V3 column defaults supported.yesyesyesyes
CREATE TABLE t (...) PARTITIONED BY (transform(col), ...)sqe-sql/partition.rsPartition transforms: bucket(N, col), truncate(N, col), year(col), month(col), day(col), hour(col), identity (just col).partial-yes-
CREATE TABLE t AS SELECT ... (CTAS)sqlparser-rs + sqe-coordinatorInferred schema; partitioning via WITH (partitioning = ARRAY['day(ts)']).yesyesyesyes
CREATE OR REPLACE TABLE t AS SELECT ...sqlparser-rs + sqe-coordinatorAtomic replace. New snapshot replaces the table; old data files retained until expire_snapshots.yesyespartialyes
CREATE TABLE [IF NOT EXISTS] t LIKE other_tablesqlparser-rs + sqe-coordinatorCopy schema only, no data.yesyesyesyes
CREATE TABLE analytics.events (
    id          BIGINT,
    user_id     BIGINT,
    event_type  VARCHAR,
    occurred_at TIMESTAMP(6),
    payload     JSON,
    region      VARCHAR DEFAULT 'unknown'
)
PARTITIONED BY (day(occurred_at), bucket(16, user_id));

CREATE TABLE analytics.daily_events AS
SELECT day(occurred_at) AS d, count(*) AS n
FROM analytics.events GROUP BY 1;

Schema evolution

StatementOriginNotesTrinoSnowflakeSpark SQLDuckDB
ALTER TABLE t ADD COLUMN c TYPE [DEFAULT expr]sqlparser-rs + sqe-coordinatorNew column. Existing rows get the default (V3) or NULL (V2).yesyesyesyes
ALTER TABLE t DROP COLUMN [IF EXISTS] csqlparser-rs + sqe-coordinatorLogical drop. Field id retained in old data files.yesyesyesyes
ALTER TABLE t RENAME COLUMN old TO newsqlparser-rs + sqe-coordinatorIceberg field id stays the same; only the name changes.yesyesyesyes
ALTER TABLE t ALTER COLUMN c SET NOT NULLsqlparser-rs + sqe-coordinatorTighten nullability. Fails if existing rows have NULL.yesyesyesyes
ALTER TABLE t ALTER COLUMN c DROP NOT NULLsqlparser-rs + sqe-coordinatorLoosen nullability.yesyesyesyes
ALTER TABLE t ALTER COLUMN c SET DEFAULT exprsqlparser-rs + sqe-coordinatorIceberg V3 column default.partialyesyesyes
ALTER TABLE t ALTER COLUMN c TYPE new_typesqlparser-rs + sqe-coordinatorType promotion only (e.g. INT -> BIGINT). Lossy changes rejected.partialpartialpartialpartial
ALTER TABLE t RENAME TO new_tsqlparser-rs + sqe-coordinatorCatalog rename. Different catalog support varies.yesyesyesyes
ALTER TABLE t SET TBLPROPERTIES (...)sqlparser-rs + sqe-coordinatorSet Iceberg properties (e.g. write.delete.mode).yesyesyes-
COMMENT ON TABLE t IS 'description'sqlparser-rs + sqe-coordinatorStored in Iceberg properties.yesyesyesyes
COMMENT ON COLUMN t.c IS 'description'sqlparser-rs + sqe-coordinatorStored on the column metadata.yesyesyesyes
ALTER TABLE analytics.events ADD COLUMN device VARCHAR DEFAULT 'unknown';
ALTER TABLE analytics.events DROP COLUMN IF EXISTS deprecated_field;
ALTER TABLE analytics.events RENAME COLUMN payload TO body;
ALTER TABLE analytics.events ALTER COLUMN region SET NOT NULL;
ALTER TABLE analytics.events SET TBLPROPERTIES (
    'write.delete.mode' = 'merge-on-read',
    'write.parquet.bloom-filter-columns' = 'user_id,event_id'
);

Partition evolution (SQE / Iceberg-specific)

Iceberg lets you change partition spec without rewriting data. SQE parses these in sqe-sql/src/partition_evolution.rs because sqlparser-rs only knows Hive-style PARTITION (col = val).

StatementNotesTrinoSnowflakeSpark SQL
ALTER TABLE t ADD PARTITION FIELD transform(col)Add a new partition field. Existing data stays in the old spec.partial-yes
ALTER TABLE t ADD PARTITION FIELD transform(col) AS aliasSame with explicit name for the partition column.--yes
ALTER TABLE t DROP PARTITION FIELD transform(col)Remove a partition field from the current spec.partial-yes
ALTER TABLE t REPLACE PARTITION FIELD old_transform(col) WITH new_transform(col)Replace one transform with another.--yes
-- Originally partitioned by day(ts); switch to hour() for finer granularity.
ALTER TABLE events REPLACE PARTITION FIELD day(occurred_at) WITH hour(occurred_at);

-- Add a bucketing field on top of existing daily partitions.
ALTER TABLE events ADD PARTITION FIELD bucket(64, user_id);

Branches and tags (SQE / Iceberg-specific)

Iceberg branches are named pointers to a snapshot, like git branches. Tags are immutable named pointers. SQE parses these in sqe-sql/src/ddl.rs.

StatementNotes
ALTER TABLE t CREATE BRANCH nameNew branch from current snapshot.
ALTER TABLE t CREATE BRANCH name AS OF VERSION snapshot_idNew branch from a specific snapshot.
ALTER TABLE t CREATE BRANCH name WITH RETENTION (max_ref_age_ms = N)Auto-expire branch after N ms of inactivity.
ALTER TABLE t CREATE [OR REPLACE] TAG nameNew tag pointing at current snapshot. OR REPLACE is allowed because tags are not strictly immutable in iceberg-rust.
ALTER TABLE t CREATE TAG name AS OF VERSION snapshot_idTag a specific snapshot.
ALTER TABLE t DROP BRANCH [IF EXISTS] nameRemove a branch.
ALTER TABLE t DROP TAG [IF EXISTS] nameRemove a tag.
-- Branch a snapshot for development work
ALTER TABLE analytics.events CREATE BRANCH dev_2026_05;

-- Pin a known-good snapshot as a tag
ALTER TABLE analytics.events CREATE TAG release_2026_q2 AS OF VERSION 8472810294831234567;

-- Query the branch
SELECT * FROM analytics.events FOR VERSION AS OF 'dev_2026_05';

Views

StatementOriginNotesTrinoSnowflakeSpark SQLDuckDB
CREATE [OR REPLACE] VIEW v AS SELECT ...sqlparser-rs + sqe-coordinatorStandard SQL view. Iceberg views format-version 1.yesyesyesyes
CREATE [OR REPLACE] VIEW v (col1, col2) AS SELECT ...sqlparser-rs + sqe-coordinatorExplicit column list.yesyesyesyes
DROP VIEW [IF EXISTS] vsqlparser-rs + sqe-coordinatorRemove a view.yesyesyesyes
CREATE VIEW v COMMENT '<text>' AS ...sqe-sql view_compat + sqe-coordinatorTrino’s comment spelling, without =. Stored as the view’s comment property.yesnonono
CREATE VIEW v SECURITY {DEFINER | INVOKER} AS ...sqe-sql view_compat + sqe-coordinatorAccepted and recorded. See below: DEFINER is not enforced.yesnonono
CREATE OR REPLACE VIEW analytics.recent_events AS
SELECT * FROM analytics.events
WHERE occurred_at >= now() - INTERVAL '7' DAY;

DROP VIEW IF EXISTS analytics.recent_events;

SECURITY DEFINER is recorded, not enforced

Trino’s SECURITY DEFINER runs a view with its creator’s privileges, so a reader needs access to the view but not to its base tables. SECURITY INVOKER checks the querying user against the base tables instead.

SQE always behaves as INVOKER, and that is architectural rather than unfinished. Every query runs as the authenticated user via bearer-token passthrough, with no service account anywhere in the engine, so there is no credential to run a view as its definer with. Honouring DEFINER would mean storing one.

The clause is accepted because rejecting it blocks every dbt model that uses the default view materialization, and because the direction of the difference is safe: INVOKER is stricter than DEFINER asks for, so a reader who was meant to be shielded from base-table grants is denied rather than allowed. It fails closed.

What SQE does with it:

Recorded asview properties sqe.view-security and, for definer, sqe.view-definer
EnforcementINVOKER semantics: the base table is authorized as the querying user
On DEFINERone warning at creation, naming the reader-denial consequence

The properties live in the Iceberg view metadata, so the intent travels with the object and a later implementation, or another engine, can honour it.

Drop

StatementOriginNotes
DROP TABLE [IF EXISTS] t [PURGE]sqlparser-rs + sqe-coordinatorPURGE deletes data files immediately; default keeps the metadata so system.remove_orphan_files can clean later.
DROP VIEW [IF EXISTS] vsqlparser-rs + sqe-coordinatorStandard.
DROP SCHEMA [IF EXISTS] s [CASCADE|RESTRICT]sqlparser-rs + sqe-coordinatorCASCADE drops contained tables.

Iceberg V3 type system

These types only exist in format-version 3. Adding one to a CREATE TABLE auto-bumps the table to V3.

TypeNotes
TIMESTAMP_NS, TIMESTAMP_NS WITH TIME ZONENanosecond precision timestamps. Arrow Timestamp(Nanosecond, ...).
GEOMETRY, GEOGRAPHYStub types in V3; SQE accepts them in CREATE but does not yet provide spatial functions.
Default values via DEFAULT exprExisting rows in older snapshots inherit the default at read time.

What CREATE / ALTER does NOT cover

  • CREATE INDEX. Iceberg has no equivalent. Bloom filter columns and partition fields cover the same ground; configure via SET TBLPROPERTIES and ADD PARTITION FIELD.
  • CREATE FUNCTION / CREATE PROCEDURE. UDFs are Rust-side. SQL-defined functions and procedures are not supported.
  • CREATE SEQUENCE. no auto-increment / sequence support today. Use row_number() over a deterministic ordering for synthetic keys.
  • CREATE TYPE. no user-defined types. Use STRUCT<...> or MAP<...>.

These are tracked but not on the immediate roadmap.

DML

Data Manipulation Language: reads, writes, updates, deletes, merges. SQE adds Iceberg time-travel clauses on SELECT, an Iceberg-aware MERGE INTO, and a SET WRITE_BRANCH shortcut for routing writes to a named branch.

Source: sqe-sql/src/time_travel.rs, crates/sqe-coordinator/src/{query_handler, write_handler}.rs.

SELECT

FormOriginNotesTrinoSnowflakeSpark SQLDuckDB
SELECT cols FROM t [WHERE ...] [GROUP BY ...] [HAVING ...] [ORDER BY ...] [LIMIT N]datafusion-builtinStandard SQL.yesyesyesyes
WITH cte AS (...) SELECT ...datafusion-builtinCTE. Multiple CTEs allowed.yesyesyesyes
WITH RECURSIVE cte AS (...) SELECT ...datafusion-builtinRecursive CTE.yesyesnoyes
SELECT DISTINCT cols FROM tdatafusion-builtinDistinct rows.yesyesyesyes
SELECT * EXCLUDE (col, ...) FROM tdatafusion-builtinExclude columns from *.yes--yes
SELECT * REPLACE (expr AS col) FROM tdatafusion-builtinSubstitute one or more columns.yes--yes
SELECT cols FROM t1 [INNER|LEFT|RIGHT|FULL] JOIN t2 ON ...datafusion-builtinAll join types including SEMI / ANTI.yesyesyesyes
SELECT cols FROM t1 USING (col1, col2)datafusion-builtinEquality on shared column names.yesyesyesyes
SELECT cols FROM t1, LATERAL (SELECT ... FROM t2 WHERE ...)datafusion-builtinCorrelated subquery in FROM.yesyespartialyes
SELECT cols FROM t TABLESAMPLE BERNOULLI (5)datafusion-builtinRandom sampling.yesyesyesyes

Time travel (Iceberg-specific)

FormOriginNotes
SELECT ... FROM t FOR VERSION AS OF snapshot_idsqe-sql/time_travel.rsRead a specific snapshot (snapshot id, branch name, or tag name).
SELECT ... FROM t FOR SYSTEM_TIME AS OF timestampdatafusion-builtin (sqlparser native)Read snapshot active at the given timestamp.
SELECT ... FROM t FOR INCREMENTAL BETWEEN SNAPSHOT s1 AND SNAPSHOT s2sqe-sql/time_travel.rsSQE-specific. Returns rows added between two snapshots; useful for CDC-style processing.
-- By snapshot id
SELECT * FROM events FOR VERSION AS OF 8472810294831234567;

-- By branch
SELECT * FROM events FOR VERSION AS OF 'dev_2026_05';

-- By tag
SELECT * FROM events FOR VERSION AS OF 'release_2026_q2';

-- By timestamp
SELECT * FROM events FOR SYSTEM_TIME AS OF TIMESTAMP '2026-04-01 00:00:00';

-- Incremental between two snapshots
SELECT * FROM events
FOR INCREMENTAL BETWEEN SNAPSHOT 1234 AND SNAPSHOT 5678;

INSERT

FormOriginNotes
INSERT INTO t (cols) VALUES (...), (...)sqlparser-rs + sqe-coordinatorMulti-row literal insert.
INSERT INTO t SELECT ... FROM ssqlparser-rs + sqe-coordinatorInsert from query.
INSERT INTO t (col1, col2) SELECT ... FROM ssqlparser-rs + sqe-coordinatorSubset of columns; others get DEFAULT or NULL.
INSERT OVERWRITE t SELECT ... FROM ssqlparser-rs + sqe-coordinatorReplace partition or table data. Targets the partitions implied by the SELECT.
INSERT INTO events VALUES
    (1, 'click', TIMESTAMP '2026-05-08 09:00:00'),
    (2, 'view',  TIMESTAMP '2026-05-08 09:01:00');

INSERT INTO events (id, event_type, occurred_at)
SELECT id, kind, ts FROM staging.raw_events;

UPDATE

FormOriginNotesTrinoSnowflakeSpark SQLDuckDB
UPDATE t SET col = expr [WHERE pred]sqlparser-rs + sqe-coordinatorCoW or MoR by table property.yesyesyesyes
UPDATE t SET col1 = e1, col2 = e2 [WHERE pred]sqlparser-rs + sqe-coordinatorMulti-column set.yesyesyesyes
UPDATE t SET col = expr FROM other o WHERE t.k = o.ksqlparser-rs + sqe-coordinatorUpdate from another table.partialyesyesyes
UPDATE orders
SET status = 'shipped', shipped_at = now()
WHERE tracking_id IS NOT NULL;

DELETE

FormOriginNotesTrinoSnowflakeSpark SQLDuckDB
DELETE FROM t [WHERE pred]sqlparser-rs + sqe-coordinatorCoW (default) or MoR (write.delete.mode = 'merge-on-read').yesyesyesyes
DELETE FROM t USING other o WHERE t.k = o.ksqlparser-rs + sqe-coordinatorDelete by join.partialyesyesyes
TRUNCATE TABLE tsqe-sql/classifier.rsRewrites to DELETE FROM t. Same MoR / CoW behaviour.yesyesyesyes
DELETE FROM events WHERE event_type = 'spam';
TRUNCATE TABLE staging.tmp;

MERGE

FormOriginNotes
MERGE INTO t USING s ON cond WHEN MATCHED THEN UPDATE SET ... WHEN NOT MATCHED THEN INSERT (...) VALUES (...)sqlparser-rs + sqe-coordinatorCoW or MoR. Multiple WHEN MATCHED branches with extra predicates allowed.
MERGE INTO t USING s ON cond WHEN MATCHED THEN DELETEsqlparser-rs + sqe-coordinatorDelete matched rows.
MERGE INTO t USING s ON cond WHEN MATCHED AND pred THEN UPDATE SET ... WHEN MATCHED THEN DELETEsqlparser-rs + sqe-coordinatorConditional MATCHED branches.
MERGE INTO orders t
USING staging.order_updates s
ON t.id = s.id
WHEN MATCHED AND s.status = 'cancelled' THEN DELETE
WHEN MATCHED THEN UPDATE SET status = s.status, updated_at = s.updated_at
WHEN NOT MATCHED THEN INSERT (id, status, created_at) VALUES (s.id, s.status, s.created_at);

Copy-on-Write vs Merge-on-Read

Three table properties control write semantics:

PropertyDefaultEffect
write.delete.mode'copy-on-write'Switch to 'merge-on-read' to write position / equality delete files instead of rewriting whole data files.
write.update.mode'copy-on-write'Same options. MoR UPDATE writes both deletes and inserts.
write.merge.mode'copy-on-write'Same options.

Set per-table via ALTER TABLE:

ALTER TABLE orders SET TBLPROPERTIES (
    'write.delete.mode' = 'merge-on-read',
    'write.update.mode' = 'merge-on-read',
    'write.merge.mode'  = 'merge-on-read'
);

When to choose:

  • CoW: small tables, infrequent writes, predictable read latency. Default.
  • MoR: large tables, frequent small deletes, willing to trade read amplification for write speed. Compact periodically with system.rewrite_data_files.

COPY TO

FormOriginNotesTrinoSnowflakeSpark SQLDuckDB
COPY (SELECT ...) TO 'path' (FORMAT csv|json|parquet)datafusion-builtinWrite query result to a file.-yes-yes
COPY t TO 'path' (FORMAT parquet)datafusion-builtinWrite whole table.-yes-yes
COPY (SELECT ...) TO 'path' (FORMAT parquet, PARTITION_BY 'col1, col2')datafusion-builtinHive-style partitioned output.-yes-yes
COPY (SELECT * FROM events WHERE occurred_at >= DATE '2026-05-01')
    TO '/tmp/may_events.parquet'
    (FORMAT parquet);

COPY events TO 's3://export/events.csv'
    (FORMAT csv, COMPRESSION gzip, HEADER true);

Branch routing (SQE-specific)

Iceberg branches let you isolate writes from the production snapshot. SQE exposes branch routing as a session variable.

StatementNotes
SET WRITE_BRANCH = 'name'Subsequent INSERT / UPDATE / DELETE / MERGE writes go to the named branch.
SET WRITE_BRANCH = DEFAULTReset to the default (main) branch.
SET WRITE_BRANCH = NULLSame as DEFAULT.
ALTER TABLE events CREATE BRANCH staging;
SET WRITE_BRANCH = 'staging';

INSERT INTO events SELECT * FROM new_data;
-- ^ writes to the 'staging' branch only

SELECT count(*) FROM events FOR VERSION AS OF 'staging';
-- ^ reads from staging branch

SET WRITE_BRANCH = DEFAULT;

Session control

StatementOriginNotes
USE catalog.schemasqe-sql/classifier.rsSwitch active catalog and schema. Subsequent unqualified table references use this scope.
USE schemasqe-sql/classifier.rsSwitch schema only.
SET <variable> = <value>sqlparser-rs (DataFusion)DataFusion session config. See EXPLAIN ANALYZE documentation for valid keys.
BEGIN / COMMIT / ROLLBACKsqe-sql/classifier.rsNo-op stubs for JDBC compatibility. SQE does not implement multi-statement transactions; each commit is single-statement.

Comparison summary

OperationSQETrino + IcebergSpark + IcebergDuckDB
SELECT time travelyes (FOR VERSION AS OF, branch / tag / id)yesyespartial
Incremental SELECTFOR INCREMENTAL BETWEEN (SQE-specific)partial via Iceberg incremental APIsyes-
INSERT INTOyesyesyesyes
UPDATE (CoW + MoR)yesyesyesyes
DELETE (CoW + MoR)yesyesyesyes
MERGE INTO (CoW + MoR)yesyesyes-
TRUNCATE TABLEyes (rewrites to DELETE)yesyesyes
COPY TOyes--yes
Branch-routed writesSET WRITE_BRANCH (SQE)partialyes-
Multi-statement transactionsno (no-op stubs)nonoyes

CALL procedures

Iceberg maintenance operations exposed as SQL CALL statements. SQE wraps the vendored iceberg-rust action APIs (RewriteFilesAction, RemoveSnapshotAction, RewriteManifestsAction) and adds an SQE-specific bloom-filter suggestion procedure that walks recent query history.

All procedures use Iceberg’s named-argument syntax: CALL system.<proc>(name => value, ...). Unknown argument names raise a parse error so typos fail fast.

Source: sqe-sql/src/procedures.rs. Handlers in crates/sqe-coordinator/src/maintenance.rs.

Reference

ProcedureOriginRequired argsOptional argsNotes
system.rewrite_data_filessqe-sql + sqe-coordinatortable => 'ns.t'target_file_size_bytes => N, min_input_files => N, max_concurrent_file_group_rewrites => N, strategy => 'binpack'|'sort', sort_order => 'col ASC, ...'|'zorder(a, b)', delete_file_threshold => N, distributed => 'auto'|'local'|'require', rewrite_all => trueCompacts small data files (delete-aware). Default target 512 MiB, min 5 files per group, max 4 concurrent groups. strategy => 'sort' sorts a whole partition by sort_order (a column list or zorder(...)) via a spillable DataFusion sort and rolls output at the target size, producing files with disjoint key ranges. delete_file_threshold => N also rewrites any data file with at least N delete files applying to it, even when it is already large. rewrite_all => true forces a rewrite of every file regardless of size or file count. distributed => ... overrides [maintenance.distribution] mode for this one call (see Configuration and Distributed compaction); omit it to use the configured mode. A manual CALL commits with no extra snapshot properties; the auto-compaction scheduler (see Maintenance (auto-compaction)) calls this same handler internally and stamps sqe.maintenance.job-id/principal/trigger onto the snapshot it commits, so an autonomous compaction is attributable in the table’s history while a manual one is not.
system.expire_snapshotssqe-sql + sqe-coordinatortable => 'ns.t'older_than => TIMESTAMP, retain_last => NDrops old snapshots. older_than and retain_last combine: a snapshot must be older than older_than and beyond the retain_last window before it is removed.
system.remove_orphan_filessqe-sql + sqe-coordinatortable => 'ns.t'older_than => TIMESTAMPDeletes files under the table prefix not referenced by any live snapshot. Default older_than is 3 days ago, to avoid racing with in-flight writes.
system.rewrite_manifestssqe-sql + sqe-coordinatortable => 'ns.t'-Consolidates many small manifest files into fewer larger ones. Speeds up planning on large tables.
system.suggest_bloom_filter_columnssqe-sql + sqe-coordinatortable => 'ns.t'history_limit => NSQE-specific. Walks the last N finished queries (default 1000), counts equality predicates per column, returns ranked suggestions for write.parquet.bloom-filter-columns.
system.table_healthsqe-sql + sqe-coordinatortable => 'ns.t'-SQE-specific (auto-compaction maintenance subsystem, see Maintenance (auto-compaction)). Read-only compaction-debt report: live/small file counts, avg/p50 file size, delete-file and delete-heavy counts, eligible bin-pack groups, estimated rewrite bytes, last compaction snapshot, and whether the table has opted into the maintenance scheduler. Never rewrites anything, and available regardless of maintenance.mode.
system.refresh_catalog_cachesqe-sql + sqe-coordinator--SQE-specific. Table-less. Drops the calling session’s own cached SessionContext, so the next query re-enumerates catalogs and sees a catalog created out-of-band (for example by the platform) without waiting out the session-cache TTL. Self-scoped: it touches only the caller’s view and no process-global cache, needs no write privilege, and has no cross-tenant effect. Returns one row (scope, status). The global, admin-gated equivalent (which also drops the shared REST-catalog cache, covering catalog rebinds) is the POST /api/v1/catalogs/refresh endpoint on the health port (see Web UI and admin endpoints).

Comparison to other engines

ProcedureSQETrino + IcebergSpark + IcebergDuckDB
Compact small filesCALL system.rewrite_data_files(...)ALTER TABLE t EXECUTE optimizeCALL t.system.rewrite_data_files(...)-
Expire old snapshotsCALL system.expire_snapshots(...)ALTER TABLE t EXECUTE expire_snapshots(...)CALL t.system.expire_snapshots(...)-
Remove orphansCALL system.remove_orphan_files(...)ALTER TABLE t EXECUTE remove_orphan_files(...)CALL t.system.remove_orphan_files(...)-
Rewrite manifestsCALL system.rewrite_manifests(...)ALTER TABLE t EXECUTE optimize_manifestsCALL t.system.rewrite_manifests(...)-
Suggest bloom filtersCALL system.suggest_bloom_filter_columns(...)---

The Spark and SQE shapes are aligned: Spark uses t.system.<proc> (table-qualified), SQE uses system.<proc>(table => 'ns.t') (named arg). Both are explicit. Trino prefers EXECUTE-as-DDL syntax which is harder to script.

Examples

Compact a partitioned fact table

CALL system.rewrite_data_files(
    table => 'analytics.events',
    target_file_size_bytes => 268435456,    -- 256 MiB
    min_input_files => 8
);

Returns one summary row:

+----------------------+----------------------+----------------------+
| files_rewritten      | bytes_rewritten      | snapshot_id          |
+----------------------+----------------------+----------------------+
| 142                  | 39283744832          | 8472810294831234567  |
+----------------------+----------------------+----------------------+

Sort-compact for read pruning

Load fast (unsorted), then compact into sorted files once. The sort strategy gathers a whole partition into one stream, orders it by sort_order through a spillable DataFusion sort, and rolls the output at target_file_size_bytes. Sorting the partition as a single stream is what makes the result prunable: the output files come out with disjoint key ranges instead of each file spanning the full domain. The sort spills to disk, so the rewrite stays memory-bounded even when a partition is larger than RAM. Unlike bin-pack, the sort strategy also rewrites files already at or above the target size, because they still have to be re-laid-out to join the sorted layout.

-- Lexicographic sort on one or more columns.
CALL system.rewrite_data_files(
    table => 'analytics.events',
    strategy => 'sort',
    sort_order => 'event_date ASC, user_id ASC'
);

-- Z-order clustering for multi-dimensional locality.
CALL system.rewrite_data_files(
    table => 'analytics.events',
    strategy => 'sort',
    sort_order => 'zorder(user_id, device_id)'
);

Sorted files give the reader tight min/max stats per file, so predicate pruning skips more files. Z-order clusters several columns at once, which helps when queries filter on different subsets of those columns. Iceberg’s sort-order metadata cannot express z-order, so none is stamped for the z-order case (matches Spark).

Verify the layout with table_files: after a sort compaction the lower_bounds / upper_bounds of the output files should not overlap on the sort column.

SELECT file_path, lower_bounds, upper_bounds
FROM table_files('analytics', 'events')
ORDER BY lower_bounds;

Clean up delete-heavy Merge-on-Read files

On a Merge-on-Read table, repeated DELETE/UPDATE/MERGE accumulate delete files. A data file with many deletes is slow to read (every delete file has to be applied on scan). delete_file_threshold rewrites any data file with at least that many delete files applying to it, even when the file is already at or above the target size, so bin-pack would otherwise leave it alone.

CALL system.rewrite_data_files(
    table => 'analytics.events',
    delete_file_threshold => 10
);

The count includes every delete file the scan attaches to the data file, both position and equality deletes. A low threshold on an equality-heavy table therefore rewrites broadly, since one equality delete can apply to many files. The option is off by default and is a no-op under strategy => 'sort', which already rewrites the whole partition.

Override the distribution mode for one call

distributed => 'auto'|'local'|'require' overrides [maintenance.distribution] mode (see Configuration) for this one CALL, without touching the coordinator’s config file. 'require' fails the call immediately if fewer than min_workers workers are currently healthy, rather than silently falling back to a coordinator-local rewrite:

CALL system.rewrite_data_files(
    table => 'analytics.events',
    distributed => 'require'
);

'local' forces a coordinator-local rewrite even with a healthy fleet present, useful for a one-off run you want to keep off the workers (a small table, or a maintenance window where the fleet is busy with query traffic). Omitting distributed entirely uses the configured [maintenance.distribution] mode. See Distributed compaction for how a distributed call plans, dispatches, and commits.

max_concurrent_file_group_rewrites only bounds concurrency on the coordinator-local path; a distributed rewrite is instead bounded by [maintenance.distribution] max_inflight_groups_per_worker (per-worker, not global), configured in Configuration.

Force a full rewrite

rewrite_all => true rewrites every data file, including files already at or above the target size and partitions below min_input_files. It applies all deletes and re-encodes at the target size. Use it to force a clean pass after a schema or partition-spec change, or to apply accumulated deletes across a whole table in one commit.

CALL system.rewrite_data_files(
    table => 'analytics.events',
    rewrite_all => true
);

Because it re-encodes everything, it costs a full read and write of the table. It is off by default and subsumed by strategy => 'sort', which already rewrites the whole partition. rewrite_all is supported on both the coordinator-local and distributed paths: it forces every file into the group plan and bypasses the min_input_files floor on either path.

Check compaction debt before deciding whether to run a rewrite

table_health is read-only: it reuses the same file-collection and bin-pack logic rewrite_data_files uses to plan a rewrite, but never writes a file or commits a snapshot. It bypasses the write-privilege gate entirely, so a SELECT-only session can run it.

CALL system.table_health(table => 'analytics.events');

Returns one summary row:

+-----------------+-------------+----------------+----------------+--------------+---------------------+------------------+--------------------+-----------------------------+----------------------+
| live_data_files | small_files | avg_file_bytes | p50_file_bytes | delete_files | delete_heavy_files  | eligible_groups  | est_rewrite_bytes  | last_compaction_snapshot_ms| maintenance_enabled  |
+-----------------+-------------+----------------+----------------+--------------+---------------------+------------------+--------------------+-----------------------------+----------------------+
| 1842            | 611         | 41943040       | 33554432       | 96           | 12                  | 7                | 2248146944         | NULL                       | true                 |
+-----------------+-------------+----------------+----------------+--------------+---------------------+------------------+--------------------+-----------------------------+----------------------+

Column notes:

  • small_files counts live data files below [maintenance.compaction].target_file_size_bytes (default 512 MiB).
  • eligible_groups / est_rewrite_bytes report pure bin-pack debt: groups that meet min_input_files on file count alone. delete_heavy_files is a separate signal, files with at least delete_file_threshold delete files applying to them. A later rewrite_data_files(delete_file_threshold => N) call rewrites the union of both sets, so treat the two counts as additive, not eligible_groups already including delete-heavy files.
  • last_compaction_snapshot_ms is always NULL. Active-mode compactions do stamp sqe.maintenance.job-id/principal/trigger onto the snapshot they commit (see the system.rewrite_data_files note above), but table_health does not yet read that snapshot property back; check the table’s snapshot history directly for compaction attribution until a later phase wires this column up.
  • maintenance_enabled reflects the sqe.maintenance.enabled table property, i.e. whether the advisory/active scheduler would even consider this table. It does not mean a rewrite ran: advisory mode never mutates, and active mode may still find no eligible compaction debt on a given tick.

Drop snapshots older than 30 days, keeping the last 10

CALL system.expire_snapshots(
    table => 'analytics.events',
    older_than => TIMESTAMP '2026-04-08 00:00:00',
    retain_last => 10
);

The retain_last floor is enforced even when older_than would clear more. Useful for keeping a rollback budget while clamping storage growth.

Bloom filter suggestion before a tuning pass

CALL system.suggest_bloom_filter_columns(
    table => 'analytics.events',
    history_limit => 5000
);

Returns one row per column with a positive equality-predicate count, ranked descending:

+----------+-------------------+------------------+
| column   | equality_pred_hits | recommendation  |
+----------+-------------------+------------------+
| user_id  | 4823              | strongly suggested |
| event_id | 1241              | suggested         |
| device   | 312               | weak             |
+----------+-------------------+------------------+

Apply with:

ALTER TABLE analytics.events SET TBLPROPERTIES (
    'write.parquet.bloom-filter-columns' = 'user_id,event_id'
);

The next write picks up the new property; existing files are unaffected until rewritten.

Combined maintenance run

-- Once a week, in this order:
CALL system.expire_snapshots(table => 'analytics.events',
    older_than => TIMESTAMP '2026-04-08 00:00:00', retain_last => 30);
CALL system.remove_orphan_files(table => 'analytics.events',
    older_than => TIMESTAMP '2026-04-08 00:00:00');
CALL system.rewrite_manifests(table => 'analytics.events');
CALL system.rewrite_data_files(table => 'analytics.events');

Order matters: expire snapshots before removing orphan files (otherwise files referenced by snapshots about to expire look orphaned), and rewrite manifests before rewriting data files (so the rewrite plan reads compact manifests).

Permissions

Procedures inherit the calling user’s grants on the target table:

  • system.rewrite_data_files, system.rewrite_manifests need MODIFY (writes new files, commits a snapshot).
  • system.expire_snapshots, system.remove_orphan_files need MODIFY and DROP (alters retention, deletes files).
  • system.suggest_bloom_filter_columns is read-only against query history; SELECT on the table is enough.
  • system.table_health is read-only against the table’s live metadata; SELECT on the table is enough. It bypasses the write-privilege gate entirely, unlike every other procedure in this table.
  • system.refresh_catalog_cache is self-scoped (it refreshes only the caller’s own session view) and bypasses the write-privilege gate, like system.table_health. A global flush across all sessions is intentionally not a SQL procedure: use the admin-gated POST /api/v1/catalogs/refresh endpoint instead.

A user without the right grant gets a clear “policy denied” error instead of a generic execution failure.

When no OPA / Cedar policy store is wired, an engine-level heuristic acts as the last line of defence. A session is treated as read-only when any of its roles matches read*, select*, or contains readonly, and no role contains write, admin, or owner. Read-only sessions are denied every maintenance procedure and the attempt is recorded in the audit log with status = "denied". A policy store overrides this heuristic once configured.

Safety notes

  • remove_orphan_files with no older_than uses the 3-day default, which is conservative against compaction or COPY jobs in flight. Override with older_than only after confirming no concurrent writers.
  • expire_snapshots is destructive for time-travel queries. Once a snapshot is expired, FOR VERSION AS OF <id> for that snapshot fails. Document a retention window your team agrees on, and stick to it.
  • rewrite_data_files rewrites entire data files, not row groups. Two consecutive calls can churn the same files; rely on the min_input_files floor (default 5) to keep churn bounded.
  • rewrite_data_files is delete-aware on Merge-on-Read tables. It reads each file group through the Iceberg scan, so position and equality deletes are applied during the rewrite and deleted rows never reappear. The compacted output is pinned to the sequence number of the snapshot it read, so an equality delete another writer commits mid-compaction still applies to the compacted files. Fully-covered position delete files are dropped in the same commit; equality deletes are left to age out via expire_snapshots. It groups files per partition, so partitioned tables consolidate within each partition.
  • rewrite_data_files retries on conflict. A concurrent writer that commits between the read and the commit produces a retryable conflict; the procedure re-reads and retries with backoff a bounded number of times before surfacing the conflict.
  • Run procedures in a quiet window. A concurrent writer that commits mid-run can cause rewrite_data_files to return a retryable error. The other procedures tolerate concurrency and reconcile against the live snapshot.

Commit failures

Every procedure commits through the same REST catalog path that CTAS and INSERT use, so commit failures surface as SqeError::Execution and fall into two buckets:

  • Retryable. The message contains conflict or retry. rewrite_data_files already re-reads and retries a bounded number of times internally; a retryable error surfaced to the caller means those attempts were exhausted, so schedule another run after a back-off. The other procedures surface the conflict directly.
  • Permanent. Everything else. Check the message for the upstream cause.

What is not exposed

The vendored iceberg-rust crate has more transaction actions than SQE wires up. Notable omissions:

  • expire_snapshots_by_id (drop a specific snapshot rather than by age). easy to add if needed.
  • rewrite_position_deletes (compact MoR delete files). not yet wrapped; on the V13 backlog.
  • cherrypick_snapshot (apply a non-current snapshot’s changes to the head). out of scope for now; rare use case.

File an issue if you hit one of these in production.

GRANT and REVOKE

Chameleon / SBP-specific. The access-control SQL surface on this page (column masks, row filters, effective-grant inspection, CHECK ACCESS) is an SQE security extension built for the Chameleon platform. It is not part of the core open-source Iceberg SQL surface, and the grant backend is pluggable: SQE ships a Polaris backend and a Chameleon backend. A default OSS deployment can run without it. It is documented here for completeness; treat it as an optional, platform-specific layer.

SQE-specific security extensions on top of the SQL standard GRANT / REVOKE. The base shapes are parsed by sqlparser-rs; SQE adds:

  • Column masks: GRANT SELECT ON ... TO ... MASKED WITH expr.
  • Row filters: GRANT SELECT ON ... TO ... ROWS WHERE expr.
  • Effective-grant inspection: SHOW EFFECTIVE GRANTS FOR USER "x" returns the resolved policy for a user across roles and inheritance.
  • Resource-scoped listing: SHOW GRANTS ON ns.table.
  • Pre-flight check: CHECK ACCESS SELECT ON ns.table FOR USER "x" returns boolean without executing.

These extensions are parsed in sqe-sql/src/classifier.rs (pre-parser scan) and enforced by the policy engine in sqe-policy/. The plan rewriter injects row filters above TableScan and substitutes column masks before DataFusion’s optimizer runs, so the optimizer cannot push user predicates through a mask.

This page is the SQL surface reference.

Privileges

PrivilegeApplies toEffect
SELECTtable, view, schema, catalogRead rows. Combines with row filters and column masks.
INSERTtableAppend new rows.
UPDATEtableModify existing rows.
DELETEtableRemove rows.
MODIFYtableShorthand for INSERT + UPDATE + DELETE + MERGE. Required by maintenance procedures.
DROPtable, schemaRequired by DROP TABLE, DROP SCHEMA, system.expire_snapshots.
CREATEschema, catalogRequired to create new tables / schemas.
ALL PRIVILEGEScatalog to GRANT, any to REVOKEEvery privilege on the resource. See Closing a gate for why the two directions differ.

Grantee types

TypeSyntaxSource
UserTO USER "alice"OIDC subject claim.
RoleTO ROLE "analyst"Group claim from the OIDC provider, or a manually mapped role.
PublicTO PUBLICEvery authenticated user. Avoid in production.

Statements

Standard GRANT / REVOKE

GRANT SELECT ON analytics.events TO ROLE "analyst";
GRANT INSERT, UPDATE ON staging.tmp TO USER "etl";
GRANT ALL PRIVILEGES ON SCHEMA analytics TO ROLE "data_engineer";
REVOKE INSERT ON staging.tmp FROM USER "etl";

The standard form is parsed by sqlparser-rs and routed via StatementKind::Grant / StatementKind::Revoke.

Closing a gate

REVOKE SELECT does not necessarily stop a principal reading. Privileges expand to Polaris access types, and a writer must hold the metadata reads that authorize a table load, so a surviving INSERT keeps conferring read. The statement reports success and the rows still come back. Measured on Polaris: stripping table-data-read alone changes nothing, and table-properties-read is what unlocks LOAD_TABLE.

Use REVOKE ALL PRIVILEGES to close a gate. It means “this grantee holds nothing on this object afterwards”, at any level, and needs no knowledge of which privileges were granted:

REVOKE ALL PRIVILEGES ON sales.orders FROM ROLE "analyst";

It reads the access types the grantee actually holds from Ranger rather than planning them from a privilege name, so grants written before SQE tracked provenance, or written straight through the Ranger console, are removed too. It also clears that grantee’s DENY items at the object, and is idempotent: running it against an object the grantee never held is a successful no-op.

GRANT ALL PRIVILEGES still binds no deeper than the catalog, and the asymmetry is deliberate. Granting “everything” at a coordinate requires a definition of everything, and getting that wrong once wrote a catalog-wide policy from a single-table grant. Revoking everything needs no such definition: it only removes, so it cannot widen access.

Column masks (SQE extension)

GRANT SELECT (id, name, email)
    ON users
    TO ROLE "support"
    MASKED WITH (
        email = sha256(email)
    );

The MASKED WITH clause is post-parse: SQE walks the AST after sqlparser succeeds and lifts the trailing extension into a PolicyStatement node. Anyone with the support role sees the masked email; the unmasked column never reaches the user’s session. Plan optimization happens after the substitution so a WHERE email = '[email protected]' predicate cannot bypass the mask.

Row filters (SQE extension)

GRANT SELECT ON orders TO ROLE "regional_eu"
    ROWS WHERE region = 'EU';

The filter expression is injected as a Filter node directly above TableScan for the orders reference. DataFusion’s predicate pushdown can move user WHERE clauses through the row filter (because filters compose), but cannot eliminate it.

SHOW GRANTS

FormWhat it returns
SHOW GRANTS ON ns.tableAll grants on the resource.
SHOW GRANTS ON SCHEMA nsAll grants on the schema.
SHOW GRANTS TO USER "alice"Direct grants to the user (does not include role-inherited).
SHOW GRANTS TO ROLE "analyst"Direct grants to the role.
SHOW EFFECTIVE GRANTS FOR USER "alice"Resolved policy: direct grants + role-inherited + masks + row filters. The view a query planner uses.
sqe> SHOW EFFECTIVE GRANTS FOR USER "alice";
+------------------+--------+-----------+--------------+----------------------+
| resource         | privilege | grantee  | row_filter   | column_masks         |
+------------------+--------+-----------+--------------+----------------------+
| analytics.events | SELECT | role "an" | region='EU'  | none                 |
| users            | SELECT | role "su" | none         | email -> sha256(...) |
+------------------+--------+-----------+--------------+----------------------+

CHECK ACCESS

A pre-flight test. Returns boolean without executing the query.

CHECK ACCESS SELECT ON analytics.events FOR USER "alice";
-- true

CHECK ACCESS DELETE ON analytics.events FOR USER "alice";
-- false

Useful in scripts that want to bail out before a long-running query if the user lacks permission, and in the test suite to verify policy logic.

Comparison

FeatureSQETrino + IcebergSpark + IcebergDuckDB
GRANT / REVOKE (SQL standard)yesyes (with Ranger)yes (Ranger / Lake Formation)no
Column masksGRANT ... MASKED WITHexternal (Ranger)external (Ranger)no
Row filtersGRANT ... ROWS WHEREexternal (Ranger)external (Ranger)no
SHOW EFFECTIVE GRANTSyesnonono
CHECK ACCESS (pre-flight)yesnonono
Per-user OIDC bearer to storageyes (catalog + writes; reads use the configured storage key)no (service account)no (service account)no
Plan-level enforcementyes (rewriter)external middlewareexternal middlewareno

The structural difference: SQE keeps policy in-engine, plan-rewritten before optimization, and tied to the per-query bearer token. Trino and Spark push the responsibility to Apache Ranger, which lives outside the engine and intercepts at the connector boundary.

Backends

Two orthogonal settings control access control, and both default to off:

[access_control] backend decides where GRANT/REVOKE are stored and resolved:

backendUse caseWhere it lives
none (default)OSS default. GRANT/REVOKE parse but are not enforced.n/a
polarisGrants resolved from Apache Polaris (PRINCIPAL / PRINCIPAL_ROLE / CATALOG_ROLE).sqe-policy/src/grants/polaris.rs
rangerGrants written to Ranger Admin via the Polaris embedded authorizer.sqe-policy/src/grants/ranger.rs
chameleonSchuberg Philis platform API (GROUP / USER grantees).platform API client

[policy] engine decides how row filters and column masks are evaluated:

engineUse caseWhere it lives
passthrough (default)No enforcement. Plans returned unmodified.PassthroughEnforcer
in-memorySingle-node dev and tests. Policies in a hash map.sqe-policy/src/policy_store.rs
rangerApache Ranger fine-grained policies (row-filter + data-mask), requires [policy.ranger].url.sqe-policy/src/ranger_store.rs
opa / cedarDefined in config but not yet wired (selecting them errors today).sqe-policy/src/opa.rs (OPA)

Configure them in the engine TOML:

[access_control]
backend = "ranger"          # none (default) | polaris | ranger | chameleon

[policy]
engine = "ranger"           # passthrough (default) | in-memory | ranger

A default OSS deployment leaves both at their defaults and runs the open Iceberg SQL surface with grants parsed but unenforced.

Why plan rewriting, not connector hooks

A short rationale:

  1. Optimization safety: filters added above TableScan survive predicate pushdown but cannot be eliminated. Connector-level hooks run after planning and can be bypassed by a clever WHERE clause.
  2. Information leakage: a user querying column_that_is_masked = 'secret' gets zero rows, exactly as if the column did not exist. PostgreSQL RLS uses the same model.
  3. Auditability: the rewritten plan is logged. Reviewers see exactly what filter was applied per query, per user.
  4. Composability: row filters from multiple grants AND together; column masks from multiple grants are applied innermost-out. The semantics are explicit instead of implementation-defined.

Known gaps

  • No WITH GRANT OPTION. Grants are non-delegating; only an admin can grant.
  • No column-level INSERT (GRANT INSERT (col1, col2) ON ...). The granularity is table-level for INSERT today.
  • Mask expressions are scalar only; aggregate / table-valued mask expressions are not allowed.

File an issue if any of these block your use case.

SHOW and EXPLAIN

Metadata queries (catalog / schema / table listing) and plan inspection (EXPLAIN, EXPLAIN ANALYZE, EXPLAIN FULL). Most are routed through the coordinator; EXPLAIN FULL is SQE-specific.

Source: sqe-sql/src/classifier.rs (statement routing), crates/sqe-coordinator/src/query_handler.rs (handlers).

SHOW statements

StatementOriginNotesTrinoSnowflakeSpark SQLDuckDB
SHOW CATALOGSsqe-sql/classifier.rs:154Lists every catalog the session can see. Honours auth: catalogs the user has no SELECT on are filtered.yes-yesyes
SHOW SCHEMAS [IN cat]sqlparser-rs + sqe-coordinatorList namespaces. Filters by catalog if IN supplied.yesyesyesyes
SHOW TABLES [IN cat.ns]sqlparser-rs + sqe-coordinatorList tables.yesyesyesyes
SHOW VIEWS [IN cat.ns]sqlparser-rs + sqe-coordinatorList views.yesyesyespartial
SHOW COLUMNS FROM cat.ns.tsqe-coordinator/query_handler.rs:1858Trino syntax. Rewrites to information_schema.columns query.yesyesyesyes
SHOW CREATE TABLE cat.ns.tsqe-sql/classifier.rsReconstruct the CREATE statement from current metadata.yesyesyesyes
SHOW STATS FOR cat.ns.tsqe-sql/classifier.rs:166Per-column NDV, null fraction, min, max. From Iceberg manifest stats.yes-partialyes
DESCRIBE cat.ns.tdatafusion-builtinThree-column projection: column_name, data_type, is_nullable.yesyesyesyes
SHOW GRANTS ON ...sqe-sql/classifier.rs:186See GRANT and REVOKE.partialyespartial-
SHOW EFFECTIVE GRANTS FOR USER "x"sqe-sql/classifier.rs:174SQE-specific. See GRANT and REVOKE.----
sqe> SHOW CATALOGS;
+---------------+
| catalog_name  |
+---------------+
| default       |
| analytics     |
| iceberg_main  |
+---------------+

sqe> SHOW TABLES IN analytics;
+--------------+--------------+--------------+
| table_catalog | table_schema | table_name  |
+--------------+--------------+--------------+
| analytics    | public       | events       |
| analytics    | public       | users        |
| analytics    | staging      | tmp_dedup    |
+--------------+--------------+--------------+

DESCRIBE vs SHOW COLUMNS

Both work, slightly different shapes:

DESCRIBE analytics.events;
-- column_name | data_type | is_nullable

SHOW COLUMNS FROM analytics.events;
-- column_name | data_type | is_nullable | extra

DESCRIBE is DataFusion-native (3 columns). SHOW COLUMNS is Trino syntax, rewritten by SQE to query information_schema.columns directly so external dbt models that expect 4 columns work unmodified.

SHOW STATS

Per-column statistics from manifest aggregates. Unlike DESCRIBE, this returns one row per column with summary numbers:

sqe> SHOW STATS FOR analytics.events;
+--------------+--------------+--------------+----------------+--------+--------+
| column_name  | data_size    | distinct     | null_fraction  | min    | max    |
+--------------+--------------+--------------+----------------+--------+--------+
| id           | 96000000     | 12000000     | 0.0            | 1      | 12000000 |
| user_id      | 96000000     | 8473210      | 0.0            | 1      | 9999    |
| amount       | 144000000    | 9921458      | 0.001          | -50.00 | 12500.00 |
| occurred_at  | 96000000     | 11973247     | 0.0            | 2024-..| 2026-...|
+--------------+--------------+--------------+----------------+--------+--------+

distinct and bounds are upper bounds from manifest stats, not exact. For exact counts use count(distinct col) or .summarize. The output drives planner cost estimates.

EXPLAIN

StatementOriginNotes
EXPLAIN SELECT ...datafusion-builtinLogical and physical plans, no execution.
EXPLAIN ANALYZE SELECT ...datafusion-builtinRun the query; show physical plan with per-operator metrics.
EXPLAIN FULL SELECT ...sqe-sql/classifier.rs:159SQE-specific. Logical plan + physical plan + Iceberg scan plan (manifest counts, file counts, partition pruning, residual filter), no execution.

EXPLAIN is the cheapest:

EXPLAIN SELECT user_id, count(*) FROM events
WHERE occurred_at >= DATE '2026-05-01' GROUP BY user_id;
+---------------+--------------------------------------------------------------+
| plan_type     | plan                                                         |
+---------------+--------------------------------------------------------------+
| logical_plan  | Projection: user_id, count(*)                                |
|               |   Aggregate: groupBy=[user_id], aggr=[count(*)]              |
|               |     Filter: occurred_at >= Date32("2026-05-01")              |
|               |       TableScan: events                                      |
| physical_plan | ProjectionExec ...                                           |
|               |   AggregateExec ...                                          |
|               |     CoalesceBatchesExec ...                                  |
|               |       FilterExec ...                                         |
|               |         IcebergScanExec(events): files=12, bytes=180MB       |
+---------------+--------------------------------------------------------------+

EXPLAIN ANALYZE runs the query and overlays per-operator counters:

| physical_plan | ProjectionExec, metrics=[output_rows=4823, elapsed=12ms]
|               |   AggregateExec, metrics=[output_rows=4823, elapsed=42ms]
|               |     IcebergScanExec, metrics=[files=12, files_pruned=0, bytes=180MB, elapsed=89ms]

EXPLAIN FULL shows the iceberg planning detail without executing:

| iceberg_plan  | files_total=120, files_after_partition_prune=12,             |
|               | files_after_min_max_prune=12, residual_filter=true            |
|               | bytes_planned=180MB, partition_columns=[day(occurred_at)]     |

Comparison

StatementSQETrinoSnowflakeSpark SQLDuckDB
EXPLAINyesyesyes (EXPLAIN)yesyes
EXPLAIN ANALYZEyesyes (EXPLAIN ANALYZE)partial (query profile)yesyes
EXPLAIN FULL (planning detail w/o exec)yes (SQE-specific)partial-partial-
SHOW STATSyesyespartial (information_schema)partialyes

Information schema (DataFusion-native)

Always available; standard SQL surface.

TableNotes
information_schema.schemataSchemas in every catalog.
information_schema.tablesTables in every catalog.
information_schema.columnsPer-column metadata.
information_schema.viewsViews.
information_schema.df_settingsDataFusion session config.
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_catalog = 'analytics' AND table_type = 'BASE TABLE';

The dotcommands .tables, .schema, .catalogs are convenience wrappers around these.

Iceberg metadata

For Iceberg-specific metadata (snapshots, manifests, files, partitions, refs, history), see Table-valued functions. Both SQE TVF syntax and Trino t$snapshots syntax are accepted.

Operators

Built-in SQL operators. All from DataFusion’s parser; SQE adds none of its own. The list is here for completeness so users do not have to cross-reference the DataFusion docs for the basics.

Arithmetic

OperatorNotesTrinoSnowflakeSpark SQLDuckDB
+Add. Numeric or interval.yesyesyesyes
-Subtract. Numeric, interval, or unary negation.yesyesyesyes
*Multiply.yesyesyesyes
/Divide. Integer / Integer returns Double in DataFusion.yesyesyesyes
%Modulo. Integer or numeric. Same as mod().yesyesyesyes
^Not exponentiation in DataFusion. Use pow(x, y).---yes
SELECT 10 + 5,                  -- 15
       10 - 5,                  -- 5
       10 * 5,                  -- 50
       10 / 3,                  -- 3.333... (Double)
       10 % 3,                  -- 1
       -10                      -- unary minus
;

For integer division use floor(a / b) or div(a, b) (DataFusion).

String

OperatorNotesTrinoSnowflakeSpark SQLDuckDB
||Concatenate. NULL propagates.yesyesyesyes
LIKEPattern with _ and %.yesyesyesyes
ILIKECase-insensitive LIKE.yesyespartialyes
NOT LIKE / NOT ILIKENegated.yesyesyesyes
SIMILAR TOSQL/POSIX-light regex.yesyes-yes
~ / ~*Regex match (case-sensitive / insensitive).-yes-yes
!~ / !~*Negated regex.-yes-yes
SELECT name FROM users WHERE email ILIKE '%@example.com';
SELECT * FROM logs WHERE message ~ '^ERROR:';

Comparison

OperatorNotes
=, <>, !=Equal, not-equal. NULL propagates (returns NULL, not true / false).
<, <=, >, >=Ordering.
BETWEEN x AND yInclusive range. NULL propagates.
NOT BETWEEN x AND yNegated.
IS DISTINCT FROMLike <> but treats NULL = NULL as false (i.e. NULLs are equal).
IS NOT DISTINCT FROMLike = but treats NULL = NULL as true.
-- These differ on NULLs
SELECT a = b      FROM (VALUES (1, NULL)) AS t(a, b);  -- NULL
SELECT a IS NOT DISTINCT FROM b FROM (VALUES (1, NULL)) AS t(a, b);  -- false
SELECT a IS NOT DISTINCT FROM b FROM (VALUES (NULL, NULL)) AS t(a, b);  -- true

IS NOT DISTINCT FROM covers what Snowflake DECODE does for NULL = NULL match without needing the conditional construct.

NULL tests

OperatorNotes
IS NULLTrue if NULL.
IS NOT NULLTrue if not NULL.
IS TRUE / IS FALSEThree-valued logic: NULL is not TRUE and is not FALSE.
IS NOT TRUE / IS NOT FALSEInverse, including NULL.
IS UNKNOWN / IS NOT UNKNOWNSame as IS NULL / IS NOT NULL for boolean expressions.

Logical

OperatorNotes
ANDThree-valued: NULL AND TRUE = NULL; NULL AND FALSE = FALSE.
ORThree-valued: NULL OR TRUE = TRUE; NULL OR FALSE = NULL.
NOTNULL stays NULL.

Set membership

OperatorNotes
IN (a, b, c)List membership. NULL in list is ignored.
IN (subquery)Subquery membership.
NOT IN (...)Negated. CARE: NOT IN with NULL in list returns NULL, not TRUE.
EXISTS (subquery)True if subquery returns any row.
NOT EXISTS (subquery)Negated. Safer than NOT IN for NULL handling.
ANY (subquery) / SOME (subquery)Compares to any row. x = ANY (subquery) = x IN (subquery).
ALL (subquery)Compares to every row.
-- IN (NOT recommended when subquery may produce NULLs)
SELECT * FROM users WHERE id IN (SELECT user_id FROM blocked);

-- NOT EXISTS (safer)
SELECT * FROM users u WHERE NOT EXISTS (
    SELECT 1 FROM blocked b WHERE b.user_id = u.id
);

Type cast

OperatorNotesTrinoSnowflakeSpark SQLDuckDB
CAST(expr AS type)SQL standard cast. Errors on overflow / parse failure.yesyesyesyes
TRY_CAST(expr AS type)Returns NULL on failure.yesyespartialyes
expr::typePostgres-style shorthand for CAST.yesyes-yes
try(CAST(...))try() wraps any expression; same effect for casts.yes---
SELECT CAST('42' AS BIGINT);          -- 42
SELECT TRY_CAST('not a number' AS BIGINT);  -- NULL (no error)
SELECT '42'::BIGINT;                  -- 42 (Postgres style)
SELECT try(CAST(payload AS BIGINT)) FROM events;

Field access

OperatorNotes
expr.fieldStruct field access.
expr['key']Map subscript.
expr[index]Array subscript. 1-based, NULL on out-of-bounds.
SELECT
    address.city,                          -- struct field
    settings['theme'],                     -- map lookup
    tags[1]                                -- first array element
FROM users;

Quantifier shortcut

OperatorNotes
expr IN (subquery)Equivalent to expr = ANY (subquery).
expr NOT IN (subquery)Equivalent to expr <> ALL (subquery).

Operator precedence

Higher binds tighter:

  1. :: (postfix cast)
  2. [] (subscript), . (field access)
  3. unary +, unary -, NOT
  4. *, /, %
  5. +, -
  6. ||
  7. LIKE, ILIKE, SIMILAR TO, ~, BETWEEN, IN, IS NULL, IS NOT NULL
  8. =, <>, !=, <, <=, >, >=, IS DISTINCT FROM, IS NOT DISTINCT FROM
  9. AND
  10. OR

Use parentheses when the order is not obvious; the planner does not warn on ambiguity.

What is NOT supported

  • @>, <@ (Postgres array containment). Use array_has_all / array_contains.
  • ->, ->> (Postgres JSON arrow). Use json_get / json_get_str (DataFusion JSON layer) or json_extract / json_extract_scalar (Trino layer). See JSON.
  • <<, >> (bit shift). Use power(2, n) * x for left shift; floor(x / power(2, n)) for right shift.
  • <=> (MySQL null-safe equals). Use IS NOT DISTINCT FROM.
  • Regex named captures ((?P<name>...)). DataFusion’s regex backend (Rust regex crate) does not support PCRE-style named captures; use numbered captures via regexp_extract(s, p, n).

Dot-commands

Embedded-CLI shortcuts. Lines beginning with . bypass the SQL parser and run client-side; everything else is SQL. The convention matches sqlite3 and the DuckDB shell.

Dot-commands work in the embedded CLI (sqe-cli --embedded) only. Cluster mode uses the same shortcuts indirectly through dbt-sqe or a Flight SQL client; the dot-commands themselves are a REPL feature.

Source: crates/sqe-cli/src/dotcommands.rs.

Reference

CommandAliasesArgumentOriginAction
.help.h, .?-sqe-cliPrint the dot-command list.
.exit.quit, .q-sqe-cliLeave the REPL. End-of-input does the same.
.tables [schema]-optional schema namesqe-cliQuery information_schema.tables. Filter by schema if given.
.schema <table>.describe, .drequired table namesqe-cliQuery information_schema.columns. Accepts 1-, 2-, or 3-part names.
.summarize <table>.summaryrequired table namesqe-cliPer-column count, distinct, null_count, min, max via UNION ALL. SQE’s V9 answer to DuckDB’s SUMMARIZE.
.catalogs.databases-sqe-cliQuery information_schema.schemata.
.read <path>-required file pathsqe-cliExecute a SQL script file. Errors abort.
.timer on|off-required on or offsqe-cliToggle per-query elapsed-time output below each result.
.format [table|csv|tsv|json]-optional formatsqe-cliShow the current format with no argument; set with one.

Comparison to other shells

CommandSQEsqlite3DuckDB CLIpsqlTrino CLI
help.help.help.help\?help
exit.exit, .quit.exit, .quit.exit\qquit
list tables.tables.tables.tables\dtSHOW TABLES
describe table.schema t.schema t.schema t / DESCRIBE t\d tDESCRIBE t
summary stats.summarize t-SUMMARIZE t--
toggle timing.timer on.timer on.timer on\timing-
run script.read f.sql.read f.sql.read f.sql\i f.sql--file=f.sql

Examples

Inspect a table you just created

sqe> CREATE TABLE orders AS SELECT * FROM read_parquet('s3://bucket/orders.parquet');
sqe> .schema orders
+-------------+-----------------+-------------+
| column_name | data_type       | is_nullable |
+-------------+-----------------+-------------+
| id          | BigInt          | NO          |
| customer_id | BigInt          | YES         |
| amount      | Decimal(18, 2)  | YES         |
| created_at  | Timestamp(Microsecond, None) | YES |
+-------------+-----------------+-------------+

.schema accepts qualified names: .schema iceberg.staging.orders works the same way against a 3-part name.

Summarize before deciding

sqe> .summarize orders
+-------------+-------+----------+------------+--------+----------+
| column      | count | distinct | null_count | min    | max      |
+-------------+-------+----------+------------+--------+----------+
| id          | 12000 | 12000    | 0          | 1      | 12000    |
| customer_id | 12000 | 8473     | 0          | 1      | 9999     |
| amount      | 12000 | 9921     | 12         | -50.00 | 12500.00 |
| created_at  | 12000 | 11973    | 0          | 2024-... | 2026-...|
+-------------+-------+----------+------------+--------+----------+

A count == distinct column is a candidate primary key. A high null_count rules out a NOT NULL constraint. The min / max range hints at distribution skew.

Time queries while iterating

sqe> .timer on
sqe> SELECT count(*) FROM read_parquet('hf://datasets/squad/plain_text/train.parquet');
+----------+
| count(*) |
+----------+
| 87599    |
+----------+
1 row in set (1.412s)

Pipe results to a file via .format + shell redirect

$ echo ".format csv
SELECT id, name FROM users" | sqe-cli --embedded --warehouse /data/wh > users.csv

What dot-commands do not do

  • They do not run on the cluster. Use a Flight SQL client (pyarrow, dbt-sqe) or call information_schema directly in SQL.
  • They do not support tab completion or up-arrow recall of dot-command syntax. Tab completion exists for SQL keywords and table names but not for .foo arguments.
  • They are not pluggable. Adding a new dot-command means a code change in crates/sqe-cli/src/dotcommands.rs.

Adding new dot-commands

The pattern is small enough to read in one sitting. Each new command needs:

  1. A new DotCommand enum variant in dotcommands.rs.
  2. A match arm in parse_dot_command().
  3. A line added to help_text().
  4. Optional: a query builder helper if the command translates to SQL.
  5. A handler in the REPL loop (crates/sqe-cli/src/repl.rs).

Two existing examples cover the spectrum: .tables (one-shot SQL builder), .summarize (multi-step: read schema, then build aggregate UNION ALL).

Limitations and Known Gaps

Every engine has edges. This page collects the ones SQE already documents elsewhere into a single list, grouped by area. Each entry says what the limitation is and what the workaround or roadmap status is, with a link to where it is covered in detail. Nothing here is new. If a constraint matters to your deployment, follow the link and read the full treatment.

Availability

The coordinator is a single point of failure

The coordinator runs as a single replica. Session state, the worker registry, and in-flight query state are process-local. There is no shared store. A coordinator restart drops every in-flight query and invalidates client sessions, so connected clients re-authenticate and re-run. A node drain that moves the coordinator pod is a brief outage, not a transparent failover.

Running more than one coordinator replica is not yet safe. Two replicas do not share sessions or the registry, so a client would land on a coordinator that never saw its session. Keep coordinator.replicas: 1. Full coordinator HA with shared session and registry state is a separate design, not yet built.

Workers are different. They are stateless and scale horizontally. A worker loss costs the queries that were running fragments on it, not the cluster.

See Kubernetes & Helm. The chart ships a coordinator PodDisruptionBudget (minAvailable: 1) to block an unforced eviction, but a budget protects the SPOF, it does not remove it.

Security and data path

Read-path S3 access uses the static storage key, not a per-user credential

Writes already consume per-table credentials vended by the catalog: INSERT, MERGE, and DELETE go through the loaded table’s file IO, which carries the vended credentials. Reads do not. The coordinator reads data files with the static key configured in the [storage] section, the same key for every user. Per-user read credential vending (the catalog returns short-lived, table-scoped S3 credentials and SQE reads with those) is designed but not yet built.

The practical consequence: the metadata path is gated per user (the catalog enforces table and namespace permission via the user’s bearer token), but the data path is not gated per user on reads. Scope the [storage] key to the minimum the engine needs.

See S3 Credential Vending for the full design and the phase shape, and the security model for where this sits in the trust boundary.

Fine-grained policy enforcement is off by default

SQE parses the security SQL surface: GRANT ... MASKED WITH, GRANT ... ROWS WHERE, SHOW EFFECTIVE GRANTS, CHECK ACCESS. Plan-rewriting enforcement of row filters and column masks is shipped but off by default: the default [policy] engine = "passthrough" returns plans unmodified. Set engine = "ranger" (Apache Ranger fine-grained policies, row-filter + data-mask, shared with Spark/Kyuubi) or engine = "in-memory" (dev and tests) to turn enforcement on. The opa and cedar engines are defined in config but not yet wired (selecting them errors today).

The gap to know about is the default, not the capability: enforcement does nothing until you select an engine. See Fine-grained access control, GRANT and REVOKE, and Spark / Ranger Parity.

Grant model gaps

Within the grant SQL surface itself, three things are not supported:

  • No WITH GRANT OPTION. Grants are non-delegating. Only an admin can grant.
  • No column-level INSERT. INSERT granularity is table-level.
  • Mask expressions are scalar only. Aggregate and table-valued mask expressions are rejected.

See GRANT and REVOKE, Known gaps.

Iceberg and types

Iceberg V3 advanced types are blocked upstream

V3 landed end to end (default values, schema evolution, nanosecond timestamps, partition evolution, equality and position deletes). Five advanced features are still blocked on upstream work, not on SQE:

FeatureBlocker
Variant type (and shredded variant)iceberg-rust PR not merged
Geometry typeDataFusion user-defined-type support
Vector / embedding typeIceberg V3 vector spec not finalised
Multi-arg partition transformsIceberg Java spec alignment in progress
Row lineageDeferred upstream

There is no SQE-side workaround. These unblock when the upstream dependency ships. See Roadmap, V3 features still blocked upstream.

SQL and policy surface

Statements DataFusion’s parser does not accept

PIVOT, UNPIVOT, QUALIFY, ASOF JOIN, and FROM-first syntax are not parseable. This is intentional and tracked upstream, not an SQE bug. Lambda expressions and list comprehensions have no AST node in DataFusion. The full list, with the reasoning for each, is on the SQL Reference overview, and the SQL cheat-sheet carries the scannable version.

read_parquet schema and write constraints

The file-format table-valued functions read external files directly. The constraints:

  • All files matched by a glob must share an identical Arrow schema. Schema evolution across files in one glob is not supported.
  • read_parquet() is read-only. It cannot be the target of an INSERT.
  • Very large match sets (more than ten thousand files) can slow planning due to the object listing step.

See read_parquet TVF, Limitations.

Scale

Single-node memory cutoff around 100GB

Single-node mode is the default and the recommendation for development and datasets under roughly 100GB. Beyond that, enable workers so scans and joins distribute instead of funnelling every intermediate result through one process. See System Overview, Single-node vs distributed and Sizing and capacity.

The cutoff is a guideline, not a hard limit. Spill-to-disk lets a memory-constrained coordinator survive large queries, so the real number depends on the query shape and the spill budget. Measure against your workload.

Hash aggregation can still OOM on a memory-constrained single node

Spill-to-disk covers sorts and sort-merge joins. Hash aggregation spill is limited by what DataFusion supports upstream. The documented edge is TPC-H q18: a high-cardinality GROUP BY with HAVING that produces millions of intermediate groups overruns a 512MB single-node budget, because the grouped hash aggregate does not yet spill. The fix is distribution. Phase B two-phase aggregation spreads the groups across workers and q18 passes. On a single node, raise memory_limit or distribute. See Streaming Execution, Benchmark Results.

Hash joins are not spillable upstream either. SQE rewrites a hash join to a sort-merge join when the estimated build side exceeds hash_join_memory_threshold, trading speed for survival. See Streaming Execution, SortMergeJoin Fallback.

SQL at a glance

A scannable answer to “is X supported?”. Every row points at the detailed SQL Reference page that carries the dialect-comparison columns and the source line. This page is the index, not the authority. When a row and a reference page disagree, the reference page wins.

Statements

DDL

StatementSupportedPage
CREATE SCHEMA / DROP SCHEMA / ALTER SCHEMA RENAMEyesDDL
CREATE TABLE (cols) with V3 column defaultsyesDDL
CREATE TABLE ... PARTITIONED BY (transform(col))yes (bucket, truncate, year, month, day, hour, identity)DDL
CREATE TABLE AS SELECT / CREATE OR REPLACE TABLE AS SELECTyesDDL
CREATE TABLE LIKEyes (schema only)DDL
ALTER TABLE ADD/DROP/RENAME COLUMN, nullability, type promotionyesDDL
ALTER TABLE RENAME TO, SET TBLPROPERTIES, COMMENT ONyesDDL
Partition evolution (ADD/DROP/REPLACE PARTITION FIELD)yesDDL
Branches and tags (CREATE/DROP BRANCH, CREATE/DROP TAG)yesDDL
CREATE [OR REPLACE] VIEWyesDDL

DML

StatementSupportedPage
SELECT with WHERE / GROUP BY / HAVING / ORDER BY / LIMITyesDML
WITH and WITH RECURSIVE CTEsyesDML
SELECT * EXCLUDE / SELECT * REPLACEyesDML
Joins (INNER/LEFT/RIGHT/FULL, SEMI/ANTI, USING, LATERAL)yesDML
TABLESAMPLE BERNOULLIyesDML
Time travel (FOR VERSION AS OF, FOR SYSTEM_TIME AS OF, FOR INCREMENTAL BETWEEN)yesDML
INSERT INTO ... VALUES / INSERT INTO ... SELECT / INSERT OVERWRITEyesDML
UPDATE ... SET ... WHERE (CoW or MoR)yesDML
DELETE FROM ... WHERE, TRUNCATE TABLE (CoW or MoR)yesDML
MERGE INTO ... WHEN MATCHED / NOT MATCHEDyesDML
COPY (...) TO 'path' (FORMAT ...)yesDML

Copy-on-Write is the default for UPDATE / DELETE / MERGE. Set write.delete.mode = 'merge-on-read' (and the update / merge siblings) per table to switch.

CALL procedures

ProcedureSupportedPage
system.rewrite_data_filesyesCALL procedures
system.expire_snapshotsyesCALL procedures
system.remove_orphan_filesyesCALL procedures
system.rewrite_manifestsyesCALL procedures
system.suggest_bloom_filter_columnsyes (SQE-specific)CALL procedures
rewrite_position_deletes, cherrypick_snapshot, expire_snapshots_by_idnot exposedCALL procedures

SHOW and EXPLAIN

StatementSupportedPage
SHOW CATALOGS / SCHEMAS / TABLES / VIEWS / COLUMNSyesSHOW and EXPLAIN
SHOW CREATE TABLE, SHOW STATS, DESCRIBEyesSHOW and EXPLAIN
EXPLAIN, EXPLAIN ANALYZE, EXPLAIN FULLyes (FULL is SQE-specific)SHOW and EXPLAIN
information_schema.tables / columns / schemata / viewsyesSHOW and EXPLAIN

GRANT, REVOKE, and policy

The security SQL surface parses today. The active enforcer is passthrough by default, so the masks and filters below are a documented surface rather than a live control out of the box. See Limitations.

StatementSupportedPage
GRANT / REVOKE (SQL standard)yes (parsed)GRANT and REVOKE
GRANT ... MASKED WITH (column masks)parsed; enforcement off by defaultGRANT and REVOKE
GRANT ... ROWS WHERE (row filters)parsed; enforcement off by defaultGRANT and REVOKE
SHOW GRANTS, SHOW EFFECTIVE GRANTS, CHECK ACCESSyes (SQE-specific)GRANT and REVOKE
WITH GRANT OPTION, column-level INSERT grants, aggregate masksnot supportedGRANT and REVOKE

Functions

Function names are case-insensitive. SQE registers DataFusion built-ins plus a Trino-compatibility layer (Trino-named aliases for things DataFusion calls differently). Each page lists the Trino, Snowflake, Spark SQL, and DuckDB equivalents per function.

FamilyNotable supportedPage
Conditional / nullif, iff, case, coalesce, nullif, greatest, least, nvl, nvl2, typeof, tryConditional
Stringconcat, substring, trim, lower, upper, regex, split, format, normalisationString
Mathtrig, rounding, logs, exponents, sign, modular, base conversionMath
Date / timeconstruction, extraction, formatting, parsing, arithmetic, time zones; Trino year() / month() / day_of_week()Date and time
Array / map / struct40+ nested functions plus map_agg, histogramArray, map, struct
JSONtwo surfaces: Trino-named (json_extract, json_parse) and the json_get_* familyJSON
Encoding / hashing / URLbase64, hex, md5, sha224..512, url_extract_*, url_encode, url_decodeEncoding, URL
Aggregatecount, sum, avg, statistical, regression, array_agg, string_agg / listagg, histogram, map_agg, approximationAggregate
Windowrow_number, rank, lag, lead, first_value, frames (ROWS/RANGE/GROUPS BETWEEN)Window

Table-valued functions

FunctionPurposePage
read_parquet, read_csv, read_json, read_deltaRead external files (local, S3, HTTPS, hf://)Table-valued functions
SELECT * FROM 'file.ext'Quoted-string auto-detect by extensionTable-valued functions
table_snapshots, table_history, table_files, table_manifests, table_partitions, table_refsIceberg metadataTable-valued functions
generate_series, range, unnestGeneratorsTable-valued functions

Intentionally not in SQE

These are absent on purpose. The reasoning lives on the SQL Reference overview.

ConstructWhy it is out
PIVOT, UNPIVOT, QUALIFY, ASOF JOIN, FROM-first syntaxDataFusion’s parser does not accept them. Tracked upstream.
Lambda expressions, list comprehensionsNo AST node for closures in DataFusion.
Oracle / Snowflake DECODEName collides with DataFusion’s decode(input, encoding). Use CASE WHEN.
IIF (T-SQL)Covered by if and iff, both registered.
postgres_table_scanner, mysql_table_scanner, sqlite_scannerOut of scope. SQE is Iceberg-first.
spatial, vss, fts, excelNiche. Use a tool built for the job.

SQE vs Trino, DuckDB, Spark

SQE is not trying to win every workload. It is built for one shape: query Apache Iceberg tables as the authenticated user, on your own hardware, with a single Rust binary and no JVM. This page is a decision aid. Where another engine is the better fit, it says so.

The detailed function-by-function matrices live on getsqe.com: compare/trino and compare/duckdb. The per-page comparison columns in the SQL Reference carry the same data at function granularity.

SQE vs Trino

Trino is the industry-standard SQL engine for lakehouses, and it is what SQE replaces in our own platform. The reasons we moved off it are specific:

DimensionTrinoSQE
Identity to catalog and storageSingle service accountPer-user OIDC bearer passthrough, no service account
Fine-grained securityExternal (Apache Ranger), at the connector boundaryIn-engine SQL surface, plan-rewritten before optimization (enforcement off by default today)
RuntimeJVM: significant heap, GC pauses, 10-30s startSingle Rust binary, no GC, fast start
Maturity and breadthMature, huge connector ecosystem, many enginesIceberg-first, narrower surface

Choose Trino when you need its breadth: a wide connector catalog beyond Iceberg, a mature Ranger-based security deployment you already run, or features SQE does not have. Trino is the stronger general-purpose engine.

Choose SQE when per-user identity to the catalog and storage matters, when JVM start time and GC pauses fight your autoscaling, or when maintaining a patched Trino fork for token passthrough is a cost you want to drop. See Why SQE for the full account of that migration, and GRANT and REVOKE for the security comparison.

A note on performance: at SF1, SQE wins six of seven benchmark suites head-to-head against Trino 465 on identical Iceberg tables and S3 storage, with all 222 queries differentially validated. The one suite it loses is SSB (0.70x), where Trino’s build-side key-set shipping prunes the fact table better than SQE’s does today. The full table and method are on the benchmark page. At SF10 the gap narrows: TPC-H distributed lands roughly par to ahead of Trino, TPC-DS distributed sits inside Trino’s range, and SSB still trails. Looking further out, SQE’s stated parity goal is within 2x of Trino on TPC-H SF100 (a roadmap target, Roadmap, Phase 10). Treat the SF100 parity claim as a goal, and measure on your workload.

SQE vs DuckDB

DuckDB is an in-process analytical engine. It is excellent on a single machine: a laptop, a notebook, an ETL step inside one process. SQE’s embedded mode borrows the same feeling (sqe-cli --embedded, quoted-string file reads, read_csv / read_json / read_delta, hf:// URLs), so the file-level ergonomics overlap.

The split is about deployment and identity. DuckDB is a library you embed; there is no server, no per-user auth, no distributed execution, and no GRANT model (see the grant comparison, where DuckDB is “no” across the security row). SQE runs as a server with OIDC auth, distributes across workers, and is Iceberg-native with a Polaris-style REST catalog.

Choose DuckDB for single-machine analytics, embedding into an application, or fast local exploration where a server is overhead. It is the stronger tool there.

Choose SQE when you need multi-user access with per-user identity, a shared Iceberg catalog, distributed scans on data larger than one box, or a server other clients connect to. For the file-reading ergonomics on one machine, SQE’s embedded mode is the bridge.

SQE vs Spark

Spark SQL is the reference for large-scale batch and the breadth of the Spark ecosystem. It runs on the JVM, uses a service-account model for catalog and storage like Trino, and pushes fine-grained security to Ranger or Lake Formation at the connector boundary (see the grant comparison).

Choose Spark for very large batch transformation, when you are already invested in the Spark ecosystem (MLlib, structured streaming, the broad connector set), or for scale beyond what SQE has been validated against. Spark is the stronger heavy-batch engine.

Choose SQE for interactive and analytical SQL on Iceberg where per-user identity and a small operational footprint matter, and where a single Rust binary beats standing up a Spark cluster. SQE distributes (coordinator plus stateless workers) but is positioned for query serving, not as a general batch framework.

The through-line

SQE’s distinguishing property is sovereignty: every query runs with the identity and permissions of the user who submitted it, no service-account intermediary, on hardware you control. Trino, DuckDB, and Spark each beat SQE on some axis (breadth, single-machine simplicity, batch scale). None of them pass a per-user bearer token through to both the catalog and storage. That is the trade SQE is built around. See Why SQE.

Quickstart

Get SQE running locally in under 5 minutes. Start embedded (no server, no catalog, no auth), then move to the full coordinator + workers setup when you need a cluster.

Embedded mode (fastest)

The embedded CLI runs an in-process DataFusion engine. No coordinator, no Polaris, no Keycloak, no network listeners. You only need Rust.

# Install the CLI (Rust 1.85+: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh)
cargo install --path crates/sqe-cli

# Start an embedded session (persistent Iceberg catalog under ~/.sqe/warehouse)
sqe-cli --embedded

Query local and remote files directly:

sqe> SELECT * FROM '/data/sales.parquet' LIMIT 5;
sqe> SELECT * FROM read_csv('s3://bucket/orders.tsv.gz');
sqe> SELECT * FROM read_delta('/data/delta/sales', version => '5');

Or run a single query without entering the shell:

sqe-cli --embedded -e "SELECT COUNT(*) FROM read_parquet('data.parquet')"

Full embedded reference: Using the CLI.

Production / cluster setup

The cluster path runs a coordinator (plus optional workers) against a real catalog and storage with OIDC auth. It is what you deploy for shared, multi-user, secured access.

Prerequisites

  • Rust 1.85+ (curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh)
  • A running data platform stack: Keycloak, Polaris, MinIO/S3 (see the quickstart stack in data-platform/quickstart/full/)

1. Clone and Build

git clone https://github.com/schuberg/sqe.git
cd sqe
cargo build --release --bin sqe-server --bin sqe-cli

Or use the build script:

./scripts/build.sh release

2. Configure

Copy the example config and adjust for your environment:

cp sqe.toml.example sqe.toml

Key settings to update:

[auth]
keycloak_url = "https://your-keycloak:8443"   # Your Keycloak URL
realm = "iceberg"                               # Your realm
client_id = "sqe-client"                        # OIDC client ID

[catalog]
catalog_url = "http://your-polaris:8181/api/catalog"
warehouse = "your-warehouse"

[storage]
s3_endpoint = "http://your-minio:9000"
s3_region = "us-east-1"
s3_access_key = "minioadmin"                    # Or set via SQE_STORAGE__S3_ACCESS_KEY
s3_secret_key = "minioadmin"                    # Or set via SQE_STORAGE__S3_SECRET_KEY

3. Start the Server

# Single-node coordinator (default mode)
./target/release/sqe-server --config sqe.toml

You should see:

INFO Starting sqe-server mode=Coordinator config="sqe.toml"
INFO Health endpoints on port 9091 (/healthz, /readyz)
INFO Prometheus metrics on port 9090
INFO SQE coordinator listening on 0.0.0.0:50051

4. Connect with the CLI

./target/release/sqe-cli --host localhost --port 50051
Username: alice
Password: ****
sqe-cli 0.1.0 connected to http://localhost:50051 (flight)
Type SQL queries, or \q to quit. End multi-line queries with ;

sqe> SHOW SCHEMAS;
 schema_name
-------------
 analytics
 raw
(2 rows)

sqe> SELECT * FROM raw.orders LIMIT 5;
 order_id | customer_id | amount | region
----------+-------------+--------+--------
 1        | 100         | 250.00 | EU
 2        | 101         | 150.00 | US
 3        | 100         | 300.00 | EU
 4        | 102         | 75.00  | APAC
 5        | 103         | 500.00 | EU
(5 rows)

5. Run a Single Query

./target/release/sqe-cli -H localhost -p 50051 -u alice -e "SELECT COUNT(*) FROM raw.orders;"

Set SQE_PASSWORD to avoid the password prompt:

export SQE_USER=alice
export SQE_PASSWORD=secret
./target/release/sqe-cli -e "SHOW TABLES IN raw;"

Health Check

curl http://localhost:9091/healthz   # → ok
curl http://localhost:9091/readyz    # → 200 when ready

Pointing at a different catalog

The walkthrough above runs SQE against the local Polaris stack over Iceberg REST. SQE supports five other catalog backends out of the box: AWS Glue (native SDK), AWS S3 Tables (managed Iceberg), Hive Metastore (Thrift), JDBC (Postgres / MySQL / SQLite), and Hadoop (filesystem-only). Each uses the same binary, just with a different [catalog.backend] block.

See Catalog backends for the full per-backend recipe with TOML examples, AWS credential setup, verification queries, and a troubleshooting checklist. Glue and S3 Tables are verified live against AWS deployments.

For the operator-friendly version of the same content (BI tool connection, slim builds, cargo features), see QUICKSTART.md in the repo root.

Next Steps

Connecting clients

SQE speaks two wire protocols. Arrow Flight SQL is primary and Arrow-native, used by the CLI, ADBC drivers, and the dbt adapter. The Trino HTTP endpoint is an optional compatibility surface for existing Trino clients and BI tools. This page has copy-pasteable snippets for both.

The ports below are the defaults from Configuration: Flight SQL on 50051, Trino HTTP on 8080. Local test compose files remap them (60051 and 28080); use the ports your deployment actually exposes.

Authentication is bearer-token passthrough. The username and password you supply are exchanged for an OIDC token at connect time, and that token rides through to the catalog and storage. There is no service account. The exact provider (Keycloak password grant, client credentials, a JWKS-validated bearer, and others) is set in the server’s [auth] config, not on the client. See Authentication Modes.

Flight SQL with ADBC (Python)

The ADBC Flight SQL driver gives you a DB-API 2.0 connection and Arrow result batches. This is the same driver the dbt-sqe adapter uses.

from adbc_driver_flightsql.dbapi import connect
from adbc_driver_manager import DatabaseOptions

conn = connect(
    "grpc://localhost:50051",
    db_kwargs={
        DatabaseOptions.USERNAME.value: "jacob",
        DatabaseOptions.PASSWORD.value: "your-password",
    },
)

cur = conn.cursor()
cur.execute("SELECT 1 AS one")
print(cur.fetch_arrow_table())   # Arrow table, no row-by-row tax
cur.close()
conn.close()

Use grpc:// for plaintext and grpc+tls:// when the coordinator runs with TLS ([coordinator.tls] cert and key set). The username and password are exchanged by the server for an OIDC token; what credentials are valid depends on the server’s auth config.

dbt with the dbt-sqe adapter

The dbt-sqe adapter connects over ADBC Flight SQL. A profiles.yml target looks like this:

my_project:
  target: dev
  outputs:
    dev:
      type: sqe
      host: localhost
      port: 50051
      user: jacob
      password: "{{ env_var('SQE_PASSWORD') }}"
      catalog: production
      schema: finance
      threads: 4

dbt debug validates the connection and reports the SQE version. The adapter runs every dbt operation as the authenticated user, so models only see and write what that user is allowed to. See dbt Compatibility for the materialization details.

Flight SQL over JDBC

The Arrow Flight SQL JDBC driver connects with a URL of this shape:

jdbc:arrow-flight-sql://localhost:50051?user=jacob&password=your-password&useEncryption=false

Set useEncryption=true when the coordinator runs with TLS. JDBC tools (DBeaver, query consoles, BI connectors that take a JDBC URL) point at this string with the Flight SQL driver on the classpath.

Trino HTTP

The Trino HTTP endpoint is on by default ([coordinator] trino_http_port = 8080, set to 0 to disable). Trino clients, the Trino JDBC driver, and Trino-compatible BI tools (Metabase, Superset) point at it unchanged. Basic auth carries the user; the password is the OIDC secret (empty for a local root client).

curl -s -u jacob:your-password \
  -H "X-Trino-User: jacob" \
  -d "SELECT 1 AS one" \
  http://localhost:8080/v1/statement

The first response carries a nextUri; a client follows it until results are exhausted. A Trino JDBC client connects against http://localhost:8080. Basic auth is required to populate the session, not just the X-Trino-User header.

The Trino JDBC driver and the Trino CLI refuse to send a username and password over plain HTTP. The endpoint serves plain HTTP, so for password auth put a TLS-terminating reverse proxy in front and connect over https://. Bearer-token auth works over plain HTTP if you cannot terminate TLS. See Connect BI tools for Metabase, Superset, DBeaver, and CLI recipes, and Trino HTTP connectivity for the endpoint reference. Flight SQL is the recommended protocol for SQE-native clients; reach for Trino HTTP when a tool only speaks Trino.

Which protocol

You areUse
Writing Python, an ETL job, or dbtFlight SQL via ADBC
Connecting a JDBC tool that supports the Flight SQL driverFlight SQL via JDBC
Pointing an existing Trino client or a Trino-only BI toolTrino HTTP
Running ad-hoc SQL from a terminalsqe-cli (Flight SQL); see Using the CLI

Catalog backends

SQE talks to Iceberg tables through one of six catalog backends. The choice is per-deployment in sqe.toml. Default release builds ship every backend; slim builds drop the unused ones to save binary size.

The five non-Hadoop backends share one dispatch path through the upstream iceberg-catalog-loader crate. Hadoop is the lone outlier because it is filesystem-only: no metadata service to talk to, just a warehouse path to walk.

Quick reference

Backendtype valueRequired keysOptional keysCargo featureVendored crate
RESTrest (default)catalog_url, warehouse (on [catalog])bearer / OAuth headers via runtime authrest (always)iceberg-catalog-rest
HMShmsuri, warehousehmsiceberg-catalog-hms
Glueglueregion, warehouseendpointglueiceberg-catalog-glue
S3 Tabless3tablestable_bucket_arnendpoint_urls3tablesiceberg-catalog-s3tables
JDBCjdbcurl, warehousesql-postgresiceberg-catalog-sql
Hadoophadoopwarehousehadoop(SQE-native)

All six are smoke-tested in CI. Two of them, Glue and S3 Tables, are verified live against production AWS deployments (account 123456789012, eu-example-1 and eu-example-2).

Multiple catalogs in one coordinator

SQE supports attaching several named catalogs (potentially of different backend types) to one coordinator. Each one becomes a top-level SQL identifier and cross-catalog joins work without any session-state setup.

# Legacy single-catalog block kept as a placeholder for backwards
# compatibility. When `[catalogs.*]` is populated the legacy block
# is dropped unless `query.default_catalog` names it explicitly.
[catalog]
catalog_url = ""

[catalogs.polaris]
catalog_url = "http://polaris:8181/api/catalog"
warehouse = "production"

[catalogs.polaris.backend]
type = "rest"

[catalogs.nessie]
catalog_url = "http://nessie:19120/iceberg"
warehouse = "lake"

[catalogs.nessie.backend]
type = "rest"

[catalogs.aws_glue]
catalog_url = ""
[catalogs.aws_glue.backend]
type = "glue"
region = "eu-example-1"
warehouse = "s3://my-bucket/wh"

[catalogs.aws_s3tables]
catalog_url = ""
[catalogs.aws_s3tables.backend]
type = "s3tables"
table_bucket_arn = "arn:aws:s3tables:eu-example-2:123456789012:bucket/my-bucket"

[catalogs.legacy_hms]
catalog_url = ""
[catalogs.legacy_hms.backend]
type = "hms"
uri = "metastore.example.com:9083"
warehouse = "s3a://my-bucket/wh"

[query]
# Optional. Picks the catalog DataFusion uses for unqualified
# names. Defaults to the first entry from `[catalogs.*]` sorted
# alphabetically (so `aws_glue` would win the example above).
default_catalog = "polaris"

3-part SQL identifiers route to the right catalog:

SELECT *
FROM polaris.sales.orders p
LEFT JOIN nessie.archive.orders n ON p.id = n.id
WHERE n.id IS NULL;

SELECT count(*) FROM aws_glue.iceberg_demo_analytics.iceberg_user_events;

SELECT * FROM aws_s3tables.testnamespace.daily_sales LIMIT 10;

Each catalog uses its own backend dispatch path (REST + bearer token, native AWS SDK, Thrift, etc.). The user’s bearer token from the session auth applies to all REST catalogs registered. Per-catalog credential scoping is a future change; today storage credentials are coordinator-wide.

The legacy single-catalog form (the [catalog] block alone, no [catalogs.*]) keeps working unchanged. Existing deployments need no migration.

Per-catalog auth and storage

Each catalog can override the global session bearer token and the global S3 credentials via optional [catalogs.<name>.auth] and [catalogs.<name>.storage] blocks. Federation across organisations becomes a config change rather than a separate deployment.

# Default Polaris uses the user's session token (V6 behaviour).
[catalogs.polaris]
catalog_url = "http://polaris:8181/api/catalog"
warehouse = "main"
[catalogs.polaris.backend]
type = "rest"

# A partner Polaris uses its own OAuth client and S3 bucket.
[catalogs.partner]
catalog_url = "https://partner.com/iceberg"
warehouse = "shared"
[catalogs.partner.backend]
type = "rest"
[catalogs.partner.auth]
type = "client_credentials"
token_endpoint = "https://partner.com/oauth/tokens"
client_id = "sqe-partner"
client_secret = "..."   # use env override SQE__catalogs__partner__auth__client_secret
[catalogs.partner.storage]
s3_endpoint = "https://partner-s3.example.com"
s3_region = "us-east-1"
s3_access_key = "..."
s3_secret_key = "..."

# A public Nessie endpoint we read anonymously.
[catalogs.public_archive]
catalog_url = "https://nessie.public.example.com/iceberg"
warehouse = "public"
[catalogs.public_archive.backend]
type = "rest"
[catalogs.public_archive.auth]
type = "anonymous"

# AWS Glue lets the AWS SDK provider chain handle auth.
[catalogs.aws_glue]
[catalogs.aws_glue.backend]
type = "glue"
region = "eu-example-1"
warehouse = "s3://wh/"
[catalogs.aws_glue.auth]
type = "aws"

[catalogs.<name>.auth].type values

typeWhat it doesWhen to use
session_bearer (default)Pass the user’s session bearer token through unchangedOne OIDC provider fronts every Iceberg REST endpoint (the common case)
client_credentialsCluster-level OAuth2 client_credentials grant against the catalog’s own token endpointFederation with a partner Iceberg REST that has its own OAuth
anonymousNo Authorization headerPublic read-only Nessie or Polaris
staticPre-issued bearer tokenInternal gateway with a fixed key, integration tests
awsAWS SDK provider chainGlue / S3 Tables native backends, AWS REST endpoints with SigV4

[catalogs.<name>.storage] overrides

The block accepts the same keys as the top-level [storage] block: s3_endpoint, s3_region, s3_access_key, s3_secret_key, s3_path_style, s3_allow_http. Iceberg credential vending from REST catalogs still wins per-table over both this and the global block, so you only need to fill out per-catalog storage when the catalog does not vend credentials (Hadoop, raw Iceberg over Ceph, etc.) or when the underlying buckets live behind different S3 endpoints.

Today storage overrides apply at scan / write time. The client_credentials token is fetched once at session-build time and reused for the session lifetime; refresh-on-expiry is a future change.

REST: Polaris, Nessie, Unity OSS, AWS Glue REST, AWS S3 Tables REST

The default. Most production deployments speak Iceberg REST.

[catalog]
catalog_url = "https://polaris.example.com:18181/api/catalog"
warehouse   = "production_warehouse"

[catalog.backend]
type = "rest"   # default; this block can be omitted entirely

Local Polaris stack from the repo:

docker compose -f docker-compose.test.yml up -d
# Polaris listens on http://localhost:18181

AWS REST endpoints (Glue REST, S3 Tables REST) work transparently: when the server’s /v1/config response advertises rest.sigv4-enabled=true, SQE engages SigV4 automatically. AWS credentials come from the standard SDK chain (env vars, profiles, IMDS).

ServiceREST endpointAuth
Apache Polarishttps://polaris/api/catalogOIDC bearer
Project Nessie 0.107+https://nessie/api/v1/icebergbearer / anonymous
Unity Catalog OSShttps://unity/api/2.1/unity-catalog/icebergbearer (Databricks) / anonymous (OSS)
AWS Glue Iceberg RESThttps://glue.<region>.aws-endpoint/icebergAWS SigV4 (auto-detected)
AWS S3 Tables RESThttps://s3tables.<region>.aws-endpoint/iceberg/v1AWS SigV4 (auto-detected)

REST is the most-tested path. Every benchmark suite (TPC-H, SSB, TPC-DS, TPC-C, TPC-E, TPC-BB, ClickBench) runs against the local Polaris stack on every release build.

Namespace visibility filtering

On the REST backend, SQE hides the names of namespaces the caller holds no grants in from every metadata listing: SHOW SCHEMAS, information_schema.schemata, and Flight SQL GetDbSchemas. When the session’s catalog provider is built, each namespace returned by listNamespaces is probed once with the caller’s bearer token (GetNamespace, which Polaris authorizes as LOAD_NAMESPACE_METADATA per caller). A 403 drops the name. The probes run 8 at a time, once per session, never per query.

The filter fails open. A probe that times out or errors for any reason other than 403 keeps the name listed. Namespace contents are protected by the per-operation checks regardless of what the list shows, so a catalog hiccup degrades to unfiltered listings instead of blanking the user’s schema tree. information_schema itself is always listed and never probed.

[catalog]
# default true; set false to restore unfiltered listings
namespace_visibility_filter = false

Single-identity backends (Glue, HMS, JDBC, Hadoop) skip the filter entirely. They authenticate as the coordinator’s service identity, so there is no per-caller answer to give. Those backends log a shared-identity warning at startup.

HMS: Hive Metastore over Thrift

For deployments still on Hive Metastore.

[catalog.backend]
type      = "hms"
uri       = "metastore.example.com:9083"     # Thrift host:port
warehouse = "s3a://my-bucket/warehouse"

Pulls in volo-thrift and pilota (~10-15 MB).

Authentication via Kerberos / Knox is not supported directly. Deployments that need it should sit behind a sidecar that handles the SASL handshake and exposes a plain Thrift port. SQE expects the metastore to speak unauthenticated Thrift on its data plane.

The HMS path is verified by the integration test in sqe-catalog/tests/backends_integration.rs and runs against a docker-compose overlay during CI.

Glue: AWS Glue Data Catalog

[catalog.backend]
type      = "glue"
region    = "eu-example-1"
warehouse = "s3://my-bucket/warehouse"
# endpoint = "http://localhost:4566"   # optional, e.g. LocalStack

Run with the right AWS credentials:

AWS_PROFILE=my-profile ./target/release/sqe-server --config ~/sqe-config.toml

The AWS SDK reads AWS_PROFILE, AWS_ACCESS_KEY_ID, AWS_REGION, or IMDS in that order. The region field in the config sets the Glue API region; warehouse is the S3 path Glue uses for new tables.

Pulls in aws-sdk-glue + aws-config (~50-80 MB).

Live verification (2026-05-05) against AWS Glue in eu-example-1 (account 123456789012, database iceberg_demo_analytics):

sqe> SHOW SCHEMAS;
+------------------------+
| schema_name            |
+------------------------+
| admin_consumer         |
| admin_producer         |
| default                |
| iceberg-demo_catalog   |
| iceberg_demo_analytics |
| saleslhdev_pub_db      |
| saleslhdev_sub_db      |
+------------------------+
(7 rows)

sqe> SELECT region, event_type, COUNT(*) AS n
   . FROM iceberg_demo_analytics.iceberg_user_events
   . GROUP BY region, event_type ORDER BY n DESC LIMIT 5;
+------------+------------+-------+
| region     | event_type | n     |
+------------+------------+-------+
| ap-south   | login      | 50524 |
| eu-example-1 | login      | 50424 |
| eu-example-1 | click      | 50391 |
| eu-example-1 | view       | 50251 |
| us-west    | click      | 50155 |
+------------+------------+-------+

Aggregations, filter pushdown, and ORDER BY all work correctly across ~1.5M rows.

S3 Tables: AWS managed Iceberg

AWS’s first-class managed Iceberg service. Different from Glue (which is metadata-only): S3 Tables manages metadata and storage in one product. Tables live in a “table bucket” addressed by ARN.

[catalog.backend]
type             = "s3tables"
table_bucket_arn = "arn:aws:s3tables:eu-example-2:123456789012:bucket/my-bucket"
# endpoint_url   = "http://localhost:4566"   # optional, custom endpoint

Same AWS credential story as Glue. The bucket ARN format is arn:aws:s3tables:REGION:ACCOUNT:bucket/NAME.

Pulls in aws-sdk-s3tables. Shares the AWS SDK runtime that glue already pulls, so the incremental binary cost on top of an AWS-enabled build is small (~5 MB).

Live verification (2026-05-05) against arn:aws:s3tables:eu-example-2:123456789012:bucket/testtablebucket:

sqe> SHOW SCHEMAS;
+---------------+
| schema_name   |
+---------------+
| testnamespace |
+---------------+

sqe> SELECT product_category, COUNT(*) AS sales_count, SUM(sales_amount) AS total_sales
   . FROM testnamespace.daily_sales
   . GROUP BY product_category ORDER BY total_sales DESC;
+------------------+-------------+-------------+
| product_category | sales_count | total_sales |
+------------------+-------------+-------------+
| Laptop           | 4           | 4500.0      |
| Monitor          | 3           | 925.0       |
| Keyboard         | 1           | 60.0        |
| Mouse            | 1           | 25.0        |
+------------------+-------------+-------------+

Two backends in one repo, both writing to AWS through SQE’s identical scan + aggregation path. The only thing that differs is which CatalogBuilder the loader hands back from load("glue") vs load("s3tables").

JDBC: Postgres / MySQL / SQLite

Iceberg’s JDBC catalog stores table metadata in a relational database. Useful when you want a single SQL endpoint without running a metadata service.

[catalog.backend]
type      = "jdbc"
url       = "postgresql://user:pass@host:5432/iceberg"
warehouse = "s3://my-bucket/warehouse"

The URL prefix selects the driver:

PrefixDriverNotes
sqlite:path/to/file.dbSQLiteLocal file, no separate server
postgresql://... or postgres://...PostgreSQLProduction-grade, recommended
mysql://...MySQLTested on MySQL 8.0+

The catalog tables follow the Iceberg JDBC catalog schema (iceberg_tables, iceberg_namespace_properties). SQE creates them on first connect.

Pulls in sqlx + the requested DB driver (~5-10 MB for Postgres).

The Postgres path is verified by an integration test against a docker-compose Postgres in sqe-catalog/tests/backends_integration.rs.

Hadoop: filesystem-only catalog

No metadata service. SQE walks warehouse for metadata.json files and treats the prefix as the catalog. Useful for read-only access to a warehouse another engine wrote, or for one-off investigations on a S3 / GCS / Azure prefix without standing up Polaris.

[catalog.backend]
type      = "hadoop"
warehouse = "s3://my-bucket/warehouse"

This is SQE’s only native catalog backend. The other five all delegate to the upstream iceberg-rust builder via the iceberg-catalog-loader crate. Hadoop has no upstream loader counterpart because it is not really a catalog. There is no metadata service to talk to. Implementation lives in sqe-catalog/src/backends/hadoop.rs.

Read-only. No commit path. Use a real catalog if you need INSERT, UPDATE, DELETE, or MERGE.

How the loader works

Every non-REST backend’s dispatch goes through one function call:

#![allow(unused)]
fn main() {
let catalog = iceberg_catalog_loader::load(catalog_type)?
    .load(name.to_string(), props)
    .await?;
}

catalog_type is the lowercase string ("glue", "s3tables", etc). props is a HashMap<String, String> of the upstream *_CATALOG_PROP_* keys. The loader’s registry is feature-gated so a slim build only links the backends the SQE binary actually uses.

The patch sits in vendor/iceberg-rust/crates/catalog/loader/src/lib.rs, documented inline at the touch site and in the vendor README under “SQE-only patches.” It is forward-compatible with upstream: every existing caller of the loader sees all backends present by default; nobody loses anything.

Slim builds

Default release builds include every backend. Operators who want a smaller image can opt out:

# REST only: no AWS SDK, no Thrift, no sqlx
cargo build --release --no-default-features --features rest -p sqe-coordinator

# REST + AWS managed Iceberg
cargo build --release --no-default-features --features rest,glue,s3tables -p sqe-coordinator

# REST + Hive
cargo build --release --no-default-features --features rest,hms -p sqe-coordinator

Approximate cost on top of a rest-only build:

FeatureAddsWhy
hadoop~0Reuses existing object_store
sql-postgres5-10 MBsqlx + Postgres driver
hms10-15 MBvolo-thrift + pilota
glue50-80 MBfull AWS SDK
s3tables~5 MB on top of glueshares AWS SDK runtime

Default release binary lands around 180-200 MB on Linux x86_64.

Verifying the connection

Once the coordinator is up, run these in order. Each one exercises a deeper layer and tells you exactly where things break if they do.

# 1. Auth + Flight handshake
SQE_PASSWORD=s3cr3t sqe-cli --port 60051 --user root -e "SELECT 1"

# 2. Catalog reachable, namespaces visible
SQE_PASSWORD=s3cr3t sqe-cli --port 60051 --user root -e "SHOW SCHEMAS"

# 3. Pick a namespace, list its tables
SQE_PASSWORD=s3cr3t sqe-cli --port 60051 --user root -e "SHOW TABLES IN <namespace>"

# 4. Read a row
SQE_PASSWORD=s3cr3t sqe-cli --port 60051 --user root \
    -e "SELECT * FROM <namespace>.<table> LIMIT 1"

If step 4 works, every other Iceberg query path works too: filter pushdown, GROUP BY, JOIN, time-travel, write back.

Troubleshooting

Invalid or expired bearer token when the CLI passes --token: the bearer was minted by something SQE’s auth chain does not recognize. Use --user + SQE_PASSWORD instead and let SQE mint its own token via the auth endpoint configured in [auth].

Catalog '<X>' build failed with no further detail: check the coordinator log. Common causes:

  • AWS credentials not on the chain (no AWS_PROFILE, no env vars, not running on EC2 / EKS).
  • HMS Thrift port not reachable.
  • JDBC url typo (the prefix selects the driver).
  • S3 Tables ARN region mismatch (the ARN’s region must match whatever the AWS SDK resolves; set AWS_REGION to be safe).

No such table but the table exists in the catalog: namespace case sensitivity. Iceberg namespaces are usually lowercase; some HMS deployments treat them as case-insensitive.

Slow first query every time the coordinator restarts: cold manifest cache. Subsequent queries hit ObjectCache and run faster. Expected.

Where to go from here

Storage backends

SQE separates two concerns:

  • Catalog backend: where the table metadata lives. Polaris, Nessie, AWS Glue, S3 Tables, Hive Metastore, JDBC, Hadoop. See Catalog backends.
  • Storage backend: where the data files live. AWS S3, GCS, ADLS Gen2, R2, MinIO, Ceph, local filesystem. This page.

Both are independent. A table whose metadata is in Polaris can have data files in any storage backend the engine knows how to talk to. The catalog hands SQE a s3://... (or gs://..., abfss://...) URL when loading a table; SQE picks the right object-store driver from the URL scheme.

Implementation lives in sqe-catalog/src/file_tvf_common.rs and sqe-catalog/src/lazy_object_store.rs.

Compatibility matrix

BackendURL schemeDefault buildTVF readsCatalog readsWritesNotes
Local filesystem/path or ./pathyesyesyesyesNo setup required.
AWS S3s3://bucket/keyyesyesyesyesProvider chain (env / ~/.aws / IMDS / IRSA) when no inline creds.
AWS S3 (SSE / KMS)s3://bucket/keyyesyesyesyesServer-side encryption is transparent.
Cloudflare R2s3://bucket/key (S3-compatible endpoint)yesyesyesyesSet endpoint = https://<account>.r2.cloudflarestorage.com, region = auto.
MinIOs3://bucket/keyyesyesyesyesAllow plain HTTP via s3_allow_http = true.
Ceph RGWs3://bucket/keyyesyesyesyesSame as MinIO.
SeaweedFSs3://bucket/keyyesyesyesyesSame as MinIO.
Garages3://bucket/keyyesyesyesyesSame as MinIO.
rustfss3://bucket/keyyesyesyesyesSame as MinIO.
HTTPShttps://host/pathyesyespartialnoLazy HttpStore per host (V10). Read-only.
HuggingFacehf://datasets/...yesyesnonoV10 + V12.1. Auto-resolves to HTTPS. Read-only.
Azure ADLS Gen2abfss://[email protected]/pathyesyesyesyesShared key, SAS, and Azurite emulator supported.
Azure (shorthand)azure://container/path, az://container/pathyesyesyesyesAccount name from [storage.azure] or azure_account => '...'.
Google Cloud Storagegs://bucket/path, gcs://bucket/pathyesyesyesyesService-account JSON path or inline; ADC fallback when neither set.

All backends ship in the default cargo build. The object_store workspace dependency is built with aws, http, azure, and gcp features; no opt-in feature flip needed.

Local filesystem

No configuration. Pass an absolute or relative path:

SELECT * FROM '/data/orders.parquet';
SELECT * FROM read_csv('./report.csv');
SELECT * FROM read_delta('/var/lake/orders');

Catalog backends that store data on local disk (hadoop type, or any REST catalog with file:// warehouse paths) work the same way.

AWS S3

The default storage backend. Two ways to provide credentials:

When inline access_key / secret_key are absent, SQE delegates to the AWS SDK provider chain:

  1. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ optional AWS_SESSION_TOKEN).
  2. ~/.aws/credentials profile (AWS_PROFILE selects which).
  3. EC2 IMDS instance role.
  4. EKS IRSA service-account role.
# config.toml
[storage]
type   = "s3"
region = "eu-example-2"
# No access_key / secret_key here -> provider chain.
SELECT * FROM read_parquet('s3://bucket/key.parquet');

2. Inline credentials (per-query override)

SELECT * FROM read_parquet(
    's3://bucket/path/*.parquet',
    access_key => 'AKIA...',
    secret_key => '...',
    endpoint   => 'https://s3.eu-example-2.aws-endpoint',
    region     => 'eu-example-2'
);

Inline values win over [storage] defaults for that query only.

Cloudflare R2

R2 speaks the S3 protocol. Two pieces are non-default:

  • Endpoint points at your R2 account URL.
  • Region is the literal string auto (R2 ignores region but rejects the empty string).
SELECT * FROM read_parquet(
    's3://my-r2-bucket/data.parquet',
    access_key => '<R2_ACCESS_KEY_ID>',
    secret_key => '<R2_SECRET_ACCESS_KEY>',
    endpoint   => 'https://<account-id>.r2.cloudflarestorage.com',
    region     => 'auto'
);

For permanent setup put the endpoint and creds in [storage]:

[storage]
type            = "s3"
endpoint        = "https://<account-id>.r2.cloudflarestorage.com"
region          = "auto"
s3_access_key   = "<R2_ACCESS_KEY_ID>"
s3_secret_key   = "<R2_SECRET_ACCESS_KEY>"

MinIO, Ceph RGW, SeaweedFS, Garage, rustfs

All are S3-compatible. Two extra knobs versus AWS S3:

  • Endpoint: your server URL. Typical: http://minio:9000 (Docker), https://s3.internal:9000 (TLS).
  • s3_allow_http: set to true if the endpoint is plain HTTP. SQE refuses HTTP by default to prevent accidental cleartext credentials.
SELECT * FROM read_parquet(
    's3://bucket/data.parquet',
    access_key => 'minio-access-key',
    secret_key => 'minio-secret-key',
    endpoint   => 'http://localhost:9000',
    region     => 'us-east-1'
);
[storage]
type            = "s3"
endpoint        = "http://localhost:9000"
region          = "us-east-1"
s3_access_key   = "minio-access-key"
s3_secret_key   = "minio-secret-key"
s3_allow_http   = true

HTTPS

Any https:// URL works without registration. The first request to a new scheme://host builds an HttpStore on the fly and caches it in the session’s object-store registry. Subsequent reads from the same host reuse the cached store.

SELECT * FROM 'https://example.com/data.parquet';
SELECT * FROM read_csv('https://raw.githubusercontent.com/.../titanic.csv');
SELECT * FROM read_json('https://api.example.com/events.ndjson');

Configurable via the [storage.http] block (custom headers, bearer auth):

[storage.http]
default_headers = { Authorization = "Bearer ${API_TOKEN}" }

HTTPS reads are stateless: each request fetches the byte range needed for the current Parquet operation. Range request support is required (every modern object store has it).

HuggingFace hf://

The hf:// resolver translates HuggingFace dataset, model, and spaces URLs into HTTPS calls against the Hub. Public datasets work anonymously; private datasets read HF_TOKEN from the environment.

-- Default revision (main)
SELECT * FROM read_parquet(
    'hf://datasets/squad/plain_text/train-00000-of-00001.parquet'
);

-- Pinned revision (DuckDB-style, V12.1)
SELECT * FROM read_csv('hf://datasets/foo/[email protected]/data.csv');

-- Auto-generated Parquet view (V12.1)
SELECT * FROM read_parquet(
    'hf://datasets/foo/bar@~parquet/default/train/0.parquet'
);

-- Equivalent ?revision query parameter
SELECT * FROM read_csv('hf://datasets/foo/bar/data.csv?revision=v1.0');

Glob expansion (**/*.parquet) on hf:// is tracked for V12.2; today the path must point to a specific file.

See File-format TVFs for the full path-form table.

Azure ADLS Gen2 / Blob

Three URL shapes are accepted:

URL formWhen to use
abfss://<container>@<account>.dfs.core.windows.net/<path>Hadoop-style; account encoded in URL. Most portable across tools.
abfs://...Same shape, plaintext variant. Avoid in production.
azure://<container>/<path>, az://<container>/<path>Shorthand. Account comes from [storage.azure] or the azure_account inline arg.

Three auth methods:

-- 1. Shared key (storage account key)
SELECT * FROM read_parquet(
    'abfss://[email protected]/path/data.parquet',
    azure_access_key => '<storage-account-key>'
);

-- 2. SAS token (sub-account scope)
SELECT * FROM read_csv(
    'abfss://[email protected]/2026-05-08/events.csv',
    azure_sas_token => 'sv=2024-08-04&ss=b&srt=sco&sp=r...'
);

-- 3. Azurite emulator (local development)
SELECT * FROM read_parquet('azure://devstoreaccount1/test/data.parquet');

For permanent setup put credentials in [storage.azure]:

[storage]
azure_account     = "myaccount"
azure_access_key  = "<storage-account-key>"
# OR:
azure_sas_token   = "sv=2024-08-04&..."
# Local development against Azurite:
azure_use_emulator = true

OAuth2 / managed-identity auth is not yet wired through the inline args; service-account flows go through the AWS-style env-var fallback that object_store::azure::MicrosoftAzureBuilder provides.

Google Cloud Storage

-- 1. Service-account JSON file
SELECT * FROM read_parquet(
    'gs://my-bucket/path/data.parquet',
    gcs_service_account_path => '/var/secrets/gcs-key.json'
);

-- 2. Inline service-account JSON
SELECT * FROM read_csv(
    'gs://my-bucket/data.csv',
    gcs_service_account_key => '{"type":"service_account",...}'
);

-- 3. Application Default Credentials (gcloud config / GCE metadata / GKE Workload Identity)
SELECT * FROM read_parquet('gs://my-bucket/data.parquet');

For permanent setup:

[storage]
gcs_service_account_path = "/var/secrets/gcs-key.json"
# OR inline:
gcs_service_account_key  = "{\"type\":\"service_account\",...}"

When neither is set the underlying GCS driver falls back to ADC: GOOGLE_APPLICATION_CREDENTIALS env var, gcloud config, GCE metadata server, GKE Workload Identity. No SQE config needed for the workload-identity path.

The gcs:// scheme is also accepted as a synonym for gs://.

Per-query vs configured

Inline TVF arguments override [storage] defaults for one query. This matters when:

  • Querying a dataset in a different region than your default storage.
  • Running ad-hoc reads against a customer’s bucket without changing engine config.
  • Passing through end-user credentials in a multi-tenant deployment (see Authentication Flow for the policy-controlled variant).

The Iceberg catalog backend has its own credential flow (catalog credential vending; see Iceberg Integration). Storage credentials there come from the catalog’s STS exchange, not [storage].

Why two layers

Catalogs name tables; storage holds bytes. Iceberg already separates these in the spec (every table’s location field is independent of the catalog hosting it). SQE keeps the same separation. The pay-off:

  • The same dataset can be served by multiple catalogs simultaneously (Polaris in dev, Glue REST in prod, with both pointed at the same S3 prefix).
  • A catalog migration (Polaris -> Nessie) does not move any data files.
  • Storage feature flags (R2 vs AWS) do not touch metadata.

Implementation references

  • sqe-catalog/src/file_tvf_common.rs: shared inline-arg parsing for read_parquet / read_csv / read_json / read_delta. URL-scheme dispatch via is_s3_path, is_http_path, is_hf_path, is_azure_path, is_gcs_path.
    • register_s3_store_if_needed, register_azure_store_if_needed, register_gcs_store_if_needed, register_http_store_if_needed: per-backend SessionContext registration.
    • extract_bucket, extract_azure_container_account, extract_gcs_bucket: URL parsers.
  • sqe-catalog/src/lazy_object_store.rs: V10’s lazy HTTPS object-store registry.
  • sqe-catalog/src/iceberg_storage.rs: catalog-backed storage credential resolution (vended creds vs static [storage]).
  • Workspace Cargo.toml line 37: object_store = { ..., features = ["aws", "http", "azure", "gcp"] }.
  • The DuckDB-comparison audit row for backends lives at getsqe.com/compare/duckdb.

Building from Source

Prerequisites

ToolVersionPurpose
Rust1.85+Compiler
Cargo(bundled)Build system
protoc3.x+Protobuf compiler (for gRPC/Flight)
cmake3.x+Build dependency for some crates
pkg-configanyLibrary discovery
OpenSSL dev3.xTLS support

macOS

brew install protobuf cmake pkg-config openssl

Ubuntu/Debian

sudo apt-get install -y protobuf-compiler cmake pkg-config libssl-dev

Build

# Debug (fast compile, slow runtime)
cargo build --bin sqe-server --bin sqe-cli

# Release (slow compile, fast runtime)
cargo build --release --bin sqe-server --bin sqe-cli

# Or use the build script
./scripts/build.sh release

Binaries are placed in target/release/ (or target/debug/):

  • sqe-server: the server binary (coordinator or worker)
  • sqe-cli: the SQL CLI client

sqe-server is the supported entrypoint for both roles: it takes --config <path> and --mode coordinator|worker, and it is the binary shipped in the Docker image and run by the Helm chart. The sqe-coordinator crate also produces an older coordinator-only binary of the same name that takes the config path as a positional argument and has no worker mode; prefer sqe-server.

Test

# All workspace tests
cargo test --workspace

# Specific crate
cargo test -p sqe-coordinator

# Integration tests (require running quickstart stack)
cargo test --workspace -- --ignored

Docker Build

# Build the image
docker build -t sqe:latest .

# With OCI labels
docker build -t sqe:0.1.0 \
  --build-arg VERSION=0.1.0 \
  --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --build-arg GIT_REVISION=$(git rev-parse HEAD) \
  .

The Dockerfile uses a multi-stage build:

graph LR
    subgraph "Build Stage (rust bookworm)"
        BUILD["cargo build --release --locked"]
    end
    subgraph "Runtime Stage (Chainguard glibc-dynamic)"
        BIN["sqe-server + sqe-worker + sqe-cli"] --> RUN["Non-root user (UID 65532)"]
    end
    BUILD -->|COPY binaries| BIN

One Dockerfile for every consumer (local, data-platform quickstart, aikido). The builder matches rust-toolchain.toml; the runtime is Chainguard glibc-dynamic.

Workspace Structure

sqe/
├── Cargo.toml          # Workspace root
├── Cargo.lock
├── Dockerfile
├── sqe.toml.example
├── crates/
│   ├── sqe-core/       # Shared types, config, errors
│   ├── sqe-auth/       # Keycloak OIDC
│   ├── sqe-catalog/    # Iceberg REST catalog client
│   ├── sqe-sql/        # SQL parser & classifier
│   ├── sqe-policy/     # Policy enforcement (pluggable)
│   ├── sqe-planner/    # Plan splitting for distributed exec
│   ├── sqe-coordinator/# Coordinator + sqe-server binary
│   ├── sqe-worker/     # Worker executor
│   ├── sqe-cli/        # SQL CLI client
│   ├── sqe-metrics/    # Prometheus + OTel + audit
│   └── sqe-trino-compat/ # Trino wire protocol adapter
├── deploy/
│   ├── helm/sqe/       # Helm chart
│   └── k8s/            # Raw K8s manifests
├── docs/
│   └── book/           # This documentation (mdBook)
├── scripts/
│   ├── build.sh
│   └── test.sh
└── tests/
    └── integration_test.rs

Using the CLI

sqe-cli is the SQL client. By default it connects to a remote coordinator over Arrow Flight SQL or Trino HTTP. Pass --embedded to skip the network entirely and run an in-process engine. That mode is useful for ad-hoc analysis on local Parquet, CSV, or JSON files without standing up a cluster.

Usage

sqe-cli [OPTIONS]

Options:
  -H, --host <HOST>          Coordinator host [default: localhost]
  -p, --port <PORT>          Coordinator port [default: 50051]
      --protocol <PROTOCOL>  Wire protocol: flight or http [default: flight]
  -u, --user <USER>          Username (prompts if not set)
      --token <TOKEN>        Bearer token (skips password flow)
  -e, --execute <SQL>        Execute a single query and exit
      --file <PATH>          Read statements from a SQL script file
      --stop-on-error        Abort the script on first error (default: continue)
      --embedded             Run the engine in-process (no remote coordinator)
      --memory-limit <SIZE>  Per-process memory pool when --embedded [default: 1GB]
      --warehouse <PATH>     Single catalog at PATH named `iceberg`
                             (shorthand for --catalog iceberg=PATH)
      --catalog NAME=PATH    Attach a named persistent catalog (repeatable)
      --memory               Skip persistent catalogs entirely
  -f, --format <FORMAT>      Output format: table, csv, tsv, json [default: table]
      --tls                  Use HTTPS/TLS
      --insecure             Accept invalid TLS certificates
  -h, --help                 Print help
  -V, --version              Print version

Embedded mode

--embedded boots a single-process SessionContext with the same DataFusion tuning the cluster coordinator uses (parse_float_as_decimal, 64MB hash-join broadcast threshold, dynamic filter pushdown, Parquet filter pushdown). It registers all the same scalar functions, Trino-dialect aliases, JSON helpers, and the read_parquet(...) table-valued function. No auth, no Polaris, no network listeners.

# One-shot query against a local Parquet file
sqe-cli --embedded -e "SELECT COUNT(*) FROM read_parquet('data.parquet')"

# Trino-dialect functions work out of the box
sqe-cli --embedded -e "SELECT year(DATE '2026-05-07')"

# Run a script of statements
sqe-cli --embedded --file setup.sql

# Combine: script first, then ad-hoc query
sqe-cli --embedded --file setup.sql -e "SELECT COUNT(*) FROM staging"

# Interactive REPL (the default if no -e or --file is given)
sqe-cli --embedded

S3 access works too. Pass credentials inline to read_parquet:

SELECT *
FROM read_parquet(
    's3://bucket/path/*.parquet',
    access_key  => 'AKIA...',
    secret_key  => '...',
    region      => 'eu-example-1'
);

File format TVFs

Alongside read_parquet(), the embedded engine ships read_csv() and read_json() for direct file access. They share the same calling convention (positional path, named keyword args) and the same S3 credential bag.

-- Local CSV (auto-detect schema, headers on by default)
SELECT count(*) FROM read_csv('/data/sales.csv');

-- Tab- or semicolon-separated, no header
SELECT * FROM read_csv('/data/raw.tsv',
    delimiter   => '\t',
    has_header  => 'false');

-- NDJSON
SELECT * FROM read_json('/data/events.jsonl');

-- S3-hosted CSV with inline credentials
SELECT * FROM read_csv('s3://bucket/sales/*.csv',
    access_key => 'AKIA...',
    secret_key => '...',
    endpoint   => 'http://minio:9000',
    region     => 'us-east-1');

CSV-specific named args: delimiter, has_header, quote, escape, comment, null_regex, file_extension. JSON-specific: newline_delimited, file_extension.

read_csv also accepts the DuckDB-style aliases sep / delim for the delimiter, header for has_header, nullstr for null_regex, and compress / compression for the codec. Delimiter and codec default from the path extension: .csv is comma, .tsv is tab, .psv is pipe, .ssv is semicolon, and a .gz / .bz2 / .xz / .zst suffix is stripped before delimiter detection.

read_delta()

read_delta() reads a Delta Lake table directly. It takes the same S3 credential args as the other TVFs plus time-travel args: version (a snapshot id) or timestamp (RFC3339). The two are mutually exclusive, and reads are read-only.

-- Latest snapshot
SELECT * FROM read_delta('/data/delta/transactions');

-- Time travel to a specific version
SELECT * FROM read_delta('/data/delta/transactions', version => '12');

-- Land a Delta table into local Iceberg via CTAS
CREATE TABLE iceberg.warehouse.legacy_sales AS
    SELECT * FROM read_delta('/legacy/delta/sales');

Auto-detect: SELECT * FROM 'file.ext'

DuckDB-style sugar for “I just want to query this file.” The engine looks at the file extension and picks the right reader:

SELECT * FROM '/data/sales.parquet';
SELECT * FROM '/data/events.jsonl';
SELECT * FROM '/data/log.csv';

Works with globs and S3 URLs too. For S3, you still need credentials configured somewhere (default in [storage], or use read_csv()/read_parquet() and pass them inline).

HTTP / HTTPS URLs

Every file-format TVF and the SELECT * FROM 'file.ext' auto-detect accept HTTP and HTTPS URLs out of the box:

-- Public CSV from any HTTP(S) host
SELECT count(*) FROM read_csv(
  'https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv'
);

-- Auto-detect on a quoted URL
SELECT count(*) FROM
  'https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv';

-- Parquet over HTTP (range-request reads, no full download)
SELECT count(*) FROM read_parquet('https://example.com/dataset.parquet');

The engine builds an HTTP object store on first request per scheme://host[:port] and caches it for the rest of the session. No configuration needed.

HuggingFace Hub: hf://

hf:// URLs resolve to public HuggingFace Hub download URLs:

-- hf://datasets/<owner>/<name>/<path>
SELECT * FROM read_csv(
  'hf://datasets/datasets-examples/doc-formats-csv-1/data.csv'
);

-- Pin a revision via ?revision=
SELECT * FROM read_parquet(
  'hf://datasets/squad/plain_text/train.parquet?revision=v1.0.0'
);

-- Models and Spaces work the same way
SELECT * FROM read_json('hf://models/<owner>/<name>/config.json');

The resolver expands hf://datasets/<owner>/<name>/<path> to https://huggingface.co/datasets/<owner>/<name>/resolve/<rev>/<path> and routes through the same HTTP object store as raw HTTPS URLs. Default revision is main.

Public datasets work without any auth. Private datasets read HF_TOKEN from the environment if it is set.

Storage backends

The TVFs resolve S3 and S3-compatible stores, Azure, and GCS. Credentials default from the engine’s [storage] block (or the relevant provider chain) and can be overridden per query with named args.

Cloudflare R2 is S3-compatible. Point the endpoint at the account URL and use auto for the region:

SELECT * FROM read_parquet(
    's3://my-r2-bucket/data.parquet',
    access_key => '<R2_ACCESS_KEY_ID>',
    secret_key => '<R2_SECRET_ACCESS_KEY>',
    endpoint   => 'https://<account-id>.r2.cloudflarestorage.com',
    region     => 'auto'
);

MinIO, Ceph RGW, SeaweedFS, Garage, and rustfs are also S3-compatible: same args, endpoint pointing at the local server. Set s3_allow_http = true in [storage] (or pass an http:// endpoint) to allow plain HTTP for local development.

Azure ADLS Gen2 / Blob accepts three URL forms: abfss://<container>@<account>.dfs.core.windows.net/<path>, the plaintext abfs://..., or the shorthand azure://<container>/<path> with the account from config.

-- Shared key
SELECT * FROM read_parquet(
    'abfss://[email protected]/data.parquet',
    azure_access_key => '<storage-account-key>'
);

-- SAS token
SELECT * FROM read_csv(
    'abfss://[email protected]/events.csv',
    azure_sas_token => 'sv=2024-08-04&ss=b&...'
);

Google Cloud Storage uses gs:// or gcs://. Auth is a service-account JSON file path, an inline JSON key, or Application Default Credentials.

SELECT * FROM read_parquet(
    'gs://my-bucket/data.parquet',
    gcs_service_account_path => '/var/secrets/gcs-key.json'
);

-- ADC (gcloud config, GCE metadata, GKE Workload Identity)
SELECT * FROM read_parquet('gs://my-bucket/data.parquet');

Permanent credentials for any backend live in the engine’s [storage] block (azure_account, azure_access_key, azure_sas_token, gcs_service_account_path, gcs_service_account_key).

Catalog backends

Startup --catalog NAME=PATH flags attach SQLite-backed Iceberg catalogs. To mount any other backend from the REPL, use SQL ATTACH / DETACH and the secret primitives, which behave the same as on the cluster server. Mounts are process-local: the registry and secret store reset on exit.

sqe> CREATE SECRET prod (TYPE bearer, TOKEN 'eyJ...');
sqe> ATTACH 'http://catalog.example.com/api/catalog' AS prod_cat
       (TYPE iceberg_rest, WAREHOUSE 'analytics', SECRET prod);
sqe> SELECT * FROM prod_cat.sales.orders LIMIT 5;
sqe> DETACH prod_cat;

Supported TYPE values are iceberg_rest, glue, s3tables, hms, jdbc, sqlite, and hadoop. See Runtime catalog management for the full reference. The matrix of where each backend can be reached:

BackendCluster (TOML)Embedded (--catalog)Embedded (ATTACH)
Iceberg REST (Polaris, Nessie, Unity)yesnoyes
AWS Glueyesnoyes
AWS S3 Tablesyesnoyes
Hive Metastoreyesnoyes
JDBC (Postgres / MySQL / SQLite)yesSQLite onlyyes
Hadoop (storage-only)yesyes (file:// path scan)yes

COPY ... TO 'file'

Export query results to disk. Format is auto-detected from the extension.

COPY (SELECT * FROM iceberg.sales.orders WHERE year = 2026)
  TO '/exports/orders-2026.parquet';

COPY (SELECT customer_id, total FROM iceberg.sales.orders)
  TO '/exports/orders.csv';

-- Force a format / pass options
COPY orders TO '/exports/orders.json'
  (FORMAT 'json');

Persistent catalog

By default, --embedded attaches a SQLite-backed Iceberg catalog at ~/.sqe/warehouse/. Tables created here survive across sessions; SQL DDL (CREATE SCHEMA, CREATE TABLE, DROP TABLE, DROP SCHEMA) routes through the iceberg catalog without any out-of-band setup:

# Session 1: declare a schema and a table via plain SQL
sqe-cli --embedded -e "CREATE SCHEMA iceberg.staging"
sqe-cli --embedded -e \
    "CREATE TABLE iceberg.staging.events (event_id BIGINT, ts TIMESTAMP, kind VARCHAR)"

# Session 2: same warehouse, table is still there
sqe-cli --embedded -e "SELECT count(*) FROM iceberg.staging.events"

The full DML surface works against the embedded catalog: CREATE TABLE, CREATE TABLE AS SELECT (CTAS), INSERT INTO, UPDATE, DELETE, and MERGE INTO. Streaming writes keep CTAS and INSERT constant-memory, so loading a large external file straight into a local Iceberg table does not OOM:

sqe-cli --embedded -e "CREATE TABLE iceberg.staging.orders_2026 AS \
    SELECT id, region, total FROM read_parquet('s3://bucket/2026/*.parquet') WHERE total > 0"

Default DML mode is Copy-on-Write. Set write.delete.mode, write.update.mode, or write.merge.mode to merge-on-read on the table to opt into the delete-file writer.

The on-disk layout:

~/.sqe/warehouse/
├── sqe.db              # SQLite catalog (namespaces, table pointers)
└── iceberg/            # Iceberg metadata + Parquet data files
    └── staging/
        └── events/
            ├── metadata/
            └── data/

The catalog name is iceberg. Three-part identifiers (iceberg.staging.events) work; unqualified names resolve against DataFusion’s default in-memory catalog, so SELECT * FROM read_parquet(...) still works without any catalog interaction.

Override the path:

sqe-cli --embedded --warehouse /data/my-warehouse -e "..."

Skip the catalog entirely (ephemeral session, nothing written to disk):

sqe-cli --embedded --memory -e "SELECT 1"

Tables in the warehouse are valid Iceberg. If you later upgrade to a cluster deployment, point the cluster catalog at the same path and the tables come along. No migration, no re-export.

Multiple catalogs

Attach more than one warehouse with repeated --catalog NAME=PATH flags. Each becomes a top-level SQL identifier; cross-catalog joins work without any session-state setup.

sqe-cli --embedded \
    --catalog prod=/data/prod \
    --catalog stage=/data/stage \
    -e "SELECT *
        FROM prod.sales.orders p
        LEFT JOIN stage.sales.orders s ON p.id = s.id
        WHERE s.id IS NULL"

The catalog name shows up in information_schema.tables.table_catalog, in .catalogs, and in 3-part SQL identifiers. Names cannot contain . (it would clash with the SQL namespace separator) and cannot repeat (DataFusion’s register_catalog would silently overwrite).

--warehouse <path> remains as a shorthand for --catalog iceberg=<path>. The three flags --memory, --warehouse, and --catalog are mutually exclusive. Pick one.

Dot-commands

The REPL recognises sqlite/DuckDB-style commands that start with .. They run client-side, never reach the engine, and don’t end with ;:

sqe> .help
Dot commands:
  .help                show this list
  .exit, .quit         leave the REPL
  .tables [schema]     list tables (optionally filter by schema)
  .schema <table>      describe a table's columns
  .describe <table>    alias for .schema
  .summarize <table>   per-column count, distinct, null, min, max
  .catalogs            list catalogs visible to the session
  .read <path>         execute a SQL script file
  .timer on|off        toggle per-query elapsed-time output
  .format [fmt]        show or set output format (table|csv|tsv|json)

Examples:

sqe> .timer on
Timer: on

sqe> SELECT count(*) FROM read_parquet('events.parquet');
+----------+
| count(*) |
+----------+
| 1500000  |
+----------+
Time: 0.243s

sqe> .tables
sqe> .schema iceberg.staging.events
sqe> .summarize iceberg.staging.events
sqe> .read setup.sql
sqe> .format json

.summarize runs a per-column UNION ALL of count, null_count, distinct_count, min, and max. It is a two-step flow: the REPL fetches the column list from information_schema.columns, then generates and executes the aggregate query. Min/max are cast to VARCHAR so columns of mixed types render in one table.

SQL surface

In addition to the standard dialect, embedded mode (and the cluster coordinator) supports DuckDB-style projection sugar that DataFusion 53.1 ships natively:

-- Drop columns from the projection.
SELECT * EXCLUDE (secret, internal_id) FROM users;

-- Substitute a column with an expression while keeping order.
SELECT * REPLACE (UPPER(name) AS name, total / 100 AS total)
FROM orders;

-- Native column-level metadata.
DESCRIBE iceberg.staging.events;

The legacy \format and \q forms still work for backward compatibility.

What embedded mode does not include

  • Authentication, RBAC, or column masking. Embedded mode runs as the local user. Use the cluster path when you need policy enforcement.
  • Distributed execution. Embedded mode is single-process by design.
  • Concurrent writers. The SQLite catalog is single-process; running two sqe-cli --embedded instances against the same warehouse simultaneously will likely produce errors. The cluster path handles concurrent writes correctly.

Script files

--file reads a SQL script and executes statements in order, separated by ;. The splitter respects single-quoted strings, double-quoted identifiers, line comments (--), and block comments (/* ... */), so semicolons inside those don’t accidentally split a statement.

By default, errors print to stderr and execution continues. Pass --stop-on-error to abort on the first failure. That is the right setting for CI scripts where any failure means the schema setup is broken.

sqe-cli --embedded --file setup.sql

Interactive Mode

sqe-cli --host sqe-coordinator --port 50051 --user alice
Password: ****
sqe-cli 0.1.0 connected to http://sqe-coordinator:50051 (flight)
Type SQL queries, or \q to quit. End multi-line queries with ;

sqe> SELECT * FROM raw.orders LIMIT 3;
 order_id | customer_id | amount | region
----------+-------------+--------+--------
 1        | 100         | 250.00 | EU
 2        | 101         | 150.00 | US
 3        | 100         | 300.00 | EU
(3 rows)

sqe> \q

Multi-line Queries

Queries are executed when you type ;:

sqe> SELECT
  ->   region,
  ->   COUNT(*) AS orders,
  ->   SUM(amount) AS total
  -> FROM raw.orders
  -> GROUP BY region
  -> ORDER BY total DESC;

Commands

CommandAction
\qQuit
quitQuit
exitQuit
Ctrl+CCancel current input / quit
Ctrl+DQuit (EOF)

History is saved to ~/.sqe_history.

Single Query Mode

Execute one query and exit. Useful for scripts:

sqe-cli -H localhost -p 50051 -u alice -e "SELECT COUNT(*) FROM raw.orders;"

Output Formats

Table (default)

sqe-cli -e "SELECT 1 AS a, 'hello' AS b;" --format table
 a | b
---+-------
 1 | hello
(1 rows)

CSV

sqe-cli -e "SELECT 1 AS a, 'hello' AS b;" --format csv
a,b
1,hello

JSON (newline-delimited)

sqe-cli -e "SELECT 1 AS a, 'hello' AS b;" --format json
{"a":"1","b":"hello"}

Authentication

Username/Password

# Interactive prompt
sqe-cli --user alice

# Environment variables (no prompts)
export SQE_USER=alice
export SQE_PASSWORD=secret
sqe-cli -e "SHOW SCHEMAS;"

Bearer Token

Skip the password flow entirely with a pre-obtained token:

sqe-cli --token eyJhbGciOiJSUzI1NiIs... -e "SELECT 1;"

Connecting in Kubernetes

# Port-forward to the coordinator
kubectl port-forward svc/sqe-coordinator 50051:50051

# Then connect locally
sqe-cli --host localhost --port 50051

# Or exec directly into the pod
kubectl exec -it deploy/sqe-coordinator -- sqe-cli

Using with Trino Protocol

For compatibility with tools that speak Trino HTTP:

sqe-cli --protocol http --host localhost --port 8080 --user alice

This uses the Trino-compatible /v1/statement endpoint instead of Flight SQL.

Quickstart Recipes

This is the per-catalog recipe collection: one runnable quickstart per backend and use case. For the single get-running-in-5-minutes walkthrough, see Getting Started > Quickstart.

Each quickstart is a self-contained directory you can run end to end. It brings up everything the use-case needs, runs a few useful queries, and captures the real output as committed evidence. These pages describe each one at a high level, what it shows and how it works, and link to the repo for the full config, compose files, queries, and captured output.

These are the user-facing source of truth for “how do I run SQE for X.” The rest of the book explains why SQE is built the way it is; the quickstarts show how to use it.

What’s possible

Catalog + authentication (local Docker stack)

QuickstartWhat it showsStatus
Polaris + Keycloak (client credentials)Polaris + Keycloak; SQE mints user tokens via the OIDC password grantvalidated
Polaris + Keycloak (user token)Same stack; clients bring a pre-minted Keycloak token, SQE validates + passes it throughvalidated
Project NessieNessie as the Iceberg REST catalog (auth-less, anonymous SQE)validated
Unity Catalog OSSUnity Catalog OSS over Iceberg REST (read-only; catalog-browse demo)validated

AWS managed catalogs (CDK bootstrap + teardown)

QuickstartWhat it showsStatus
AWS S3 TablesAWS S3 Tables (managed Iceberg); CDK bootstrap + teardown; SQE creates the namespacevalidated
AWS GlueAWS Glue Data Catalog; SQE creates the DB and does a full round-tripvalidated
AWS Glue + Lake FormationGlue governed by Lake Formation: denied until an explicit LF grant, then succeedsvalidated

Embedded (single binary, sqe-cli)

QuickstartWhat it showsStatus
Query local and remote filesRead files directly with the read_* TVFs (no server, no catalog)validated
Persistent local catalog (SQLite)Local persistent Iceberg catalog backed by SQLite (no server)validated
Attach multiple catalogsAttach several persistent catalogs and JOIN across themvalidated
Quack (DuckDB wire protocol)SQE’s DuckDB Quack RPC endpoint, both directionsexperimental

Operations

QuickstartWhat it showsStatus
Observability: metrics + GrafanaScrape SQE’s Prometheus metrics with VictoriaMetrics + Grafanavalidated

Benchmarks

QuickstartWhat it showsStatus
TPC-H / TPC-DS / SSBGenerate, load, and run the TPC suites against SQE with per-query timingsvalidated

How a quickstart is laid out

Each directory has a README.md (the why/how), a standalone docker-compose.yml, the annotated config, a run.sh that brings the stack up and captures output, the demo queries.sql, and an OUTPUT.md with the real captured result.

Run any of them from a clone of the repo:

cd quickstart/<name>
cp .env.example .env
./run.sh

All quickstarts live in the repo under quickstart/.

Polaris + Keycloak (client credentials)

Goal

Run SQE against an Apache Polaris catalog where Keycloak owns the user identities. A client connects to SQE with a username and password; SQE exchanges those credentials for that user’s bearer token via its own confidential OIDC client (sqe-client), then passes the token through to Polaris. Polaris enforces what each user can see. Every query runs as the authenticated user — no service account, no shared credential.

Use this quickstart when an OIDC provider already manages your users and you want SQE to mint tokens on their behalf (JDBC tools, the CLI, dbt). If your clients already hold a token and need SQE to accept it directly, see the polaris-keycloak-user-token quickstart instead.

Components

ServiceImageRole
keycloakquay.io/keycloak/keycloak:26.5.4Identity provider. Issues bearer tokens via the OIDC password grant.
keycloak-configadorsys/keycloak-config-cliOne-shot: imports the iceberg realm (one confidential client, three users), then exits.
rustfsrustfs/rustfsS3-compatible object store. The Iceberg warehouse lives here.
bucket-initamazon/aws-cliOne-shot: creates the warehouse bucket (RustFS does not auto-create), then exits.
polarisapache/polaris:1.6.0Iceberg REST catalog, federated to Keycloak. Validates the tokens SQE forwards.
polaris-setupcurlimages/curlOne-shot: creates the catalog, RBAC roles, OIDC principals, and the demo namespace, then exits.
sqebuilt from this repoThe query engine. Flight SQL on 50051, Trino-compat HTTP on 8080.

Configuration

Backend (sqe.toml)

[[auth.providers]]
type = "oidc_password"
token_url = "http://keycloak:8080/realms/iceberg/protocol/openid-connect/token"
client_id = "sqe-client"
client_secret = "sqe-secret-change-me"   # must match Keycloak's sqe-client secret
roles_claim = "realm_access.roles"        # where SQE reads the user's roles

[catalogs.quickstart]
polaris_url = "http://polaris:8181/api/catalog"
warehouse = "quickstart"

[storage]
s3_endpoint = "http://rustfs:9000"
s3_access_key = "s3admin"
s3_secret_key = "s3adminpw"
s3_path_style = true
s3_allow_http = true

type = "oidc_password" selects the Resource Owner Password Credentials (ROPC) grant. SQE posts the user’s username + password plus its own client_id + client_secret to token_url and gets back the user’s bearer token, which is forwarded to Polaris. The TOML key quickstart becomes the SQL catalog name, so tables are addressed as quickstart.<namespace>.<table>.

SQL (queries.sql)

SHOW SCHEMAS;

DROP TABLE IF EXISTS quickstart.demo.events;
CREATE TABLE quickstart.demo.events (
    id     BIGINT,
    kind   VARCHAR,
    amount DOUBLE
);

INSERT INTO quickstart.demo.events VALUES
    (1, 'click',    1.50),
    (2, 'purchase', 42.00),
    (3, 'click',    0.75),
    (4, 'purchase', 13.25);

SELECT kind, COUNT(*) AS n, ROUND(SUM(amount), 2) AS total
FROM quickstart.demo.events
GROUP BY kind
ORDER BY total DESC;

The test

run.sh brings the full stack up with docker compose up --wait, then runs queries.sql twice via sqe-cli over Flight SQL — once as adminuser (catalog_admin + data_writer + table_reader) to exercise the complete create/write/read path, and once as testuser (table_reader only) to confirm that a lower-privileged user can read the table written by adminuser. Success is asserted by --stop-on-error; the output is captured to OUTPUT.md.

An optional --with-tests flag additionally runs the test_keycloak_auth_with_test_users and test_keycloak_token_refresh tests in the sqe-coordinator integration suite against the live stack (requires a Rust toolchain). Both tests passed on last validation (2026-06-06, 2 passed; 0 failed). Tear down with ./run.sh --down.

Output

## adminuser (catalog_admin + data_writer + table_reader)

sqe-cli 0.31.4 connected to http://localhost:50051 (flight)
+-------------+
| schema_name |
+-------------+
| demo        |
+-------------+
+----------+---+-------+
| kind     | n | total |
+----------+---+-------+
| purchase | 2 | 55.25 |
| click    | 2 | 2.25  |
+----------+---+-------+

## testuser (table_reader only): read is allowed

sqe-cli 0.31.4 connected to http://localhost:50051 (flight)
+----------+---+
| kind     | n |
+----------+---+
| click    | 2 |
| purchase | 2 |
+----------+---+

Polaris + Keycloak (user token)

Goal

The bring-your-own-token path. An upstream application or identity provider has already authenticated the user and holds their bearer token. The client sends that token to SQE with --token; SQE validates it (signature, issuer, expiry) against the realm’s public JWKS endpoint and passes it through to Polaris. SQE never sees a password and holds no client secret.

Use this quickstart when callers are pre-authenticated: a backend service that already completed the OIDC dance, a CI job with a service-account token, or a gateway that injects the user’s JWT. If you want SQE to mint tokens from a username and password instead, use the polaris-keycloak-client-id quickstart.

Components

This quickstart runs the same Docker stack as polaris-keycloak-client-id. The only difference is the SQE auth provider in sqe.toml.

ServiceImageRole
keycloakquay.io/keycloak/keycloak:26.5.4Identity provider. Issues tokens; SQE fetches its public JWKS to validate them.
keycloak-configadorsys/keycloak-config-cliOne-shot: imports the iceberg realm (one confidential client + one public client, three users), then exits.
rustfsrustfs/rustfsS3-compatible object store. The Iceberg warehouse lives here.
bucket-initamazon/aws-cliOne-shot: creates the warehouse bucket, then exits.
polarisapache/polaris:1.6.0Iceberg REST catalog, federated to Keycloak. Validates the tokens SQE forwards.
polaris-setupcurlimages/curlOne-shot: creates the catalog, RBAC roles, OIDC principals, and the demo namespace, then exits.
sqebuilt from this repoThe query engine. Flight SQL on 50051, Trino-compat HTTP on 8080.

Configuration

Backend (sqe.toml)

[[auth.providers]]
type = "bearer_token"
jwks_url = "http://keycloak:8080/realms/iceberg/protocol/openid-connect/certs"
issuer = "http://keycloak:8080/realms/iceberg"   # must equal the token's `iss`
user_claim = "preferred_username"
roles_claim = "realm_access.roles"
allow_unbounded_audience = true   # accept any aud this realm signed
allow_insecure_jwks = true        # JWKS over plain HTTP in-network

[catalogs.quickstart]
polaris_url = "http://polaris:8181/api/catalog"
warehouse = "quickstart"

[storage]
s3_endpoint = "http://rustfs:9000"
s3_access_key = "s3admin"
s3_secret_key = "s3adminpw"
s3_path_style = true
s3_allow_http = true

type = "bearer_token" makes SQE a pure validator. It fetches the realm’s signing keys (JWKS) once, then verifies every incoming token’s signature and iss claim — no client_secret, no call to the token endpoint. issuer must match the token’s iss exactly, which is why the stack pins KC_HOSTNAME=http://keycloak:8080. For production, add an audience mapper in the realm and set audience = "sqe" instead of allow_unbounded_audience.

SQL (queries.sql)

SHOW SCHEMAS;

DROP TABLE IF EXISTS quickstart.demo.events;
CREATE TABLE quickstart.demo.events (
    id     BIGINT,
    kind   VARCHAR,
    amount DOUBLE
);

INSERT INTO quickstart.demo.events VALUES
    (1, 'click',    1.50),
    (2, 'purchase', 42.00),
    (3, 'click',    0.75),
    (4, 'purchase', 13.25);

SELECT kind, COUNT(*) AS n, ROUND(SUM(amount), 2) AS total
FROM quickstart.demo.events
GROUP BY kind
ORDER BY total DESC;

The test

run.sh brings the stack up, then mints tokens from Keycloak’s public client (polaris-frontend-client, no client secret) to simulate an upstream application. It queries SQE with --token and asserts three behaviors: a valid adminuser token authorizes the full read/write flow (create table, insert, aggregate); a testuser token (table_reader) is allowed to read but is denied a write by Polaris RBAC (403 Forbidden); and a malformed token is rejected by SQE’s JWKS validation before any query reaches the catalog. All three results are captured to OUTPUT.md. Tear down with ./run.sh --down.

Output

## adminuser, authenticated by a pre-minted Keycloak token

sqe-cli 0.31.4 connected to http://localhost:50051 (flight)
+-------------+
| schema_name |
+-------------+
| demo        |
+-------------+
+----------+---+-------+
| kind     | n | total |
+----------+---+-------+
| purchase | 2 | 55.25 |
| click    | 2 | 2.25  |
+----------+---+-------+

## testuser token (table_reader): read works, write is denied by Polaris RBAC

+------+
| rows |
+------+
| 4    |
+------+

$ sqe-cli --token <testuser-jwt> -e "INSERT ..."   # expect 403
Error: "Failed to commit INSERT transaction: ... 403 Forbidden: Principal 'testuser' ... is not authorized for op ADD_TABLE_SNAPSHOT"

## an invalid token is rejected by SQE before any query runs

$ sqe-cli --token not.a.real.jwt -e "SELECT 1"
Error: "Invalid or expired bearer token"

Overview: Polaris + Apache Ranger + Keycloak

The two halves

Access control splits into a write path and an enforcement path.

Write path (SQE). SQE’s ranger access-control backend turns each GRANT / REVOKE into a call to the Ranger Admin REST API (POST /service/plugins/services/grant/polaris). SHOW GRANTS reads Ranger policies back. SQE never enforces anything itself on this path.

Enforcement path (Polaris). Polaris 1.5 runs its embedded Ranger authorizer (polaris.authorization.type=ranger). When SQE asks Polaris to load a table (carrying the user’s Keycloak token), Polaris asks Ranger whether that principal may perform the operation. An ungranted operation fails at Polaris with a 403, which SQE surfaces as an error.

SQE  --GRANT/REVOKE-->  Ranger Admin        (policies stored here)
SQE  --query+token-->   Polaris  --check-->  Ranger    (enforcement)

Identity model

This is the part that needs care, and the part the quickstart pins down through live testing. The mapping has two halves: users and roles, handled differently.

Users (principals). Polaris federates the principal from the Keycloak token: the principal name is preferred_username. But federation RESOLVES an existing principal entity; it does not create one. So each user must be pre-created as a Polaris principal (the data bootstrap creates alice, bob, carol, dave, erin, frank). A token for a principal that does not exist is rejected with 401 “Failed to resolve principal”. Polaris sends this principal name to Ranger as the user.

This was tested directly with polaris.authentication.type=external (not just mixed): the DefaultAuthenticator still logs “Failed to resolve principal” and returns 401 for a Keycloak user with no Polaris principal, even though the JWT verifies and Ranger holds the user’s roles. So Keycloak + Ranger alone is NOT enough; the Polaris principal entity is required regardless of auth.type. External mode also disables the internal root token, creating a bootstrap chicken-and-egg (nothing can authenticate to create the first principal), which is why this stack uses mixed (internal token for provisioning + external OIDC for users). Confirmed against source: DefaultAuthenticator (@Identifier("default")) is the ONLY authenticator in Polaris 1.5.0 and current main; it always looks the principal up in the metastore (findPrincipalByName/findPrincipalById) and 401s if absent, and its javadoc states it “does not support federated principals that are not managed by Polaris”. polaris.authentication.authenticator.type only accepts default. So eliminating per-user principal provisioning is NOT a config option; it would require a custom Authenticator bean (a code change / custom build). Provision a principal per user instead.

Roles. This is the surprising part. Polaris IGNORES the token’s realm roles (they lack Polaris’s expected PRINCIPAL_ROLE: prefix, so they are dropped during authentication). And Polaris principal-roles cannot help either: the 1.5.0 Ranger authorizer leaves all principal-role management operations unmapped, so creating or assigning them is always denied. The mapping that actually works is Ranger role membership: the user-to-role relationship lives in Ranger’s own role store. Polaris sends the user to Ranger, and Ranger resolves that user’s roles from membership. In production this membership comes from Ranger usersync (LDAP/AD/SCIM); here ranger-setup sets it explicitly:

analyst  -> alice, bob, carol
engineer -> bob, carol
sqe_admin -> carol

Groups are not forwarded by Polaris at all (no usersync of groups here), so this backend supports USER and ROLE grantees only; GROUP grants are rejected.

So the end-to-end mapping is:

  1. Keycloak issues a token with preferred_username (and realm roles, which Polaris ignores).
  2. Polaris resolves preferred_username to a pre-created principal entity and sends that username to Ranger.
  3. Ranger resolves the user’s roles from its role-membership store.
  4. SQE writes Ranger policies keyed on usernames (GRANT TO USER) and role names (GRANT TO ROLE); Ranger matches them against the resolved user+roles.

Grant granularity: baseline vs the LOAD gate

A single SQL operation through SQE touches several Polaris operations, each needing a specific Ranger access type, and the embedded authorizer does NOT honor service-def implied-grants. So SQE expands each SQL privilege to the full explicit set (map_sql_to_ranger_access): SELECT -> the read set, INSERT -> table-data-write plus every snapshot/schema/properties commit type.

The effective read gate is LOAD_TABLE (table-properties-read), not credential vending. SQE reads parquet with its own configured S3 credentials, so once a user can load a table’s metadata it can read the data; Polaris’s table-data-read (vended-credential) check never fires for this deployment. The quickstart uses that fact to make GRANT the visible gate:

  • Baseline (provisioning): ranger-setup grants each role a traverse set (catalog-list, catalog-properties-read, namespace-list, namespace-properties-read, table-list). This is the “USAGE” level: a member can connect and list, but cannot load a table. It deliberately omits table-properties-read.
  • Data (SQE GRANT): GRANT SELECT writes table-properties-read + table-data-read; GRANT INSERT writes the full write+commit set. Because the baseline omits table-properties-read, GRANT SELECT is what actually lets a member load and read a table, and REVOKE takes it away. A Ranger DENY on table-properties-read (added to the same policy) overrides the allow.

A denied table is invisible: SQE surfaces a load denial as “table not found” rather than a permission error, matching the Polaris information-hiding model.

Why a seed admin policy

With the Ranger authorizer enabled, Polaris delegates every decision to Ranger, including the bootstrap’s own catalog and namespace creation by the root principal. Without a policy, that bootstrap is denied. ranger/bootstrap-ranger.sh seeds a broad admin grant for the root user and the sqe_admin role before Polaris starts.

The resource-shape note

A Ranger policy SQE writes must match the resource Polaris sends at enforcement. The Polaris service-def hierarchy is root -> catalog -> namespace -> table. The root level carries a realm/context value. SQE controls it through [access_control.ranger] realm in sqe.toml:

  • "*" (this stack): every policy carries root = *, which matches the realm value Polaris sends. This is required: a {catalog:*} policy without root never matches Polaris’s CREATE_NAMESPACE/table checks (verified), so a granted user would still be denied.
  • A precise realm string can be used instead of "*" for tighter scoping if you confirm the exact value Polaris sends (Ranger Admin audit tab or docker compose logs polaris), then restart SQE.

Resolved value for this stack: "*" (root required, wildcard-matched).

Fine-grained enforcement (SQE-side)

The Polaris gate tested above is coarse: it answers “may this user load this table?” SQE also enforces row filters and column masks at the query-plan layer, reading a separate hive-servicedef Ranger service. These two paths are independent.

How SQE reads the query service. On startup (and on a configurable refresh interval) SQE calls GET /service/plugins/policies/download/query to download the policy set. The [policy] engine = "ranger" setting activates the RangerStore: PolicyStore, which caches these policies and evaluates them against each query’s catalog, namespace, and table.

Plan rewriting. SQE rewrites the LogicalPlan before DataFusion optimization. Row filters inject as Filter nodes above the TableScan; column masks replace column references with CASE WHEN ... THEN NULL END expressions. DataFusion’s optimizer can push user predicates through row-filter nodes but not through masked columns (masking a column blocks predicate pushdown on that column’s raw value, matching PostgreSQL RLS semantics).

Resource mapping. SQE passes the last dotted component of the namespace as the database resource. For sales_wh.sales.orders the resource sent to Ranger is database = "sales", table = "orders". Ranger policies must use "sales" as the database value, not the full three-part path "sales_wh.sales".

Separation from the coarse path. GRANT/REVOKE go to the polaris Ranger service via [access_control]; Polaris enforces those at the catalog level. The query Ranger service is read by SQE’s policy engine for row/column enforcement. A query must pass both gates: the Polaris gate (can the user load the table?) and SQE’s rewriter (what rows and columns may the user see?). Revoking the coarse SELECT grant still denies the query before any fine-grained check runs.

Shared with Apache Spark / Kyuubi. The query service is the same service those engines read, so the same policy set is shared across tools. A mask or row filter written for SQE applies to Spark queries through the same Ranger service and vice versa.

Supported mask types (Phase 2A, shipped). The full Ranger hive built-in mask vocabulary is now enforced. SQE translates each dataMaskType string from the policy into a DataFusion UDF call or a NULL substitution before optimization. The complete set:

dataMaskTypeEffect
MASK_NULLReplace column value with NULL.
MASKFull character redact: uppercase -> X, lowercase -> x, digit -> n, punctuation kept.
MASK_SHOW_LAST_4Show last 4 characters; mask all others with x (digits and letters alike). Dashes and punctuation pass through. For 111-11-1111: output is xxx-xx-1111.
MASK_SHOW_FIRST_4Show first 4 characters; mask the rest with x.
MASK_HASHReplace column value with an HMAC-SHA256 hex digest.
MASK_DATE_SHOW_YEARTruncate a date to year: month and day zeroed out.
CUSTOMArbitrary SQL expression evaluated per row.

The char convention matches the hive serviceDef transformers. Full MASK uses X/x/n; MASK_SHOW_* use x for every replaced character type. This is the complete Ranger hive built-in mask set.

The quickstart seeds a MASK_NULL policy on orders.amount and a CUSTOM show-last-4 policy on orders.ssn, both for role engineer. Test section 5 proves both: bob (engineer) sees xxx-xx-1111 for ssn and an empty amount cell; alice (analyst-only) sees the raw values. No row-filter policy is seeded (see the SQE <-> Spark cross-compare below for why).

SQE <-> Spark cross-compare

parity-test.sh runs SELECT id, ssn FROM sales_wh.sales.orders as bob (role engineer) in both SQE and Apache Spark 3.5 + the Kyuubi Spark AuthZ (Ranger) plugin, and asserts byte-identical masked output. Both engines read the SAME Polaris catalog and the SAME Ranger query service.

bob --ROPC------> SQE   --reads query svc--> mask applied by PlanRewriter
bob --OS user---> Spark --reads query svc--> mask applied by RangerSparkExtension (Kyuubi)
                   |
                   +-- both read the Iceberg table from the Polaris REST catalog
                   +-- both resolve bob -> role engineer from the SAME Ranger `query` service

Spark reaches Polaris as bob, using bob’s own Keycloak bearer token on the Iceberg catalog, so both tiers see the same identity. The mask is still Kyuubi’s job, keyed on bob via HADOOP_USER_NAME. SQE’s coarse Polaris gate (the embedded Ranger authorizer on the polaris service) and Kyuubi’s frontend-service access policy are seeded separately because the two engines authorize through two different Ranger services.

Spark used to connect as the root service account here, which meant this demo showed mask parity with the object tier bypassed. Worse, a root-credentialed catalog alias defeats per-user identity for the whole session: a per-user token governs only the alias it is attached to, so a caller denied on their own alias reads the same table by naming the credentialed one. Overriding that alias’s token does not help, because Iceberg prefers credential when both are set. parity-test.sh now asserts the property directly: a spark-sql with no caller token must fail to load the table. See spark/spark-defaults.conf for the full note.

Why the ssn mask is a CUSTOM portable-SQL expression, not a named type. Named Ranger mask types are NOT byte-portable between SQE and Kyuubi:

Mask formSQESpark / Kyuubi
Named MASK_SHOW_LAST_4honors the servicedef transformer -> xxx-xx-1111ignores it, applies its own mask chars -> nnnUnnU1111
CUSTOM mask_show_last_n({col},4,'x','x','x',-1,'1')plan-rewrite error (type_coercion)xxx-xx-1111, but only after registering the Hive UDF
CUSTOM concat('xxx-xx-', substr({col},8,4))xxx-xx-1111xxx-xx-1111

concat and substr are built-ins in both DataFusion (SQE) and Spark, so each engine injects the expression verbatim and both render the same result. That is the policy bootstrap-ranger.sh seeds.

Why no row filter in this cross-compare. The base quickstart can seed a Ranger row filter, but parity-test.sh requires bob to see both rows of orders, and Kyuubi Spark 3.5 throws MISSING_ATTRIBUTES (#6889) on a row filter over a column the query does not project (region). Row-filter parity is out of scope on Spark 3.5 + Kyuubi 1.11 until #6889 is resolved.

Why Spark 3.5, not Spark 4.0. Spark 4.0 is Scala 2.13-only and kyuubi-spark-authz_2.13 is not published to Maven Central (verified 2026-06-19). Spark 3.5 (Scala 2.12) + kyuubi-spark-authz-shaded_2.12-1.11.1 is the latest pre-built combo. The shaded Kyuubi jar bundles the Ranger plugin runtime (ranger-plugins-common + ranger-plugins-audit), which avoids the AuditProviderFactory ClassNotFound that the plain kyuubi-spark-authz jar hits. See spark/Dockerfile.

Phase 2B (not yet implemented). Session-context SQL functions (current_user(), current_role()) inside row-filter expressions; richer role model in SessionUser for inherited and secondary roles.

Phase 2C (not yet implemented). Cross-engine dynamic transformer configuration for arbitrary-N show-first/show-last masks; tag-based masking via Ranger tag policies.

Versions

  • Apache Polaris 1.5.0 (embedded Ranger authorizer, Beta).
  • Apache Ranger 2.8.0 (required by the Polaris plugin; it uses the new embedded authorizer API).
  • Keycloak 26.5.

Service principals: per-connection client_credentials

Goal

Connect to SQE with a service principal’s own OAuth2 client_id and client_secret instead of a human username/password. SQE runs the OAuth2 client_credentials grant per connection — with the credentials that connection supplies — and forwards the minted token to Polaris; Apache Ranger authorizes the principal at the Polaris boundary. Each distinct client is a distinct service principal: authorization is per-connection, not a single server-baked identity, and SQE itself holds no service-principal secret.

Use this quickstart when machines connect — pipelines, dashboards, services — rather than humans. For the human username/password flow see polaris-keycloak-client-id; for the GRANT/REVOKE-to-Ranger story on the same stack see polaris-ranger-keycloak.

What happens

client (client_id + client_secret as Flight Basic auth)
   -> SQE  runs the OAuth2 client_credentials grant with THOSE credentials
   -> Keycloak mints a token: preferred_username = the SP name, aud = account
   -> SQE forwards the token to Polaris
   -> Polaris maps preferred_username -> principal
   -> Apache Ranger authorizes (USER grants keyed on the SP name)

Components

ServiceImageRole
keycloakquay.io/keycloak/keycloakIdentity provider. Mints the service-principal tokens.
keycloak-configadorsys/keycloak-config-cliOne-shot: imports the realm with the three SP confidential clients, then exits.
rustfsrustfs/rustfsS3-compatible object store. The Iceberg warehouse lives here.
bucket-initamazon/aws-cliOne-shot: creates the warehouse bucket, then exits.
ranger-dbpostgresRanger Admin’s database.
ranger-adminapache/rangerPolicy store. Holds the USER grants keyed on the SP names.
ranger-setupcurlimages/curlOne-shot: creates the Ranger users + grants for the SPs, then exits.
polarisapache/polarisIceberg REST catalog. Maps preferred_username to a principal; enforces via its Ranger authorizer.
polaris-setupcurlimages/curlOne-shot: creates catalogs, namespaces, and the Polaris USER principals, then exits.
sqebuilt from this repoThe query engine. Flight SQL on 50051.

Configuration

[[auth.providers]]
type = "client_credentials_passthrough"
token_url = "http://keycloak:8080/realms/iceberg-ranger/protocol/openid-connect/token"
roles_claim = "realm_access.roles"

[policy]
engine = "passthrough"   # authorization is enforced at Polaris + Ranger

There is no client_id/client_secret in the config: those arrive per connection. Any Flight SQL client uses the client_id as the username and the client_secret as the password.

The three service principals

The realm and bootstrap provision three SPs — each a Keycloak confidential client with serviceAccountsEnabled, a hardcoded preferred_username mapper, and an aud=account mapper; a matching Polaris USER principal; and a Ranger user + grant:

Service principalRanger grantResult
sp-adminfull access (ADMIN)creates + seeds sales_wh.sales.orders
sp-readerread on sales_wh.sales.ordersSELECT allowed
sp-deniednoneSELECT denied

The proof of per-connection identity: the same SELECT succeeds for sp-reader and is denied for sp-denied. Only the connection credentials differ.

The test

run.sh brings the stack up (Ranger’s first boot takes 2–4 minutes), then runs test.sh. The test first mints each SP token straight from Keycloak and asserts the token shape (preferred_username + aud), then drives SQE: seed as sp-admin, SELECT allowed as sp-reader, SELECT denied as sp-denied, a write denied for the read-only SP, and a wrong secret rejected at auth. Tear down with ./run.sh --down.

Constraints worth knowing

  • Flight SQL only. The Trino-compat HTTP Basic-auth path does not route through the provider chain, so the passthrough provider is reachable over Flight SQL, not Trino HTTP Basic auth.
  • Service-principal-only listener. This provider consumes username/password, so it cannot share a listener with oidc_password — a human username would be tried as a client_id and rejected.
  • No SQE-side masking here. A client_credentials SP carries no per-user role for SQE to key column masks on, so this quickstart sets policy.engine = "passthrough" and relies on Polaris + Ranger.
  • Token shape is the make-or-break. The SP client needs the hardcoded preferred_username mapper (so Polaris maps the right principal) and an aud=account mapper (Polaris validates the token audience). The profile client scope is excluded from the SP clients so its built-in username mapper does not collide with the hardcoded one. test.sh verifies all of this.

Project Nessie (Iceberg REST catalog)

Goal

Connect SQE to Project Nessie, a transactional git-like catalog for Iceberg tables. Nessie exposes the Iceberg REST protocol, the same surface Apache Polaris exposes, so SQE connects through the identical rest catalog code path. Swapping Polaris for Nessie is a single config line: point polaris_url at Nessie’s /iceberg endpoint instead.

This quickstart is about the catalog integration, not authentication. Nessie runs auth-less and SQE uses its anonymous provider, so there is no identity provider to configure. For the full auth story (real identities, RBAC, token passthrough) see the polaris-keycloak-client-id and polaris-keycloak-user-token quickstarts.

Components

ServiceImageRole
rustfsrustfs/rustfsS3-compatible object store. The Iceberg warehouse lives here.
bucket-initamazon/aws-cliOne-shot: creates the warehouse bucket, then exits.
nessieghcr.io/projectnessie/nessie:0.107.5Iceberg REST catalog with in-memory version store and S3 storage on RustFS.
sqebuilt from this repoThe query engine, running in anonymous auth mode.

Configuration

Backend (sqe.toml)

[[auth.providers]]
type = "anonymous"
user = "anonymous"
roles = ["admin"]

[catalogs.nessie]
polaris_url = "http://nessie:19120/iceberg"   # Nessie's Iceberg REST mount
warehouse = "warehouse"

[storage]
s3_endpoint = "http://rustfs:9000"
s3_access_key = "s3admin"
s3_secret_key = "s3adminpw"
s3_path_style = true
s3_allow_http = true

type = "anonymous" accepts every connection as a single anonymous identity. SQE logs a security warning on startup because this disables authentication entirely — use it only to connect to auth-less catalogs without standing up an identity provider; do not use it in production. For polaris_url, the key difference from a Polaris setup is the path: Nessie’s Iceberg REST surface mounts at /iceberg, not /api/catalog. SQE issues the same GET /v1/config?warehouse=... handshake either way and reads the catalog prefix Nessie returns (main|warehouse, the branch + warehouse name).

SQL (queries.sql)

CREATE SCHEMA IF NOT EXISTS nessie.demo;

DROP TABLE IF EXISTS nessie.demo.events;
CREATE TABLE nessie.demo.events (
    id     BIGINT,
    kind   VARCHAR,
    amount DOUBLE
);

INSERT INTO nessie.demo.events VALUES
    (1, 'click',    1.50),
    (2, 'purchase', 42.00),
    (3, 'click',    0.75),
    (4, 'purchase', 13.25);

SHOW SCHEMAS;

SELECT kind, COUNT(*) AS n, ROUND(SUM(amount), 2) AS total
FROM nessie.demo.events
GROUP BY kind
ORDER BY total DESC;

Note that CREATE SCHEMA is included in the script: unlike the Polaris quickstarts, Nessie starts empty with no pre-bootstrapped namespace. SQE maps CREATE SCHEMA to an Iceberg create_namespace call.

The test

run.sh brings the stack up with docker compose up --wait and runs queries.sql as the anonymous user over Flight SQL. The script asserts the complete create/write/read flow: namespace creation, table creation, insert, and aggregation all succeed against Nessie. Output is captured to OUTPUT.md. The same rest catalog client is also exercised by the nessie_namespace_round_trip live test in the sqe-catalog integration suite (last validated 2026-06-06). Tear down with ./run.sh --down.

Output

sqe-cli 0.31.4 connected to http://localhost:50051 (flight)
+-------------+
| schema_name |
+-------------+
| demo        |
+-------------+
+----------+---+-------+
| kind     | n | total |
+----------+---+-------+
| purchase | 2 | 55.25 |
| click    | 2 | 2.25  |
+----------+---+-------+

Unity Catalog OSS (Iceberg REST, read-only)

Goal

Connect SQE to Unity Catalog OSS over its Iceberg REST adapter and browse the catalog. SQE uses the same rest catalog code path it uses for Polaris and Nessie — the connection works the same way.

Be clear-eyed about the current limitation: Unity OSS’s Iceberg REST adapter is read-only at this version. Create, drop, and commit operations are not supported, and the bundled table is not served as a loadable Iceberg table, so SELECT does not work either. This quickstart exists to confirm the connection works and to document where the boundary lies. For full read and write against an Iceberg REST catalog, use the Polaris or Nessie quickstarts.

Components

ServiceImageRole
unityunitycatalog/unitycatalog:main-2f2e32dUnity Catalog OSS. Exposes the Iceberg REST adapter at /api/2.1/unity-catalog/iceberg. Ships a bundled unity.default.marksheet_uniform table.
sqebuilt from this repoThe query engine, running in anonymous auth mode (Unity OSS runs auth-less).

Configuration

Backend (sqe.toml)

[[auth.providers]]
type = "anonymous"
user = "anonymous"
roles = ["admin"]

[catalogs.unity]
polaris_url = "http://unity:8080/api/2.1/unity-catalog/iceberg"
warehouse = "unity"     # the Unity catalog name, used as the REST "warehouse"

type = "anonymous" is appropriate because Unity OSS runs without authentication. A Databricks-hosted Unity Catalog with bearer auth enabled would instead use SQE’s bearer_token provider. The Iceberg REST mount path for Unity OSS differs from both Polaris (/api/catalog) and Nessie (/iceberg): it lives at /api/2.1/unity-catalog/iceberg.

SQL (queries.sql)

-- 1. List the namespaces Unity exposes (the bundled catalog has `default`).
SHOW SCHEMAS;

-- 2. List the tables in the default namespace.
SHOW TABLES IN unity.default;

No DML is included because Unity OSS does not support writes or reads via Iceberg REST at this version.

The test

run.sh brings up Unity and SQE, then runs queries.sql as the anonymous user via sqe-cli over Flight SQL. It asserts that SQE can connect to Unity and enumerate the catalog (SHOW SCHEMAS, SHOW TABLES). The script also runs a SELECT against the bundled table to capture the read limitation transparently, then writes everything to OUTPUT.md. The same rest catalog client is exercised by the list_via_unity_rest live test in the sqe-catalog integration suite (last validated 2026-06-06). Tear down with ./run.sh --down.

Output

## SQE browsing Unity Catalog OSS over Iceberg REST

sqe-cli 0.31.4 connected to http://localhost:50051 (flight)
+-------------+
| schema_name |
+-------------+
| default     |
+-------------+
+-----------+-------------------+
| namespace | table_name        |
+-----------+-------------------+
| default   | marksheet_uniform |
+-----------+-------------------+

## Read attempt: Unity OSS does not serve the bundled table for SELECT

$ sqe-cli -e "SELECT * FROM unity.default.marksheet_uniform LIMIT 3"
Error: "table 'unity.default.marksheet_uniform' not found"

AWS S3 Tables (managed Iceberg)

Goal

Point SQE at AWS S3 Tables, AWS’s managed Iceberg product. Unlike Glue (metadata only), S3 Tables bundles the catalog and the storage into one service: you create a table bucket, and namespaces plus tables live inside it. SQE talks to it over the AWS SDK with your IAM credentials.

A small CDK stack bootstraps the throwaway table bucket and tears it down after the run, so the quickstart leaves nothing behind.

Components

PieceRole
cdk/ (TypeScript)Creates an S3 Tables table bucket (cdk deploy) and removes it (cdk destroy).
docker-compose.ymlRuns just the SQE coordinator with the s3tables backend; AWS credentials passed via env.
sqe.tomlAnnotated config template; run.sh fills in the table-bucket ARN and region.

Configuration

Backend (sqe.toml)

[catalog.backend]
type = "s3tables"
table_bucket_arn = "__TABLE_BUCKET_ARN__"   # run.sh fills this in from CDK outputs

[storage]
s3_region = "__REGION__"
s3_path_style = false

[[auth.providers]]
type = "anonymous"
user = "anonymous"
roles = ["admin"]

The s3tables backend registers under the SQL catalog name iceberg, so tables are iceberg.<namespace>.<table>. Auth is the anonymous dev provider; S3 Tables authenticates via AWS IAM.

SQL (queries.sql)

-- Create the namespace (SQE -> S3 Tables CreateNamespace)
CREATE SCHEMA IF NOT EXISTS iceberg.demo;

DROP TABLE IF EXISTS iceberg.demo.events;
CREATE TABLE iceberg.demo.events (
    id     BIGINT,
    kind   VARCHAR,
    amount DOUBLE
);

INSERT INTO iceberg.demo.events VALUES
    (1, 'click',    1.50),
    (2, 'purchase', 42.00),
    (3, 'click',    0.75),
    (4, 'purchase', 13.25);

SELECT kind, COUNT(*) AS n, ROUND(SUM(amount), 2) AS total
FROM iceberg.demo.events
GROUP BY kind
ORDER BY total DESC;

The test

run.sh runs the full create/write/read round-trip against a real S3 Tables table bucket. It: deploys the CDK stack (table bucket) → generates sqe.toml.local from the stack outputs → starts SQE → executes queries.sql (CREATE SCHEMA → CREATE TABLE → INSERT → SELECT) and captures output to OUTPUT.md → then tears down: deletes the SQE-created table and namespace (S3 Tables won’t delete a non-empty bucket), then cdk destroy.

Validated live 2026-06-06 (account 123456789012, eu-example-1): full round-trip succeeded, teardown left no leftover stack, bucket, namespace, or table.

Output

sqe-cli 0.31.4 connected to http://localhost:50051 (flight)
(0 rows)
(0 rows)
(0 rows)
(0 rows)
+----------+---+-------+
| kind     | n | total |
+----------+---+-------+
| purchase | 2 | 55.25 |
| click    | 2 | 2.25  |
+----------+---+-------+
(2 rows)

AWS Glue Data Catalog

Goal

Point SQE at the AWS Glue Data Catalog with S3 as storage. Glue is the catalog (table metadata) and S3 is the storage; SQE talks to both over the AWS SDK using your IAM credentials. No Polaris, no Keycloak, no RustFS.

A small CDK stack bootstraps the throwaway S3 warehouse bucket and tears it back down after the run, so the quickstart leaves nothing behind.

Components

PieceRole
cdk/ (TypeScript)Creates an S3 warehouse bucket (cdk deploy) and removes it (cdk destroy).
docker-compose.ymlRuns just the SQE coordinator with the glue backend; AWS credentials passed via env.
sqe.tomlAnnotated config template; run.sh fills in the bucket URI and region.

Configuration

Backend (sqe.toml)

[catalog.backend]
type = "glue"
region = "__REGION__"
warehouse = "__WAREHOUSE__"   # s3://<bucket>/ from CDK outputs; run.sh fills this in

[storage]
s3_region = "__REGION__"
s3_path_style = false

[[auth.providers]]
type = "anonymous"
user = "anonymous"
roles = ["admin"]

The glue backend registers under the SQL catalog name iceberg, so tables are iceberg.<glue_database>.<table>. Auth is the anonymous dev provider; Glue authenticates via AWS IAM. For real multi-user auth, put SQE behind Keycloak while the catalog still uses IAM.

SQL (queries.sql)

-- SQE creates the Glue database (makes the caller its owner — Lake Formation safe)
CREATE SCHEMA IF NOT EXISTS iceberg.sqe_glue_quickstart;

DROP TABLE IF EXISTS iceberg.sqe_glue_quickstart.events;
CREATE TABLE iceberg.sqe_glue_quickstart.events (
    id     BIGINT,
    kind   VARCHAR,
    amount DOUBLE
);

INSERT INTO iceberg.sqe_glue_quickstart.events VALUES
    (1, 'click',    1.50),
    (2, 'purchase', 42.00),
    (3, 'click',    0.75),
    (4, 'purchase', 13.25);

SELECT kind, COUNT(*) AS n, ROUND(SUM(amount), 2) AS total
FROM iceberg.sqe_glue_quickstart.events
GROUP BY kind
ORDER BY total DESC;

The test

run.sh runs the full create/write/read round-trip against a real Glue catalog and S3 bucket. It: deploys the CDK stack (S3 bucket only) → generates sqe.toml.local from the stack outputs → starts SQE → executes queries.sql (CREATE SCHEMA → CREATE TABLE → INSERT → SELECT) and captures output to OUTPUT.md → stops SQE → drops the Glue database → cdk destroy.

SQE creates the Glue database via CREATE SCHEMA rather than CDK. This is deliberate: in a Lake-Formation-enabled account, a database created out-of-band is LF-governed with no grants, which would deny CreateTable. A database SQE creates makes the calling principal its owner, granting the required permissions. This pattern works with or without Lake Formation. The glue-lake-formation quickstart explores the governed path instead.

Validated live 2026-06-06 (account 123456789012, eu-example-1): full round-trip succeeded, teardown left no leftover stack, bucket, or database.

Output

sqe-cli 0.31.4 connected to http://localhost:50051 (flight)
(0 rows)
(0 rows)
(0 rows)
(0 rows)
(2 rows)
+----------+---+-------+
| kind     | n | total |
+----------+---+-------+
| purchase | 2 | 55.25 |
| click    | 2 | 2.25  |
+----------+---+-------+

AWS Glue + Lake Formation

Goal

Point SQE at an AWS Glue database that Lake Formation governs. Unlike the aws-glue quickstart (which lets SQE create the database, making the caller its owner to side-step LF), here the database is created by CloudFormation. In a Lake-Formation-enabled account that means it is governed with no grants, so SQE is denied until the principal is granted LF permissions explicitly.

The run demonstrates the full arc: denial, the grant, and the same statements succeeding. Be precise about the boundary: LF governs the Glue catalog operations SQE calls (CreateTable, GetTable). SQE reads Iceberg data files straight from S3 with the caller’s IAM credentials and does not enforce LF column-masking or row-filtering. Fine-grained access here means table/database-level permission gating, not cell-level filtering. SQE’s own column/row masking is the OPA/Cedar policy engine, independent of the catalog.

Components

PieceRole
cdk/ (TypeScript)Creates an S3 warehouse bucket and an LF-governed Glue database sqe_lf_quickstart (cdk deploy). cdk destroy removes both.
docker-compose.ymlRuns just the SQE coordinator with the glue backend; AWS credentials passed via env.
sqe.tomlAnnotated config template; run.sh fills in the bucket URI and region.

Configuration

Backend (sqe.toml)

[catalog.backend]
type = "glue"
region = "__REGION__"
warehouse = "__WAREHOUSE__"   # s3://<bucket>/ from CDK outputs; run.sh fills this in

[storage]
s3_region = "__REGION__"
s3_path_style = false

[[auth.providers]]
type = "anonymous"
user = "anonymous"
roles = ["admin"]

Config is identical to aws-glue; the difference is operational. The database is created by CloudFormation (not SQE), so Lake Formation governs it with no grants until one is added explicitly.

SQL (queries.sql)

-- No CREATE SCHEMA: the database already exists (CloudFormation made it).
-- Phase A: denied by LF. Phase B (after the grant): succeeds.
CREATE TABLE iceberg.sqe_lf_quickstart.events (
    id     BIGINT,
    kind   VARCHAR,
    amount DOUBLE
);

INSERT INTO iceberg.sqe_lf_quickstart.events VALUES
    (1, 'click',    1.50),
    (2, 'purchase', 42.00),
    (3, 'click',    0.75),
    (4, 'purchase', 13.25);

SELECT kind, COUNT(*) AS n, ROUND(SUM(amount), 2) AS total
FROM iceberg.sqe_lf_quickstart.events
GROUP BY kind
ORDER BY total DESC;

The test

run.sh runs the full denial → grant → success arc against a real LF-governed Glue database. It: deploys the CDK stack (S3 bucket + LF-governed Glue database) → starts SQE → Phase A: executes queries.sql and captures the LF denial → issues aws lakeformation grant-permissions (CREATE_TABLE ALTER DROP DESCRIBE on the database) → restarts SQE to clear any stale catalog state → Phase B: re-runs the same statements and captures success → drops the SQE-created table (so CDK can delete the database) → cdk destroy (also revokes the LF grant).

The caller must be a Lake Formation data-lake admin. The account must have LF enforcement on (CreateDatabaseDefaultPermissions empty); if IAMAllowedPrincipals is still the default, Phase A will not produce a denial.

Validated live 2026-06-07 (account 123456789012, eu-example-1): Phase A returned the LF AccessDeniedException; Phase B did CREATE TABLE → INSERT → SELECT cleanly; teardown left no stack, database, bucket, or LF grant.

Output

## Phase A -- before the LF grant: Lake Formation denies CREATE TABLE

AccessDeniedException: Insufficient Lake Formation permission(s):
Required Create Table on sqe_lf_quickstart

## Phase B -- after the LF grant: the same statements succeed

sqe-cli 0.31.4 connected to http://localhost:50051 (flight)
(0 rows)
(0 rows)
(2 rows)
+----------+---+-------+
| kind     | n | total |
+----------+---+-------+
| purchase | 2 | 55.25 |
| click    | 2 | 2.25  |
+----------+---+-------+

Embedded: query local and remote files

Goal

SQE’s engine runs in-process. sqe-cli --embedded --memory starts DataFusion, the Iceberg writers, and the file-reader table-valued functions in a single binary: no coordinator, no workers, no network listeners, no catalog.

read_csv, read_json, and read_parquet read files directly, whether they live on local disk or behind an HTTPS URL. Nothing persists between runs — the --memory flag makes the session ephemeral. This is the fastest way to query data with SQE SQL: no stack to bring up, no catalog to configure.

Components

ComponentRole
sqe-cliEmbedded engine binary (in-process; no separate server)
data/Three sample files (CSV, JSON, Parquet) — five rows each
Docker (optional)Container wrapper when no local sqe-cli build is available

Configuration

CLI

# Wrapper: run the embedded CLI in the SQE image with ./data mounted read-only
sqe() { docker run --rm --entrypoint sqe-cli -v "$PWD/data":/data:ro \
          sqe-quickstart:latest --embedded --memory "$@"; }

# Local CSV — aggregate by kind
sqe -e "SELECT kind, COUNT(*) AS n, ROUND(SUM(amount),2) AS total
        FROM read_csv('/data/events.csv') GROUP BY kind ORDER BY total DESC"

# Local JSON — count + sum
sqe -e "SELECT COUNT(*) AS rows, ROUND(SUM(amount),2) AS total
        FROM read_json('/data/events.json')"

# Local Parquet — sum by kind
sqe -e "SELECT kind, ROUND(SUM(amount),2) AS total
        FROM read_parquet('/data/events.parquet') GROUP BY kind ORDER BY kind"

# Join two files of different formats in one query
sqe -e "SELECT c.id, c.kind FROM read_csv('/data/events.csv') c
        JOIN read_parquet('/data/events.parquet') p ON c.id = p.id
        WHERE c.amount > 10 ORDER BY c.id"

# Remote file over HTTPS
sqe -e "SELECT COUNT(*) AS rows FROM read_parquet('https://example.com/data.parquet')"

--memory runs with no persistent catalog (nothing survives the process). --embedded without --memory attaches a SQLite-backed Iceberg catalog at ~/.sqe/warehouse instead, so CREATE TABLE persists. See embedded-sqlite-catalog for that mode.

The test

run.sh exercises read_csv, read_json, and read_parquet on the local sample files in data/, runs a cross-format JOIN between CSV and Parquet, and queries a remote Parquet file over HTTPS. All output is captured to OUTPUT.md. The local Parquet path mirrors the test_read_parquet_local_file integration test. Last validated 2026-06-06.

Output

## Local CSV (read_csv)
sqe-cli 0.31.4 embedded engine (1GB memory pool, ephemeral)
+----------+---+-------+
| kind     | n | total |
+----------+---+-------+
| purchase | 2 | 55.25 |
| click    | 2 | 2.25  |
| refund   | 1 | -5.0  |
+----------+---+-------+
(3 rows)

## Join two files of different formats in one query
sqe-cli 0.31.4 embedded engine (1GB memory pool, ephemeral)
(2 rows)
+----+----------+
| id | kind     |
+----+----------+
| 2  | purchase |
| 4  | purchase |
+----+----------+

## Remote file over HTTPS (read_parquet on a URL)
sqe-cli 0.31.4 embedded engine (1GB memory pool, ephemeral)
+------+
| rows |
+------+
| 1000 |
+------+
(1 rows)

Embedded: persistent local catalog (SQLite)

Goal

sqe-cli --embedded --warehouse <dir> runs the engine in-process and attaches a SQLite-backed Iceberg catalog at <dir>. Unlike the embedded-files quickstart (which uses --memory and keeps nothing), CREATE TABLE and its data persist on disk between CLI invocations: the catalog is a sqe.db SQLite file and the table data lives next to it as Iceberg metadata and Parquet files.

This is the single-binary, local-first way to keep Iceberg tables on a laptop. No server, no Polaris, no catalog service — just a directory on disk.

Components

ComponentRole
sqe-cliEmbedded engine binary (in-process; no separate server)
./warehouse/SQLite catalog (sqe.db) + Iceberg metadata/data on local disk
queries-init.sqlMulti-statement script: create schema, create table, insert rows
Docker (optional)Container wrapper when no local sqe-cli build is available

Configuration

CLI

# Process 1: create schema + table + insert (writes to the SQLite catalog)
docker run --rm --entrypoint sqe-cli \
  -v "$PWD/warehouse":/data/wh \
  -v "$PWD/queries-init.sql":/init.sql:ro \
  sqe-quickstart:latest \
  --embedded --warehouse /data/wh --file /init.sql --stop-on-error

# Process 2: a separate invocation reopens the same warehouse and reads
docker run --rm --entrypoint sqe-cli \
  -v "$PWD/warehouse":/data/wh \
  sqe-quickstart:latest \
  --embedded --warehouse /data/wh \
  -e "SELECT kind, COUNT(*) AS n, ROUND(SUM(amount),2) AS total
      FROM iceberg.demo.events GROUP BY kind ORDER BY total DESC"

--warehouse <dir> names the catalog iceberg, so tables are referenced as iceberg.<namespace>.<table>. Use --file script.sql for multi-statement scripts; sqe-cli accepts a single -e per invocation. ./run.sh --clean resets the warehouse directory.

The test

run.sh runs two separate sqe-cli processes against the same ./warehouse directory: the first process executes queries-init.sql (create schema, create table, insert 4 rows); the second opens the same warehouse in a fresh invocation and reads the rows back with a GROUP BY query. The on-disk presence of sqe.db and the iceberg/ data directory is verified after the read. Last validated 2026-06-06.

Output

## Process 1 -- create schema + table + insert (writes to the SQLite catalog)
$ sqe-cli --embedded --warehouse ./warehouse --file queries-init.sql
sqe-cli 0.31.4 embedded engine (1GB memory pool, warehouse: /data/wh)
(0 rows)
(0 rows)
+-------+
| count |
+-------+
| 4     |
+-------+
(1 rows)

## Process 2 -- a *separate* invocation reopens the same warehouse and reads
$ sqe-cli --embedded --warehouse ./warehouse -e "SELECT ... FROM iceberg.demo.events"
sqe-cli 0.31.4 embedded engine (1GB memory pool, warehouse: /data/wh)
(2 rows)
+----------+---+-------+
| kind     | n | total |
+----------+---+-------+
| purchase | 2 | 55.25 |
| click    | 2 | 2.25  |
+----------+---+-------+

## On disk: the SQLite catalog (sqe.db) + Iceberg metadata/data
iceberg
sqe.db

Embedded: attach multiple catalogs

Goal

sqe-cli --embedded --catalog NAME=PATH (the flag is repeatable) mounts several persistent, SQLite-backed Iceberg catalogs in one in-process session. Each catalog appears under its own name in 3-part SQL identifiers (name.namespace.table), and a single query can JOIN across them.

This is useful for local analysis that spans more than one warehouse — for example, a sales catalog and a ref (reference-data) catalog joined in one query — with no server, no catalog service, and no configuration file required.

Components

ComponentRole
sqe-cliEmbedded engine binary (in-process; no separate server)
./catalogs/sales/SQLite-backed Iceberg catalog for the sales warehouse
./catalogs/ref/SQLite-backed Iceberg catalog for the ref (reference-data) warehouse
seed-sales.sqlCreates and populates sales.public.orders
seed-ref.sqlCreates and populates ref.public.regions
Docker (optional)Container wrapper when no local sqe-cli build is available

Configuration

CLI

# Step 1: seed the sales catalog
docker run --rm --entrypoint sqe-cli \
  -v "$PWD/catalogs/sales":/d/sales \
  -v "$PWD/seed-sales.sql":/s.sql:ro \
  sqe-quickstart:latest \
  --embedded --catalog sales=/d/sales --file /s.sql --stop-on-error

# Step 2: seed the ref catalog
docker run --rm --entrypoint sqe-cli \
  -v "$PWD/catalogs/ref":/d/ref \
  -v "$PWD/seed-ref.sql":/r.sql:ro \
  sqe-quickstart:latest \
  --embedded --catalog ref=/d/ref --file /r.sql --stop-on-error

# Step 3: attach BOTH catalogs and JOIN across them in one query
docker run --rm --entrypoint sqe-cli \
  -v "$PWD/catalogs/sales":/d/sales \
  -v "$PWD/catalogs/ref":/d/ref \
  sqe-quickstart:latest \
  --embedded --catalog sales=/d/sales --catalog ref=/d/ref \
  -e "SELECT r.name, COUNT(*) AS n, ROUND(SUM(o.amount),2) AS total
      FROM sales.public.orders o
      JOIN ref.public.regions r ON o.region_id = r.region_id
      GROUP BY r.name ORDER BY total DESC"

--catalog NAME=PATH is mutually exclusive with --memory and --warehouse. Each catalog persists under its own path. ./run.sh --clean resets the ./catalogs directory.

The test

run.sh seeds two independent catalogs (sales and ref) in separate sqe-cli invocations, each using --file for the multi-statement seed script. It then opens both catalogs in a single session and runs a cross-catalog JOIN (sales.public.orders against ref.public.regions), asserting that tables from each catalog resolve correctly. Output is captured to OUTPUT.md. Last validated 2026-06-06.

Output

## Seed the `sales` and `ref` catalogs (separate warehouses)
$ sqe-cli --embedded --catalog sales=./catalogs/sales --file seed-sales.sql
sqe-cli 0.31.4 embedded engine (1GB memory pool, warehouse: /d/sales)
(0 rows)
(0 rows)
(1 rows)
+-------+
| count |
+-------+
| 3     |
+-------+
$ sqe-cli --embedded --catalog ref=./catalogs/ref --file seed-ref.sql
sqe-cli 0.31.4 embedded engine (1GB memory pool, warehouse: /d/ref)
(0 rows)
(0 rows)
+-------+
| count |
+-------+
| 2     |
+-------+
(1 rows)

## Attach BOTH catalogs and JOIN across them in one query
$ sqe-cli --embedded --catalog sales=... --catalog ref=... -e "... JOIN ..."
sqe-cli 0.31.4 embedded engine (1GB memory pool, catalogs: sales=/d/sales, ref=/d/ref)
+------+---+-------+
| name | n | total |
+------+---+-------+
| EU   | 2 | 49.25 |
| US   | 1 | 13.5  |
+------+---+-------+
(2 rows)

Quack: the DuckDB wire protocol

Goal

Quack is DuckDB’s RPC protocol. A DuckDB client can ATTACH 'quack:host:port' and query a remote engine as though it were a local database.

SQE speaks Quack in both directions. As a server, a DuckDB client queries SQE’s Iceberg catalogs over the Quack endpoint (coordinator.quack_port). As a client, SQE’s quack_query() table function pulls rows from a remote Quack endpoint — another SQE instance, or a DuckDB running quack_serve. This quickstart is experimental: Quack is pre-release upstream (targeting DuckDB v2.0) and the client extension ships from core_nightly. The round-trip works today with duckdb 1.5.3 but the protocol surface is not yet stable.

Components

ServiceRole
rustfsS3-compatible object store (the Iceberg data warehouse)
bucket-initOne-shot container that creates the warehouse bucket in RustFS
nessieIceberg REST catalog (auth-less, in-memory version store)
sqeCoordinator with quack_port = 9494 — the Quack RPC endpoint
DuckDB CLI (optional)Local client for the forward round-trip; not part of the stack

Configuration

Backend (sqe.toml)

[coordinator]
flight_sql_port = 50051
trino_http_port = 8080
quack_port = 9494        # enables the DuckDB Quack RPC endpoint
mode = "hybrid"

[auth]
[[auth.providers]]
type = "anonymous"
user = "anonymous"
roles = ["admin"]

[catalogs.nessie]
polaris_url = "http://nessie:19120/iceberg"
warehouse = "warehouse"

[storage]
s3_endpoint = "http://rustfs:9000"
s3_region = "us-east-1"
s3_access_key = "s3admin"
s3_secret_key = "s3adminpw"
s3_path_style = true
s3_allow_http = true

Setting quack_port enables the endpoint. The anonymous auth provider accepts any non-empty token — dev mode only. For real auth, swap in the polaris-keycloak-* quickstart’s auth section.

The test

run.sh brings the full stack up via docker compose up -d --wait, then probes the Quack endpoint with GET / to confirm SQE identifies as a DuckDB Quack server. If a local duckdb 1.5.3+ is on PATH, it goes further: seeds an Iceberg table in SQE (nessie.demo.events), installs the pre-release quack extension (INSTALL quack FROM core_nightly), and has DuckDB run quack_query() against SQE — aggregating the result locally. The server probe always runs; the round-trip is skipped (with instructions) when DuckDB is not found. Output is captured to OUTPUT.md. Last validated 2026-06-07 with duckdb 1.5.3.

Tear down with ./run.sh --down.

Output

## The Quack endpoint identifies itself (`GET /`)
$ curl http://localhost:19494/
This is a DuckDB Quack RPC endpoint, served by SQE.

## A DuckDB CLI queries an SQE Iceberg table over Quack
┌──────────┬───────┬────────┐
│   kind   │   n   │ total  │
│ varchar  │ int64 │ double │
├──────────┼───────┼────────┤
│ purchase │     2 │  55.25 │
│ click    │     2 │   2.25 │
└──────────┴───────┴────────┘

## SQE logs on startup (Quack enabled, with its security warnings)
WARNING: the Quack endpoint has NO rate limiting on its auth path -- it is an
un-throttled brute-force / IdP-amplification oracle. Restrict network access to
the Quack port until QUACK-08 lands.
WARNING: the Quack endpoint is PLAINTEXT (no TLS) and binds 0.0.0.0 -- user OIDC
bearer tokens travel in cleartext and can be captured and replayed. Set
[coordinator.tls] cert_file/key_file to enable TLS, or do not expose the Quack
port on untrusted networks.
DuckDB Quack RPC on port 9494 (plaintext)

Quack Protocol Reference (as of DuckDB extension v1.5-variegata)

Reference notes for implementing a Quack-compatible server and client in Rust. Extracted from the duckdb/duckdb-quack source (MIT, ~356 commits, May 2026) and the DuckDB v1.5.2+ release. Cross-checked against the announcement post and duckdb-quack’s own docs/usage.md.

The Quack protocol is pre-release and the DuckDB project plans to stabilise it for v2.0 in September 2026. Treat this document as a snapshot, not a stable contract.

Status of upstream documentation

The upstream documentation has two surfaces with mismatched naming:

  • README + source code: quack_serve, quack_stop, quack: URI scheme, HTTP endpoint POST /quack, content type application/vnd.duckdb.
  • duckdb-quack docs/usage.md and FAQ: rpc_start, rpc_stop, POST /rpc, MIME type application/duckdb.

The source is authoritative. The rpc_* doc names appear to be an older or aspirational naming. We follow the source.

The FAQ states “Quack uses HTTP v2.0”. The source uses httplib (a small C++ HTTP/1.1 library) with keep_alive_max_count(128). We treat the wire as HTTP/1.1 with keep-alive, not HTTP/2.

Transport

FieldValue
ProtocolHTTP/1.1, keep-alive enabled
Default port9494
URI schemequack:host[:port] (HTTPS by default for non-localhost, plain HTTP for localhost)
EndpointPOST /quack
Content-Type (request and response)application/vnd.duckdb
TLSOptional. Server generates self-signed cert via quack_generate_keys(). Production deployments expected to terminate TLS at a reverse proxy.
CORSServer returns Access-Control-Allow-Origin: * on OPTIONS /quack and on every response

There is also a root path that returns a plain-text identification string:

GET / HTTP/1.1

HTTP/1.1 200 OK
Content-Type: text/plain

This is a DuckDB Quack RPC endpoint. Use ATTACH 'quack:...' to connect here.

Useful for sniffing whether a host speaks Quack.

Wire format

Every request body and every response body is a serialised QuackMessage. The serializer is DuckDB’s BinarySerializer with SerializationCompatibility::FromIndex(7). This is the same code path DuckDB uses for its Write-Ahead Log files.

Each message on the wire is:

[ serialized MessageHeader (BinarySerializer Begin/End block) ]
[ serialized message body  (BinarySerializer Begin/End block) ]

BinarySerializer uses field-tagged encoding. Every field has a numeric ID, a type, and a value. Optional fields can be omitted. The schema (with stable field IDs) is captured in src/include/quack_message.json in the upstream repo and reproduced below for stability.

Message header

Field IDNameTypeNotes
1typeMessageType (enum)See message types below
2connection_idstringServer-assigned, returned in CONNECTION_RESPONSE
3client_query_idoptional_idx (u64)Monotonic per-client query ID for log correlation

MessageType is an enum encoded as idx_t:

INVALID = 0
CONNECTION_REQUEST = 1
CONNECTION_RESPONSE = 2
PREPARE_REQUEST = 3
PREPARE_RESPONSE = 4
FETCH_REQUEST = 5
FETCH_RESPONSE = 6
APPEND_REQUEST = 7
SUCCESS_RESPONSE = 8
DISCONNECT_MESSAGE = 9
ERROR_RESPONSE = 10

The exact wire ordering of enum tags depends on DuckDB’s EnumUtil; do not hard-code numeric values. Always go through the named enum.

Message bodies

ConnectionRequest

Initial handshake. Sent once per connection.

FieldTypeNotes
1 auth_stringstringBearer token. Server’s auth function decides validity
2 client_duckdb_versionstringe.g. "v1.5.2"
3 client_platformstringe.g. "osx_arm64"
4 min_supported_quack_versionidx_tclient min
5 max_supported_quack_versionidx_tclient max

ConnectionResponse

FieldTypeNotes
1 server_duckdb_versionstring
2 server_platformstring
3 quack_versionidx_tCurrently 1

Header carries the server-assigned connection_id; clients echo it in subsequent requests.

PrepareRequest

FieldTypeNotes
1 sql_querystringRaw SQL

PrepareResponse

FieldTypeNotes
1 result_typesvector<LogicalType>Per-column DuckDB type
2 result_namesvector<string>Column names
3 needs_more_fetchboolIf true, client must follow up with FETCH_REQUEST using result_uuid
4 resultsvector<DataChunkWrapper>Optional first batch of rows
5 result_uuidhugeint_tServer-side handle for follow-up fetches

The server may inline the entire result if it fits; otherwise it returns a result_uuid and the client pulls more via FETCH_REQUEST.

FetchRequest

FieldTypeNotes
1 uuidhugeint_tResult handle from PrepareResponse

FetchResponse

FieldTypeNotes
1 resultsvector<DataChunkWrapper>Batched chunks
2 batch_indexoptional_idxSequence number for ordering

AppendRequest

Bulk insert from client to server.

FieldTypeNotes
1 schema_namestringTarget schema
2 table_namestringTarget table
3 append_chunkDataChunkWrapperRow data

SuccessResponse

Empty body. Used to acknowledge DisconnectMessage, AppendRequest, etc.

DisconnectMessage

Empty body. Client signals end of session. Server responds with SuccessResponse and closes the connection.

ErrorResponse

FieldTypeNotes
1 messagestringRaw error message

DataChunk wire format

Results travel as DataChunkWrapper, which serialises one DuckDB DataChunk (vectorised columnar batch). The wrapper has a single field:

Field IDNameType
300chunkDataChunk

A DataChunk is DuckDB’s native columnar batch type. Its serialisation includes:

  • Number of columns
  • Per-column LogicalType (recursive for nested types)
  • Per-column Vector data (validity bitmap + data buffer + optional dictionary/auxiliary buffers)

This is not Arrow IPC. DuckDB has its own columnar layout. The two formats are not interchangeable without conversion.

For SQE to read these, we either:

  1. Link libduckdb and let DuckDB’s C++ code deserialise into a DataChunk, then convert to Arrow inside our process; or
  2. Reimplement DuckDB’s BinarySerializer and DataChunk::Serialize semantics in Rust.

Option 1 ties us to a specific DuckDB version but gets correctness for free. Option 2 is purer Rust but the maintenance cost tracks DuckDB releases. Decision recorded in openspec/changes/duckdb-quack-protocol-support/design.md (Open Questions section).

Authentication

The server’s quack_authentication_function (default quack_check_token) is a SQL scalar function with signature (sid VARCHAR, token VARCHAR) -> BOOLEAN. The default implementation compares the token against quack_default_token.

Users can plug their own auth by registering a scalar function with that signature and pointing the setting at it.

The token travels in ConnectionRequestMessage.auth_string. There is no separate Auth frame. Once ConnectionResponse returns, the connection is authenticated for the lifetime of that connection.

Per-query authorisation: quack_authorization_function is (sid VARCHAR, query VARCHAR) -> BOOLEAN. Default allows everything. Called server-side before executing each PrepareRequest.

Pushdown semantics

The server supports the following pushdowns when a client ATTACHes and then scans a remote table:

  • Projection pushdown: only requested columns are returned
  • Filter pushdown: constant comparisons (=, <, >, <=, >=, <>), IS NULL, IS NOT NULL, IN (...), and AND/OR combinations

Filters are evaluated server-side. Other predicates (function calls, joins) execute on the client.

For SQE-as-server: the SQL the client sends is already the filtered/projected SQL. We do not need to extract pushdowns from a separate field. The SQL string carries everything.

Logging

The extension registers two log types:

  • quack log: structured per-message (message_type, connection_id, client_query_id, query, duration_ms, error)
  • HTTP log: per-request URL + status

For SQE compatibility, we should emit equivalent structured logs from the server crate.

Compatibility matrix

Server quack_versionClient min..maxBehaviour
1min<=1<=maxOK
1min>1Server returns ErrorResponse
Future Nclient max < NServer should downgrade if possible; otherwise reject

Current quack_version = 1. The protocol is expected to bump versions before v2.0 stabilisation.

Things SQE will need to handle differently from DuckDB

  • Iceberg-backed catalogs: DuckDB Quack assumes its own catalog. Our Attach returns SQE’s Iceberg catalog tree. DuckDB clients see Iceberg namespaces as schemas.
  • OIDC tokens vs static tokens: the auth function receives an opaque string. We treat it as an OIDC bearer and validate via sqe-auth. Bare static tokens are still accepted if sqe-auth is configured for them.
  • Result format: SQE’s existing query engine produces Arrow RecordBatch. We must convert each RecordBatch to a DuckDB DataChunk before serialising. This conversion is non-trivial but tractable (both are columnar, both have validity bitmaps).
  • Policy enforcement: server-side SQL goes through sqe-policy SQL-text rewriter (see openspec/changes/duckdb-quack-protocol-support/design.md) before reaching the planner.

References

  • Upstream repo: https://github.com/duckdb/duckdb-quack (MIT)
  • Announcement: https://duckdb.org/2026/05/12/quack-remote-protocol
  • DuckDB docs (overview): https://duckdb.org/docs/current/quack/overview
  • FAQ: https://duckdb.org/quack/faq
  • Local reference clone: /tmp/duckdb-quack-src/ (during research; delete after Phase 1)

Quack RPC datatype matrix

How DuckDB, Arrow/DataFusion, SQE’s LogicalTypeId, and Iceberg primitive types line up for the Quack RPC path. Status reflects what works through a real duckdb 1.5.3 CLI session (SELECT ... FROM quack_query('quack:localhost:9494', ...)) against sqe-server on a feature branch and later.

Scalar types

DuckDBArrow / DataFusionLogicalTypeIdIcebergQuackNotes
BOOLEANBooleanBooleanbooleannulls round-trip
TINYINTInt8TinyInt(none)
SMALLINTInt16SmallInt(none)
INTEGERInt32Integerint
BIGINTInt64BigIntlong
UTINYINT / USMALLINT / UINTEGER / UBIGINTUInt8 / UInt16 / UInt32 / UInt64UTinyInt etc.(none)⚠️wire encoding works; DataFusion SQL planner rejects unsigned literals in SELECT (upstream limitation, not ours)
HUGEINT / UHUGEINT(no native Arrow)HugeInt / UHugeIntdecimal(38, 0)⚠️wire encoding works; DataFusion SQL planner rejects HUGEINT
FLOATFloat32Floatfloat
DOUBLEFloat64Doubledouble
DECIMAL(p, s)Decimal128Decimal + ExtraTypeInfo::Decimal { precision, scale }decimal(p, s)physical width tier-narrowed to i16/i32/i64/i128 per DuckDB; Decimal256 not supported; negative scale rejected
VARCHARUtf8 / LargeUtf8 / Utf8ViewVarcharstringDataFusion 54 emits Utf8View by default
BLOBBinary / LargeBinary / BinaryViewBlobbinarynulls round-trip
DATEDate32Datedateboth sides use days-since-1970-01-01
DATE from Date64Date64Datedatenarrowed to i32 days
TIMESTAMP_S / _MS / _US (default TIMESTAMP) / _NSTimestamp(Second/Millisecond/Microsecond/Nanosecond, None)TimestampSec / TimestampMs / Timestamp / TimestampNstimestamptimezone discarded; see follow-ups
TIMESTAMP WITH TIME ZONETimestamp(*, Some(tz))TimestampTztimestamptztimezone stripped today
TIMETime32(Second/Millisecond) / Time64(Microsecond)TimetimeTime32 variants widen ×1_000_000 / ×1_000 to i64 microseconds-of-day
TIME_NSTime64(Nanosecond)TimeNs(none, project as time)i64 nanoseconds-of-day passthrough
UUIDFixedSizeBinary(16)Uuiduuid16-byte raw passthrough; other widths rejected
INTERVALInterval(YearMonth/DayTime/MonthDayNano)Interval(none)widens into DuckDB’s 16-byte interval_t { months, days, micros }; ns floored to micros
BIT(no native Arrow)Bit(none)

Nested types

DuckDBArrowLogicalTypeIdIcebergQuack
LIST<T>List / LargeListList + ExtraTypeInfo::List { child }list<T>✅ recursive child type; child element vector reused under field 106
STRUCT(...)StructStruct + ExtraTypeInfo::Struct { fields }struct<...>✅ pairs of (name, LogicalType) via child_list_t (pair fields 0/1)
MAP<K, V>MapMap + ExtraTypeInfo::List { child: STRUCT(key, value) }map<K, V>✅ DuckDB stores MAP as LIST<STRUCT<key,value>>; we reuse the LIST vector layout and stamp the parent type id as Map
ARRAY<T, N> (fixed)FixedSizeListArray + ExtraTypeInfo::Array { child, size }(none)✅ fields 103 (array_size) + 104 (child vector with size*count elements); size=0 honors WritePropertyWithDefault elision
UNIONUnion (dense or sparse)Union + ExtraTypeInfo::Struct { fields } (tag-prefixed)(none)⚠️ codec verified by unit test: DuckDB’s LogicalType::UNION factory builds a StructTypeInfo with a UTINYINT “tag” prepended to members, so we reuse the STRUCT wire layout and stamp the parent id as Union. DataFusion doesn’t emit UnionArray in practice; Arrow bridge mapping is deferred.
ENUMDictionary(Int8/16/32/64 or UInt8/16/32/64, Utf8/LargeUtf8)Enum + ExtraTypeInfo::Enum { values }(none, project as string)⚠️ wire codec verified by unit tests (hand-written EnumTypeInfo: fields 200=values_count u64, 201=string list; per-row indices narrow to u8/u16/u32 by dict-size tier). DataFusion’s SQL planner rejects the ENUM(...) type literal and doesn’t dictionary-encode repeated strings, so end-to-end SQL exercising it via DataFusion isn’t currently possible. The path is ready for non-DataFusion engines or future DataFusion support

Parameterised types

DuckDB’s LogicalType carries optional ExtraTypeInfo on the wire (field 101 of the LogicalType object). Wave 2a added the framework plus the DECIMAL variant; the remaining variants still surface as WireError::UnsupportedExtraTypeInfo:

  • DECIMAL(p, s): ✅ encoded via ExtraTypeInfo::Decimal { precision, scale }. Storage tier follows DuckDB: precision 1-4 -> i16, 5-9 -> i32, 10-18 -> i64, 19-38 -> i128.
  • LIST<T>: ✅ encoded via ExtraTypeInfo::List { child }. Recursive child type, child element vector under field 106.
  • STRUCT(...): ✅ encoded via ExtraTypeInfo::Struct { fields }. Pair entries with field 0 (name) + field 1 (LogicalType).
  • MAP<K, V>: ✅ reuses ExtraTypeInfo::List { child: STRUCT(key, value) } per DuckDB’s internal LogicalType::MAP factory; no separate MapTypeInfo.
  • ARRAY<T, N>: ✅ encoded via ExtraTypeInfo::Array { child, size }. Field 200 (child_type, WriteProperty) + field 201 (size, WritePropertyWithDefault default 0).
  • ENUM: ✅ encoded via ExtraTypeInfo::Enum { values }. Custom serializer: field 200 (values_count u64, WriteProperty) + field 201 (WriteList<string>). Per-row index width follows DuckDB’s EnumTypeInfo::DictType: <=256 entries -> u8, <=65536 -> u16, otherwise u32.
  • UNION: ✅ codec routes through STRUCT’s wire layout. DuckDB models UNION(members) as a StructTypeInfo with a UTINYINT tag prepended to the members, so no new ExtraTypeInfo variant is needed. Arrow bridge mapping is deferred because DataFusion never emits UnionArray.
  • User-defined types: not implemented.

The full parameterised-type family is wired in the codec, in both directions. Forward (Arrow -> DataChunk) handles every type DataFusion emits; reverse (DataChunk -> Arrow) handles every type a remote DuckDB returns through the quack_query() TVF: LIST, STRUCT, MAP, ARRAY, ENUM (as Dictionary(UIntX, Utf8)), and arbitrarily deep compositions like STRUCT(tags VARCHAR[], counts MAP(VARCHAR, INT)). The remaining gaps are upstream (DataFusion’s planner rejecting certain SQL syntax) or low-traffic enough to defer (Arrow bridge for UNION’s UnionArray, which DataFusion does not emit in practice).

ExtraTypeInfo wire layout (verified against DuckDB v1.5.3 generated serializer):

  • Base field 100 (u8): ExtraTypeInfoType discriminant, WriteProperty, always written.
  • Base field 101 (string): alias, WritePropertyWithDefault, omitted when “”.
  • Base field 102: deleted; readers tolerate but writers never emit.
  • Base field 103 (unique_ptr<ExtensionTypeInfo>): WritePropertyWithDefault, omitted when null. Unsupported in the codec.
  • Subclass fields per variant. For DECIMAL: field 200 (width, u8, WritePropertyWithDefault default 0) and field 201 (scale, u8, WritePropertyWithDefault default 0). Scale 0 is the common case and omits field 201 entirely.

How to reproduce the matrix

# 1. Start the test stack and bootstrap once.
docker compose -f docker-compose.test.yml up -d
./scripts/bootstrap-test.sh

# 2. Start sqe-server with a Quack listener + BearerPassthrough auth.
cargo build --release --bin sqe-server
target/release/sqe-server --config tests/sqe-quack-test.toml &

# 3. Grab a Polaris bearer.
TOKEN=$(curl -s -X POST http://localhost:18181/api/catalog/v1/oauth/tokens \
  -d "grant_type=client_credentials&client_id=root&client_secret=s3cr3t&scope=PRINCIPAL_ROLE:ALL" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

# 4. Query through real DuckDB CLI (1.5.2+):
duckdb -c "
  INSTALL quack FROM core_nightly; LOAD quack;
  CREATE SECRET (TYPE quack, TOKEN '${TOKEN}');
  SELECT * FROM quack_query('quack:localhost:9494',
                            'SELECT 42 AS id, ''alice'' AS name, DATE ''2026-05-25'' AS joined');
"

tests/sqe-quack-test.toml is a copy of tests/sqe-test.toml with coordinator.quack_port = 9494 and an [[auth.providers]] type = "bearer_passthrough" entry.

Status

Every row marked ✅ has been verified end-to-end with a real duckdb 1.5.3 CLI session. The verification command for each row is SELECT <literal>::<type> ... against quack_query, and the assertion is that DuckDB renders the value back without error.

Client mode (Option B)

In addition to serving Quack RPC, SQE can act as a Quack client and pull rows from a remote DuckDB or another sqe-server:

  • sqe-quack-client crate exposes a synchronous QuackClient for programmatic use.

  • QuackTableProvider adapts a Quack query result to a DataFusion TableProvider (eager fetch, in-memory).

  • quack_query(uri, [token,] sql) is registered as a TVF on every coordinator session, so any SQL client can pull remote tables inline:

    -- 2-arg form (no auth)
    SELECT * FROM quack_query('quack:remote-duckdb:9495', 'SELECT * FROM colors');
    
    -- 3-arg form (bearer / static token)
    SELECT * FROM quack_query('quack:remote-duckdb:9495', 'remote-secret', 'SELECT * FROM colors');
    

This is symmetric to DuckDB’s own quack_query built-in. Composing the two lets a single DuckDB CLI session route queries through sqe-server, which itself fetches from a remote DuckDB, useful for federated reads or for treating DuckDB as an execution backend for specific workloads.

Federation: Iceberg + Quack in one query

Because both quack_query() and Iceberg tables surface as DataFusion TableProviders on the same session, a single SQL statement can freely mix the two:

-- JOIN an Iceberg table with rows pulled from a remote DuckDB
SELECT p.id, p.name AS person, r.color
FROM "default".quack_demo p                  -- Iceberg / Polaris
JOIN quack_query(
       'quack:remote-duckdb:9495',
       'remote-secret',
       'SELECT id, name AS color FROM colors'
     ) r                                     -- remote DuckDB via Quack
  ON p.id = r.id;

Live-verified shapes:

  • INNER JOIN (Iceberg and Quack) on matching keys.
  • COUNT/SUM aggregation across the join.
  • UNION ALL of Iceberg rows with Quack rows (same projected schema).
  • CROSS JOIN with DECIMAL preserved end-to-end.
  • Filter/projection from either side.

DataFusion plans each query end-to-end; the Iceberg scan reads Parquet from object storage and the Quack TVF round-trips Arrow batches over HTTP/Quack. Both feed into the same execution plan.

Connect BI tools (Metabase, Superset, DBeaver)

SQE speaks the Trino wire protocol, so Trino-compatible BI tools connect with no SQE-specific driver. Metabase uses the Trino JDBC driver, Superset uses the Trino SQLAlchemy dialect, DBeaver has a built-in Trino connection, and the official Trino CLI works too. All four were verified live against a Polaris + Keycloak stack: schema sync, column browse, typed queries with date bucketing, and result pagination.

The one rule that trips everyone up: TLS

The Trino JDBC driver and the Trino CLI refuse to send a username and password over a plain HTTP connection. They fail with TLS/SSL is required for authentication with username and password. SQE’s Trino HTTP endpoint ([coordinator] trino_http_port, default 8080) serves plain HTTP, so you put a TLS-terminating reverse proxy in front of it and point the BI tool at the HTTPS URL.

The reference quickstart terminates TLS at nginx and proxies /v1/ to the SQE container, so the tools connect to https://<host>/v1/ rather than the raw :8080. Any ingress or load balancer that terminates TLS works the same way.

If you cannot terminate TLS, the alternative is bearer-token auth, which the Trino driver allows over plain HTTP: obtain an OIDC access token yourself and pass it as Authorization: Bearer <token>. Most BI tools drive username/password, so the TLS route is the practical one.

Basic auth carries the user; the password is the OIDC secret (for a local root client it may be empty). SQE exchanges the credentials for a token against the configured OIDC provider.

Metabase

Add a database of type Trino (or Starburst), then:

  • Host: your TLS host (for example sqe.example.com)
  • Port: 443
  • Catalog: your catalog (for example main_warehouse)
  • Username / Password: the OIDC user and secret
  • Enable SSL. For a self-signed cert in a demo, allow the untrusted certificate.

Metabase runs a metadata handshake on connect (prepare a statement, list catalogs and schemas, list tables, describe columns) before it syncs. SQE matches Trino’s response shape at each step, so the sync populates tables and columns and date-bucketed questions (month, quarter, year) work.

Superset

Add a database with a SQLAlchemy URI using the trino dialect over HTTPS:

trino://<user>:<password>@<host>:443/<catalog>

Set the connection to use HTTPS (the Superset Trino dialect defaults to the protocol in the URI host settings). Superset reflects tables through SHOW COLUMNS and information_schema, both of which resolve against the session catalog.

DBeaver

Create a new Trino connection:

  • Host and Port: your TLS host and 443
  • Enable SSL in the connection’s SSL tab
  • Authentication: username and password (the OIDC user and secret)

The schema browser walks catalogs, then schemas, then tables, and column metadata renders from DESCRIBE.

Trino CLI

export TRINO_PASSWORD='your-secret'
trino --server https://<host> --user <user> --password \
      --catalog main_warehouse --schema <schema> \
      --execute "SHOW TABLES"

Add --insecure for a self-signed certificate. Pointing at plain http://<host>:8080 with --password fails the TLS check described above.

What works

Verified over the wire protocol these tools share:

  • SHOW CATALOGS, SHOW SCHEMAS, SHOW TABLES return Trino’s exact single-column shapes.
  • DESCRIBE and SHOW COLUMNS resolve double-quoted identifiers ("catalog"."schema"."table").
  • Types map to their Trino equivalents: timestamp(6) carries its precision, computed aggregates like count(*) are bigint.
  • Large results paginate: the client follows nextUri through 1000-row pages until the query reaches FINISHED.

Catalog context

Set the catalog in the connection (or send the X-Trino-Catalog header). SQE resolves unqualified names against the session catalog on both the SELECT and SHOW paths, so a tool that syncs against one catalog sees the same tables its query editor does. See Trino Compatibility for the endpoint reference and Connecting clients for the protocol choice.

Observability: metrics + Grafana

Goal

SQE exposes Prometheus metrics on its metrics.prometheus_port (/metrics, port 9090 in this quickstart). This stack scrapes them with VictoriaMetrics and renders them in Grafana, giving you a live view of query rate, cache hit/miss, active sessions, scan pruning, and coordinator memory while queries run.

A minimal queryable SQE (Nessie catalog + RustFS, anonymous auth) sits underneath just to generate real metrics — the focus here is the monitoring pipeline, not the data layer.

Components

ServiceRole
sqeCoordinator: Flight SQL on 50051, Prometheus /metrics on 9090.
nessieIceberg REST catalog (auth-less).
rustfs + bucket-initS3-compatible warehouse storage.
victoriametricsScrapes sqe:9090/metrics every 5 s.
grafanaProvisioned VictoriaMetrics datasource + “SQE Overview” dashboard.

Configuration

Backend (sqe.toml)

[coordinator]
flight_sql_port = 50051
trino_http_port = 8080
mode = "hybrid"

[worker]
memory_limit = "4GB"

[[auth.providers]]
type = "anonymous"
user = "anonymous"
roles = ["admin"]

[catalogs.nessie]
polaris_url = "http://nessie:19120/iceberg"
warehouse = "warehouse"

[storage]
s3_endpoint = "http://rustfs:9000"
s3_region = "eu-example-1"
s3_access_key = "s3admin"
s3_secret_key = "s3adminpw"
s3_path_style = true
s3_allow_http = true

[metrics]
prometheus_port = 9090

SQL (queries.sql)

CREATE SCHEMA IF NOT EXISTS nessie.demo;

DROP TABLE IF EXISTS nessie.demo.events;
CREATE TABLE nessie.demo.events (
    id     BIGINT,
    kind   VARCHAR,
    amount DOUBLE
);

INSERT INTO nessie.demo.events VALUES
    (1, 'click',    1.50),
    (2, 'purchase', 42.00),
    (3, 'click',    0.75),
    (4, 'purchase', 13.25);

SELECT kind, COUNT(*) AS n, ROUND(SUM(amount), 2) AS total
FROM nessie.demo.events
GROUP BY kind
ORDER BY total DESC;

The test

run.sh brings the full stack up (docker compose up -d --wait), then runs queries.sql three times via sqe-cli to populate real metric counters. It polls the VictoriaMetrics API until sqe_rows_returned_total appears (up to ~45 s, 5 s scrape interval), then queries each key metric via the /api/v1/query endpoint and asserts the scrape target is up{job="sqe-coordinator"} = 1. Finally it curls sqe:9090/metrics directly and captures a sample of raw sqe_* counters to OUTPUT.md. Open Grafana at http://localhost:13000 (admin / admin) for the provisioned SQE Overview dashboard. Tear down with ./run.sh --down.

Output

# Captured output

_Generated by `./run.sh` on 2026-06-07T09:12:02Z._

## VictoriaMetrics is scraping the SQE coordinator
scrape target up{job="sqe-coordinator"} = 1
sqe_rows_returned_total                    = 9
sqe_active_sessions                        = 0
sqe_cache_misses_total                     = 0
sqe_coordinator_memory_used_bytes          = 0

## A sample of SQE's raw /metrics (sqe_*)
sqe_active_sessions 0
sqe_cache_hits_total 0
sqe_cache_misses_total 0
sqe_coordinator_memory_used_bytes 0
sqe_files_pruned_minmax_total 0
sqe_rows_returned_total 27
sqe_s3_bytes_read_total 0

Benchmarks: TPC-H / TPC-DS / SSB

Goal

Generate a benchmark dataset, load it into SQE as Iceberg tables, and run the suite’s queries with per-query timings. Everything runs in Docker: a Nessie catalog over RustFS holds the tables, and sqe-bench drives the three phases — generate, load, and test. The default is TPC-H at scale factor 0.01, which finishes in seconds.

This is a local smoke-check and timing harness, not a correctness gate against committed baselines. Use it to confirm SQE runs all queries cleanly and to get a rough timing profile on your machine.

Components

ServiceRole
rustfs + bucket-initS3-compatible warehouse storage.
nessieIceberg REST catalog (auth-less).
sqeCoordinator: Flight SQL on 50051. Reads generated Parquet via read_parquet.
sqe-benchOne-shot tool: generateloadtest. Built from the shared Dockerfile’s bench-runtime target.

Configuration

Backend (sqe.toml)

[coordinator]
flight_sql_port = 50051
trino_http_port = 8080
mode = "hybrid"

[worker]
memory_limit = "4GB"

[[auth.providers]]
type = "anonymous"
user = "anonymous"
roles = ["admin"]

[catalogs.nessie]
polaris_url = "http://nessie:19120/iceberg"
warehouse = "warehouse"

[storage]
s3_endpoint = "http://rustfs:9000"
s3_region = "eu-example-1"
s3_access_key = "s3admin"
s3_secret_key = "s3adminpw"
s3_path_style = true
s3_allow_http = true

# Allows the coordinator to read Parquet from the shared bench-data volume.
# Leave this off in production; stage data in object storage instead.
[storage.tvf]
allow_local_paths = true

[metrics]
prometheus_port = 9090

Queries come from the TPC suite generators: sqe-bench generate writes benchmarks/queries/<suite>/*.sql (one file per query) to the shared bench-data volume, which sqe-bench test then executes over Flight SQL.

The test

run.sh brings the infrastructure stack up (rustfs, nessie, sqe), then runs the three sqe-bench phases via docker compose run --rm:

  1. generate — writes Parquet tables to the bench-data Docker volume shared with the coordinator.
  2. load — issues one CTAS per table (CREATE TABLE … AS SELECT * FROM read_parquet(…)); the coordinator reads the volume directly (allow_local_paths = true). Tables land in nessie.<suite>_sf<scale>.
  3. test — runs every query in the suite over Flight SQL, reports pass / fail / diff / skip / error per query plus per-query timings, and emits a machine-parseable BENCH_SUMMARY: line. Output is captured to OUTPUT.md.

Suite and scale factor are configurable: BENCH=ssb SCALE=0.1 ./run.sh or BENCH=tpcds SCALE=1 ./run.sh. Tear down with ./run.sh --down.

Output

TPCH SF0.01 — flight protocol
────────────────────────────────────────────────────────────
v q01          0.04s          6 rows
v q02          0.05s          4 rows
v q03          0.03s         10 rows
...
v q22          0.01s          0 rows

Results: 22 pass, 0 fail, 0 diff, 0 skip, 0 error  (total 0.4s)
BENCH_SUMMARY:tpch:22:0:0:0:0:22:378

Distributed SQE: coordinator + workers

Goal

Run a real distributed SQE cluster: one coordinator and two stateless DataFusion workers over Arrow Flight, querying Iceberg tables in the shared Polaris + RustFS stack. The coordinator plans a query, splits it into fragments, and dispatches them to the workers; the workers execute their fragments against the Iceberg data and stream results back over Flight; the coordinator assembles the final result.

This is the scenario for seeing distribution actually happen — query history, worker dispatch, the system tables, and the Trino HTTP endpoint, all against a four-container cluster rather than the single-process coordinator the other quickstarts run.

Components

ServiceHost portRole
coordinator60051 Flight SQL, 28080 Trino HTTP, 29090 metricsPlans, schedules, holds query history + result cache.
worker-160061Stateless DataFusion executor.
worker-260062Stateless DataFusion executor.
Polaris18181Iceberg REST catalog (test_warehouse).
RustFS19000S3-compatible storage for the Iceberg data.
Postgres(internal)Polaris’s metastore.

The compose setup is a two-file overlay: docker-compose.distributed.yml adds the coordinator and the two workers, and inherits Polaris, RustFS, and Postgres from docker-compose.test.yml. Both files are required, which is why run.sh always passes both -f flags.

Configuration

coordinator.toml

[coordinator]
worker_urls = ["http://worker-1:50052", "http://worker-2:50052"]
allow_unauthenticated_workers = true

worker_urls is the static list of workers the coordinator dispatches fragments to. allow_unauthenticated_workers = true opts out of the worker_secret requirement that production must satisfy — safe here and only here, because the stack runs on a private Docker network. The coordinator also enables the result cache ([query_cache], 128 MB, 5-minute TTL) and query history ([query_history], up to 10000 finished queries for 30 minutes), which is what system.runtime.queries reads from.

worker.toml

[worker]
flight_port = 50052
coordinator_url = "http://coordinator:50051"
heartbeat_interval_secs = 5
memory_limit = "512MB"

Workers are stateless: they hold no catalog or session state, only the plan fragment and the data they fetch. Each worker registers with the coordinator over its coordinator_url and sends heartbeats; its Trino endpoint is disabled (trino_http_port = 0) — only the coordinator serves clients. Each worker’s [catalog] and [storage] match the coordinator’s, because workers read Iceberg data directly from RustFS.

The test

run.sh brings up both compose files, bootstraps Polaris (warehouse, namespace, grants), then runs the demo queries over Flight SQL and Trino HTTP and captures them to OUTPUT.md. A correct run shows the cluster topology from system.runtime.nodes, a CTAS round-trip read back in order, and the finished queries in query history.

./run.sh --check asserts the distributed invariants end to end: connectivity over Flight SQL, system.runtime.nodes lists the coordinator, system.runtime.queries records FINISHED history, a CTAS round-trip and read-back, cache invalidation on write, the Trino HTTP endpoint answers, and — the distributed-execution invariant — system.runtime.tasks shows fragments dispatched to worker-1 / worker-2 after a query that forces a split, not just the coordinator.

Tear down with ./run.sh --down.

Gotchas

  • Both compose files are required. docker-compose.distributed.yml is an overlay; without docker-compose.test.yml the coordinator comes up with no catalog behind it.
  • The stack is heavy. It builds the SQE image and runs four containers plus Polaris and RustFS, so it is excluded from the self-contained scenario suite and run on demand.
  • allow_unauthenticated_workers is test-only. Production deployments must set a worker_secret.
  • The host needs a Rust toolchain. run.sh builds sqe-cli on the host to drive the coordinator; the cluster itself runs in Docker.

Configuration

SQE is configured via a TOML file with environment variable overrides. Environment variables take precedence over the config file.

Config File

Default path: sqe.toml in the current directory. Override with:

sqe-server --config /etc/sqe/sqe.toml
# or
SQE_CONFIG=/etc/sqe/sqe.toml sqe-server

Full Reference

[coordinator]
flight_sql_port = 50051         # Flight SQL gRPC port
trino_http_port = 8080          # Trino-compat HTTP port (0 to disable)
mode = "hybrid"                 # "coordinator", "worker", "hybrid", "local", "distributed"
worker_urls = []                # Worker Flight URLs for distributed mode
worker_secret = ""              # Shared secret for worker heartbeat auth (empty disables the check)
debug = false                   # When true, error messages include internal details (dev only)
flight_compression = "lz4"      # IPC compression for client DoGet responses
shuffle_compression = "zstd"    # IPC compression for internal DoExchange shuffle
session_context_cache_ttl_secs = 60  # Per-user SessionContext cache TTL. Also the
                                # passive backstop for catalog-set discovery: a
                                # catalog created/rebound out-of-band appears within
                                # this window. POST /api/v1/catalogs/refresh (health
                                # port, admin) is the instant path. Lower = fresher
                                # catalogs, more session rebuilds under concurrency.

[coordinator.tls]
cert_file = ""                  # PEM certificate (TLS enabled when both cert + key are set)
key_file = ""                   # PEM private key
ca_file = ""                    # Optional PEM CA for mTLS client certificate verification

[worker]
coordinator_url = "http://coordinator:50051"
flight_port = 50052             # Worker Flight port
advertise_url = ""              # URL the coordinator uses to reach this worker.
                                # Empty -> auto-derived (POD_IP, else HOSTNAME if
                                # an IP, else first non-loopback interface). Never
                                # advertise 0.0.0.0; the coordinator rejects it.
heartbeat_interval_secs = 5     # Health check interval
memory_limit = "8GB"            # Worker memory limit (supports B/KB/MB/GB/TB)
spill_to_disk = true            # Allow spilling large sorts/joins to disk
spill_dir = "/tmp/sqe-spill"    # Temp directory for spilling

[auth]
keycloak_url = ""               # Keycloak base URL (OIDC password grant mode)
realm = ""                      # Keycloak realm name
token_endpoint = ""             # Generic OAuth2 token endpoint (client_credentials mode)
client_id = "sqe-client"        # OIDC client ID (required)
client_secret = ""              # Set via SQE_AUTH__CLIENT_SECRET env var
token_refresh_buffer_secs = 60  # Refresh tokens this many seconds before expiry
ssl_verification = true         # Set false for dev (self-signed certs)

[catalog]
catalog_url = "http://polaris:8181/api/catalog"   # REST catalog endpoint
warehouse = "iceberg"           # warehouse identifier the catalog expects
metadata_cache_ttl_secs = 30    # Table metadata cache TTL
default_table_format_version = 2 # Iceberg table format version (2 or 3)
trust_sort_order = false        # Trust Iceberg sort order for all columns, not just partition keys
small_file_threshold_mb = 3     # Max file size for the direct-read fast path (0 to disable)
parquet_compression = "zstd"    # Write-path Parquet codec: zstd, lz4, snappy, none

# `catalog_url` accepts any Iceberg REST endpoint. SQE has been
# verified live against Apache Polaris, Project Nessie 0.107+,
# Unity Catalog OSS, AWS Glue Iceberg REST, and AWS S3 Tables REST.
# For AWS REST endpoints the vendored REST client signs requests
# with SigV4 when the server advertises `rest.sigv4-enabled=true`
# in its /v1/config defaults.

# When `[catalog.backend]` is omitted, SQE defaults to `type = "rest"`
# and uses `catalog_url` + `warehouse` above. To target a non-REST
# catalog (HMS, AWS Glue native, AWS S3 Tables native, JDBC, Hadoop),
# set the backend block explicitly. See `docs/book/src/getting-started/
# catalogs.md` for the full per-backend reference.

# [catalog.backend]
# type = "hms"
# uri  = "metastore.example.com:9083"
# warehouse = "s3a://my-bucket/warehouse"

# [catalog.backend]
# type   = "glue"
# region = "eu-example-1"
# warehouse = "s3://my-bucket/warehouse"
# # endpoint = "http://localhost:4566"   # optional, e.g. LocalStack

# [catalog.backend]
# type             = "s3tables"
# table_bucket_arn = "arn:aws:s3tables:eu-example-2:123456789012:bucket/my-bucket"
# # endpoint_url   = "http://localhost:4566"

# [catalog.backend]
# type      = "jdbc"
# url       = "postgresql://user:pass@host:5432/iceberg"
# warehouse = "s3://my-bucket/warehouse"

# [catalog.backend]
# type      = "hadoop"
# warehouse = "s3://my-bucket/warehouse"

# Non-REST backends dispatch through the upstream
# `iceberg-catalog-loader` crate. End-to-end SQL through HMS, Glue,
# S3 Tables, and JDBC works on main today. Hadoop has its own
# dispatch in `sqe-catalog/src/backends/hadoop.rs`.

[storage]
s3_endpoint = "http://s3:9000"
s3_region = "us-east-1"
s3_access_key = ""              # Set via SQE_STORAGE__S3_ACCESS_KEY
s3_secret_key = ""              # Set via SQE_STORAGE__S3_SECRET_KEY
s3_path_style = true            # true for MinIO/Ceph, false for AWS S3
s3_allow_http = false           # Allow plaintext HTTP for S3 (dev/test only)
concurrent_requests_per_file = 4 # Max concurrent byte-range requests per file
max_concurrent_files = 8        # Max files fetched concurrently
prefetch_buffer = "32MB"        # Prefetch buffer for overlapping footer reads
# coalesce_threshold and footer_cache_size are documented in
# architecture/streaming-execution.md alongside the S3 I/O pipeline.

# Access control and policy are two independent axes. See
# [GRANT and REVOKE](../sql-reference/grant-revoke.md) for the full model.

[access_control]
# Where GRANT/REVOKE are stored and resolved.
backend = "none"                # none (default) | polaris | ranger | chameleon

[policy]
# Fine-grained enforcement engine (row filters + column masks).
# Wired: passthrough (default), in-memory, ranger.
# opa and cedar are defined but not yet wired; selecting them errors at startup.
engine = "passthrough"

[session]
idle_timeout_secs = 900         # 15 min, sessions idle longer are expired
absolute_timeout_secs = 28800   # 8 hours, hard session lifetime cap
persistence = "memory"          # "memory" (default) or "file"
persistence_path = "/tmp/sqe-sessions.json"  # Path for file-based persistence
snapshot_interval_secs = 60     # How often file persistence snapshots sessions to disk
# Optional CREATE SECRET snapshot (plaintext JSON, mode 0600). Empty = memory
# only. ATTACH mounts stay process-local even when this is set.
# secrets_path = "/var/lib/sqe/secrets.json"

[query]
timeout_secs = 300              # 5 min, max execution time per query
max_result_rows = 1000000       # Max rows per query (0 = unlimited)
max_concurrent_queries = 100    # Concurrency limit (0 = unlimited)
max_query_memory = "256MB"      # Per-query memory limit
slow_query_threshold_secs = 30  # WARN-log threshold for slow queries
distribution_threshold = "128MB" # Min scan size to distribute to workers
distribution_file_threshold = 4 # Min file count to distribute
target_task_size = "256MB"      # Target scan task size for bin-packing
sort_mode = "adaptive"          # "adaptive", "partition_only", or "strict"

# Write-path memory safety (see Features -> Write Path)
write_buffer_tracking = true    # Pool-track write buffers; big writes fail with ResourceExhausted, not OOM
fanout_max_open_writers = 0     # Cap on open per-partition writers (0 = auto: pool-derived, 8..64); opt-in bounded fanout
fanout_buffer_budget = "0"      # Byte budget for buffered fanout memory, "512MB" style (0 = auto); opt-in bounded fanout
merge_target_streaming = false  # Stream CoW MERGE target from files instead of buffering (opt-in, needs write_buffer_tracking)

[query.role_overrides]          # Per-role timeout overrides (seconds)
# admin = 3600                  # Admins get 1 hour
# analyst = 600                 # Analysts get 10 minutes

[query_cache]
enabled = false                 # Enable query result caching
max_memory_mb = 128             # Total cache memory budget
max_entry_mb = 5                # Max size per cached result
ttl_secs = 300                  # Cache entry TTL

[query_history]
max_entries = 10000             # Max queries retained in history
ttl_secs = 1800                 # History entry TTL (30 min)

[rate_limit]
enabled = false                 # Enable per-user and global rate limiting
per_user_queries_per_minute = 60
global_queries_per_minute = 1000

[metrics]
prometheus_port = 9090          # Prometheus /metrics endpoint
otlp_endpoint = ""              # OTLP gRPC endpoint (empty = disabled)
traces_otlp_endpoint = ""       # trace-only OTLP gRPC endpoint
trace_sample_rate = 0.01         # 0.0 to 1.0
audit_log_path = ""             # Audit JSONL file (empty = disabled)

# Advisory / active auto-compaction (Phase 4a+). Off by default: no
# maintenance principal is constructed and no scheduler task runs
# unless mode is set. See "Maintenance (auto-compaction)" below.
[maintenance]
mode = "off"                    # "off" (default) | "advisory" | "active"

# Required only when mode != "off" (validation rejects mode without this block).
# [maintenance.principal]
# token_endpoint = "https://idp.example.com/realms/sqe/protocol/openid-connect/token"
# client_id = "sqe-maintenance"
# client_secret = ""            # TOML-only in Phase 4a; no env var override yet
# scope = "PRINCIPAL_ROLE:sqe_maintenance"
# user_id = "svc-sqe-maintenance"   # audit display identity
# roles = ["maintenance"]
# refresh_skew_secs = 60

[maintenance.scheduler]
enabled = false                 # in-process tick loop off by default; drive via
                                 # an external Kubernetes CronJob instead, or flip
                                 # this on for an in-coordinator loop
tick_secs = 60                  # how often the loop wakes up when enabled
schedule = "0 2 * * *"          # global default cron; per-table property overrides
jitter_secs = 900               # per-table jitter so a fleet doesn't all fire at once
max_concurrent_jobs = 1
lease = "catalog"               # "none" | "catalog" | "kubernetes"
lease_ttl_secs = 300
state_table = "sqe_system.maintenance_log"  # operator-created; see note below
single_scheduler_acknowledged = false       # required true for enabled=true + lease="none"

[maintenance.compaction]
target_file_size_bytes = 536870912   # 512 MiB
min_input_files = 5
delete_file_threshold = 2
strategy = "binpack"             # "binpack" | "sort" | "zorder"

[maintenance.distribution]
mode = "auto"                    # "auto" (default) | "local" | "require"
min_workers = 2
max_inflight_groups_per_worker = 1
group_attempts = 2
group_timeout_secs = 3600
group_heartbeat_timeout_secs = 120
partial_progress = false
partial_progress_batch = 10

Environment Variable Overrides

Every config field can be overridden via environment variable. Convention: SQE_<SECTION>__<FIELD> (double underscore separating section from field).

Env VarConfig FieldType
Coordinator
SQE_COORDINATOR__FLIGHT_SQL_PORTcoordinator.flight_sql_portu16
SQE_COORDINATOR__TRINO_HTTP_PORTcoordinator.trino_http_portu16
SQE_COORDINATOR__MODEcoordinator.modestring
SQE_COORDINATOR__DEBUGcoordinator.debugbool
TLS
SQE_TLS__CERT_FILEcoordinator.tls.cert_filestring
SQE_TLS__KEY_FILEcoordinator.tls.key_filestring
SQE_TLS__CA_FILEcoordinator.tls.ca_filestring
Worker
SQE_WORKER__COORDINATOR_URLworker.coordinator_urlstring
SQE_WORKER__FLIGHT_PORTworker.flight_portu16
SQE_WORKER__ADVERTISE_URLworker.advertise_urlstring
SQE_WORKER__HEARTBEAT_INTERVAL_SECSworker.heartbeat_interval_secsu64
SQE_WORKER__MEMORY_LIMITworker.memory_limitstring
SQE_WORKER__SPILL_TO_DISKworker.spill_to_diskbool
SQE_WORKER__SPILL_DIRworker.spill_dirstring
Auth
SQE_AUTH__KEYCLOAK_URLauth.keycloak_urlstring
SQE_AUTH__REALMauth.realmstring
SQE_AUTH__TOKEN_ENDPOINTauth.token_endpointstring
SQE_AUTH__CLIENT_IDauth.client_idstring
SQE_AUTH__CLIENT_SECRETauth.client_secretstring
SQE_AUTH__TOKEN_REFRESH_BUFFER_SECSauth.token_refresh_buffer_secsu64
SQE_AUTH__SSL_VERIFICATIONauth.ssl_verificationbool
Catalog
SQE_CATALOG__CATALOG_URLcatalog.catalog_urlstring
SQE_CATALOG__POLARIS_URLcatalog.catalog_url (legacy alias)string
SQE_CATALOG__WAREHOUSEcatalog.warehousestring
SQE_CATALOG__METADATA_CACHE_TTL_SECScatalog.metadata_cache_ttl_secsu64
SQE_CATALOG__DEFAULT_TABLE_FORMAT_VERSIONcatalog.default_table_format_versionu8
Storage
SQE_STORAGE__S3_ENDPOINTstorage.s3_endpointstring
SQE_STORAGE__S3_REGIONstorage.s3_regionstring
SQE_STORAGE__S3_ACCESS_KEYstorage.s3_access_keystring
SQE_STORAGE__S3_SECRET_KEYstorage.s3_secret_keystring
SQE_STORAGE__S3_PATH_STYLEstorage.s3_path_stylebool
Policy
SQE_POLICY__ENGINEpolicy.enginestring
Session
SQE_SESSION__IDLE_TIMEOUT_SECSsession.idle_timeout_secsu64
SQE_SESSION__ABSOLUTE_TIMEOUT_SECSsession.absolute_timeout_secsu64
Query
SQE_QUERY__TIMEOUT_SECSquery.timeout_secsu64
Rate Limit
SQE_RATE_LIMIT__ENABLEDrate_limit.enabledbool
SQE_RATE_LIMIT__PER_USER_QUERIES_PER_MINUTErate_limit.per_user_queries_per_minuteu32
SQE_RATE_LIMIT__GLOBAL_QUERIES_PER_MINUTErate_limit.global_queries_per_minuteu32
Metrics
SQE_METRICS__PROMETHEUS_PORTmetrics.prometheus_portu16
SQE_METRICS__OTLP_ENDPOINTmetrics.otlp_endpointstring
SQE_METRICS__TRACES_OTLP_ENDPOINTmetrics.traces_otlp_endpointstring
SQE_METRICS__TRACE_SAMPLE_RATEmetrics.trace_sample_ratef64
SQE_METRICS__AUDIT_LOG_PATHmetrics.audit_log_pathstring

Boolean values accept: true/false, 1/0, yes/no.

TLS

SQE supports optional TLS encryption for the Flight SQL gRPC listener.

Server-side TLS: Set cert_file and key_file to enable. When both are set, the server listens on TLS; when omitted, plaintext.

mTLS (mutual TLS): Set ca_file to a PEM CA bundle. Clients must present a certificate signed by this CA.

[coordinator.tls]
cert_file = "/etc/sqe/server.crt"
key_file  = "/etc/sqe/server.key"
ca_file   = "/etc/sqe/ca.crt"    # Optional: enables mTLS

Validation rules:

  • If either cert_file or key_file is set, both must be set
  • All referenced files must exist when TLS is enabled
  • ca_file is optional – when set, it must also exist

Authentication Modes

SQE supports two OAuth2 flows, selected by which config fields are populated:

OIDC Password Grant (Keycloak)

For environments with Keycloak (or any OIDC provider supporting ROPC). The coordinator exchanges the user’s username/password for tokens:

[auth]
keycloak_url = "https://keycloak.example.com"
realm = "iceberg"
client_id = "sqe-client"

OAuth2 Client Credentials

For service-to-service auth or providers without ROPC support. The coordinator obtains tokens using a client ID and secret. Set token_endpoint directly:

[auth]
token_endpoint = "http://polaris:8181/api/catalog/v1/oauth/tokens"
client_id = "root"
client_secret = "s3cr3t"

At least one of keycloak_url or token_endpoint must be configured. If both are set, keycloak_url takes priority (OIDC mode).

Provider chain

The two flows above are the single-provider shorthand. For anything beyond one OIDC provider, configure a chain of [[auth.providers]] entries. SQE tries each in order and the first that authenticates a request wins. The chain takes precedence over the legacy [auth] fields when it is non-empty, and the legacy fields stay backward-compatible for existing single-provider configs.

Each entry requires a type:

TypeRequired fieldsDescription
oidc_passwordtoken_url, client_idOIDC Resource Owner Password Credentials
client_credentialstoken_endpoint, client_id, client_secretOAuth2 client credentials
oidc_m2mtoken_endpoint, client_id, client_secretOIDC machine-to-machine client-credentials (Unity Catalog and generic IdPs)
bearer_tokenjwks_urlPre-obtained JWT validated via JWKS
token_exchangetoken_url, client_idRFC 8693 token exchange
aws_iamnoneAWS IAM via STS GetCallerIdentity
api_keykeys_fileAPI key from a TOML keys file
mtlsnoneClient certificate authentication
anonymousnoneFixed identity for dev / test

A common production chain accepts both interactive logins (password grant) and pre-minted JWTs from programmatic clients:

[[auth.providers]]
type = "oidc_password"
token_url = "https://keycloak.example.com/realms/iceberg/protocol/openid-connect/token"
client_id = "sqe-client"
client_secret = "your-client-secret"   # via SQE_AUTH__CLIENT_SECRET
roles_claim = "realm_access.roles"

[[auth.providers]]
type = "bearer_token"
jwks_url = "https://keycloak.example.com/realms/iceberg/protocol/openid-connect/certs"
issuer = "https://keycloak.example.com/realms/iceberg"

Auth0 and Okta use the same two-provider shape, differing only in token_url, jwks_url, issuer, and the roles_claim path (Auth0 uses a namespaced claim, Okta uses groups).

AWS IAM maps caller ARNs to SQE roles:

[[auth.providers]]
type = "aws_iam"
region = "eu-example-2"
validate_with_sts = true

[auth.role_mappings]
"arn:aws:iam::123456789012:role/DataAnalyst" = ["analyst", "reader"]
"arn:aws:iam::123456789012:role/DataEngineer" = ["admin"]

API keys read from a separate TOML file, each key carrying a user and roles:

[[auth.providers]]
type = "api_key"
keys_file = "/etc/sqe/api-keys.toml"
key_prefix = "sqe_"
# api-keys.toml
[[keys]]
key = "sqe_abc123def456"
user = "service-account-etl"
roles = ["writer"]

Unity Catalog REST accepts OAuth2 client-credentials (machine-to-machine) in addition to personal access tokens. The oidc_m2m provider caches the access token and refreshes it shortly before expiry, so catalog requests never see a stale token:

[catalog]
catalog_url = "https://<workspace>.cloud.databricks.com/api/2.1/unity-catalog"
warehouse = "main"

[[auth.providers]]
type = "oidc_m2m"
token_endpoint = "https://<workspace>.cloud.databricks.com/oidc/v1/token"
client_id = "<service-principal-application-id>"
client_secret = "<service-principal-secret>"
scope = "all-apis"

The anonymous provider pins a fixed identity for dev and test. SQE logs a startup warning whenever it is configured.

[[auth.providers]]
type = "anonymous"
user = "dev-user"
roles = ["admin"]

For CLI logins without a username and password, configure the interactive device-code flow under [auth.external]:

[auth.external]
issuer = "https://keycloak.example.com/realms/iceberg"
client_id = "sqe-cli"
scopes = ["openid", "profile"]

[auth.external.device]
client_id = "sqe-cli-device"
scopes = ["openid", "profile"]

Maintenance (auto-compaction)

[maintenance] configures SQE’s background compaction subsystem: a non-human service principal, an in-coordinator scheduler loop, and the sizing knobs CALL system.rewrite_data_files and CALL system.table_health both use. Phase 4a shipped the advisory arm: it reports compaction debt but never mutates a table. Phase 4b adds the active arm: with mode = "active", the scheduler commits real rewrite_data_files rewrites against opted-in, due tables on a cron schedule, through the same code path CALL system.rewrite_data_files uses interactively.

Advisory is the recommended first step for any new deployment. Run it long enough to see real compaction debt and validate the schedule and per-table knobs before opting a table into active mode. Active mode mutates data files and commits new snapshots; treat the switch from advisory to active per table as a deliberate, reviewed change, not a default.

The mode ladder

maintenance.mode gates the whole subsystem and only moves up a ladder an operator chooses explicitly:

  • "off" (default). No maintenance principal is constructed, no scheduler task is spawned, [maintenance.principal] is not required.
  • "advisory". The scheduler loop (if scheduler.enabled = true) discovers opted-in tables and publishes health/metrics per table, the same report CALL system.table_health returns. Nothing is rewritten.
  • "active". The scheduler commits real rewrite_data_files rewrites, on the configured cron schedule, against tables that are both due and opted in via the per-table sqe.maintenance.enabled property. A due, opted-in table with no eligible compaction debt is skipped (a skipped maintenance_log row, not a rewrite); see “The three gates” and “sqe_system.maintenance_log” below.

Any mode beyond "off" requires a [maintenance.principal] block. Validation rejects the config otherwise, so a typo’d mode value cannot silently run with no credentials.

mode = "off" is total absence, not a runtime no-op: coordinator bootstrap constructs neither the maintenance principal nor the scheduler task when mode is "off", so there is nothing in the process that could reach a table, not merely a loop that declines to run. CALL system.table_health is unaffected by mode: it is a plain read-only procedure available to any session with SELECT on the table, regardless of the maintenance subsystem’s state.

The maintenance principal

[maintenance.principal] is a dedicated OAuth2 client-credentials (M2M) identity used solely by the maintenance scheduler. It is never added to the interactive auth chain ([[auth.providers]]), so the query path cannot authenticate as this principal even by accident. Fields: token_endpoint, client_id, client_secret, scope, user_id (the audit display identity for events this principal emits), roles, and refresh_skew_secs (pre-emptive token refresh before expiry, default 60).

client_secret is a SecretString: it never round-trips through a config-dump path and zeroizes on drop, same treatment as auth.client_secret. Unlike auth.client_secret, there is no SQE_MAINTENANCE__... environment variable override wired up in Phase 4a, so keep it out of a checked-in TOML file and mount it in via a file-based secret instead.

Startup validation warns (not an error) if maintenance.principal.client_id matches a configured auth-provider client_id: sharing an identity between the interactive and maintenance paths makes audit trails ambiguous about which one acted.

The scheduler loop

[maintenance.scheduler].enabled defaults to false. A false value suits external-trigger deployments: leave the in-process loop off and drive timing from a Kubernetes CronJob (concurrencyPolicy: Forbid) that issues the maintenance CALL on its own schedule. Set enabled = true to run the tick loop inside the coordinator instead; it wakes every tick_secs seconds and evaluates schedule for every opted-in table.

schedule is a standard 5-field cron expression (minute hour day-of-month month day-of-week), e.g. the default "0 2 * * *" for daily at 02:00. SQE evaluates it in UTC, never the host’s local timezone. A per-table sqe.maintenance.compaction.schedule property overrides the global schedule for that table; see “Per-table overrides” below.

jitter_secs adds a deterministic, per-table delay on top of each cron fire time, so a fleet of tables sharing one schedule does not all fire in the same tick and thunder against Polaris/S3 simultaneously. The delay is a hash of the table’s identifier modulo jitter_secs, so it is stable across ticks and restarts for a given table. jitter_secs = 0 removes that stagger delay only: the effective fire instant then equals the raw cron fire time exactly, but the cron schedule is still parsed and enforced as normal. A table is never treated as always-due just because its jitter is zero.

The lease ladder

lease is the double-fire guard for multi-coordinator deployments: which backend (if any) the scheduler uses to keep two coordinators from both compacting the same table in the same window. Three settings:

  • "none". No lease. Fine for a single-coordinator deployment, and only for one: validation rejects scheduler.enabled = true with lease = "none" unless single_scheduler_acknowledged = true is also set, an explicit operator opt-in rather than a silent default. Running the in-process scheduler unleased against more than one coordinator can make them both dispatch a rewrite for the same table at once; see “Multi-coordinator HA” below for why that wastes work rather than corrupting data.
  • "catalog" (default). Before dispatching the one expensive step of a tick, the rewrite itself, the scheduler claims a lease row in state_table (sqe_system.maintenance_log). A coordinator that finds the lease already held by another holder skips its tick for that table. lease_ttl_secs (default 300) bounds how long a crashed holder’s claim stays valid: past the TTL with no renewal, the next coordinator to check steals the expired lease instead of waiting forever. Claiming and releasing the lease each commit a row to state_table, so catalog mode costs a couple of extra state-table commits per due table per tick beyond lease = "none". A single-coordinator deployment that does not need the guard can set lease = "none" (with single_scheduler_acknowledged = true) to skip that overhead.
  • "kubernetes". Not implemented in this release. Validation rejects it outright when scheduler.enabled = true, naming "catalog" (works today for multi-coordinator HA) or "none" (single-coordinator only) as the settings that actually start. Reserved for a future Kubernetes Lease-object backend.

tick_secs and lease_ttl_secs must both be greater than zero; either at zero fails validation.

Multi-coordinator HA

Two supported ways to run the maintenance subsystem safely across more than one coordinator:

  • Set scheduler.enabled = true with lease = "catalog" (the default) on every coordinator. Each one ticks independently against the same cron schedule and the same tables; the catalog lease arbitrates so only one of them actually dispatches a rewrite for a given table in a given window, and the others skip that tick for that table.
  • Leave scheduler.enabled = false on every coordinator and drive timing externally instead: a Kubernetes CronJob with concurrencyPolicy: Forbid that issues the maintenance CALL (e.g. CALL system.rewrite_data_files(...)) on its own schedule. concurrencyPolicy: Forbid guarantees the CronJob itself never overlaps its own runs, so this shape is HA-safe with no internal lease at all: there is only ever one caller in flight.

Neither shape depends on the lease for correctness. If a lease operation fails, or two coordinators somehow compact the same table concurrently anyway, Iceberg’s optimistic-concurrency commit still guarantees exactly one of them wins; the other re-plans against the winner’s new snapshot and finds a no-op. The lease only avoids paying for the loser’s redundant scan and rewrite. See Distributed compaction for the full argument.

Per-table overrides

A table owner can override the global schedule and every [maintenance.compaction] sizing knob for one table, without touching the coordinator’s config file, via ALTER TABLE ... SET TBLPROPERTIES:

Table propertyOverrides
sqe.maintenance.compaction.schedulemaintenance.scheduler.schedule
sqe.maintenance.compaction.target-file-size-bytesmaintenance.compaction.target_file_size_bytes
sqe.maintenance.compaction.min-input-filesmaintenance.compaction.min_input_files
sqe.maintenance.compaction.delete-file-thresholdmaintenance.compaction.delete_file_threshold
sqe.maintenance.compaction.strategymaintenance.compaction.strategy

An absent or blank property falls back to the global config value. A numeric override that fails to parse also falls back to the global value; the scheduler logs a warning naming the property and the rejected value rather than failing the whole tick over one bad property on one table. In active mode the resolved, per-table value is what both gates eligibility and drives the rewrite: a table that loosens an override (for example a lower min-input-files) is evaluated against its own knob, not the global default it opted out of.

The three gates

Autonomous mutation requires all three to line up. The advisory scheduler loop already respects the first two when deciding which tables to discover and report on; active mode additionally needs the third to hold before it will commit anything:

  1. Global maintenance.mode is "advisory" or "active" (never "off").
  2. The table owner has set the per-table property sqe.maintenance.enabled = true via ALTER TABLE. A table without this property is never selected, no matter what mode is set to.
  3. The maintenance principal holds a least-privilege Polaris grant on the opted-in namespace: TABLE_READ_DATA for advisory mode, plus TABLE_WRITE_DATA for active mode, no CREATE/DROP/admin either way. Polaris enforces this server-side as defense-in-depth on top of SQE’s own gates. In active mode, a table with the property but no write grant never silently skips: the rewrite attempt fails, and SQE records a failed sqe_system.maintenance_log row plus a sqe_maintenance_job_total{status="failed"} metric sample for it.

sqe_system.maintenance_log

maintenance.scheduler.state_table (default sqe_system.maintenance_log) holds job history, last-run state, and the catalog lease rows. SQE treats this table as operator-created: nothing in SQE creates it, and the scheduler degrades to warn-and-skip rather than failing hard when the table is absent. Create it once with a schema matching (job_id, table, trigger, principal, started_at, finished_at, status, files_in, files_out, bytes_in, bytes_out, rows_removed, snapshot_id, error) before turning on scheduler.enabled.

status is "advisory" for every table an advisory-mode tick analyzes. In active mode a due, opted-in table produces exactly one terminal row per tick: "success" for a committed rewrite, "skipped" when the table had no eligible compaction debt (or the underlying rewrite itself chose to skip), or "failed" for any error along the way (session mint, token refresh, catalog build, or the rewrite commit itself). One table’s failure never aborts the tick or blocks any other opted-in table from being considered.

An audit event (AuditKind::Maintenance) accompanies every advisory-mode analysis and every active-mode "success" commit. "skipped" and "failed" rows are not paired with an audit event; the maintenance_log row plus the sqe_maintenance_job_total{status=...} metric sample are the record of those outcomes.

Snapshot stamping

Every active-mode commit stamps three Iceberg snapshot-summary properties: sqe.maintenance.job-id (ties the snapshot back to its maintenance_log row), sqe.maintenance.principal (the maintenance service identity that committed it), and sqe.maintenance.trigger (currently always "scheduled"). A compaction snapshot is therefore attributable in the table’s own history, independent of the state table: inspect the snapshot summary directly (Iceberg snapshot metadata, or a future DESCRIBE HISTORY-style surface) rather than CALL system.table_health. table_health’s last_compaction_snapshot_ms column is reserved for this but is not yet wired to read it; it always returns NULL in this release.

Distribution: mode picks coordinator-local vs the worker fleet

[maintenance.distribution] is active: mode decides whether an active-mode rewrite runs on the coordinator alone or fans its file groups out to the worker fleet. See Distributed compaction for the full data flow (planning, dispatch, worker rewrite, coordinator commit).

  • "auto" (default). Coordinator-local when the healthy worker count is below min_workers, fans out to the fleet once it reaches min_workers.
  • "local". Always coordinator-local, even with a fleet present.
  • "require". Always fans out; never runs coordinator-local. Below min_workers it does not fall back to "local", and the two call paths react differently, on purpose:
    • A scheduled active-mode tick SKIPS the job loudly: a skipped sqe_system.maintenance_log row, the existing AuditKind::Maintenance skip event, and a dedicated sqe_maintenance_skipped_total{reason="insufficient_workers"} metric sample an operator can alert on independently of the generic sqe_maintenance_job_total{status="skipped"} counter (which also fires for “no eligible debt”).
    • A manual CALL system.rewrite_data_files(..., distributed => 'require') ERRORS instead: an interactive caller who explicitly asked to require the fleet gets a loud failure, never a silent coordinator-local rewrite.

CALL system.rewrite_data_files also accepts a per-call distributed => 'auto'|'local'|'require' argument that overrides the configured mode for that one call; omit it to use [maintenance.distribution] mode. See CALL procedures.

Other knobs, all under [maintenance.distribution]:

  • min_workers (default 2). The healthy-worker floor "auto" and "require" compare against.
  • max_inflight_groups_per_worker (default 1). Hard per-worker cap on concurrently dispatched groups. A worker at the cap is never chosen for a new group; a group that cannot fit anywhere is deferred and retried briefly, never force-assigned past the cap.
  • group_attempts (default 2). Retries for one failed group, each on a worker other than the one that just failed it. A group that exhausts every currently-healthy worker, or every attempt, fails the whole job – a distributed rewrite either commits everything or nothing; dispatch never continues once one group has permanently failed.
  • group_timeout_secs (default 3600). The real end-to-end bound on one group dispatch attempt: from the coordinator’s do_action call to that group’s terminal Done frame. A worker computes the entire rewrite (read, delete-apply, re-encode) before it emits any frame at all, so nothing about a hung or slow worker is visible until either this fires or the worker finally responds. Size it for the slowest group you expect to dispatch.
  • group_heartbeat_timeout_secs (default 120). Bounds the wait between frames once a worker has started responding (its first Progress heartbeat). Workers now emit a Progress frame every few record batches while the rewrite is still running, an internal fixed cadence, not a config knob, so a fresh frame arrives well inside this window as long as the worker keeps making forward progress. That makes this field a real mid-compute liveness bound: a worker that stalls partway through, a wedged read or a hung write, stops producing frames and is caught here instead of only at the coarser group_timeout_secs. A stalled worker’s group is retried on a different healthy worker, up to group_attempts.
  • partial_progress (default false). Opt-in: commit successful groups in batches of partial_progress_batch instead of collecting every group and committing one all-or-nothing RewriteFilesAction. Off, the job behaves exactly as before: one atomic commit for the whole job, any failure commits nothing. On, a terminal failure after one or more batches have already committed keeps those batches (they are never rolled back) and records status = "partial" in sqe_system.maintenance_log instead of failing the whole job. Trades a larger commit-conflict surface, N commits instead of one, each independently racing concurrent writers, for incremental durability on very large tables, where losing an entire multi-hour job to one late group failure is expensive. See Distributed compaction for the full per-batch commit sequence and retry layering.
  • partial_progress_batch (default 10). Number of eligible groups committed per RewriteFilesAction when partial_progress is true. Ignored when partial_progress is false. Must be at least 1 when partial_progress is true; validation rejects 0.

Accepted trade-off: orphans on a commit-conflict retry. A concurrent writer that commits between the coordinator’s read and its RewriteFilesAction commit produces a retryable conflict. On retry the coordinator re-plans and re-dispatches the whole job from scratch rather than patch the stale attempt, the same correctness rule the local path already follows. Whatever the superseded attempt’s workers already wrote to S3 is never referenced by any commit and becomes an orphan, left for CALL system.remove_orphan_files’s normal age-thresholded sweep to reclaim. The trade-off is deliberate: correctness comes from never committing a stale plan, not from cleaning up every write the moment its result turns out to be unneeded.

The whole-job re-plan above applies to the first commit of a job, with partial_progress on or off. With partial_progress on, a conflict on a later batch is instead retried in place against the same worker-produced files, no re-plan and no orphaned output, up to the same retry budget; see Distributed compaction for the retry-layering detail.

Safety notes

  • Advisory first. Run advisory mode against a table long enough to see its real compaction debt and validate the schedule and per-table knobs before opting that table into active mode.
  • Opt-in is per table, twice over. A table is only ever touched by active mode when its owner has both set sqe.maintenance.enabled = true and granted the maintenance principal TABLE_WRITE_DATA in Polaris. Neither alone is sufficient.
  • Least-privilege grant. Give the maintenance principal only TABLE_READ_DATA / TABLE_WRITE_DATA on the opted-in namespace, never CREATE/DROP/admin.
  • Compactions are reversible within the retention window. A compaction commit is an ordinary Iceberg snapshot; the data files and manifests it superseded remain in place until expire_snapshots removes them. Reading a compacted table’s history back to a prior snapshot and running CALL system.rollback_to_snapshot(table => 'ns.t', snapshot_id => <id>) undoes the compaction as long as that prior snapshot has not aged out.
  • distribution.mode decides the footprint. "local", and "auto" below min_workers, run coordinator-local: size target_file_size_bytes and max_concurrent_jobs for that single-process footprint. "auto" at or above min_workers, and "require", fan groups out to the worker fleet instead; see Distributed compaction.
  • Commit authority never leaves the coordinator. In distributed mode workers read and write S3 directly but never obtain a catalog token and never commit; the coordinator validates every worker’s output and commits one atomic RewriteFilesAction, exactly like the local path.

Validation

SQE validates config at startup and fails fast on errors:

  • auth.client_id must not be empty
  • catalog.catalog_url must not be empty
  • At least one of auth.keycloak_url or auth.token_endpoint must be set
  • coordinator.flight_sql_port must differ from coordinator.trino_http_port
  • coordinator.flight_sql_port must differ from metrics.prometheus_port
  • TLS: if either cert or key is set, both must be set; referenced files must exist
  • maintenance.mode other than "off" requires a [maintenance.principal] block
  • maintenance.scheduler.tick_secs and maintenance.scheduler.lease_ttl_secs must both be greater than zero
  • maintenance.scheduler.enabled = true with lease = "none" requires single_scheduler_acknowledged = true
  • maintenance.scheduler.enabled = true with lease = "kubernetes" is rejected outright (not implemented; use "catalog" or "none")
  • maintenance.distribution.partial_progress = true requires maintenance.distribution.partial_progress_batch to be at least 1

Priority Order

CLI flags (--mode, --config) > Environment variables > Config file > Defaults

Sensitive Values

Never put secrets in the TOML config file. Use environment variables or Kubernetes Secrets:

# Environment
export SQE_AUTH__CLIENT_SECRET="my-secret"
export SQE_STORAGE__S3_ACCESS_KEY="minioadmin"
export SQE_STORAGE__S3_SECRET_KEY="minioadmin"

# Kubernetes Secret (via Helm)
helm install sqe deploy/helm/sqe/ \
  --set secrets.SQE_AUTH__CLIENT_SECRET=xxx \
  --set secrets.SQE_STORAGE__S3_SECRET_KEY=xxx

Sizing and capacity

This page is about how to reason about provisioning, not a table of numbers to copy. The right memory and worker counts depend on your data, your query shapes, and your concurrency. The principles below tell you what to watch and which knobs move it. Measure against your own workload before committing to fixed limits.

What drives memory

A query’s memory cost is dominated by the operators that hold state, not by the bytes scanned. Scan is streaming. The pressure comes from:

  • Join build sides. A hash join builds a table from one side in memory. A large build side is the most common way to run out of memory. SQE rewrites a hash join to a sort-merge join when the estimated build side exceeds hash_join_memory_threshold, trading speed for survival.
  • Sorts. ORDER BY over a large input needs the input in memory or spilled. SQE’s external merge sort spills sorted runs to disk and merges them with constant overhead.
  • Aggregations. A high-cardinality GROUP BY holds one entry per group. This is the documented hard edge: hash-aggregate spill is limited by upstream DataFusion, so a single node can OOM on a query like TPC-H q18 that produces millions of groups. Distribution is the fix.
  • Result buffering. Large result sets held before streaming to the client.
  • Write buffers. Copy-on-Write MERGE and per-file rewrites for UPDATE, DELETE, and Merge-on-Read buffer rows before committing. These register against the pool, so an oversized write now fails with ResourceExhausted instead of OOM-killing the coordinator. See Write Path, Memory Safety.

Scan volume drives I/O and parallelism, not steady-state memory. The scan optimizations (file and page pruning, late materialization, the S3 I/O pipeline) reduce bytes read; they do not change the fact that the join, sort, and aggregate operators are where memory goes. See Streaming Execution for the full operator-by-operator behaviour.

Coordinator vs worker

In single-node mode the coordinator does everything: scan, join, sort, aggregate, final assembly. Its memory must cover the heaviest single query you run.

In distributed mode the split changes the shape of the pressure. Workers do the scans, partial aggregations, partial sorts, and join probes. The coordinator handles final aggregation, the final sort or limit, and result assembly. A distributed ORDER BY on a large dataset spreads the spill across workers instead of landing all of it on the coordinator. Two-phase aggregation spreads a high-cardinality GROUP BY across workers so no single process holds every group.

Provision the coordinator for planning, scheduling, and final-stage work; provision workers for the bulk of the scan and the join and sort state. The documented defaults illustrate the asymmetry: the streaming-execution reference uses an 8GB runtime memory_limit default for both roles, while the Helm chart ships a smaller coordinator limit (2Gi) and a larger worker limit (8Gi) with workers disabled by default. Both are documented starting points, not targets. Pick yours by measuring.

The single-node cutoff

Single-node mode is the default and the recommendation for development and datasets under roughly 100GB. Beyond that, enable workers. The cutoff is a guideline: spill-to-disk lets a memory-constrained coordinator survive queries far larger than its memory, so the real ceiling depends on query shape and spill budget. A scan-and-filter over a large table behaves very differently from a high-cardinality aggregation over the same data. See System Overview, Single-node vs distributed.

How adding workers helps, and the caveat

Workers are stateless and scale horizontally. Adding workers buys you parallel scan I/O, distributed join and sort memory, and the two-phase aggregation that turns single-node OOM cases into passing queries. A worker loss costs the fragments it was running, not the cluster. Scale by raising the worker replica count.

The caveat is the coordinator. It is a single replica and a single point of failure. Adding workers does not add coordinator redundancy. The coordinator still plans, schedules, and assembles the final stage, and a coordinator restart drops in-flight queries and invalidates sessions. Size and protect the coordinator with that in mind. See The coordinator is a single point of failure and Limitations.

The knobs

The provisioning levers, all in Configuration and detailed in Streaming Execution, Configuration Reference:

KnobSectionWhat it controls
memory_limit[coordinator] / [worker]Total DataFusion runtime memory for the role. The primary lever.
spill_to_disk[worker]Allow large sorts and joins to spill rather than OOM.
spill_dir[coordinator] / [worker]Where spill files land. Size and speed of this disk matter under heavy spill.
spill_compression[coordinator] / [worker]zstd, lz4, or none. Trades CPU for spill I/O.
hash_join_memory_threshold[optimizer]Build-side size above which a hash join becomes a sort-merge join.
broadcast_threshold[optimizer]Join side size below which a broadcast join is used (no shuffle).
max_query_memory[query]Per-query memory cap, independent of the runtime pool.
max_concurrent_queries[query]Concurrency limit. More concurrency means each query gets a smaller slice of the pool.
distribution_threshold[query]Minimum scan size before a query is distributed to workers.

How to provision in practice

  1. Start from a documented default (8GB runtime, or the Helm 2Gi coordinator / 8Gi worker) and the single-node mode if your data is under roughly 100GB.
  2. Run your real queries. Watch for spill activity and ResourceExhausted errors. The memory watermark metrics (green / yellow / orange / red) show when the pool is under pressure.
  3. If a single node spills heavily or OOMs on aggregation, enable workers and distribute rather than chasing a bigger single box.
  4. Raise memory_limit and tune the optimizer thresholds against the operators that actually dominate your workload, not against scan volume.
  5. Keep the coordinator a single replica, protect it, and size it for planning and final-stage work plus your concurrency.

Docker

SQE ships as a single Docker image containing the engine binaries (sqe-server, sqe-worker) and the client (sqe-cli).

Image Layout

graph TB
    subgraph "sqe:latest (chainguard/glibc-dynamic)"
        BIN1["/usr/local/bin/sqe-server"]
        BIN2["/usr/local/bin/sqe-worker"]
        BIN3["/usr/local/bin/sqe-cli"]
        HC["/usr/local/bin/wget (healthcheck only)"]
        USER["User: nonroot (UID 65532)"]
        EP["ENTRYPOINT: sqe-server"]
    end
  • Build: one multi-stage Dockerfile. Stage 1 is rust:<toolchain>-bookworm and a plain cargo build --release --locked. No cargo-chef, no sccache. Same file for local compose, data-platform quickstart/sqe, and aikido/kaniko.
  • Base (runtime): cgr.dev/chainguard/glibc-dynamic (digest-pinned). glibc, libgcc, and CA certificates only. No shell, no package manager, no OpenSSL.
  • Why not Debian slim / distroless: the binaries link only libc / libm / libgcc (TLS is rustls). Bookworm-slim carried hundreds of unused OS CVEs. Distroless cut most of that but still fails the Aikido image gate on unfixed debian libc criticals. Chainguard is clean under grype --fail-on high.
  • User: Non-root UID/GID 65532. Helm securityContext matches this UID.
  • Healthcheck: static busybox wget (exec form). Kubernetes uses HTTP probes on /healthz and does not need wget.
  • Entrypoint: sqe-server. Mode is selected via --mode or SQE_MODE.
  • CI (Aikido): aikido-build runs kaniko with --target=runtime. On merge, aikido-image-vuln runs grype registry:$AIKIDO_IMAGE --fail-on high.
  • Bench image: docker build --target bench-runtime -t sqe-bench:latest . (same Dockerfile).

There is no shell in the image. docker exec -it <container> sqe-cli still works (the CLI binary is present). docker exec ... bash does not.

Build

docker build -t sqe:latest .

# With metadata labels
docker build -t sqe:0.1.0 \
  --build-arg VERSION=0.1.0 \
  --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --build-arg GIT_REVISION=$(git rev-parse HEAD) \
  .

Run Coordinator

docker run -d \
  --name sqe-coordinator \
  -p 50051:50051 \
  -p 8080:8080 \
  -p 9090:9090 \
  -p 9091:9091 \
  -v $(pwd)/sqe.toml:/etc/sqe/sqe.toml:ro \
  -e SQE_AUTH__CLIENT_SECRET=my-secret \
  -e SQE_STORAGE__S3_ACCESS_KEY=minioadmin \
  -e SQE_STORAGE__S3_SECRET_KEY=minioadmin \
  sqe:latest --config /etc/sqe/sqe.toml

The default mode is coordinator, so no --mode flag needed.

Run Worker

docker run -d \
  --name sqe-worker-1 \
  -p 50052:50052 \
  -v $(pwd)/sqe.toml:/etc/sqe/sqe.toml:ro \
  sqe:latest --mode worker --config /etc/sqe/sqe.toml

Use the CLI

# Interactive SQL against running coordinator
docker exec -it sqe-coordinator sqe-cli

# One-shot query
docker exec sqe-coordinator sqe-cli -e "SELECT COUNT(*) FROM raw.orders;"

# With explicit connection
docker run --rm -it --network host \
  --entrypoint /usr/local/bin/sqe-cli \
  sqe:latest --host localhost --port 50051 --user alice

Note: when using docker exec, sqe-cli connects to localhost:50051 by default, which is the coordinator running in the same container. Override the entrypoint when docker run should start the CLI instead of the server.

Docker Compose

services:
  coordinator:
    image: sqe:latest
    command: ["--config", "/etc/sqe/sqe.toml"]
    ports:
      - "50051:50051"
      - "8080:8080"
      - "9090:9090"
      - "9091:9091"
    volumes:
      - ./sqe.toml:/etc/sqe/sqe.toml:ro
    environment:
      SQE_AUTH__CLIENT_SECRET: ${SQE_AUTH_SECRET}
      SQE_STORAGE__S3_ACCESS_KEY: ${S3_ACCESS_KEY}
      SQE_STORAGE__S3_SECRET_KEY: ${S3_SECRET_KEY}
    healthcheck:
      # exec form: no shell in the image
      test: ["CMD", "/usr/local/bin/wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9091/healthz"]
      interval: 10s
      timeout: 5s
      retries: 3

  worker:
    image: sqe:latest
    command: ["--mode", "worker", "--config", "/etc/sqe/sqe.toml"]
    deploy:
      replicas: 2
    volumes:
      - ./sqe.toml:/etc/sqe/sqe.toml:ro
    depends_on:
      coordinator:
        condition: service_healthy

Why One Image?

ConcernAnswer
Version skewCoordinator, workers, and CLI are always the same build
CI/CDOne image to build, scan, and promote
K8s simplicitySame image: field, different --mode arg
Debuggingkubectl exec / docker exec can run sqe-cli (no shell)
Size overheadMinimal: both roles share 95% of their code
CVEsOS surface is distroless + a static healthcheck wget, not a full Debian userland

Kubernetes & Helm

SQE includes a Helm chart for production Kubernetes deployment.

Architecture on K8s

graph TB
    subgraph "Kubernetes Cluster"
        subgraph "Coordinator Deployment"
            C1["sqe-server<br/>--mode coordinator"]
        end

        subgraph "Worker Deployment (optional)"
            W1["sqe-server<br/>--mode worker"]
            W2["sqe-server<br/>--mode worker"]
        end

        SVC["Service: sqe-coordinator<br/>ClusterIP"]
        CM["ConfigMap: sqe-config<br/>(sqe.toml)"]
        SEC["Secret: sqe-secrets<br/>(credentials)"]
        SM["ServiceMonitor<br/>(optional)"]

        SVC --> C1
        CM --> C1
        CM --> W1
        CM --> W2
        SEC --> C1
        SEC --> W1
        SEC --> W2
        C1 --> W1
        C1 --> W2
    end

    CLIENT["Clients"] --> SVC
    PROM["Prometheus"] --> SM

Install with Helm

Single-Node (small environments)

helm install sqe deploy/helm/sqe/ \
  --set config.auth.keycloak_url=https://keycloak.example.com \
  --set config.catalog.catalog_url=http://polaris:8181/api/catalog \
  --set secrets.SQE_AUTH__CLIENT_SECRET=my-secret \
  --set secrets.SQE_STORAGE__S3_ACCESS_KEY=minioadmin \
  --set secrets.SQE_STORAGE__S3_SECRET_KEY=minioadmin

Workers are disabled by default. The coordinator runs queries locally.

values.yaml is the easy dev path (production_mode: false). For production, overlay values-production.yaml: it turns on production_mode, rate limiting, a durable audit PVC, and ServiceMonitor. You must still supply existing Secrets, TLS/ingress, and workerSecret when workers are on. Templating fails if production_mode and worker.enabled are both true with an empty worker secret.

helm install sqe deploy/helm/sqe/ \
  -f deploy/helm/sqe/values-production.yaml \
  --set existingSecret=sqe-credentials

Distributed (production)

helm install sqe deploy/helm/sqe/ \
  --set worker.enabled=true \
  --set worker.replicas=4 \
  --set coordinator.resources.limits.memory=4Gi \
  --set worker.resources.limits.memory=16Gi \
  --set worker.resources.limits.cpu=8 \
  --set config.auth.keycloak_url=https://keycloak.example.com \
  --set config.catalog.catalog_url=http://polaris:8181/api/catalog \
  --set existingSecret=sqe-credentials

Using an Existing Secret

Create the secret separately (e.g., via sealed-secrets or external-secrets-operator):

apiVersion: v1
kind: Secret
metadata:
  name: sqe-credentials
type: Opaque
stringData:
  SQE_AUTH__CLIENT_SECRET: "my-secret"
  SQE_STORAGE__S3_ACCESS_KEY: "AKIA..."
  SQE_STORAGE__S3_SECRET_KEY: "wJalrXUtnFEMI/K7MDENG..."

Then reference it:

helm install sqe deploy/helm/sqe/ --set existingSecret=sqe-credentials

Values Reference

Image

image:
  repository: sqe
  tag: latest           # Defaults to Chart.appVersion
  pullPolicy: IfNotPresent
imagePullSecrets: []

Coordinator

The coordinator runs as a single replica and is a single point of failure: a restart drops in-flight queries and process-local session state. Coordinator high availability is on the roadmap.

coordinator:
  replicas: 1
  resources:
    requests: { memory: "512Mi", cpu: "500m" }
    limits:   { memory: "2Gi",   cpu: "2" }
  nodeSelector: {}
  tolerations: []
  affinity: {}
  podAnnotations: {}

Workers

worker:
  enabled: false         # Enable for distributed execution
  replicas: 2
  resources:
    requests: { memory: "1Gi", cpu: "1" }
    limits:   { memory: "8Gi", cpu: "4" }
  nodeSelector: {}
  tolerations: []
  affinity: {}
  podAnnotations: {}

Service

service:
  type: ClusterIP
  flightSqlPort: 50051
  trinoHttpPort: 8080
  metricsPort: 9090

Health Probes

healthPort: 9091
livenessProbe:
  initialDelaySeconds: 5
  periodSeconds: 10
readinessProbe:
  initialDelaySeconds: 5
  periodSeconds: 5

Monitoring

serviceMonitor:
  enabled: false
  interval: 30s
  labels: {}            # e.g., { release: prometheus }

Operations

Scaling Workers

kubectl scale deployment sqe-worker --replicas=8
# or
helm upgrade sqe deploy/helm/sqe/ --set worker.replicas=8

Rolling Update

Config changes trigger automatic rolling restarts (via checksum annotation on the ConfigMap):

helm upgrade sqe deploy/helm/sqe/ --set config.catalog.metadata_cache_ttl_secs=60

Interactive SQL

kubectl exec -it deploy/sqe-coordinator -- sqe-cli

Port Forwarding

# Flight SQL
kubectl port-forward svc/sqe-coordinator 50051:50051

# Trino HTTP (for dashboards)
kubectl port-forward svc/sqe-coordinator 8080:8080

# Metrics
kubectl port-forward svc/sqe-coordinator 9090:9090

Logs

kubectl logs deploy/sqe-coordinator -f
kubectl logs deploy/sqe-worker -f

Logs are structured JSON. Pipe to jq for readability:

kubectl logs deploy/sqe-coordinator | jq .

Worker Secret (distributed mode)

In distributed mode the coordinator and every worker share a secret that authenticates worker registration and credential push. The engine refuses to start when coordinator_url / worker_urls is set with an empty worker_secret, so a distributed install without a secret crashloops. The chart renders coordinator_url only when worker.enabled=true, so a single-node install needs no secret.

Provide the secret one of two ways. The chart injects the value under both SQE_COORDINATOR__WORKER_SECRET and SQE_WORKER__WORKER_SECRET from one Secret key, so the guards on both pods are satisfied with matching values.

# Preferred: reference a Secret you manage, naming the key that holds the value.
kubectl create secret generic sqe-worker-secret \
  --from-literal=SQE_WORKER_SECRET="$(openssl rand -hex 32)"

helm install sqe deploy/helm/sqe/ \
  --set worker.enabled=true \
  --set workerSecret.existingSecret=sqe-worker-secret \
  --set workerSecret.key=SQE_WORKER_SECRET

# Dev/test: inline value. The chart creates the Secret for you.
helm install sqe deploy/helm/sqe/ \
  --set worker.enabled=true \
  --set workerSecret.value=dev-only-shared-secret

Availability and Disruption Budgets

The chart ships PodDisruptionBudgets and default pod anti-affinity so a node drain or rolling upgrade cannot take the cluster down at once.

  • Coordinator PDB: minAvailable: 1. The coordinator is single-replica today, so this blocks an unforced eviction of the only coordinator. A node drain that targets it will not proceed until you act (cordon and delete the pod, or scale the deployment to 0 first). The budget protects a single point of failure; it does not provide HA.
  • Worker PDB: maxUnavailable: 1. A drain rolls through one node at a time while the rest keep serving fragments. Rendered only when worker.enabled.
  • Anti-affinity: when a component’s affinity is empty, the chart applies a preferred podAntiAffinity by hostname so replicas spread across nodes. Workers always get it; the coordinator gets it only at replicas > 1. Set affinity to override the default entirely, or defaultAntiAffinity: false to render none.

Toggle the budgets with podDisruptionBudget.enabled (default true) and tune podDisruptionBudget.coordinator.minAvailable / podDisruptionBudget.worker.maxUnavailable.

The coordinator is a single point of failure

The coordinator runs as a single replica. Session state, the worker registry, and in-flight query state are process-local; there is no shared store. A coordinator restart drops every in-flight query and invalidates client sessions, so connected clients must re-authenticate and re-run. A node drain that moves the coordinator pod is a brief outage, not a transparent failover.

Running more than one coordinator replica is not yet safe. Two replicas do not share sessions or the registry, so clients would land on a coordinator that has never seen their session. Keep coordinator.replicas: 1. Full coordinator HA with shared session and registry state is a separate design.

OpenLineage

SQE emits OpenLineage 2-0-2 events for queries that mutate data: INSERT, CTAS, MERGE, UPDATE, DELETE, plus DDL on tables. SELECT events are off by default and can be enabled per environment.

Use these events to drive a lineage UI (Marquez, DataHub) or to feed a metadata catalog. Off by default. Zero hot-path overhead when disabled.

What gets emitted

StatementEmits?InputsOutputs
SELECTonly if emit_selects = trueyesnone
INSERT, CTAS, MERGEalwaysyesyes
UPDATE, DELETEalwaysyesyes
CREATE TABLE, ALTER, DROPalwaysnoneyes (with schema facet)
OPTIMIZE, VACUUM, REWRITE_MANIFESTSnevern/an/a

Each query produces two events: a START at submit time and a COMPLETE on success or FAIL on error.

Configuration

[metrics.openlineage]
enabled        = true
job_namespace  = "sqe-prod"     # per-env

# at least one sink required
file_path      = "/var/log/sqe/lineage.jsonl"
http_endpoint  = "https://marquez.example.com/api/v1/lineage"

# HTTP transport
auth_mode           = "bearer"           # "none" | "bearer" | "user_token"
api_key             = "..."              # required when auth_mode = "bearer"
http_timeout_ms     = 5000
http_retry_attempts = 1

# disk spool fallback (recommended when HTTP is configured)
spool_path           = "/var/spool/sqe-ol"
spool_max_bytes      = 104857600
replay_interval_secs = 30

# back-pressure (rarely needs tuning)
channel_capacity = 10000
emit_selects     = false

Every TOML key has an SQE_METRICS__OPENLINEAGE__* env override.

Sink choice

Pick one or both:

  • File sink: append-only JSONL at file_path. Use for SIEM ingestion, debug, or local development.
  • HTTP sink: POST to an OL collector. Use for Marquez/DataHub. Add a spool_path so a collector outage does not lose events.

When both are set, every event goes to both. Failures on one sink do not block the other.

Marquez quickstart

docker run -p 5000:5000 -p 5001:5001 marquezproject/marquez

In sqe.toml:

[metrics.openlineage]
enabled = true
http_endpoint = "http://localhost:5000/api/v1/lineage"
auth_mode = "none"

Restart SQE. Run a query. Browse Marquez at http://localhost:3000.

DataHub quickstart

DataHub’s OL receiver expects a bearer token:

[metrics.openlineage]
enabled = true
http_endpoint = "https://datahub.example.com/openapi/openlineage/api/v1/lineage"
auth_mode = "bearer"
api_key = "${DATAHUB_TOKEN}"
spool_path = "/var/spool/sqe-ol"

Troubleshooting

No events appear in Marquez or DataHub. Check enabled = true and that at least one sink is configured. Validation is enforced at startup; the server refuses to start with a misconfigured block.

Spool directory grows. The collector is unreachable. SQE buffers up to spool_max_bytes then drops newest events. Inspect /var/spool/sqe-ol/spool.jsonl*. Restore the collector and the replay loop drains within replay_interval_secs.

sqe_lineage_dropped_events_total counter increments. The bounded mpsc channel was full. Increase channel_capacity or investigate why the emitter is slow (collector too slow? HTTP timeouts?).

sqe_lineage_sink_errors_total{sink="http"} increments. Same: collector unreachable. With spool_path configured, events still reach disk.

v1 limitations

  • No mTLS to the collector. Bearer auth only.
  • auth_mode = "user_token" (forwarding the user’s OIDC bearer per event) is wired but currently falls back to the static api_key in v1; full per-event token forwarding is a follow-up.
  • Maintenance procedures (OPTIMIZE, VACUUM, REWRITE_MANIFESTS) are never emitted.
  • MERGE column-level lineage is emitted at the dataset level; per-branch annotations (MERGE_INSERT vs MERGE_UPDATE) are deferred until DataFusion exposes a Merge LogicalPlan node.
  • DDL paths (CREATE TABLE, DROP, etc.) emit events with plan = None. The dataset target is captured but column lineage is empty for DDL.
  • Embedded CLI (sqe-cli ad-hoc mode) does not emit lineage. Production server (sqe-server) is the only emit path.

Runtime catalog management

SQE supports DuckDB-style ATTACH and DETACH for mounting Iceberg catalogs from SQL at runtime. Credentials live in a session-local secret store managed with CREATE SECRET / DROP SECRET. The same six backends documented in Catalog backends work here, plus a SQLite backend for local prototyping.

Use this when:

  • An analyst wants to point at a partner’s catalog for a single session without redeploying.
  • The dev loop needs a quick local catalog without editing TOML.
  • Operators want to provision shared bearer tokens or AWS profiles centrally and have queries reference them by name.

ATTACH is process-local. Catalogs attached via SQL are wiped on coordinator restart. Static TOML catalogs (the [catalog] and [catalogs.*] blocks) are the right shape for “this is part of the deployment.” ATTACH is the right shape for “this is part of this session.”

Syntax

ATTACH '<location>' AS <name> (TYPE <kind>, <key> = <value>, ...);

DETACH <name>;

CREATE SECRET <name> (TYPE <kind>, <key> = <value>, ...);
DROP SECRET <name>;
SHOW SECRETS;

<location> is the connection target (URL, ARN, file path) and its meaning depends on TYPE. Option keys are case-insensitive. String values are single-quoted. The one exception is SECRET <name> which takes a bare identifier so it can be looked up in the secret store.

Catalog kinds

TYPE value<location> shapeRequired optionsOptional options
iceberg_restURL of the Iceberg REST endpointWAREHOUSESECRET (bearer)
glueempty string (region drives discovery)WAREHOUSESECRET (aws), REGION
s3tablesempty stringTABLE_BUCKET_ARNSECRET (aws), ENDPOINT_URL
hmsThrift URI (thrift://host:9083)WAREHOUSE
jdbcJDBC connection stringWAREHOUSESECRET (basic)
sqlitelocal directory path
hadoopwarehouse path on object store or local FS

Secret kinds

TYPE valueRequired keysUsed by
bearerTOKENiceberg_rest
basicUSERNAME, PASSWORDjdbc
awsany of ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, REGION, PROFILEglue, s3tables

A bearer secret stores one token. A basic secret stores a username and password. An AWS secret can hold any combination of credential fields; missing fields fall through to the standard AWS credential chain (env vars, profile, IMDS).

Example: REST catalog with bearer token

CREATE SECRET partner_tok (TYPE bearer, TOKEN 'eyJhbGciOiJSUzI1...');

ATTACH 'http://catalog.example.com:9090/api/catalog' AS partner
  (TYPE iceberg_rest, WAREHOUSE 'analytics', SECRET partner_tok);

SELECT * FROM partner.sales.orders LIMIT 10;

DETACH partner;
DROP SECRET partner_tok;

The token never appears in plan history or query logs after the CREATE SECRET statement; subsequent statements reference it by name. The token bytes are zeroized when the secret is dropped or the coordinator exits cleanly.

Example: AWS Glue with explicit credentials

CREATE SECRET aws_dev (TYPE aws,
  ACCESS_KEY_ID = 'AKIA...',
  SECRET_ACCESS_KEY = 'wJalrXUt...',
  REGION = 'eu-example-1');

ATTACH '' AS glue_dev
  (TYPE glue, WAREHOUSE 's3://my-warehouse/', SECRET aws_dev);

SELECT * FROM glue_dev.public.events LIMIT 5;

Example: AWS Glue using the standard credential chain

Skip SECRET and the AWS SDK uses its default chain (env vars, shared profile, IMDS, container credentials).

ATTACH '' AS glue_prod
  (TYPE glue, WAREHOUSE 's3://prod-warehouse/');

This is the same chain aws-sdk-glue uses everywhere else. EKS service accounts, EC2 instance roles, and ~/.aws/credentials profiles all work without an explicit CREATE SECRET.

Example: SQLite for local prototyping

ATTACH '/tmp/sqe-dev' AS local (TYPE sqlite);

CREATE SCHEMA local.tutorial;
CREATE TABLE local.tutorial.events (id BIGINT, ts TIMESTAMP);
INSERT INTO local.tutorial.events VALUES (1, NOW());

The location is a directory. SQE creates <dir>/catalog.db (SQLite-backed Iceberg catalog) and a <dir>/warehouse/ subdirectory for table data. Useful for dbt model development without a Polaris deployment.

Example: a shared PostgreSQL catalog over JDBC

CREATE SECRET pg_cat (TYPE basic, USERNAME 'iceberg', PASSWORD 's3cr3t');

ATTACH 'jdbc:postgresql://db.internal:5432/iceberg' AS shared (
  TYPE jdbc,
  WAREHOUSE 's3://lake/warehouse',
  SECRET pg_cat
);

SELECT * FROM shared.analytics.events LIMIT 10;

The backend is the same SQL catalog TYPE sqlite uses, so PostgreSQL, MySQL, and SQLite all work through one code path. Write the location either as jdbc:postgresql://..., the form Java tools use, or as postgresql://...; the jdbc: prefix is stripped when present.

WAREHOUSE is required because a SQL catalog stores table metadata pointers rather than the data itself, so the data root cannot be inferred from the connection URL.

Credentials belong in a basic secret rather than inline in the URL. The secret wins if the URL also carries a user:password@ pair, which keeps rotating the secret meaningful. Characters that would otherwise break a URL, such as @, :, and /, are percent-encoded on the way in, so a password does not need pre-escaping.

Requires one of the sql, sql-postgres, or sql-sqlite cargo features. A binary built without them reports which feature is missing.

SHOW CATALOGS and SHOW SECRETS

SHOW CATALOGS includes every TOML-configured catalog plus the two coordinator-registered system catalogs (system, datafusion) plus every name added via ATTACH. The list updates immediately after each ATTACH or DETACH.

SHOW SECRETS;

returns a two-column result (name, type). Secret values are not exposed; the table is for inventory only.

Authorization

Out of the box, ATTACH and CREATE SECRET are open to any authenticated session. Lock them down through the same OPA / Cedar policy backend that gates GRANT and REVOKE: write a rule that denies statement_kind == "attach" for non-admin roles. The plan rewriter sees the statement before it reaches the registry, so a denied ATTACH errors at policy enforcement time, not at catalog build time.

Lifecycle and persistence

ATTACH mounts are process-local and in-memory. A coordinator restart forgets every catalog attached via SQL. Static TOML catalogs (the [catalog] and [catalogs.*] blocks) are the right shape for “this is part of the deployment.” ATTACH is the right shape for “this is part of this session.”

CREATE SECRET is memory-only by default. Set [session] secrets_path (or SQE_SESSION__SECRETS_PATH) to snapshot the store as plaintext JSON after every create or drop. The file is written at mode 0600. Startup logs a warning because the bytes are credentials. Do not put the file on a shared volume.

The snapshot restores secrets after a restart. It does not restore ATTACH mounts. Re-issue ATTACH after boot, or keep shared catalogs in TOML.

Persistent ATTACH is a feature operators ask for but most do not want once they think it through. A catalog attached at 9 AM on Monday is in the system at 3 AM on Sunday because someone forgot to DETACH it. The credentials behind it have rotated. Queries against it return 401. The on-call engineer wakes up to a query failure for a catalog they did not know existed.

Troubleshooting

catalog '<name>' is already attached; DETACH it first. A catalog with that name is in the registry. Issue DETACH <name> first or choose a different name. The check is case-sensitive.

catalog '<name>' is not attached. DETACH was issued for an unknown name. Check SHOW CATALOGS for the spelling.

secret '<name>' is referenced by attached catalogs: <list>. DROP SECRET while one or more attached catalogs reference it. DETACH the listed catalogs first, then retry the drop. The error names every referencing catalog so you do not have to chase them one at a time.

secret '<name>' not found. ATTACH ... SECRET nonexistent was issued without a matching CREATE SECRET. Names are case-sensitive.

Failed to list namespaces: ... during ATTACH. The catalog was built but the initial list_namespaces call against the backend failed. Check that <location> and the credentials are correct, and that the network path between the coordinator and the catalog is reachable. The error message includes the upstream HTTP status or SDK error.

Bearer token is in the request but the catalog returns 401. Check that the token is valid against the catalog’s expected issuer. Bearer tokens stored as secrets are forwarded as-is; SQE does not reissue or refresh them.

Dynamic Polaris catalog discovery

ATTACH and [catalogs.*] both name catalogs explicitly. For dynamically-provisioned Polaris warehouses (IaC, per-tenant, random-suffixed), enable lazy discovery instead:

[query]
catalog_discovery = "polaris-auto"   # default is "static"

With polaris-auto, a query referencing a 3-part identifier whose catalog is not statically declared triggers a one-time probe against Polaris for a warehouse of that name, using the caller’s own bearer token. If Polaris resolves it (and the caller is authorized), SQE registers it into the session exactly like a static catalog – same policy enforcement, dynamic-filter pushdown, and credential passthrough – and the query proceeds. No sqe.toml change, no restart:

-- main_warehouse_9d679d was created in Polaris at runtime, never declared in TOML
SELECT count(*) FROM "main_warehouse_9d679d".analytics.orders;

Properties:

  • Authorization is unchanged. The probe uses the caller’s bearer; Polaris rejects warehouses they are not authorized for. A denied or nonexistent warehouse returns the same unknown catalog error – existence is not leaked.
  • Per-session scoping. The discovered catalog is registered into the caller’s session, not shared process-wide, so vended credentials and visibility stay per-user. A second reference in the same session reuses it without re-probing.
  • Drop-out within the session TTL. A renamed or dropped warehouse stops resolving on the next session refresh.
  • static (the default) is unchanged – an undeclared catalog errors with no Polaris probe.
  • REST/Polaris only. Glue, S3 Tables, and HMS still require static declaration or ATTACH. SHOW CATALOGS lists statically-configured and already-discovered catalogs, not warehouses never yet referenced.

v1 limitations

  • No on-disk persistence. ATTACH does not survive a restart.
  • No encryption-at-rest for secrets. The store holds plaintext bytes in memory; Drop zeroizes on clean shutdown but does not protect against process dumps or memory snapshots taken while running.
  • No mTLS to attached REST catalogs. Bearer tokens only.
  • No KERBEROS for HMS. The HMS path uses the upstream Thrift client’s default authentication.
  • Authorization is enforced through the policy backend (OPA / Cedar). There is no built-in role check for ATTACH or CREATE SECRET out of the box; operators wire it themselves through policy rules.
  • The embedded CLI (sqe-cli ad-hoc mode) supports the same syntax as the cluster server. Embedded ATTACH targets the same in-memory registry but does not share state across CLI invocations.

Audit Logging

SQE writes a tamper-evident audit log for authentication events, session lifecycle changes, permission grants and revokes, catalog DDL, and a subset of query executions (see Coverage in this release below). The log is append-only JSONL (one JSON object per line). Each record carries an integrity block that lets offline tooling detect modification or truncation.

Enabling the log

Set audit_log_path under [metrics]:

[metrics]
audit_log_path = "/var/log/sqe/audit/audit.jsonl"

An empty string disables logging. The path must exist or be writable by the SQE process. Under Helm, set audit.enabled = true (default) and audit.persistence.enabled = true to back the log with a persistent volume claim.

[metrics.audit] config block

[metrics.audit]
format                = "native"   # "native" | "ocsf" | "both"
gdpr_tags             = []         # tag names that mark a column as GDPR-sensitive
gdpr_identifier_mode  = "tokenize" # "tokenize" | "drop" | "keep"
superdebug_log_results = false      # NEVER true in production

All keys are optional. The defaults above are the production-safe values.

format

Controls which wire schema is written to disk.

ValueBehavior
nativeCanonical AuditEvent JSON written to audit_log_path. Default.
ocsfOCSF JSON written to <stem>.ocsf.jsonl; native file carries legacy flat entries only.
bothNative JSON written to audit_log_path; OCSF JSON also written to <stem>.ocsf.jsonl.

For example, if audit_log_path = "/var/log/sqe/audit/audit.jsonl" and format = "both", the files are audit.jsonl (native) and audit.ocsf.jsonl (OCSF).

gdpr_tags

A list of tag names. Any column in a queried Iceberg table whose tag set (read from the Iceberg table property sqe.column-tags) contains one of these names is treated as GDPR-sensitive. Before the event is chained and written, the matching column identifiers and their adjacent literal values are removed from the logged SQL text.

Empty list (the default) disables GDPR column masking. PII pattern redaction (emails, SSNs, phone numbers, card numbers, and secret-keyword literals) runs unconditionally regardless of this setting.

Tag resolution uses the existing policy metadata cache via sqe-policy’s TagSource. No extra network calls are made on the audit write path.

Fail-closed rule: when the tag state for a table is unknown at write time (cache miss, parse error), all SQL literals are stripped from the query text rather than risking a leak. A known-empty tag map is not a cache miss; it means the table has no tags and no masking is applied.

gdpr_identifier_mode

Controls how tagged column identifiers appear after masking. Has no effect when gdpr_tags is empty.

ValueResult
tokenizeIdentifier replaced with a stable per-column token (col_<8 hex chars>). The same column produces the same token within a deployment, so log lines remain correlatable. Default.
dropIdentifier replaced with the literal string [GDPR].
keepIdentifier left in place. Literal values adjacent to the column are still stripped.

The token is derived as sha256(salt + lowercase(column_name)). The salt is set once at startup and does not need to be secret; it separates token namespaces across deployments.

superdebug_log_results

Default false. When true, SQE emits a loud WARN log line and writes a self-audit event of kind admin_ddl to the audit trail recording that the flag is active. Result rows are never written to any audit sink regardless of this flag; the flag name is intentionally alarming. Enable only in isolated development environments and disable before going to production.

Enabling superdebug_log_results in production violates SOC 2, ISO 27001, and GDPR data-minimisation requirements.

Auth provider claim paths

The oidc_password and bearer_token auth providers accept three optional claim-path fields that enrich the audit actor identity:

FieldProviderDefaultPurpose
subject_claimoidc_password, bearer_token"sub"JWT claim used as actor.subject (stable opaque identifier, distinct from user_claim).
email_claimoidc_password, bearer_token""Dot-separated JSON path to the email address in the JWT payload. Empty string disables extraction.
groups_claimoidc_password, bearer_token""Dot-separated JSON path to the groups array. Separate from roles_claim. Empty string disables extraction.

Example bearer_token provider config:

[[auth.providers]]
type = "bearer_token"
jwks_url = "https://auth.corp.example/.well-known/jwks.json"
audience = "sqe"
subject_claim = "sub"
email_claim = "email"
groups_claim = "groups"

When extraction is enabled, the enriched fields appear in the actor block of every event: actor.subject, actor.email, actor.groups. Fields that are absent from the token are omitted from the event rather than serialized as null.

OCSF class mapping

When format = "ocsf" or format = "both", each canonical AuditEvent is mapped to an OCSF class before writing. The mapping is fixed at the kind level.

SQE event kindOCSF classClass UIDCategoryCategory UID
queryDatastore Activity6005Application Activity6
policy_decisionDatastore Activity6005Application Activity6
authAuthentication3002Identity & Access Management3
sessionAuthorize Session3003Identity & Access Management3
grantAccount Change3001Identity & Access Management3
admin_ddlEntity Management3004Identity & Access Management3

A policy denial (policy_decision kind with Failure outcome) maps to class 6005 with status_id = 2. This lets SIEM tools correlate policy denials alongside normal query activity in a single class.

Standard OCSF fields used:

  • class_uid / category_uid: from the table above.
  • status_id: 1 for success, 2 for failure.
  • time: millisecond epoch timestamp.
  • severity_id: fixed at 1 (Informational).
  • actor.user.name: username.
  • actor.user.uid: subject claim, when present.
  • actor.user.email_addr: email claim, when present.
  • actor.user.groups: array of group objects {"name": "..."}.
  • actor.user.roles: roles array.
  • resources: array of {"name": "catalog.namespace.table", "type": "Table"|"View"}.
  • src_endpoint.ip: client IP, when present.
  • metadata.product.name: "SQE".
  • metadata.uid: integrity hash of the record.

SQE-specific fields that have no OCSF home (query hash, statement type, scan stats, policy decisions) travel under unmapped as a flat object.

Tamper-evident hash chain

Every record carries an integrity block:

"integrity": {
  "seq": 42,
  "prev_hash": "a3b4c5...",
  "hash": "d6e7f8..."
}

The hash formula is:

hash = sha256(prev_hash || canonical_json_with_hash_field_blanked)

The first record uses a genesis sentinel (0000...0000, 64 hex zeros) as prev_hash. Sequence numbers are zero-based and strictly increasing.

The verify_chain function in sqe-metrics walks a loaded slice of events and returns an error if any record has an unexpected sequence number, a prev_hash that does not match the previous record’s hash, or a recomputed hash that does not match the stored value. This detects tampering, record deletion, and truncation anywhere in the file.

Redaction and GDPR masking run before chain stamping so the chain covers the post-redaction bytes. Modifying a record to restore redacted content will break the chain.

PII redaction (always-on)

Before any record is written, redact_pii runs unconditionally on the SQL query text. It replaces:

  • Email addresses with [EMAIL]
  • SSNs (XXX-XX-XXXX) with [SSN]
  • Phone numbers with [PHONE]
  • Credit-card-like sequences with [CARD]
  • Secret-keyword literals (TOKEN '...', PASSWORD '...', ACCESS_KEY_ID '...', SECRET_ACCESS_KEY '...', SESSION_TOKEN '...', API_KEY '...', CLIENT_SECRET '...', BEARER '...') with [REDACTED]

The secret-literal pass guards against CREATE SECRET ... TOKEN '<jwt>' landing verbatim in the log. This is belt-and-suspenders: the token is redacted regardless of whether the statement is the direct SQL text or arrives via a prepared statement.

redact_pii is pattern-matching, not a SQL parser. It catches known PII shapes but does not catch free-form sensitive literals such as WHERE patient_id = 'P-998877'. For that, GDPR column masking (see above) strips all literals adjacent to tagged columns, and the fail-closed path strips all literals when tag state is unknown.

Coverage in this release

The table below describes what produces a canonical AuditEvent written to the OCSF spool and OCSF file.

PathSinkFormat
Buffered execute SELECTs (Trino-compat, quack-server, Flight prepared statements, Flight ticket statements)OCSF file + native sinkCanonical AuditEvent with structured Actor and resources
Flight SQL streaming SELECTsOCSF file + native sinkCanonical AuditEvent with structured Actor and resources
DML / DDL (INSERT INTO, CTAS, DELETE, UPDATE, MERGE, CREATE TABLE, ALTER TABLE, DROP TABLE, etc.)OCSF file + native sinkCanonical AuditEvent
GRANT / REVOKEOCSF file + native sinkCanonical AuditEvent
Authentication eventsOCSF file + native sinkCanonical AuditEvent
Session lifecycle eventsOCSF file + native sinkCanonical AuditEvent
Secret-bearing statements (CREATE SECRET, DROP SECRET, SHOW SECRETS, ATTACH, DETACH)Native sink onlyLegacy flat AuditEntry (redacted path; credentials never reach canonical form)

Secret-bearing statements stay on the redacted legacy path. Routing them through the canonical path would risk writing credential literals to the OCSF file before redaction applies. The legacy path runs redaction inline, so bearer tokens and catalog credentials embedded in SQL text are stripped before any byte is written.

The legacy AuditEntry format is flat JSON. It carries username, statement type, duration, status, and tables_touched (unqualified table names), but not structured resources, actor email, groups, or policy decision fields.

Exporting to a SIEM (OTLP)

SQE ships a background exporter that tails the OCSF spool and forwards records to any OTLP-compatible collector (OpenTelemetry Collector, Grafana Alloy, Datadog Agent, etc.). The exporter is off by default.

Config block

[metrics.audit_export]
enabled          = false          # set to true to activate
target           = "otlp"         # only "otlp" is supported; "kafka" is reserved but not built
otlp_endpoint    = ""             # e.g. "http://otel-collector:4317"; empty -> falls back to metrics.otlp_endpoint
spool_path       = ""             # empty -> <audit_log_path>.ocsf.spool.jsonl
batch_max        = 512            # maximum records per OTLP export batch
flush_interval_ms = 2000          # shipper poll interval in milliseconds
max_spool_bytes  = 1073741824     # 1 GiB; spool size above this emits a WARN
start_at         = "now"          # "now" (default) | "beginning"

All keys are optional. The defaults shown are the production-safe values.

enabled

false by default. Setting to true activates the OTLP exporter and the spool writer. Disabling (false) leaves the rest of the audit stack unchanged: the OCSF file is still written when format = "ocsf" or format = "both", and the hash chain is unaffected. The export spool is a separate file.

target

Only "otlp" is implemented. "kafka" is reserved for a future release. Configuring any other value logs a warning and the exporter does not start.

otlp_endpoint

The OTLP/gRPC endpoint URL for the collector. If empty, the exporter falls back to metrics.otlp_endpoint. If both are empty the server logs a warning at startup and the exporter does not start.

spool_path

Path to the OCSF JSONL spool file that buffers events before export. If empty, the path is derived from audit_log_path by replacing the extension with .ocsf.spool.jsonl (e.g. audit.jsonl -> audit.ocsf.spool.jsonl). The file is created on startup if it does not exist.

The export spool is independent of the format setting. When audit_export.enabled = true, every canonical event written via log_event goes to the spool regardless of whether format is native, ocsf, or both.

batch_max

Maximum number of OCSF records sent in a single OTLP export call. Default: 512.

flush_interval_ms

How often the background shipper polls the spool for new records. Default: 2000 ms. Lower values reduce latency to the SIEM at the cost of more frequent OTLP calls.

max_spool_bytes

Spool size threshold in bytes. When the spool file exceeds this value the exporter emits a WARN log. The exporter continues and queries are never blocked. Default: 1 073 741 824 (1 GiB). Spool rotation and automatic pruning are not implemented in this release; size management is left to the operator.

start_at

Controls where the shipper starts on the first run, when no cursor file exists.

ValueBehavior
"now"Scan the spool to its current tail and advance the cursor there without shipping. Historical records are not replayed. New records written after startup are shipped. Default.
"beginning"Ship from the oldest record in the spool. Use after moving the spool or recovering from a cursor loss.

On subsequent restarts the persisted cursor (<spool_path>.cursor) always wins. start_at is not re-applied when a cursor file exists. This guarantees at-least-once delivery: a restart resumes exactly where the last successful export acked.

Behavior and durability

The exporter provides at-least-once delivery. Records are never removed from the spool. The background shipper tails the spool from the byte offset recorded in the cursor file, batches up to batch_max records, sends them to the collector, and advances the cursor only after the collector returns a successful ack.

A collector outage grows the spool. Queries continue without delay. When the collector recovers, the shipper replays from the last cursor position.

The exporter uses a dedicated OTLP log pipeline. It is not connected to the trace_sample_rate tracing bridge, so audit records are never sampled or dropped by the trace sampler.

OTLP record mapping

Each spool line (one OCSF JSON object) becomes one LogRecord in the OTLP batch:

OTLP fieldValue
bodyFull OCSF JSON text of the record
severity_numberINFO for status_id = 1 (success); WARN for status_id = 2 (failure)
timestampOCSF time field (millisecond epoch converted to nanoseconds)
observed_timestampWall clock at ship time

Indexed attributes set on every record:

AttributeTypeSource
ocsf.class_uidintOCSF class_uid (e.g. 6005 for Datastore Activity)
ocsf.category_uidintOCSF category_uid
audit.kindstringHuman-readable class label (e.g. "datastore_activity", "authentication")
audit.status_idintOCSF status_id (1 = success, 2 = failure)
user.namestringactor.user.name from the OCSF record
audit.seqintmetadata.sequence from the OCSF record (the hash-chain sequence number)

SIEM queries that filter on any of these attributes avoid parsing the full body.

Export metrics

MetricTypeDescription
sqe_audit_export_records_total{status}CounterRecords shipped, labeled status="success" or status="failure"
sqe_audit_export_batch_failures_totalCounterOTLP export calls that returned an error
sqe_audit_export_spool_lag_bytesGaugeBytes between the cursor offset and the current end of spool
sqe_audit_export_cursor_seqGaugeLast sequence number successfully acked
sqe_audit_export_last_success_timestampGaugeUnix timestamp of the last successful export

sqe_audit_export_spool_lag_bytes = 0 means the shipper is caught up. A rising value while sqe_audit_export_batch_failures_total is also rising points to a collector connectivity problem.

Deferred

  • Spool rotation and retention. Only a bounded-growth WARN at max_spool_bytes is implemented. Rotation, age-based pruning, and size-capped compaction are not built yet.
  • Kafka target. The target = "kafka" config key is reserved. It is not implemented in this release.

Operator dashboard gate

When [metrics] web_ui = true (default: false), the operator dashboard is enabled on the health port. The dashboard exposes live query state, worker health, and performance counters to authenticated admins. It requires a bearer token and an admin role before it serves any content.

Which routes are gated

RouteAuth required
/ (dashboard HTML)Bearer + admin role
/api/v1/overviewBearer + admin role
/api/v1/queriesBearer + admin role
/api/v1/queries/{id}Bearer + admin role
/api/v1/workersBearer + admin role
/api/v1/metrics/historyBearer + admin role
/healthzOpen (no auth)
/readyzOpen (no auth)
/api/v1/statusOpen (no auth)

The three open endpoints serve Kubernetes liveness/readiness probes and load-balancer health checks. They must not require credentials.

Role check

The admin role list is auth.admin_roles. The same list gates coordinator DDL (CREATE/DROP/ALTER TABLE). A token that validates but holds none of the listed roles gets a 403 Forbidden. An empty admin_roles list is fail-closed: every caller receives 403, because has_admin_role returns false when the configured list is empty. Operators must configure at least one role to grant anyone dashboard access.

Audit behavior

Dashboard access attempts produce an OCSF Authentication event (kind: auth).

Two cases produce an audit line:

  • Access granted (200 OK): an auth event with status: success, the actor’s username, roles, and any subject/email/groups claims carried in the token.
  • Admin role missing (403 Forbidden): an auth event with status: failure, error_type: DashboardAccessDenied, and the actual principal named in actor.username (not “unknown”).

One case does NOT produce an audit line:

  • No token or wrong scheme (401 Unauthorized): no principal was established, so no audit line is written. The counter sqe_dashboard_auth_anonymous_denied_total is incremented instead. This prevents k8s probe traffic from flooding the audit spool and the downstream SIEM.

The bearer token value is never placed in any audit field.

What the dashboard shows

Authenticated admins see per-query records populated from the in-memory tracker. Each record includes:

  • username and roles of the submitting user.
  • client_ip of the submitting client, when available.
  • SQL text after redact_pii masking. Known PII patterns (emails, SSNs, phone numbers, card numbers, secret-keyword literals) are replaced with bracketed placeholders before the value leaves the query layer. Raw SQL is never placed in the response.

The redact_pii pass runs at record assembly time, not at query admission time, so the stored text in the tracker is the original SQL. Masking is applied every time a record is serialised for the dashboard wire format.

client_ip threading

client_ip is threaded end to end through the query path. The execute and execute_stream functions accept client_ip: Option<String>. The value is stored in QueryRecord by the query tracker and carried into audit events for buffered executions, streaming SELECT finalisers, DML/DDL completions, and GRANT/REVOKE statements. The dashboard displays the value directly from QueryRecord.client_ip.

Dashboard-access audit events (kind: auth) carry client_ip when the peer address is available. The health server is served with into_make_service_with_connect_info, so ConnectInfo<SocketAddr> is always present for real TCP connections. If [security] trusted_proxies is configured, the XFF header is honoured with the same rightmost-untrusted-hop rule used by the Flight SQL and Quack paths.

Field consistency

Audit and trace emit sites use the following canonical field names:

  • username (actor identifier)
  • session_id (session correlation)
  • query_id (per-query tracing field)
  • client_ip (source address)

Never-log-result-rows policy

Result rows are never written to any audit sink. AuditEvent has no field for result data and the serialization path has no code path that writes row values. The superdebug_log_results flag does not change this; it is a marker for a future diagnostic mode and its only current effect is the warning and self-audit event described above.

Web UI

SQE serves a read-only web dashboard on the coordinator’s health port (metrics_port + 1, default 9091). It shows the queries the engine is running and has run, per-query timing and fragments, the cluster nodes, and live engine metrics. The data comes from the coordinator’s in-memory query tracker and worker registry. The page adds no new instrumentation and never touches the query path.

SQE web UI: the Overview dashboard

Access

  • Open http://<coordinator-host>:<metrics_port + 1>/. With the default metrics.prometheus_port = 9090, that is http://localhost:9091/.

  • The same port also serves /healthz, /readyz, and /api/v1/status.

  • The UI is off by default. Turn it on with:

    [metrics]
    web_ui = true
    

    This is TOML-only: there is no SQE_METRICS__* environment override for it.

    When off, /healthz, /readyz, /api/v1/status, and the admin endpoints below still respond; the dashboard and the /api/v1/queries* endpoints return 404.

Security

The dashboard and its JSON API (/, /api/v1/overview, /api/v1/queries*, /api/v1/workers, /api/v1/metrics/history) are gated by require_admin_bearer (crates/sqe-coordinator/src/web_auth.rs, applied in build_health_router in sqe_server.rs). A request needs Authorization: Bearer <token> for an identity that holds an admin role. No token, a bad token, or no auth provider configured is 401. A valid non-admin token is 403. The UI is off by default ([metrics] web_ui = false); when off, those routes are not registered and return 404.

/healthz, /readyz, and /api/v1/status stay open. They are the probe surface for Kubernetes and load balancers. The Prometheus /metrics endpoint on the metrics port is also ungated. Keep the metrics and health ports on an internal network.

The UI is strictly read-only. It cannot submit queries, cancel them, or change configuration. The query-detail endpoint omits session id, client IP, and roles so a leaked admin token still exposes less session metadata.

Tabs

  • Overview carries the node identity and capabilities (enabled protocols and ports, catalog backend and URL, storage, memory limit), live resource gauges (memory pool used, concurrency against the configured cap), and the engine metrics (queries by state, rows out, average latency) as stat cards. Each card has a one-hour sparkline, and a query-activity histogram sits below them.
  • Queries lists recent queries with id, user, state, SQL, elapsed time, rows, and bytes scanned. Click a row for the detail: the queue, planning, and execution timing, the rows/bytes/spill/peak-memory totals, and the per-fragment breakdown showing which worker ran each fragment.
  • Cluster shows the worker nodes with health and in-flight load. In single-node mode the coordinator lists itself as one node doing both roles.

Every chart is hoverable. Pointing at a bar or a sparkline point shows the time and value.

JSON API

The page is a thin client over a small JSON API on the same port. The endpoints are stable and safe to scrape directly:

EndpointReturns
GET /api/v1/overviewnode, capabilities, resources, metrics
GET /api/v1/queries?state=<running|finished|failed|all>&limit=<n>recent queries, newest first
GET /api/v1/queries/{id}one query plus its fragments (404 if unknown)
GET /api/v1/workersworker nodes with health and in-flight load
GET /api/v1/metrics/historytime-bucketed series for the charts
GET /api/v1/statusBallista/DataFusion-style cluster status

Admin endpoints

Mutating endpoints on the same port sit behind the same bearer + admin gate as the dashboard. Unlike the dashboard, they are always registered, whether or not metrics.web_ui is on: they are control-plane hooks, not part of the read-only UI, and coupling catalog invalidation to “dashboard enabled” made the hook silently unavailable on a default deployment. The gate fails closed, so with no auth provider configured they answer 401 rather than running.

EndpointEffect
POST /api/v1/catalogs/refreshInvalidates SQE’s catalog caches so a catalog created or rebound out-of-band becomes visible immediately, instead of waiting out coordinator.session_context_cache_ttl_secs. Drops every session’s cached SessionContext and the shared REST-catalog cache. An optional JSON body {"username": "<u>"} scopes the session drop to one user; a bodyless POST invalidates all sessions. Returns {"invalidated": "all" | "session:<u>"}.

The platform’s workspace-provisioning path calls this right after it creates or binds a Polaris catalog, so a new workspace catalog is queryable at once. A pure-SQL client that only needs to refresh its own view can instead run CALL system.refresh_catalog_cache() (see CALL procedures).

# Global refresh (admin bearer required):
curl -XPOST -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://coordinator:9091/api/v1/catalogs/refresh

# Scope the session drop to one user:
curl -XPOST -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" -d '{"username":"alice"}' \
  http://coordinator:9091/api/v1/catalogs/refresh

How it is built

  • One HTML page with vanilla JavaScript, embedded in the binary with include_str!. No Node toolchain, no bundler, no external assets, no web fonts or logos. The visual language follows the Schuberg Philis palette and layout with system fonts.
  • The metrics history is an in-memory ring buffer. The coordinator samples query counts, rows, latency, active queries, and memory-pool usage every five seconds and keeps a rolling one-hour window. GET /api/v1/metrics/history aggregates the samples into one-minute buckets, so the charts advance a bar each minute and the current bar refreshes every sample.

For a longer history, scrape /metrics into Prometheus and chart it in Grafana. The web UI is the at-a-glance view that ships in the binary.

SQE On-Call Runbook

This is the 3 AM runbook. You were paged, you are half awake, and something is broken. Each section follows the same shape: symptom, likely cause, diagnosis, resolution, escalation. Commands assume kubectl against the namespace where SQE runs. Set it once:

export NS=sqe        # change to your namespace
kubectl config set-context --current --namespace="$NS"

Metric names below are the real ones the engine exports (see sqe-metrics/src/lib.rs). Scrape the coordinator or worker metrics port (default :9090) or query them through Prometheus.

A fast triage loop before you dig in:

kubectl get pods -l app.kubernetes.io/name=sqe -o wide
kubectl get events --sort-by=.lastTimestamp | tail -20

1. Worker crashloop

Symptom. One or more worker pods in CrashLoopBackOff or Error. Queries that need distribution slow down or fail. sqe_healthy_workers drops below worker.replicas.

Likely causes.

  • Worker secret mismatch (the coordinator and worker secrets differ, or one is missing). The engine refuses to boot.
  • /readyz never goes green: the worker cannot reach the coordinator or the catalog.
  • Memory limit vs pod limit mismatch: the kernel OOMKills the pod before DataFusion spills.
  • Spill directory not writable (read-only root filesystem, no /tmp emptyDir, or EROFS).

Diagnosis.

# Which workers, and why they restarted.
kubectl get pods -l app.kubernetes.io/component=worker
kubectl describe pod <worker-pod> | sed -n '/Last State/,/Ready/p'

# The actual boot error. A secret mismatch prints a validation message naming
# worker.worker_secret. An OOMKill shows "OOMKilled" as the last-state reason.
kubectl logs <worker-pod> --previous | tail -50

Confirm the secret matches on both sides:

# Both must resolve to the same value.
kubectl get deploy/<release>-coordinator -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="SQE_COORDINATOR__WORKER_SECRET")]}'
kubectl get deploy/<release>-worker      -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="SQE_WORKER__WORKER_SECRET")]}'

Memory pressure and spill behaviour:

# Worker engine limit (bytes). Compare against the pod memory limit.
sqe_coordinator_memory_limit_bytes
# Spill activity. Climbing counters mean the engine is defending itself.
rate(sqe_sort_spill_bytes_total[5m])
rate(sqe_join_spill_bytes_total[5m])
# Pod memory limit, for the comparison above.
kubectl get pod <worker-pod> -o jsonpath='{.spec.containers[0].resources.limits.memory}'

If the last-state reason is OOMKilled, the engine memory_limit is too close to the pod limit. The chart targets ~75% of the pod limit on purpose (see config.worker.memory_limit in values.yaml).

If logs show a spill write error (EROFS, Permission denied, No such file or directory under the spill dir), the writable /tmp emptyDir is missing or the spill dir points outside it.

Resolution.

  • Secret mismatch: set workerSecret once and let the chart inject the same value into both tiers. helm upgrade <release> deploy/helm/sqe --reuse-values --set workerSecret.value=<shared> (or point both at the same workerSecret.existingSecret). See Kubernetes & Helm ISSUE-218.
  • OOMKilled: lower config.worker.memory_limit to ~75% of the pod limit, or raise the pod limit. Roll the workers.
  • Spill on read-only root: confirm the /tmp emptyDir mount exists and config.worker.spill_dir lives under /tmp. The chart wires this by default; a custom overlay may have dropped it.

Escalation. If logs show a panic or a repeated DataFusion internal error (not a config or OOM cause), capture kubectl logs --previous, the pod spec, and the failing query, then page the engine on-call. Tag ISSUE-220 if the worker registers and then disappears (registry flapping, see section 5).


2. Polaris (catalog) down or unauthenticated

Symptom. Queries that touch new tables fail. SHOW TABLES, DESCRIBE, and first-touch reads error. Already-planned, already-cached queries may still run until the metadata cache TTL expires.

Likely cause. The Iceberg REST catalog (Polaris) is unreachable, returning 5xx, or rejecting the bearer token (401/403). SQE wraps catalog calls in a circuit breaker; repeated failures trip it open.

Diagnosis.

# 0 = closed (healthy), 1 = half_open (probing), 2 = open (failing fast).
sqe_catalog_circuit_breaker_state
# Catalog latency. A spike before the breaker opens is the leading signal.
histogram_quantile(0.95, rate(sqe_catalog_request_duration_seconds_bucket[5m]))
# Coordinator logs around catalog calls.
kubectl logs deploy/<release>-coordinator | grep -iE "catalog|circuit|polaris|401|403|5[0-9][0-9]" | tail -40

# Reach the catalog from inside the cluster (uses the configured URL).
kubectl exec deploy/<release>-coordinator -- \
  curl -sS -o /dev/null -w '%{http_code}\n' http://polaris:8181/api/catalog/v1/config

Expected error codes: a 401/403 is auth (token or Polaris RBAC), a 5xx/connection refused is availability. With the breaker open (state 2), SQE fails catalog calls fast instead of hanging.

What still works. Queries against tables whose metadata is already cached keep running until catalog.metadata_cache_ttl_secs expires (default 30s). No new table resolution, no writes, no DDL.

Resolution.

  • Availability: restore Polaris. The breaker moves open -> half_open -> closed on its own once probes succeed. No SQE restart needed.
  • Auth: a 401/403 for everyone points at Polaris or the OIDC provider (see section 3). A 403 for one user is that user’s Polaris privileges, not an outage.

Escalation. If the breaker stays open after Polaris returns healthy from its own health check, capture the coordinator logs and the breaker metric and page the catalog owner. If auth fails cluster-wide, jump to section 3.


3. OIDC provider down

Symptom. New logins fail. The Flight SQL handshake errors for clients that present a username/password. Token refresh fails for long-lived sessions.

Likely cause. The OIDC provider (Keycloak, Auth0, Okta) is unreachable or returning errors. SQE has no service account: every session authenticates against the provider, so a provider outage blocks new authentication.

Diagnosis.

# Failed auth attempts by provider.
sum by (provider) (rate(sqe_auth_attempts_total{status="failed"}[5m]))
# Failed token refreshes. Climbing here means active sessions are about to drop.
rate(sqe_token_refresh_total{status="failed"}[5m])
# Handshake latency. A jump precedes failures when the provider is slow not down.
histogram_quantile(0.95, rate(sqe_auth_duration_seconds_bucket[5m]))
kubectl logs deploy/<release>-coordinator | grep -iE "auth|oidc|token|jwks|handshake" | tail -40

# Reach the provider token endpoint from the coordinator.
kubectl exec deploy/<release>-coordinator -- \
  curl -sS -o /dev/null -w '%{http_code}\n' \
  https://keycloak.example.com/realms/iceberg/protocol/openid-connect/token

What fails vs what keeps working. New logins and token refreshes fail. Sessions with a still-valid access token keep running queries until the token expires or the session hits its idle/absolute timeout. The blast radius grows as tokens age out.

Resolution.

  • Restore the provider. Authentication recovers without an SQE restart.
  • If only refresh fails while the provider is up, check auth.token_refresh_buffer_secs and clock skew between SQE and the provider.
  • A cluster-wide 401 from the catalog (section 2) with healthy Polaris often traces back here: the provider is issuing tokens Polaris rejects.

Escalation. Provider outages are usually owned by the identity team. Hand them the failed-attempt rate by provider and the token-endpoint HTTP code from the curl above.


4. Coordinator OOM / memory pressure

Symptom. Coordinator pod restarts with OOMKilled, or queries slow down and start spilling heavily. Client sessions drop on restart (state is process-local; see Kubernetes & Helm).

Likely cause. A large result set, a heavy single-node plan, or many concurrent queries pushed the coordinator past its engine memory limit. Spill is the defense; an OOMKill means spill could not keep up or could not run.

Diagnosis.

# 0.0 - 1.0. Sustained > ~0.85 means the engine is near its limit.
sqe_coordinator_memory_pressure
# Used vs limit (bytes).
sqe_coordinator_memory_used_bytes
sqe_coordinator_memory_limit_bytes
# Spill counters. Rising = the engine is shedding memory to disk as intended.
rate(sqe_sort_spill_bytes_total[5m])
rate(sqe_join_spill_bytes_total[5m])
# Concurrency. A spike in active sessions often precedes pressure.
sqe_active_sessions
kubectl describe pod <coordinator-pod> | grep -i -A3 "Last State"
kubectl get pod <coordinator-pod> -o jsonpath='{.spec.containers[0].resources.limits.memory}'

If the last state is OOMKilled and spill counters were flat, spill was disabled or the spill dir was not writable. If spill counters were climbing and it still OOMKilled, the engine memory_limit sits too close to the pod limit.

Resolution.

  • Confirm config.coordinator.spill_to_disk = true and the /tmp emptyDir is mounted (the chart does both by default).
  • Lower config.coordinator.memory_limit to ~70-75% of the pod limit, or raise the pod limit, then roll the coordinator.
  • Cap per-query and concurrency budgets in [query]: max_query_memory, max_concurrent_queries, max_result_rows. Per-user limits live under [rate_limit].

Escalation. Repeated OOMKills after lowering the engine limit, with spill active, means a single query is too large for the node. Capture the query from the audit log and the memory metrics, then page the engine on-call.


5. Distributed query hangs / worker registry flapping

Symptom. Distributed queries stall and never return, or sqe_healthy_workers oscillates as workers register and drop. Workers look Running and Ready in kubectl but the coordinator does not keep them.

Likely cause. Worker advertise-URL or reachability mismatch (ISSUE-220): the worker registers an address the coordinator cannot connect back to, or the heartbeat path is blocked. A worker secret mismatch can also cause register -then-reject churn.

Diagnosis.

# Should equal worker.replicas and hold steady. Oscillation = flapping.
sqe_healthy_workers
# Fragment throughput per worker. A worker stuck at 0 while others move is suspect.
sum by (pod) (rate(sqe_worker_fragments_executed_total[5m]))
# Heartbeat and registration churn on the coordinator.
kubectl logs deploy/<release>-coordinator | grep -iE "register|heartbeat|worker|advertise|unreachable" | tail -50

# Can the coordinator reach a worker on the Flight port?
kubectl exec deploy/<release>-coordinator -- \
  curl -sS -o /dev/null -w '%{http_code}\n' http://<release>-worker-0.<release>-worker-headless:50052

Resolution.

  • Confirm workers register the headless-service DNS name the coordinator can resolve, not a pod-local or loopback address. See ISSUE-220 for the advertise-url fix.
  • Rule out a secret mismatch (section 1): a worker that registers then gets rejected churns the registry.
  • A genuinely hung query (workers healthy, fragments at 0) hits the query.timeout_secs ceiling and is cancelled; lower it if hangs are common while you chase the root cause.

Escalation. Flapping that survives a confirmed-correct advertise URL and a matching secret is an engine bug. Capture coordinator logs, sqe_healthy_workers over time, and the worker logs, then page the engine on-call and reference ISSUE-220.


Escalation summary

SurfaceFirst responderPage next
Worker crashloop, coordinator OOM, registry flapSQE on-callEngine on-call
Catalog (Polaris) outageSQE on-callCatalog owner
OIDC provider outageSQE on-callIdentity team

Always attach: the failing pod’s kubectl logs --previous, the relevant metric over the incident window, and the offending query from the audit log (metrics.audit_log_path) when one exists.

SQE Production Guide

How to run SQE in production when you start small but expect to scale data volume, query concurrency, and team count significantly. The goal is one honest configuration path you extend over time, not a throwaway dev stack you replace at scale.

For the full security, performance, quality, and observability audit, see docs/internal/audit/2026-07-10-sqe-full-audit.md.


Principles

  1. Coordinator stays on greedy memory pool. Fair pool starves wide analytic plans at scale.
  2. Set an honest memory_limit. Use 40-50% of allocatable RAM per process, not the config default on a small box.
  3. Spill must be on with fast local disk before you push SF10-class workloads.
  4. Security and audit from day one if compliance or multi-team access matters. Retroactive audit history is not recoverable.
  5. Add workers when scan CPU saturates, not before you have evidence single-node is the bottleneck.

Phase 0: Day one (single coordinator)

Topology

  • One sqe-coordinator or sqe-server (all-in-one). No workers.
  • Slim Docker image (Dockerfile) with REST catalog + SigV4 (rest + rest-sigv4). Use Dockerfile.full only when runtime config must dispatch to Glue, HMS, or other SDK backends.

Security (required even at small scale)

[auth]
# Use real OIDC bearer_token validation.
# Do NOT use: anonymous, bearer_passthrough, or client_credentials as the only provider.

[rate_limit]
enabled = true

[coordinator.tls]
# Enable Flight SQL TLS, or terminate TLS at ingress.

[storage.tvf]
allow_local_paths = false
allow_http = false
allowed_object_store_prefixes = ["s3://your-warehouse/"]

Memory

[coordinator]
memory_pool = "greedy"        # default; do not switch to fair on the coordinator
memory_limit = "12GB"         # tune to ~40-50% of box RAM (example for a 32 GB host)
spill_to_disk = true
spill_dir = "/fast-nvme/sqe-spill"

Greedy pool lets one large operator use the full budget until spill. Fair pool divides memory across every registered spillable consumer. Wide plans (TPC-DS class) register dozens of consumers and fail before spilling when fair is enabled. Workers use fair pool internally; that asymmetry is intentional.

Observability

Enable durable audit and lineage early if governance matters. Do not write audit logs to /tmp or container emptyDir.

[metrics]
prometheus_port = 9090
otlp_endpoint = "http://otel-collector:4317"
trace_sample_rate = 0.10      # 10% at low QPS; lower to 0.01-0.05 at high QPS

audit_log_path = "/var/log/sqe/audit/audit.jsonl"   # must survive pod restart (PVC)

[metrics.audit_export]
enabled = true
target = "otlp"
# OCSF audit ships to SIEM via OTLP logs; uses metrics.otlp_endpoint when otlp_endpoint is empty

[metrics.openlineage]
enabled = true
http_endpoint = "https://marquez.example.com/api/v1/lineage"
spool_path = "/var/spool/sqe-ol"
emit_selects = false            # enable only when read lineage is required

See docs/site/book/src/operations/openlineage.md for sink options and Marquez setup.

Catalog

  • Polaris or Nessie over Iceberg REST.
  • SigV4 in the default build covers AWS Iceberg REST (Glue/S3 Tables federated endpoints) without the full Glue SDK image.
  • Defer full-backends until runtime config actually dispatches to Glue, HMS, or JDBC SQL catalogs.

Phase 1: Growth (roughly 10x data, add workers)

When to add workers

Add sqe-worker replicas when:

  • Coordinator CPU is saturated during scans while network and object storage still have headroom.
  • Single-node benchmarks show a scan throughput ceiling, not memory exhaustion.
  • Large queries hit memory_limit with spill enabled and still need more parallel decode.

Topology

                    Ingress (TLS)
                         |
              +----------+----------+
              |   sqe-coordinator   |  planning, auth, policy, audit
              +----------+----------+
                         |  Arrow Flight + signed scan tickets
         +---------------+---------------+
         v               v               v
    sqe-worker      sqe-worker      sqe-worker

Coordinator: greedy pool. Workers: fair pool (hardcoded in sqe-worker).

Sizing

ComponentStarting pointScale-up signal
Coordinator8 vCPU, 32 GB RAMPlanning latency, auth/policy CPU
Worker8 vCPU, 32 GB RAM eachScan/decode CPU, worker OOM
Spill volume2x expected sort working setSpill stalls, long-running aggregations
memory_limit40-50% of pod RAMResourceExhausted vs host OOM kill

Set SQE_MEMORY_LIMIT in Kubernetes so the process cap matches the pod limit. A documented 64 GB default on a 31 GB node caused kernel OOM in benchmark runs.

Branches to merge before heavy SF10 load

These are reliability fixes, not optional optimizations:

Branch / issueWhat it fixes
fix/367-read-path-memory-trackingRead decode fan-out OOM under parallel scan
fix/366-single-distinct-count-companionBank-class mixed COUNT(DISTINCT) spill
fix/365-idle-timeout-operator-progressFalse abort on long spilling queries
fix/364-groupby-limit-dropGROUP BY ... LIMIT over-return (ClickBench q17)

Phase 2: Large scale (100x data, multi-team, SF10+)

Architecture

  • Distributed execution by default for large table scans.
  • Warehouse on fast object storage with a fair SQE-vs-Trino comparison path (same endpoint for both engines).
  • Polaris with Postgres persistence. In-memory Polaris loses warehouse metadata on stack restart.
  • Coordinator HA (multiple coordinators behind a load balancer) only after you understand session stickiness: sessions are coordinator-local today.

Security hardening

  • Per-user OIDC. No shared service token for all users.
  • Ranger or OPA policy backend (policy.engine not passthrough).
  • mask_key configured for hash column masks.
  • Inline-credential TVFs disabled or restricted to admin roles.
  • Restrict catalog/execution error detail for untrusted clients if needed.

Observability at scale

  • OpenTelemetry collector with tail sampling: always retain slow and failed traces; sample the rest.
  • Alert on:
    • Memory pressure in Red band (>95% pool utilization)
    • sqe_audit_export_spool_lag_bytes growing (SIEM backpressure)
    • sqe_lineage_channel_dropped_total > 0 (lineage overload)
  • Close the DML audit gap: write-path PolicyAudit and QueryStats on INSERT/MERGE/UPDATE/DELETE (tracked in audit doc).

Performance tuning order

  1. Honest memory_limit + spill_to_disk on NVMe
  2. Add workers (2, then 4, then N)
  3. Enable parallel_probe_scan only after memory clamp is validated (TPC-DS regresses without clamp)
  4. jemalloc A/B for glibc RSS parking after large sorts
  5. Do not move coordinator to memory_pool = "fair"

Production baseline (sqe.toml)

Copy and tune per environment:

[coordinator]
memory_pool = "greedy"
memory_limit = "24GB"          # 40-50% of pod allocatable RAM
spill_to_disk = true
spill_dir = "/var/sqe/spill"

[worker]
memory_limit = "24GB"
spill_to_disk = true
spill_dir = "/var/sqe/spill"

[rate_limit]
enabled = true

[metrics]
prometheus_port = 9090
otlp_endpoint = "http://otel-collector.observability:4317"
trace_sample_rate = 0.05

audit_log_path = "/var/log/sqe/audit/audit.jsonl"

[metrics.audit_export]
enabled = true
target = "otlp"

[metrics.openlineage]
enabled = true
emit_selects = false
# Set http_endpoint and/or file_path; use spool_path when HTTP is configured.

[storage.tvf]
allow_local_paths = false
allow_http = false
allowed_object_store_prefixes = ["s3://prod-warehouse/"]

Environment overrides follow the usual SQE_* / SQE_METRICS__* conventions (see sqe.toml.example and docs/site/book/src/deployment/configuration.md).


Memory pool decision

QuestionAnswer
Coordinator: greedy or fair?Greedy (default). Fair only after staging proof on your workload mix.
Worker pool?Fair (current default in sqe-worker). No change needed.
Custom SpillPool?No. Fix pool cap honesty, spill directory, read/write memory tracking, and upstream DataFusion spill limits instead.
Bank SF10 under 8 GB pool?Query-shape and spill fixes (#366, #365), not pool type.

Common scale-up mistakes

MistakeWhy it hurts
memory_pool = "fair" on coordinatorWide plans cap each consumer at pool/N and fail before spill
spill_to_disk = falseSorts and joins error instead of spilling
bearer_passthrough or anonymous authPer-user policy breaks when you add tenants
full-backends image without needLarger binary, slower builds, wider attack surface
Audit on ephemeral storageAudit trail lost on restart
emit_selects = true globallyLineage and audit volume explodes at high QPS
Custom SpillPoolWrong lever; does not fix untracked decode or DF merge spill limits

Observability planes (what to use when)

PlaneUse forSampling
OCSF audit + audit_exportCompliance, SIEM, who-ran-whatNever sample
OpenLineageDataset dependency graph (Marquez/DataHub)N/A
OpenTelemetry tracesLatency debugging, distributed worker visibilitySample in prod; tail-sample slow/failed
Prometheus metricsSLOs, pool pressure, query ratesN/A

Route audit logs and traces through an OpenTelemetry Collector when possible. Audit uses a dedicated OTLP log exporter so records are not dropped by trace sampling or log filters.


Checklist summary

Before first production traffic

  • Real OIDC auth (no anonymous / passthrough)
  • Rate limiting enabled
  • TLS on Flight or at ingress
  • TVF prefix allowlist; local paths and HTTP off
  • memory_pool = greedy, honest memory_limit, spill on fast disk
  • Audit log on persistent volume
  • audit_export and openlineage configured if governance required

Before SF10-class load

  • Distributed workers if scan CPU bound
  • Memory fixes merged (#367, #366, #365, #364)
  • SQE_MEMORY_LIMIT matches pod resources
  • Polaris (or catalog) backed by durable storage
  • OTel alerts on memory Red and audit spool lag

Before multi-tenant

  • Per-user identity end to end
  • Policy backend (Ranger/OPA) with mask_key
  • DML policy fields in audit (when implemented)
  • Inline TVF credentials restricted

  • Deployment configuration
  • OpenLineage operations
  • Full audit: docs/internal/audit/2026-07-10-sqe-full-audit.md (repo only, not published)
  • Example config: sqe.toml.example in the repo root
  • QUICKSTART.md in the repo root for local validation before production cutover

Recent Audit Remediation Notes (2026-07-11)

  • [rate_limit] section and full production examples now documented in sqe.toml.example (see MRs !590, !591).
  • Stale AUDIT.md and the full 2026-07-10-sqe-full-audit.md refreshed with progress (MRs !592, !594).
  • Jemalloc and memory recommendations added to example (an earlier change).
  • Babysat fixes for several R items verified and pushed (!579–!581).
  • Perf Group 4 branches currently at main; MRs !585–!588 have notes.
  • Production config validator, DML audit, observability wiring, and CI for write/distributed have seen substantial progress via prior and current MRs.

Refer to the full audit for the prioritized plan. Keep sqe.toml.example and this guide in sync as more items land.

Rust Crate Structure

SQE is organized as a Cargo workspace with 11 crates. Each crate has a focused responsibility.

Dependency Graph

graph TB
    SERVER["sqe-server binary<br/>(in sqe-coordinator)"]
    CLI["sqe-cli"]

    SERVER --> COORD["sqe-coordinator"]
    SERVER --> WORKER["sqe-worker"]

    COORD --> AUTH["sqe-auth"]
    COORD --> CAT["sqe-catalog"]
    COORD --> SQL["sqe-sql"]
    COORD --> POLICY["sqe-policy"]
    COORD --> PLANNER["sqe-planner"]
    COORD --> METRICS["sqe-metrics"]
    COORD --> TRINO["sqe-trino-compat"]
    COORD --> CORE["sqe-core"]

    WORKER --> PLANNER
    WORKER --> METRICS
    WORKER --> CORE

    AUTH --> CORE
    CAT --> CORE
    SQL --> CORE
    POLICY --> CORE
    PLANNER --> CORE
    METRICS --> CORE
    TRINO --> CORE

    CLI --> CORE

    style SERVER fill:#6f9,stroke:#333
    style CLI fill:#6f9,stroke:#333

Crate Reference

sqe-core

Shared types used across all crates.

ModuleContents
config.rsSqeConfig and all sub-configs, TOML loading, env var overrides
error.rsSqeError enum (Auth, Catalog, Execution, Config, NotImplemented, Internal)
session.rsSession struct (id, user, tokens, expiry), SessionUser (username, roles)
lib.rsVERSION constant

sqe-auth

Keycloak OIDC authentication.

ModuleContents
authenticator.rsAuthenticator: ROPC grant, token refresh, background refresh task
keycloak.rsKeycloakClient: HTTP calls to Keycloak token endpoint, role extraction
token_cache.rsTokenCache: DashMap of session to cached tokens, expiry tracking

sqe-catalog

Iceberg REST catalog client (wraps iceberg-rust).

ModuleContents
rest_catalog.rsSessionCatalog: per-user catalog with bearer token, namespace/table ops
catalog_provider.rsSqeCatalogProvider: DataFusion CatalogProvider bridge
schema_provider.rsSqeSchemaProvider: DataFusion SchemaProvider for Iceberg namespaces
table_provider.rsIceberg to DataFusion TableProvider
credential_vending.rsExtract S3 credentials from Polaris table load response
iceberg_scan.rsIceberg scan configuration
info_schema.rsVirtual information_schema (tables, schemata, columns)

sqe-sql

SQL parsing and statement classification.

ModuleContents
classifier.rsparse_and_classify(sql) to StatementKind, routes all SQL statement types

sqe-policy

Policy enforcement framework (pluggable backend).

ModuleContents
lib.rsPolicyEnforcer trait, PassthroughEnforcer (default no-op)

sqe-planner

Distributed query planning.

ModuleContents
scan_task.rsScanTask: serializable message from coordinator to worker
splitter.rssplit_files(): divide data files across workers

sqe-coordinator

The coordinator: SQL routing, session management, write handling.

ModuleContents
flight_sql.rsSqeFlightSqlService: Arrow Flight SQL server (735 lines)
query_handler.rsQueryHandler: central query routing and execution (596 lines)
session_manager.rsSessionManager: session lifecycle, token refresh integration
worker_registry.rsWorkerRegistry: worker discovery, health checking
catalog_ops.rsDDL operations (DROP TABLE, CREATE/DROP SCHEMA)
write_handler.rsCTAS and INSERT INTO handling
writer.rsParquet file writing to S3
distributed_scan.rsDistributed scan coordination
mode.rsMode enum, resolve_mode() for sqe-server
bin/sqe_server.rsUnified server binary entry point

sqe-worker

Stateless scan executor.

ModuleContents
executor.rsexecute_scan(ScanTask): read Parquet from S3, return Arrow batches
flight_service.rsWorkerFlightService: Flight server for receiving scan tasks

sqe-cli

Interactive SQL client.

ModuleContents
main.rsCLI argument parsing (clap), REPL loop, auth flow
client.rsSqlClient trait, QueryResult type
flight.rsFlightClient: Flight SQL client with handshake and token auth
http.rsHttpClient: Trino HTTP protocol client
display.rsOutput formatting: ASCII table, CSV, JSON

sqe-metrics

Observability stack.

ModuleContents
lib.rsMetricsRegistry: Prometheus counters, histograms, gauges
server.rsPrometheus HTTP /metrics endpoint (axum)
audit.rsAuditLogger: JSONL audit log writer
otel.rsOpenTelemetry init (traces, metrics, logs via OTLP/gRPC)

sqe-trino-compat

Trino wire protocol compatibility layer.

ModuleContents
server.rsTrino HTTP server (/v1/statement endpoint)
protocol.rsRecordBatch to Trino JSON response serialization
types.rsArrow to Trino type mapping

Key External Dependencies

CrateVersionPurpose
datafusion51Query engine (SQL planning, optimization, execution)
arrow / arrow-flight57Columnar data format and Flight SQL protocol
iceberg / iceberg-catalog-rest0.8Iceberg table format and REST catalog
tonic0.14gRPC framework (Flight SQL server + client)
axum0.8HTTP framework (health, metrics, Trino compat)
tokio1Async runtime
sqlparser0.59SQL parsing
moka0.12Async TTL cache (metadata caching)
clap4CLI argument parsing
tracing0.1Structured logging
opentelemetry0.31Distributed tracing and metrics

Testing

SQE has two test tiers, both reached through one entry point: scripts/test.sh.

TierWhat it provesStackEntry point
Tier 1 – engine integrationThe query engine is correct: SQL semantics, joins, DDL/DML, auth, distributed dispatchOne shared docker-compose.test.yml (Polaris in-memory + RustFS)scripts/test.sh engine
Tier 2 – scenariosEach documented use-case works end to end from a clean stateOne stack per scenario (a quickstart’s own docker-compose.yml)scripts/test.sh scenario <name>

Tier 1 is cargo tests against a single lightweight stack. Tier 2 runs the quickstarts: every quickstart/<name>/run.sh --check brings up that scenario’s stack, runs its demo queries, and asserts the invariants that make the scenario correct.

How to run

# Tier 1: engine integration tests (cargo, shared test stack)
scripts/test.sh engine
scripts/test.sh engine test_simple_select   # single test by name

# Tier 2: scenario tests
scripts/test.sh scenario nessie              # one scenario
scripts/test.sh scenario all                 # every self-contained scenario

# Tier 1 + all self-contained scenarios (what CI runs)
scripts/test.sh ci

scripts/test.sh engine delegates to scripts/integration-test.sh; any trailing argument is passed through as a test-name filter. scripts/test.sh scenario <name> runs quickstart/<name>/run.sh --check. You can also run a quickstart directly:

cd quickstart/nessie
cp .env.example .env
./run.sh --check        # up -> queries -> assert invariants
./run.sh                # up -> queries -> capture OUTPUT.md (no assertions)
./run.sh --down         # tear the stack down

The distributed scenario is heavy (it builds the SQE image and runs four containers plus Polaris and RustFS), so it is deliberately absent from the self-contained set and never runs under all or ci. Invoke it explicitly:

scripts/test.sh scenario distributed

Tier 1: engine integration

Tier 1 covers the engine itself. Unit tests run with no external dependencies; integration and SQL-compat tests run against the shared docker-compose.test.yml stack (Polaris in-memory + RustFS).

Test structure

crates/
├── sqe-core/src/              # 40 unit tests (config validation, error types, session, memory limit parsing)
├── sqe-auth/src/              # 17 unit tests (authenticator, OIDC, session)
├── sqe-coordinator/
│   ├── src/
│   │   ├── mode.rs            # 10 unit tests (mode selection)
│   │   ├── worker_registry.rs # 5 unit tests (health checking)
│   │   ├── write_handler.rs   # 2 unit tests (schema conversion)
│   │   ├── catalog_ops.rs     # 5 unit tests (table ref parsing)
│   │   └── distributed_scan.rs # 3 unit tests
│   └── tests/
│       ├── integration_test.rs  # 45 integration tests (end-to-end)
│       └── sql_compat_test.rs   # 5 SQL compatibility tests
├── sqe-catalog/src/
│   ├── credential_vending.rs  # 5 unit tests
│   └── info_schema.rs         # 4 unit tests
├── sqe-sql/src/
│   └── classifier.rs          # 29 unit tests (statement classification)
├── sqe-planner/src/
│   ├── scan_task.rs           # 2 unit tests (serialization)
│   └── splitter.rs            # 5 unit tests (file splitting)
├── sqe-policy/src/            # 8 unit tests (policy enforcer, passthrough)
├── sqe-metrics/src/
│   ├── lib.rs                 # 4 unit tests (metrics registry)
│   ├── server.rs              # 1 unit test (metrics endpoint)
│   ├── audit.rs               # 3 unit tests (audit logging)
│   └── otel.rs                # 1 unit test
├── sqe-trino-compat/src/      # 12 unit tests (type mapping, serialization)
└── sqe-worker/src/
    └── executor.rs            # 3 unit tests (S3 URL parsing)

Unit tests

Unit tests run without external dependencies. They test:

  • Config validation – environment variable parsing, default values, memory limit parsing
  • Error types – error construction, display formatting, conversion
  • Session management – session creation, token fingerprint, expiry
  • Authentication – OIDC flow, token validation, client credentials
  • Policy enforcement – passthrough policy, enforcer trait behavior
  • SQL classification – every statement type routes correctly
  • Mode selection – config/env var priority, case insensitivity, error cases
  • Worker health – state transitions, failure thresholds, recovery
  • Schema conversion – Arrow to Iceberg type mapping
  • Serialization – ScanTask JSON roundtrip
  • File splitting – even/uneven distribution across workers
  • Metrics – counter increment, histogram observation
  • Audit – JSONL serialization, file writing, no-op mode

Run them directly with cargo:

cargo test --workspace          # all workspace unit tests (fast, no stack)
cargo test -p sqe-sql           # one crate
cargo test -p sqe-coordinator -- mode   # one test

Integration tests

Integration tests live in crates/sqe-coordinator/tests/integration_test.rs and require the shared test stack (Polaris, RustFS). They are marked #[ignore] and run via scripts/test.sh engine, which starts the stack, bootstraps it, and runs the ignored tests.

Test inventory (45 tests)

CategoryTestsWhat they validate
Core queriestest_simple_select, test_where_conditions, test_order_limit_offset, test_case_expression, test_math_expressions, test_string_functionsBasic SELECT, filtering, ordering, CASE/WHEN, arithmetic, string functions
Joinstest_inner_join, test_left_join, test_right_join, test_full_outer_join, test_cross_join, test_self_join, test_three_way_join, test_join_with_aggregationAll join types including multi-table and join+GROUP BY
Aggregationtest_aggregation_basic, test_having_clause, test_window_functions, test_window_running_totalGROUP BY, HAVING, OVER(), running totals
Subqueriestest_subquery_where, test_in_subquery, test_exists_subquery, test_scalar_subquery_selectCorrelated and uncorrelated subqueries, IN, EXISTS
CTEstest_cte_join, test_multiple_ctesWITH clauses, multi-CTE queries
Set operationstest_union_allUNION ALL across tables
DDL/DMLtest_ctas_roundtrip, test_insert_into, test_drop_table, test_drop_table_if_exists_no_error, test_create_and_drop_view, test_view_with_aggregationCREATE TABLE AS, INSERT INTO, DROP TABLE, views
EXPLAINtest_explain_plan, test_explain_analyze, test_explain_full, test_explain_policy_awarePlan output, execution stats, policy annotation
Metadatatest_information_schema_tables, test_information_schema_schematainformation_schema virtual tables
Authtest_authentication, test_token_fingerprint, test_keycloak_auth_with_test_users, test_keycloak_token_refresh, test_different_user_catalog_visibilityToken flow, session fingerprinting, per-user catalog isolation
Distributedtest_distributed_select, test_local_fallback_without_workersCoordinator-to-worker scan, graceful fallback to local mode
Trino compattest_trino_http_queryQuery via Trino HTTP protocol adapter

The docker-compose.test.yml stack runs a coordinator with no worker behind it. test_distributed_select intentionally fails when no worker listens on :50052 (issue #122, where local fallback masked distributed dispatch bugs), so full distributed coverage is exercised by the distributed scenario (scripts/test.sh scenario distributed) on docker-compose.distributed.yml, not by this stack.

Running a single integration test

scripts/test.sh engine test_simple_select

SQL compatibility tests

SQL compatibility tests live in crates/sqe-coordinator/tests/sql_compat_test.rs. These 5 tests validate SQL semantic correctness beyond what the integration tests cover. They focus on edge cases in SQL behavior that must match ANSI SQL or Trino semantics, so queries migrating from another engine behave the same way.

The SQL compat tests use the same test stack and configuration as the integration tests. They are also marked #[ignore] and run as part of scripts/test.sh engine.

Each .sql file under crates/sqe-coordinator/tests/sql/ is one #[tokio::test] registered in sql_compat_test.rs. The files use a simple block format and rely on CTEs rather than fixture tables, so each block is self-contained:

--- test_name
SQL statement;
--- expect
col1 | col2
val1 | val2

Add a new case by appending a block to an existing file, or by creating a new file and registering it in sql_compat_test.rs.

Fixture data

Most join, aggregation, view, and window integration tests share two fixture tables, created fresh per test and torn down after:

test_ns.employees

idnamedept_idsalary
1Alice1090000.00
2Bob1085000.00
3Charlie2070000.00
4Dave2075000.00
5Eve3095000.00
6Frank9960000.00

test_ns.departments

iddept_namebudget
10Engineering500000.00
20Marketing200000.00
30Executive1000000.00
40HR150000.00

Test configuration

# tests/sqe-test.toml
[coordinator]
flight_sql_port = 50051
trino_http_port = 8080

[auth]
token_endpoint = "http://localhost:8181/api/catalog/v1/oauth/tokens"
client_id = "root"
client_secret = "s3cr3t"

[catalog]
catalog_url = "http://localhost:8181/api/catalog"
warehouse = "test_warehouse"

[storage]
s3_endpoint = "http://localhost:9000"
s3_access_key = "s3admin"
s3_secret_key = "s3admin"
s3_region = "us-east-1"
s3_path_style = true

The config uses token_endpoint (client_credentials mode) against Polaris’s built-in OAuth.

Tier 2: scenario tests

Tier 2 runs the quickstarts as tests. Each quickstart/<name>/ directory is a self-contained use-case: it brings up everything the scenario needs, runs a few demo queries, and captures the real output. The quickstarts are the user-facing source of truth for “how do I run SQE for X,” and they double as a validation base.

How a scenario asserts

Every run.sh supports three modes:

./run.sh          # up -> queries -> capture OUTPUT.md
./run.sh --check  # up -> queries -> assert the scenario's invariants
./run.sh --down   # tear the stack down (some embedded scenarios use --clean)

--check runs the same demo queries the plain run captures, then asserts the invariants that define correctness for that scenario. The assertion vocabulary lives in quickstart/_shared/lib.sh, shared by every scenario:

  • assert_contains <label> <output> <substring> – output must contain a value (case-insensitive)
  • assert_not_empty <label> <output> – output is non-empty and not 0 rows
  • assert_not_contains <label> <output> <substring> – output must not contain a value (for example error)
  • check_summary – prints the pass/fail totals and exits non-zero if any assertion failed

For example, the nessie scenario asserts that the catalog shows the demo namespace, that the purchase total reads back as 55.25, and that the run produced no error line.

OUTPUT.md and –check: one scenario, no drift

Each quickstart commits an OUTPUT.md: the captured output of a real run, shown in the quickstart README and the docs. The same scenario and the same queries.sql produce both the committed evidence and the asserted invariants. A plain ./run.sh captures OUTPUT.md; ./run.sh --check re-runs the same query file and asserts against it. Because both come from one scenario over one query file, the documented output and the tested behavior cannot drift: changing the queries changes both at once.

Scenario catalog

Scenarios fall into three buckets. The 11 self-contained scenarios run under scenario all and ci. The distributed scenario has its own overlay stack and runs on demand only. The 3 cloud-gated AWS scenarios need real cloud credentials and run only through the manual scenario-test-aws CI job.

ScenarioWhat it coversCategory
polaris-keycloak-client-idPolaris + Keycloak; SQE mints user tokens via the OIDC password grant (client credentials)self-contained
polaris-keycloak-user-tokenSame stack; clients bring a pre-minted Keycloak token (--token), SQE validates and passes it throughself-contained
polaris-ranger-keycloakPolaris + Apache Ranger access control: SQE writes GRANT/REVOKE to Ranger, Polaris enforces, column masks match Spark and Kyuubi byte for byteself-contained
nessieProject Nessie as the Iceberg REST catalog (auth-less, anonymous SQE)self-contained
unity-ossUnity Catalog OSS over Iceberg REST (read-only upstream; catalog-browse demo)self-contained
embedded-filesRead local and remote files directly with the read_* TVFs (no server, no catalog)self-contained
embedded-sqlite-catalogLocal persistent Iceberg catalog backed by SQLite (no server)self-contained
attach-catalogsAttach multiple persistent catalogs in embedded mode plus a cross-catalog JOINself-contained
quackSQE’s DuckDB Quack RPC, both ways: a DuckDB CLI queries SQE, and SQE’s quack_query() pulls from a DuckDB serverself-contained
observabilityScrape SQE’s Prometheus metrics with VictoriaMetrics + Grafana (provisioned dashboard)self-contained
benchmarkGenerate, load, and run TPC-H / TPC-DS / SSB against SQE with per-query timings (sqe-bench)self-contained
distributedA real cluster: coordinator + two stateless DataFusion workers over Arrow Flight, querying Polaris + RustFS (worker dispatch, system tables, query history, CTAS round-trip, result cache, Trino HTTP)own stack, on-demand
aws-glueAWS Glue Data Catalog; CDK bootstrap and teardown, SQE creates the databasecloud-gated
aws-s3-tablesAWS S3 Tables (managed Iceberg); CDK bootstrap and teardown, SQE creates the namespacecloud-gated
glue-lake-formationGlue database governed by Lake Formation: SQE denied until an explicit LF grant, then succeeds (table/DB-level gating, not column or row masking)cloud-gated

The distributed scenario replaces the retired standalone distributed test script: it brings up docker-compose.test.yml plus the docker-compose.distributed.yml overlay (which adds the coordinator and workers and inherits Polaris, RustFS, and Postgres), bootstraps Polaris, and asserts the distributed invariants through the same _shared/lib.sh helpers.

The AWS run.sh scripts do not yet support --check; the scenario-test-aws job invokes their default deploy, verify, and destroy flow directly. Adding a --check mode to the AWS scenarios is a follow-up.

CI

JobTierRunsWhen
integration-testTier 1scripts/integration-test.shScheduled pipelines, merge-to-main push; manual (non-blocking) on MR pipelines
scenario-testTier 2scripts/test.sh scenario all (11 self-contained)Scheduled pipelines and merge-to-main push (on changes to quickstart/, crates/, scripts/test.sh, or compose files); manual (non-blocking) on matching MR pipelines
scenario-test-awsTier 2 (cloud)the three AWS quickstart run.sh flowsManual only; gated on RUN_AWS_SCENARIOS=1 and AWS credentials, never automatic

All three jobs run docker-in-docker (a docker:24-dind sidecar) so each can stand up its own compose stack. scenario-test skips the heavy distributed scenario; run it on demand with scripts/test.sh scenario distributed. The AWS scenarios cost real money against a real account, which is why they are manual and credential-gated.

Benchmark testing

sqe-bench validates SQL correctness and measures performance across industry-standard query suites. The benchmark scenario (scripts/test.sh scenario benchmark) wraps a generate, load, and run cycle, but the benchmark CLI also runs standalone:

# Generate, load, and run TPC-H at scale factor 1 (requires a running stack)
cargo run -p sqe-bench -- generate tpch --scale 1 --output ./data
cargo run -p sqe-bench -- load tpch --scale 1 --data ./data \
  --host localhost --port 60051 --username root --password ""
cargo run -p sqe-bench -- test tpch --scale 1 \
  --host localhost --port 60051 --username root --password ""

# Or use the script wrapper
./scripts/benchmark-test.sh tpch

Benchmark tests differ from integration tests in scope: GB-scale TPC/SSB data instead of small fixtures, full query suites (22 to 99 queries) instead of targeted feature tests, and PASS / DIFF / FAIL / SKIP / ERROR timing reports instead of pass/fail assertions. TPC-H at SF1 runs as a post-merge smoke test; the full suite runs nightly. JSON reports land in benchmarks/results/ and are archived as CI artifacts for regression tracking.

For benchmark commands, scale factors, result formats, and how to add a benchmark, see Benchmark Suite.

Roadmap

SQE is developed in phases, each building on the previous. Last swept: 2026-07-05.

Phase Overview

gantt
    title SQE Implementation Phases
    dateFormat YYYY-MM
    axisFormat %b %Y

    section Core
    Phase 1 - Single Node        :done, p1, 2026-01, 2026-02
    Phase 2 - Write Path         :done, p2, 2026-02, 2026-03
    Phase 3 - Row-Level Writes   :done, p3, 2026-03, 2026-04

    section Scale
    Phase 3b - Benchmarks        :done, p3b, 2026-03, 2026-04
    Phase 4 - Pluggable Auth     :done, p4, 2026-03, 2026-04
    Phase 4b - Streaming/Distributed :done, p4b, 2026-04, 2026-04

    section Shipped
    Phase 2c - dbt Compatibility :done, p2c, 2026-03, 2026-04
    Phase 5 - Pluggable Catalogs :done, p5, 2026-04, 2026-05
    Phase 7 - Iceberg V3         :done, p7, 2026-04, 2026-05
    Embedded Mode                :done, emb, 2026-04, 2026-05
    Phase 9 - OpenLineage        :done, p9, 2026-05, 2026-05
    Phase 6 - Security Policies  :done, p6, 2026-05, 2026-07
    Phase 10 - Benchmarks vs Trino :done, p10, 2026-06, 2026-07
    Phase 8a - Trino Wire Compat :done, p8a, 2026-06, 2026-07
    Write-Path Memory Safety     :done, wms, 2026-07, 2026-07

    section Next
    SF100 Scaling                :sf100, 2026-07, 2026-09
    Phase 8b - Trino Decommission :p8b, 2026-07, 2026-09

Phase 1 - Single-Node Engine (Done)

The foundation: a working SQL engine that queries Iceberg tables through Polaris with Keycloak auth.

  • DataFusion query execution
  • Keycloak OIDC authentication (ROPC grant)
  • Per-session catalog with bearer token passthrough
  • Arrow Flight SQL server
  • CLI client (sqe-cli)
  • SELECT, SHOW CATALOGS/SCHEMAS/TABLES, EXPLAIN
  • Prometheus metrics + structured JSON logging

Phase 2 - Write Path & Views (Done)

SQL write operations and catalog DDL.

  • CREATE TABLE AS SELECT
  • CREATE OR REPLACE TABLE
  • INSERT INTO SELECT
  • INSERT OVERWRITE SELECT (full-table replace; dynamic partition overwrite on partitioned tables, preserving untouched partitions)
  • CREATE VIEW / DROP VIEW
  • CREATE SCHEMA / DROP SCHEMA
  • DROP TABLE / DROP TABLE IF EXISTS
  • Parquet writer (to S3 via Iceberg)
  • Write-path memory safety: pool-tracked write buffers (oversized writes fail with a typed ResourceExhausted instead of OOM), streaming Flight DoPut ingest, streaming MERGE output and (opt-in) MERGE target reads, and an opt-in BoundedFanoutWriter with auto-derived caps for partitioned writes (see Write Path, Memory Safety)
  • Audit logging (JSONL, OCSF): canonical AuditEvent, OCSF class mapping, tamper-evident hash chain, GDPR-tag masking, identity enrichment, SIEM export, web operator log
  • OpenTelemetry export (OTLP/gRPC)
  • Trino-compatible HTTP endpoint

Phase 2c - dbt Compatibility (Done)

Native dbt support via dbt-sqe adapter over ADBC Flight SQL.

  • information_schema virtual providers (tables, schemata, columns)
  • dbt-sqe Python adapter (connection manager, materializations)
  • ALTER TABLE RENAME
  • dbt table, view, and append-only incremental materializations
  • incremental with merge strategy (CoW + MoR)
  • OAuth profile fields: client_id/client_secret (service principal) or a pre-fetched bearer token, next to the original user/password flow
  • Adapter lives at adapters/dbt-sqe/dbt/

Phase 3 - Row-Level Writes (Done)

DELETE FROM, UPDATE, and MERGE INTO are implemented via Copy-on-Write using the iceberg-rust fork vendored at vendor/iceberg-rust/ (DataFusion 54 rebase of risingwavelabs/iceberg-rust), which provides rewrite_files() transaction support.

Strategy: Copy-on-Write

graph TB
    subgraph "Copy-on-Write (Default)"
        READ["Read affected<br/>data files"] --> FILTER["Apply WHERE filter"]
        FILTER --> REWRITE["Rewrite without<br/>deleted/modified rows"]
        REWRITE --> COMMIT["Commit via<br/>rewrite_files()"]
    end

    subgraph "Merge-on-Read (Shipped, opt-in)"
        DELFILE["Write position<br/>delete files"] --> COMMITDEL["Commit via<br/>RowDeltaAction"]
        COMMITDEL --> COMPACT["CALL system.<br/>rewrite_data_files"]
    end

    style READ fill:#6f9
    style FILTER fill:#6f9
    style REWRITE fill:#6f9
    style COMMIT fill:#6f9

CoW rewrites affected data files entirely, and is partition-aware: only the partitions a WHERE clause touches are rewritten. MoR has shipped: set TBLPROPERTIES ('write.delete.mode' = 'merge-on-read') to opt in. SQE writes a position-delete file (no PK) or an equality-delete file (with PK) and commits via FastAppendAction / RowDeltaAction. CoW remains the default for backward compatibility.

Delivered

  • DELETE FROM table WHERE condition - removes matching rows; supports cross-table subqueries; DELETE without WHERE = truncate
  • UPDATE table SET col = expr WHERE condition - modifies matching rows; supports CASE WHEN transformations and cross-table subqueries
  • MERGE INTO target USING source ON condition WHEN MATCHED/NOT MATCHED ... - full outer join approach with WHEN MATCHED/NOT MATCHED clauses
  • All operations atomic via Iceberg snapshot isolation
  • Table maintenance procedures: CALL system.rewrite_data_files, expire_snapshots, remove_orphan_files, rewrite_manifests
  • dbt incremental with merge strategy
  • Integration tests against Polaris + MinIO
  • TPC-C write queries (17/17 pass), TPC-E write queries enabled

Iceberg Dependency

Uses the iceberg-rust fork vendored at vendor/iceberg-rust/ for rewrite_files(). When upstream apache/iceberg-rust ships OverwriteAction (tracked in Epic #2186), the dependency can be migrated back to the official crate.

SQE Changes

FileChange
Cargo.tomlVendored iceberg-rust fork (DataFusion 54 rebase) at vendor/iceberg-rust/
crates/sqe-coordinator/src/delete_handler.rsDELETE FROM execution via CoW
crates/sqe-coordinator/src/update_handler.rsUPDATE execution via CoW
crates/sqe-coordinator/src/merge_handler.rsMERGE INTO execution via CoW
crates/sqe-coordinator/src/query_handler.rsRoutes Merge/Delete/Update to handlers
crates/sqe-coordinator/src/write_handler.rsShared CoW rewrite logic

Phase 4 - Pluggable Auth (Done)

A provider chain replaced the single hard-wired Keycloak ROPC path. Ten providers ship; several can share one listener.

  • oidc_password (ROPC): username/password exchanged for a bearer token per session
  • bearer_token: client-supplied JWT validated against the IdP’s JWKS
  • client_credentials_passthrough: per-connection service principals. The client presents its own client_id/client_secret as Basic auth; SQE runs the client-credentials grant per connection and forwards the resulting token, so a service principal gets the same per-query identity as a user
  • Mixed-auth single listener: opt-in fallthrough_on_reject lets ROPC, service-principal, and bearer providers coexist on one endpoint
  • The Trino HTTP Basic-auth path routes through the same chain as Flight SQL
  • Token caching, session-scoped refresh, catalog token forwarding
  • Demo: quickstart/polaris-ranger-service-principal/

Phase 4b/4c - Distributed Execution (Done)

Scale-out query execution with stateless workers. Implemented via streaming execution in two phases.

graph TB
    subgraph Coordinator
        PLAN["Query Planner"] --> STAGE["Stage Decomposition"]
        STAGE --> SCHED["Scheduler"]
    end

    subgraph Workers
        SCHED -->|ScanTask| W1["Worker 1"]
        SCHED -->|ScanTask| W2["Worker 2"]
        SCHED -->|ScanTask| W3["Worker 3"]
    end

    W1 <-->|DoExchange shuffle| W2
    W2 <-->|DoExchange shuffle| W3
    W1 -->|Arrow stream| MERGE["Multi-endpoint merge"]
    W2 -->|Arrow stream| MERGE
    W3 -->|Arrow stream| MERGE
    MERGE --> CLIENT["Client"]

Delivered

  • Phase A (spill-to-disk): memory pool with watermarks (greedy by default, memory_pool = "fair" restores FairSpillPool), late materialization, file/page pruning, TopK, S3 I/O pipeline (coalescing, footer cache, prefetch), SortMergeJoin fallback
  • Phase B (distributed): DoExchange shuffle, distributed sort (range-partition with sampling), two-phase aggregation, distributed joins (broadcast, shuffle hash, pre-sorted merge, predicate transfer), multi-endpoint Flight SQL, stage decomposition
  • Adaptive sort stripping - memory-aware sort mode selection
  • Metrics - spill, shuffle, late-mat, pruning, time-to-first-row, S3 I/O, auth, write path

Benchmark Results (SF1, distributed 2-worker)

SuitePass RateTimeSpeedup vs single
TPC-H22/2213.5s2.1x
TPC-DS98/9936.1s2.8x
SSB13/135.3s2.7x
TPC-C17/178.6s2.6x

SF10 numbers, the clean-rig methodology, and the per-shape guidance on when distribution pays live in the README performance sections.


Phase 5 - Pluggable Catalogs (Done)

CatalogBackend trait replaced the hard-coded Polaris REST catalog. Eight catalog backends ship today:

BackendStatus
Iceberg REST (Polaris, Lakeformation)Done - default
Unity Catalog OSS (REST-compatible)Done
Snowflake Horizon (REST-compatible)Done
AWS GlueDone
AWS S3 TablesDone
NessieDone
Hive Metastore (Thrift)Done
JDBC (Postgres, MySQL, SQLite)Done

Plus Hadoop storage-only warehouses (no catalog service). Catalogs also mount at runtime via SQL ATTACH ... (TYPE ..., SECRET ...), with credentials managed by CREATE SECRET.

Multi-cloud storage via object_store: S3 (+ endpoint override for R2, Ceph, Garage, MinIO), Azure ADLS Gen2/Blob, GCS, local filesystem, HTTPS, HuggingFace hf://. Engine-side session-manager wiring for Delta + remaining edge cases is the only deferred item; tracked in nextsteps.md.


Phase 6 - Security Policies (Shipped, off by default)

Fine-grained access control via LogicalPlan rewriting. Implemented and pluggable, and off by default: the policy engine defaults to passthrough and access_control.backend defaults to none, so enforcement is opt-in. See GRANT and REVOKE for the SQL surface, backends, and known gaps.

Shipped:

  • Plan-rewriting PolicyEnforcer (row filters and column masks injected before optimization)
  • GRANT/REVOKE with ROWS WHERE and MASKED WITH
  • SHOW GRANTS / SHOW EFFECTIVE GRANTS / CHECK ACCESS
  • Column restriction (invisible columns)
  • Policy caching with TTL (moka)
  • No-information-leakage model (PostgreSQL RLS style)
  • Wired backends: Apache Ranger (production) and an in-memory store (dev / tests)

Fine-Grained Enforcement with Apache Ranger (Done)

Landed June 2026 across five sub-phases; the end-to-end demo is quickstart/polaris-ranger-keycloak/.

  • GRANT/REVOKE to Ranger: access_control.backend = "ranger" translates GRANT/REVOKE/SHOW GRANTS into Ranger Admin REST calls; Polaris’s embedded Ranger authorizer enforces the coarse gate
  • Row filters + column masks (Phase 1): RangerStore downloads the hive service policy set and feeds the plan rewriter; a query must pass both the Polaris gate and the SQE-side rewrite
  • Full mask vocabulary (Phase 2A): MASK_NULL, full redact, show-first-4 / show-last-4, hash, date-show-year, and CUSTOM expressions, all type-preserving through the physical planner
  • Session-context functions (Phase 2B): current_user(), is_role_in_session(role), current_database(), current_schema() usable in policy expressions and user SQL; they const-fold to literals before plan distribution, so no session state ships to workers
  • Tag-based masking (Phase 3a): column-to-tag associations in Iceberg sqe.column-tags table properties, mask-per-tag rules from Ranger tagPolicies, configurable contested-column precedence (policy.mask-precedence, default tag), unmappable tags fail closed
  • Tag authoring DDL: ALTER TABLE ... SET TAGS (col = ('PII')) / UNSET TAGS, plus the Snowflake-compatible MODIFY COLUMN ... SET TAG form
  • FUTURE grants: GRANT ... ON FUTURE TABLES IN SCHEMA x via Ranger table wildcards
  • Conditional masks: a CUSTOM mask expression can reference sibling columns of the same row; qualified references fail closed
  • Spark parity: the same Ranger policies produce byte-exact masked output in SQE and Spark/Kyuubi (parity-test.sh in the quickstart)
  • Cross-engine walkthrough: scripts/access-control-parity-demo.sh runs 34 comparisons over an EU retail bank fixture (a 12-row customer register, a 24-row payment ledger) covering five resource masks, GDPR data-residency row filtering, tag masks that span both tables, and four personas: an analyst, an engineer, a fraud desk that sees every jurisdiction with no customer identity, and an auditor that reads the register unmasked but the ledger only inside a retention window. Each known divergence is asserted with both engines’ expected values rather than skipped

Not yet wired:

  • OPA (Rego) and Cedar were removed: opa had a policy store nothing constructed, cedar had no implementation, and both only ever failed at startup. Apache Ranger is the policy engine
  • Iceberg-to-Ranger tag sync (so Spark sees SQE-authored column tags) and SHOW TAGS read-back
  • See the “Known gaps” in GRANT and REVOKE for SQL-surface limits (no WITH GRANT OPTION, table-level INSERT only, scalar-only masks)

Phase 7 - Iceberg V3 (Done)

Iceberg V3 table format support landed end-to-end. The vendored fork at vendor/iceberg-rust/ is rebased onto DataFusion 54 and carries V3 spec coverage. SQE Iceberg matrix score: 167/189 = 88.4% (per docs/internal/design-archive/iceberg-matrix.md).

V3 Features Shipped

FeatureStatus
Default values (ALTER TABLE ADD COLUMN ... DEFAULT)Done
Schema evolution (ALTER TABLE ADD/DROP COLUMN, incl. nested struct fields)Done
Nanosecond timestamps (TIMESTAMP_NS, TIMESTAMPTZ_NS)Done
Partition evolutionDone
Equality deletes + position deletes (MoR)Done

V3 Features Still Blocked Upstream

FeatureBlocker
Variant typeiceberg-rust #2188 not merged
Geometry typeDataFusion UDT #12644
Vector / Embedding typeIceberg V3 vector spec not finalised
Multi-arg partition transformsIceberg Java spec alignment in progress
Row lineageDeferred upstream

Other Hardening

  • Metadata cache invalidation on DDL
  • Large result-set streaming (Flight SQL do_get back-pressure)
  • Error messages tuned for catalog and auth failures
  • Partition pruning across all predicate types
  • Iceberg parity harness (test/iceberg-parity-harness): DDL/DML/metadata/interop scenarios diffed against Trino on a shared catalog

Embedded Mode (Done)

sqe-cli --embedded turned out to be a DuckDB-class single-process engine with the same SQL surface as the distributed coordinator. The story is in the blog post docs/site/blog/2026-05-07-accidentally-duckdb.md.

  • Persistent SQLite-backed Iceberg warehouse at ~/.sqe/warehouse/, surviving restarts
  • File-format table functions: read_parquet, read_csv, read_json, read_avro, read_delta, plus bare 'file.parquet' paths
  • Remote sources: s3://, https://, HuggingFace hf:// datasets
  • Cross-catalog joins across multiple --catalog NAME=PATH mounts
  • Runtime ATTACH / CREATE SECRET against any of the supported catalog backends
  • Quack RPC (sqe-quack-wire / sqe-quack-server / sqe-quack-client): a pure-Rust port of DuckDB’s BinarySerializer, so DuckDB clients can talk to SQE over HTTP

Full reference: Embedded CLI.


Phase 9 - OpenLineage Emitter (Done)

Coordinator-side OpenLineage 2-0-2 emitter with column-level lineage. Off by default; zero hot-path overhead when disabled.

  • sqe-lineage crate: event types, observer, emitter task, file/HTTP/spool sinks, multi-catalog dataset extractor, column-lineage trace rules across 11 LogicalPlan node types
  • OpenLineageConfig in sqe-core with TOML + env-var overrides + startup validation
  • Hooks in QueryHandler::execute_statement (START / COMPLETE / FAIL)
  • Documented at docs/site/book/src/operations/openlineage.md

Deferrals (documented): mTLS, per-event user-OIDC bearer forwarding, MERGE per-branch column annotations, embedded CLI emit, DDL hint extraction.


Web UI (Done)

A read-only ops dashboard ships in the coordinator binary, on the health port (metrics_port + 1). Queries with per-fragment timing, cluster nodes, live engine metrics with 12h history, and the audit operator log. No login (network-gated), no build step, no external assets. Toggle with [metrics] web_ui. Reference: Web UI.


Phase 10 - Performance & Benchmarks (Done; SF100 next)

The Phase 10 plan called for systematic benchmarking against Trino. That work has run its course at SF1 and SF10; SF100 is the open frontier.

Delivered

  • sqe-bench harness: seven suites (TPC-H, TPC-DS, SSB, TPC-C, TPC-E, TPC-BB, ClickBench), 222 queries, JSON reports committed to benchmarks/results/ with per-suite history plots at docs/evidence/benchmark/
  • Differential validation: sqe-bench compare runs every query against both engines on the same Iceberg tables and diffs the rows; vacuous results (0/0 agreement) are classified, not counted as passes
  • Generator fidelity oracle: generated data validated against DuckDB’s official dbgen/dsdgen output; the oracle caught a TPC-C generator bug and a set of TPC-DS vocabulary gaps (see docs/site/blog/2026-06-12-the-benchmark-that-lied.md)
  • SF1 verdict: SQE wins six of seven suites vs Trino 465 (README receipts)
  • SF10 clean-rig verdict: dedicated host, cache off, DataFusion 54: TPC-H 1.2x, TPC-DS 1.9x, SSB still trailing at 0.5x (scan-bound star joins)
  • Forced-distribution rig: single worker, threshold zero, every fact scan over Flight; exposed and fixed the dynamic-filter pushdown gap to workers
  • Attached golden tables for the read suites: scripts/benchmark-publish-iceberg.sh publishes tpch/ssb/tpcds/tpcbb/clickbench/bank into a persistent Polaris once; BENCH_DATA_SOURCE=attach then runs tpch/ssb/tpcds/clickbench against the attached golden catalog with zero per-run load. Write suites (tpcc/tpce) still generate and load normally, as do bank (published to golden, but attach mode does not yet query it from there) and tpcbb (its own tables are not published, since it shares tpcds’s namespace). Bank/tpcbb via attach, and a shallow-clone path that lets tpcc/tpce share golden data files while writing locally, are the deferred follow-up. See Benchmark Suite.

Perf fixes the benchmarks drove: runtime (dynamic) filter pushdown into iceberg-rust scans with two-tier row-group/page pruning, the dynamic-filter snapshot cache (q12 161s to 2.7s), intra-file scan parallelism (task_split_target_size), parallel parquet decode per byte-range subtask, and the greedy memory pool.

SF100 (Next)

SF100 inverts the SF10 playbook: broadcast joins, in-memory hash tables, and single scan streams each become the bottleneck. Needs memory-pool discipline under concurrency, a true multi-node rig (separate worker hosts), and a streaming data generator. Predicted failure modes: docs/evidence/perf/sf100-scaling-risks.md.

Reliability Testing (Planned)

TestMethodWhat it validates
Chaos: kill worker mid-querykubectl delete pod during scanCoordinator retries/fails gracefully
Chaos: kill coordinatorSIGKILL during queryIn-flight queries fail cleanly, no data corruption
Chaos: Polaris unavailableBlock network to PolarisGraceful error, no hang, cached metadata still works
Chaos: Keycloak unavailableBlock network to KeycloakExisting sessions continue, new auth fails cleanly
Chaos: S3 latency spiketc netem delay on S3Query timeout, not hang
Memory pressureLarge query + small memory limitSpill-to-disk or clean OOM, no silent corruption
Token expiry during querySet very short token TTLRefresh mid-query, or clean auth error
Concurrent DDL + DMLCTAS while DROP TABLE on same tableIceberg conflict detection, clean error
Long-running soak test24h mixed workloadNo memory leaks, no connection leaks, stable latency

Also planned: performance regression CI (catch slowdowns before merge) and a memory/CPU profiling report.


Phase 8 - Trino Decommission (Compat done; wind-down next)

Complete migration from the Trino DCAF fork. The compatibility half of this phase landed in the June-July 2026 push; what remains is the wind-down of the fork itself.

Phase 8a - Wire Compatibility (Done)

  • Verified clients: official Trino CLI 476, Trino JDBC driver, Metabase, Superset, DBeaver, dbt-trino; multi-page pagination verified live
  • Async statement protocol: POST /v1/statement no longer executes inside the HTTP call. Fast queries return the first page inline; long ones return QUEUED with a poll route, so a 60s CTAS survives dbt-trino’s hardcoded 30s request timeout
  • Two test harnesses: testing/tempto/ runs the upstream trino-product-tests Iceberg suite against SQE, and a SQE-vs-Trino parity harness diffs both engines on a shared Polaris catalog
  • Gap sweep #327-#363 closed: parser and utility statements (SHOW CREATE SCHEMA, SET TIME ZONE, bare TABLE, nested ROW casts), scalar and aggregate functions (kurtosis, skewness, date_add/date_diff on nanosecond timestamps), type mapping (ROW/UUID/IPADDRESS casts, Trino type names in metadata), session properties and roles, time travel forms, FETCH FIRST ... WITH TIES, interval rendering, SHOW STATS, and unquoted-identifier folding to match Trino case semantics
  • PREPARE / EXECUTE / DEALLOCATE round-trip, information_schema scoped to the session catalog, DESCRIBE aliased to SHOW COLUMNS
  • ~96% function/feature coverage per Trino Compatibility

Phase 8b - Wind-Down (Next)

  • Dashboard migration playbook (Superset, Grafana, etc.)
  • JDBC driver migration guide (Trino JDBC to Flight SQL JDBC)
  • Performance parity validation on the production workload
  • Runbook for operators
  • Trino fork sunset and decommission

Design Notes

The engineering story behind SQE: the decisions, the dead ends, and the designs that shipped. The ebook Sovereign by Design is the narrative version; these are the technical companions, kept close to current state.

Sovereign Query Engine (SQE)

Goal: Replace patched Trino with a purpose-built, distributed SQL query engine for Iceberg REST Catalog (Apache Polaris) with Keycloak OIDC auth passthrough, OPA-based fine-grained security, and petabyte-scale execution.

Update (2026-06, DataFusion 54): This is the original design rationale, not the current implementation. Two sections are superseded:

  • Distributed execution. The “Ballista-derived” model in section 2.3 (and the “fork Ballista scheduler” approach) was evaluated and wound down on 2026-05-31. SQE ships a bespoke coordinator/worker scheduler over Arrow Flight, not a Ballista fork. See ballista-evaluation-learnings.md.
  • Engine version. SQE is on DataFusion 54 (this doc predates the 53 and 54 upgrades).

The current architecture overview lives in the published Architecture section.


1. Core Architecture

┌─────────────────────────────────────────────────────────────┐
│                      Client Layer                           │
│  JDBC (Arrow Flight SQL)  ·  Trino Wire Compat  ·  HTTP    │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│                   Coordinator Node                          │
│  SQL Parser (w/ extensions) → Analyzer → Optimizer →        │
│  Distributed Planner → Scheduler                            │
│                                                             │
│  Session Manager (Keycloak token lifecycle)                 │
│  PolicyEnforcer trait (no-op now, OPA/Cedar later)          │
│  Metrics Collector (Prometheus)                             │
└──────────────────────────┬──────────────────────────────────┘
                           │  Arrow Flight (plan fragments)
              ┌────────────┼────────────┐
┌─────────────▼──┐  ┌──────▼───────┐  ┌─▼──────────────┐
│   Worker Node   │  │  Worker Node  │  │  Worker Node   │
│   DataFusion    │  │  DataFusion   │  │  DataFusion    │
│   Execution     │  │  Execution    │  │  Execution     │
└────────┬────────┘  └──────┬───────┘  └──┬─────────────┘
         │                  │              │
┌────────▼──────────────────▼──────────────▼─────────────────┐
│              Data Access Layer                              │
│  iceberg-rust (table provider)                              │
│  → Iceberg REST Catalog (Polaris) w/ user OIDC bearer      │
│  → S3 / MinIO (object storage)                             │
│  Iceberg v3 support · Views · Manifest caching             │
└────────────────────────────────────────────────────────────┘

2. Component Breakdown

2.1 SQL Frontend & Custom Extensions

Parser: Fork or extend datafusion-sql (based on sqlparser-rs) to support custom statements:

-- Catalog-aware (Phase 1-2)
SHOW CATALOGS;
SHOW SCHEMAS IN catalog;
CREATE VIEW ... AS SELECT ...;  -- persisted via Iceberg REST

-- Security DDL (deferred to security phase)
GRANT SELECT (col1, col2) ON table TO role_x;
GRANT ROWS WHERE region = 'EU' ON table TO role_eu;
REVOKE ...;
SHOW GRANTS ON table;
SHOW EFFECTIVE POLICY FOR CURRENT_USER ON table;

Implementation path:

  • sqlparser-rs already supports GRANT/REVOKE AST nodes
  • Add custom Statement variants for policy inspection
  • Register a CustomStatementHandler trait in the coordinator that intercepts non-query statements and routes them to the policy backend or Polaris catalog
  • Security DDL handlers are stub/unimplemented until the policy phase

2.2 Authentication: Keycloak OIDC Passthrough

This mirrors your Trino DCAF branch logic, ported to Rust:

Client (JDBC) ──► Coordinator
  │  Credentials: username + password (or refresh token)
  │
  ▼
Coordinator: SessionManager
  │  POST /realms/{realm}/protocol/openid-connect/token
  │  grant_type=password, client_id=sqe-public
  │  → receives access_token, refresh_token, expires_in
  │
  │  Stores token in Session (per-connection, in-memory)
  │  Spawns refresh task (token_lifetime - 30s buffer)
  │
  ▼
On every catalog/S3 call:
  │  Authorization: Bearer {session.access_token}
  │  Forwarded to: Polaris REST, S3 (STS or presigned)

Key design decisions:

  • No fixed service account: every query runs as the authenticated user
  • Token is propagated to workers via plan fragment metadata (Arrow Flight headers)
  • Workers attach the bearer token to their own iceberg-rust catalog calls
  • Refresh is coordinator-side only; workers get fresh tokens per-fragment

Rust crate: Thin keycloak-oidc module (~300 lines), wrapping reqwest + jsonwebtoken for validation. Same scope as your earlier JWT interceptor estimate.

2.3 Distributed Execution (Ballista-derived)

Why not vanilla Ballista: Ballista gives you the scaffolding (scheduler, executor, Arrow Flight transport) but needs significant extension for:

  • Per-query auth context propagation
  • OPA-aware plan rewriting before scheduling
  • Iceberg-specific partition pruning at the scheduler level
  • Custom resource management for PB-scale scans

Approach: Fork Ballista scheduler, keep executor model.

Coordinator (Scheduler)
  ├── Receives LogicalPlan
  ├── Applies OPA row/column filters (plan rewrite)
  ├── Runs DataFusion optimizer (predicate pushdown, projection pruning)
  ├── Converts to PhysicalPlan
  ├── Partitions by Iceberg manifest/data file groups
  ├── Assigns fragments to workers (locality-aware if on-prem)
  └── Streams results back via Arrow Flight

Worker (Executor)
  ├── Receives PhysicalPlan fragment + session context (bearer token)
  ├── Opens iceberg-rust TableProvider with user's token
  ├── Executes scan → filter → project → aggregate
  ├── Streams Arrow RecordBatches back to coordinator
  └── Reports metrics (rows scanned, bytes read, duration)

Scaling model:

  • Workers are stateless, horizontally scalable (K8s Deployment)
  • Coordinator can run HA with leader election (etcd or K8s lease)
  • For PB queries: coordinator splits by Iceberg partition spec, then manifest, then data file groups
  • Backpressure via Arrow Flight flow control

2.4 Iceberg Integration (iceberg-rust)

Table Provider:

#![allow(unused)]
fn main() {
struct SovereignIcebergProvider {
    catalog_url: String,       // Polaris REST endpoint
    bearer_token: String,      // From user session
    table_ident: TableIdent,
    // Cached metadata
    schema: Arc<Schema>,
    partition_spec: PartitionSpec,
    // Config
    s3_config: S3Config,       // endpoint, region, path-style
}

impl TableProvider for SovereignIcebergProvider {
    // Schema from Iceberg metadata
    // scan() → IcebergScan with predicate pushdown to manifest filtering
    // supports_filters_pushdown() → uses partition pruning
}
}

Iceberg v3 support: ✅ Shipped

  • iceberg-rust 0.8.0 (Jan 2026) includes V3 metadata format support
  • V3 manifests with delete file content (Puffin-based deletion vectors)
  • Row lineage tracking for data governance
  • Default values for NULL handling (initial-default + write-default)
  • No blockers: build directly on 0.8.0+

Views:

  • Iceberg REST catalog supports POST /v1/namespaces/{ns}/views
  • Implement as CREATE VIEW, serializing SQL to Polaris view representation
  • On read: resolve view SQL, parse, and inline into the query plan

2.5 Security: Future Phase (OPA or similar)

Column-level and row-level security via OPA plan rewriting is a planned future extension. The architecture will support a PolicyEnforcer trait on the coordinator that rewrites the LogicalPlan before optimization (injecting row filters, column masks, projection stripping). This is intentionally deferred to keep the initial scope focused on the core query path.

Design hook for later:

#![allow(unused)]
fn main() {
/// Trait for pluggable security policy enforcement.
/// OPA, Cedar, or custom implementations can be swapped in.
trait PolicyEnforcer: Send + Sync {
    async fn evaluate(
        &self,
        user: &SessionUser,
        plan: LogicalPlan,
    ) -> Result<LogicalPlan>; // Returns rewritten plan with security filters
}

/// No-op implementation for Phase 1-3
struct PassthroughEnforcer;
}

The custom SQL extensions (GRANT, REVOKE, SHOW GRANTS) will also be deferred until this phase, as they depend on having a policy backend to write to.

2.6 JDBC Access: Arrow Flight SQL

Primary interface: Arrow Flight SQL (JDBC driver already exists: org.apache.arrow.flight.sql.FlightSqlClient)

  • Standard JDBC apps (DBeaver, Tableau, dbt) connect via Arrow Flight SQL JDBC driver
  • Wire format is Arrow IPC: zero-copy where possible, columnar-native
  • Supports getTables, getSchemas, getCatalogs metadata calls
  • Prepared statements map to DataFusion’s LogicalPlan caching

Trino wire compatibility (optional, lower priority):

  • Trino uses a custom HTTP REST protocol (v1/statement)
  • Implement as a thin HTTP adapter that translates Trino wire into internal DataFusion plan
  • Scope: POST /v1/statement, GET /v1/statement/{id}/{token}, DELETE
  • Enables existing Trino clients/dashboards to connect without driver changes
  • Consider: is this worth the maintenance cost vs. migrating clients to Flight SQL?

2.7 Observability & Metrics

Coordinator / Workers
  │
  ├── Prometheus /metrics endpoint
  │   ├── sqe_queries_total{status, user}
  │   ├── sqe_query_duration_seconds{quantile}
  │   ├── sqe_rows_scanned_total{table}
  │   ├── sqe_bytes_read_total{table}
  │   ├── sqe_active_sessions
  │   ├── sqe_catalog_requests_total{endpoint, status}
  │   ├── sqe_s3_requests_total{operation, status}
  │   └── sqe_worker_tasks_active{worker}
  │
  ├── OpenTelemetry traces
  │   └── Per-query span tree: parse → auth → opa → optimize → schedule → execute
  │
  └── Query audit log (structured JSON)
      └── {timestamp, user, query_text, tables_accessed, opa_decision, duration, rows_returned}

3. Project Structure

sovereign-query-engine/
├── Cargo.toml (workspace)
├── crates/
│   ├── sqe-core/           # Shared types, config, errors
│   ├── sqe-sql/            # Extended SQL parser (sqlparser-rs fork/extension)
│   ├── sqe-auth/           # Keycloak OIDC, session manager, JWT validation
│   ├── sqe-policy/         # PolicyEnforcer trait, no-op impl (OPA/Cedar later)
│   ├── sqe-catalog/        # Iceberg REST catalog client (wraps iceberg-rust)
│   ├── sqe-planner/        # LogicalPlan → PhysicalPlan, partition-aware splitting
│   ├── sqe-coordinator/    # Scheduler, Flight SQL server, session management
│   ├── sqe-worker/         # Executor, DataFusion runtime, Flight client
│   ├── sqe-trino-compat/   # Optional Trino wire protocol adapter
│   ├── sqe-metrics/        # Prometheus exporter, OTel integration
│   └── sqe-lineage/        # OpenLineage 2-0-2 emitter (column-level lineage; file + HTTP sinks; disk-spool fallback)
├── docker/
│   ├── Dockerfile.coordinator
│   ├── Dockerfile.worker
│   └── docker-compose.yml  # Local dev: coordinator + 2 workers + Polaris + Keycloak + MinIO
├── helm/                   # K8s deployment
├── tests/
│   ├── integration/        # End-to-end: JDBC → query → Iceberg → S3
│   └── tpc/                # TPC-H / TPC-DS benchmarks at scale
└── docs/
    └── architecture.md

4. Technology Choices

ConcernChoiceRationale
Query engineDataFusionExtensible, Rust-native, Arrow-native, active community
DistributionBallista (forked)Arrow Flight transport, scheduler model, but needs auth extension
Icebergiceberg-rust 0.8.0+Rust-native, V3 metadata shipped, REST catalog, DataFusion integration
AuthKeycloak OIDCYour existing IdP, password grant to bearer passthrough
Fine-grained securityDeferred (OPA/Cedar)Architecture has PolicyEnforcer trait hook; plug in later
JDBCArrow Flight SQLStandard driver, columnar wire format, metadata API
StorageS3 / MinIOYour existing object store
CatalogApache PolarisYour existing REST catalog
MetricsPrometheus + OTelStandard observability stack
DeploymentK8s (Helm)Stateless workers, HA coordinator
LicenseApache 2.0Matches your stack constraints

5. Implementation Phases

Phase 1: Single-node proof of concept (4-6 weeks)

  • DataFusion + iceberg-rust 0.8.0 reading tables via Polaris REST (v3 native)
  • Keycloak OIDC token acquisition and passthrough
  • Arrow Flight SQL server (single node, no distribution)
  • Basic JDBC connectivity (DBeaver test)
  • Goal: SELECT * FROM iceberg_table WHERE x = 1 works end-to-end with user auth

Phase 2: Views & write path (3-4 weeks)

  • Iceberg views (CREATE VIEW to Polaris REST, inline resolution on read)
  • INSERT INTO via iceberg-rust 0.8.0 partitioned writer
  • Manifest caching for metadata performance
  • Audit logging (structured JSON query log)

Phase 3: Distributed execution (4-6 weeks)

  • Ballista-derived scheduler + worker model
  • Auth context propagation via Flight metadata
  • Partition-aware query splitting (Iceberg manifest-level)
  • Multi-worker execution with result aggregation
  • Backpressure and failure handling

Phase 4: Production hardening (4-6 weeks)

  • Prometheus metrics + OTel tracing
  • TPC-H benchmarking
  • Helm chart + CI/CD
  • Trino wire compat (if needed)

Phase 5: Security & policy (future)

  • OPA (or Cedar) integration with plan rewriting
  • Column masks, row filters
  • Custom SQL: GRANT, REVOKE, SHOW GRANTS
  • Policy-based column redaction

Phase 6: Scale validation

  • TPC-DS at TB/PB scale
  • Concurrent query workloads
  • Worker auto-scaling

6. Key Risks & Mitigations

RiskImpactMitigation
iceberg-rust v3✅ ResolvedShipped in v0.8.0 (Jan 2026): V3 metadata, manifests, delete file content
Ballista maintenance uncertainFork divergenceKeep fork minimal; upstream what you can
Trino compat complexityScope creepMake it optional; Flight SQL is the primary interface
PB-scale partition planningCoordinator bottleneckStream manifest processing; hierarchical planning
DataFusion write path maturityMay need contribv0.8.0 added INSERT INTO partitioned + fanout writers; evaluate gaps

7. Relation to Existing Trino Patches (DCAF branch)

Your Trino DCAF branch proves three things that directly inform this design:

  1. User-scoped token passthrough works with Polaris: the catalog respects per-user bearer tokens
  2. Keycloak password grant to OIDC to catalog is a viable auth flow
  3. The catalog + S3 access pattern is well-understood and tested

SQE is essentially a clean-room rebuild of this proven pattern in a Rust-native, DataFusion-based engine where you control the full stack. No more patching a Java monolith to get the auth model you need. With iceberg-rust 0.8.0 shipping V3 metadata support and improved DataFusion integration (partitioned inserts, fanout writers), the Rust ecosystem is now mature enough to build this on.

Ballista evaluation: what we learned, and why we wound it down

Date: 2026-05-31 Status: final. Ballista integration removed from the codebase on branch a maintenance branch. The detailed design and phase reports are archived under docs/archive/ballista-evaluation/.

The one-paragraph version

We evaluated Apache Ballista 53 as a drop-in distributed execution engine, opt-in behind a [query] engine = "ballista" flag, with the bespoke layer staying the default. It reached correctness parity on the common path (TPC-H 22/22 at SF0.1 and SF1) but lost on the parts that matter: it is roughly 2.2x slower where it completes, it cannot finish the TPC-DS analytical core, and its scheduler is less capable than the one we already have. Adopting it would have been a step down in execution, traded for a maintained scheduler we do not actually need yet. So we removed the integration and kept the bespoke layer. This document records what the experiment taught us, so the next person does not repeat it, and so the genuinely useful findings survive.

What we set out to do

The goal was to reduce the bespoke distributed-execution plumbing (roughly 11.5K lines: distributed_scan, shuffle, stage_planner, distributed_join|sort|aggregate, worker_registry, heartbeat, channel_pool, credential_refresh) by leaning on Ballista’s scheduler. The contract was deliberately cautious: bespoke stays the default, Ballista is opt-in, and the bespoke layer retires only when Ballista reaches functional parity AND speed parity. Correctness first, speed last.

We built the whole opt-in path: a sqe-ballista crate with logical and physical extension codecs, a coordinator-embedded scheduler, sqe-worker running as a Ballista executor, per-user bearer threaded through the plan, and SQE’s UDFs registered on the scheduler and executor. It worked end to end for simple queries.

What we found

The honest verdict, measured on the same debug build and the same machine (co-located scheduler plus two executors):

WorkloadBespokeBallista
TPC-H (common path)22/22, ~10.8s22/22, ~24s (~2.2x slower)
TPC-DS (analytical core)99/99, ~33sdoes not complete
SSB13/1311/13, ~18x slower
Cross-stage dynamic filtersyesdisabled
Cache-affinity placementyesround-robin only
Per-task STS credential refreshyesno hook

The product surface (auth, protocols, Trino-compat, GRANT/REVOKE, masks, row filters, catalog backends) never moves to Ballista. It lives coordinator-side, above the execution seam, and we keep all of it regardless of engine. So the only real comparison is the distributed-execution layer, and there Ballista is behind what we already built, not ahead.

The catch that makes this decisive: Ballista does not sell the scheduler alone. The scheduler is welded to its executor and shuffle protocol. To get the maintained scheduling brain you have to swallow the executor, and that executor is the thing that loses on speed and cannot run TPC-DS. There is no seam to borrow the cheap half.

Why TPC-DS does not complete (the two real blockers)

These two are upstream Ballista or datafusion-proto problems, not anything SQE diverged on. Both are worth filing upstream.

  1. Aggregate output naming does not survive the proto round-trip. Multi-stage queries with count(*) over joins fail on the executor with a DataFusion internal assertion: Input field name <col> does not match with the projection expression count(*). The mismatch is in an AggregateExec/ProjectionExec pair that crosses the stage boundary via Ballista’s default physical codec (datafusion-proto), not SQE’s scan codec. We saw 48 occurrences in a single TPC-DS sweep. This blocks the analytical core.

  2. A task error evicts the whole executor. When a task fails with the assertion above (a query-level error), Ballista’s scheduler treats it as an executor transport failure and removes the executor from its registry. The mechanism is precise: in the push path, launch_tasks RPCs LaunchMultiTask; the executor’s handler decodes the physical plan from the task proto and returns Status::invalid_argument if decoding fails; that surfaces at the scheduler as an internal error, and state/mod.rs evicts the executor with the comment “It’s OK to remove executor aggressively.” So one bad query degrades the whole cluster. A task InvalidArgument (fail the query) should be distinguished from a transport failure (evict the executor).

There was also a simpler hang on the first pass (executors blocking the tokio runtime in a sync codec decode that did an async catalog round-trip per task). We fixed that by serializing enough table state into the plan bytes to rebuild the scan without a catalog call. That fix was necessary and worked, but it only peeled the top layer and exposed the two blockers above.

Ballista’s architecture, briefly

Three roles. The scheduler (a gRPC server) splits the physical plan into stages at shuffle boundaries, holds cluster state in memory, and push-schedules tasks to executors. Each executor (gRPC plus an Arrow Flight server) registers, heartbeats, runs a stage fragment, writes shuffle partitions to local disk, and serves them to peer executors over Flight. The client submits a plan to the scheduler and streams the final stage back. Plans cross the wire as protobuf, with extension codecs for custom nodes, and those codecs must match at all three sites.

What Ballista deliberately does not have: any authentication or per-user identity, any frontend protocol beyond its own, any SQL dialect or policy layer, any catalog abstraction, and any per-task credential hook. It assumes a trusted, single-tenant, internal cluster. That is the right design for what it is, and exactly why the entire SQE product surface has to sit in front of it.

Where SQE is actually ahead

This surprised us. SQE’s WeightedScheduler (least-loaded bin-packing with consistent-hash worker affinity) and WorkerRegistry (health plus in-flight load) are ahead of Ballista 53’s scheduler, which only offers Bias and RoundRobin slot-binding. Ballista 53 even removed its consistent-hash policy (a source comment notes it does not work in pull mode), has no locality/data-affinity for source scans, and has no speculative execution or straggler handling. So “learn from Ballista’s scheduling” resolved to: do not borrow its scheduling core.

Borrowable ideas (the useful residue)

These are worth lifting into the bespoke layer or the planned web UI. File and line references point into the Ballista 53 source for whoever implements them.

  1. Failure taxonomy with retryable and count_to_failures flags. A small enum of failure reasons, each tagging whether to retry and whether the attempt counts toward a cap, cleanly separates transient I/O from query-level errors from shuffle-data-loss. This is precisely the distinction the eviction bug blurred, and the lesson applies to our own failure handling. (ballista-core/src/error.rs)

  2. The REST observability API and its JSON shapes. Ballista 53 ships no bundled web UI, only a feature-gated axum REST API under /api: state, executors, executor/{id}, jobs, job/{id}, job/{id}/stages, job/{id}/dot. The response shapes (JobResponse, QueryStageSummary, TaskSummary, ExecutorResponse, including per-stage task duration and input-row percentiles) map almost one-to-one onto SQE’s existing QueryRecord, FragmentInfo, and WorkerState. This is the contract to mirror for an SQE web UI, the same way Trino’s UI organizes queries, stages, tasks, and workers. (ballista-scheduler/src/api/)

  3. DOT/SVG query-graph generation from the execution graph. Ballista turns the stage DAG into Graphviz and serves it at /api/job/{id}/dot[_svg]. A low-effort way to give a query-plan visualization. (ballista-scheduler/src/state/execution_graph_dot.rs)

  4. A five-state stage machine with explicit Resolved/UnResolved. Modeling “inputs not yet ready” as a first-class state, gated by a resolvable() check, makes shuffle-dependency scheduling explicit and testable. (ballista-scheduler/src/state/execution_stage.rs)

  5. Per-partition attempt tracking inside the stage. A Vec<usize> of per-partition failure counts alongside the task list gives clean two-tier (task then stage) retry caps. Our FragmentInfo could adopt the same shape.

  6. An encoded-stage-plan cache. Memoizing serialized physical plans so re-binding a stage’s tasks does not re-encode the plan each time. Relevant to our per-fragment dispatch hot path.

What to skip: Ballista’s slot-binding policies (ours are better), its lack of scan locality, and its absence of speculative/straggler handling (a real gap in both systems, worth solving on our own terms).

What we kept from the experiment

  • The ADBC unpadded-base64 handshake fix in the Flight SQL server. We found it while testing through an ADBC client: the coordinator’s Basic-auth decoder was padding-strict and rejected the unpadded base64 that the Go ADBC driver sends, so no ADBC client (including the dbt-sqe adapter) could connect at all. That fix stands on its own and stays.
  • The /api/v1/status health endpoint (Ballista/DataFusion-style cluster status JSON) predates this work and is unaffected.

Where the detail lives

The full design, the phase reports, the PoC, and the divergence ledger (D1 through D13) are archived under docs/archive/ballista-evaluation/. The git history on the abandoned a feature branch branch carries the implementation if it is ever needed again.

================================================================================ dbt Core Compatibility: What SQE Needs

dbt Core talks to databases through Python adapter plugins. Each adapter must:

  1. CONNECT — Python DB-API 2.0 or ODBC connection
  2. METADATA — Discover catalogs, schemas, tables, columns
  3. DDL — CREATE TABLE AS, CREATE VIEW AS, DROP, ALTER, RENAME
  4. DML — INSERT INTO, MERGE INTO (for incremental)
  5. QUERY — Standard SELECT (already works)

Here’s where SQE stands today vs. what dbt needs:

┌──────────────────────────┬────────────────────┬───────────────────────────────┐ │ dbt Requirement │ SQE Status │ Gap / Notes │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ Python connection │ ✅ Implemented │ adbc_driver_flightsql has │ │ │ │ DB-API 2.0 interface │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ getCatalogs/getSchemas/ │ ✅ Implemented │ SHOW CATALOGS/SCHEMAS/TABLES │ │ getTables/getColumns │ │ + Flight SQL metadata │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ SELECT queries │ ✅ Implemented │ Full DataFusion SQL support │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ CREATE VIEW AS SELECT │ ✅ Implemented │ Via Polaris REST view API │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ DROP VIEW [IF EXISTS] │ ✅ Implemented │ Via Polaris REST view API │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ CREATE TABLE AS SELECT │ ✅ Implemented │ iceberg-rust 0.8 write path │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ CREATE OR REPLACE TABLE │ ✅ Implemented │ DROP IF EXISTS + CTAS │ │ AS SELECT │ │ │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ DROP TABLE [IF EXISTS] │ ✅ Implemented │ Polaris REST catalog │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ ALTER TABLE RENAME │ ✅ Implemented │ Polaris REST catalog │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ ALTER TABLE schema │ ✅ Implemented │ ADD/DROP/RENAME COLUMN, │ │ evolution │ │ SET/DROP NOT NULL, type widen │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ CREATE/DROP SCHEMA │ ✅ Implemented │ Polaris namespace operations │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ INSERT INTO SELECT │ ✅ Implemented │ iceberg-rust fast_append │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ MERGE INTO │ ✅ Implemented │ CoW via rewrite_files() │ │ │ │ (RisingWave iceberg-rust fork) │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ DELETE FROM (with pred.) │ ✅ Implemented │ CoW via rewrite_files() │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ UPDATE │ ✅ Implemented │ CoW via rewrite_files() │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ information_schema │ ✅ Implemented │ Virtual tables/columns/ │ │ │ │ schemata providers │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ Transactions (BEGIN/ │ ⚠️ N/A │ Iceberg gives atomic commits │ │ COMMIT) │ │ per statement; multi-stmt │ │ │ │ txns not needed for dbt │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ Seeds (batch INSERT) │ ✅ Implemented │ dbt-sqe adapter batches rows │ │ │ │ (1000 per INSERT statement) │ ├──────────────────────────┼────────────────────┼───────────────────────────────┤ │ dbt-sqe Python adapter │ ✅ Implemented │ adapters/dbt-sqe/ — table, │ │ │ │ view, incremental, seed macros│ └──────────────────────────┴────────────────────┴───────────────────────────────┘

Summary: ALL dbt requirements are implemented as of April 2026.

Write path: DELETE, UPDATE, and MERGE use Copy-on-Write (CoW) via the RisingWave iceberg-rust fork’s rewrite_files() transaction API. No longer blocked on upstream iceberg-rust OverwriteAction.

dbt-sqe adapter: Fully implemented at adapters/dbt-sqe/ with ADBC Flight SQL connectivity, table/view/incremental (append, delete+insert, merge) materializations, seeds (batch INSERT), and dbt docs catalog generation.

Remaining work: integration tests and end-to-end dbt project validation (see task checklist at bottom of this file).

================================================================================ Two Paths to dbt Compatibility

PATH A: Native dbt-sqe adapter ✅ IMPLEMENTED ────────────────────────────────────────────── Custom dbt adapter plugin at adapters/dbt-sqe/. Talks to SQE via ADBC Flight SQL.

dbt Core ←→ dbt-sqe adapter (Python) ←→ ADBC Flight SQL ←→ SQE

Why this was chosen:

  • Full control over SQL generation and materialization macros
  • No Trino baggage or protocol translation overhead
  • Arrow-native wire format (ADBC), no JDBC serialization
  • Can tailor materializations to Iceberg-specific capabilities
  • Clean, minimal dependency chain

Supports: table, view, incremental (append, delete+insert, merge), seed, catalog

PATH B: Trino compat layer + dbt-trino (alternative) ───────────────────────────────────────────────────── Use the trino-compat wire protocol adapter and the existing dbt-trino adapter.

dbt Core ←→ dbt-trino adapter ←→ Trino HTTP protocol ←→ SQE trino-compat

Status: SQE has a functional Trino HTTP compat endpoint (26/28 SQL tests pass, see docs/trino-client-compatibility.md). dbt-trino has NOT been tested against SQE’s Trino endpoint. This path is available as a fallback but Path A is the primary integration.

Why Path A was preferred:

  • Trino wire protocol is complex (HTTP pagination, session properties, transaction semantics, error format)
  • Performance: HTTP JSON wire format instead of Arrow-native ADBC
  • Maintaining compat with dbt-trino updates is an ongoing burden
  • dbt-sqe adapter is ~2000 lines of Python with well-documented interfaces

================================================================================ FILE: openspec/specs/dbt-adapter/spec.md (new spec domain)

dbt-adapter Specification

Purpose

Enable dbt Core to use SQE as a data platform via a native Python adapter plugin (dbt-sqe) that connects over ADBC Arrow Flight SQL.

Requirements

Requirement: dbt connection via ADBC Flight SQL

The system SHALL be accessible from dbt Core via a Python adapter plugin that uses adbc_driver_flightsql for connectivity.

Scenario: dbt connection profile

  • GIVEN a dbt profile configured with:
    my_project:
      target: dev
      outputs:
        dev:
          type: sqe
          host: localhost
          port: 50051
          user: jacob
          password: "{{ env_var('SQE_PASSWORD') }}"
          catalog: production
          schema: finance
          threads: 4
    
  • WHEN dbt runs dbt debug
  • THEN the connection succeeds and reports the SQE version

Requirement: Catalog metadata discovery

The system SHALL support metadata queries that dbt uses to discover existing objects (catalogs, schemas, tables, columns, views).

Scenario: dbt resolves existing tables

  • GIVEN tables exist in production.finance
  • WHEN dbt runs dbt run and resolves {{ ref('stg_transactions') }}
  • THEN the adapter queries SQE metadata to determine if the table exists
  • AND returns schema information (column names, types)

Scenario: information_schema queries

  • GIVEN dbt macros query information_schema.tables and information_schema.columns
  • WHEN these queries are executed
  • THEN SQE returns virtual information_schema results derived from Iceberg catalog metadata

================================================================================ FILE: openspec/specs/write-path/spec.md (new spec domain)

write-path Specification

Purpose

Support write operations required for data transformation workflows: CREATE TABLE AS SELECT, INSERT INTO, MERGE INTO, DELETE, DROP, and ALTER TABLE. These are essential for dbt materializations and general ETL.

Requirements

Requirement: CREATE TABLE AS SELECT (CTAS)

The system SHALL support creating new Iceberg tables from query results.

Scenario: dbt table materialization

  • GIVEN an authenticated user with write permissions
  • WHEN the user submits:
    CREATE TABLE production.finance.monthly_totals AS
    SELECT region, month, SUM(amount) as total
    FROM production.finance.transactions
    GROUP BY region, month
    
  • THEN a new Iceberg table is created via Polaris REST
  • AND query results are written as Parquet files to S3
  • AND the table is registered in the catalog

Requirement: CREATE OR REPLACE TABLE AS SELECT

The system SHALL support atomic table replacement, creating a new snapshot that fully replaces the table contents.

Scenario: dbt full-refresh table materialization

  • GIVEN an existing table finance.monthly_totals
  • WHEN the user submits CREATE OR REPLACE TABLE finance.monthly_totals AS SELECT ...
  • THEN a new Iceberg snapshot is created with the new data
  • AND the old snapshot remains accessible via time-travel
  • AND concurrent readers see either the old or new version (never partial)

Requirement: INSERT INTO SELECT

The system SHALL support inserting query results into existing Iceberg tables.

Scenario: dbt incremental append

  • GIVEN an existing table finance.daily_events
  • WHEN the user submits INSERT INTO finance.daily_events SELECT ... WHERE date = '2026-03-13'
  • THEN new data files are written and a new snapshot is committed

Requirement: MERGE INTO

The system SHALL support the MERGE statement for conditional insert/update/delete based on a join condition.

Scenario: dbt incremental merge

  • GIVEN an existing table finance.dim_customers
  • WHEN the user submits:
    MERGE INTO finance.dim_customers AS target
    USING staging.new_customers AS source
    ON target.customer_id = source.customer_id
    WHEN MATCHED THEN UPDATE SET name = source.name, updated_at = source.updated_at
    WHEN NOT MATCHED THEN INSERT (customer_id, name, updated_at)
      VALUES (source.customer_id, source.name, source.updated_at)
    
  • THEN existing rows are updated and new rows are inserted atomically

Requirement: DELETE FROM with predicate

The system SHALL support deleting rows matching a predicate, using Iceberg’s position delete or equality delete mechanisms.

Scenario: dbt delete+insert incremental strategy

  • GIVEN an existing table finance.daily_events
  • WHEN the user submits DELETE FROM finance.daily_events WHERE date = '2026-03-13'
  • THEN matching rows are marked as deleted (position/equality delete files)
  • AND a new snapshot is committed

Requirement: DROP TABLE

The system SHALL support dropping Iceberg tables via the catalog.

Scenario: dbt clean up

  • GIVEN a table finance.tmp_staging
  • WHEN the user submits DROP TABLE finance.tmp_staging
  • THEN the table is removed from Polaris catalog
  • AND data files are optionally purged (configurable)

Requirement: DROP TABLE IF EXISTS

The system SHALL support DROP TABLE IF EXISTS without error for non-existent tables.

Scenario: Idempotent drop

  • GIVEN no table named finance.nonexistent
  • WHEN the user submits DROP TABLE IF EXISTS finance.nonexistent
  • THEN no error is raised

Requirement: ALTER TABLE RENAME

The system SHALL support renaming tables within a namespace.

Scenario: dbt rename materialization

  • GIVEN a table finance.monthly_totals__dbt_tmp
  • WHEN the user submits ALTER TABLE finance.monthly_totals__dbt_tmp RENAME TO finance.monthly_totals
  • THEN the table is renamed in Polaris catalog

================================================================================ FILE: openspec/changes/phase-2c-dbt/proposal.md

Proposal: Phase 2c, dbt Core Compatibility

Summary

Add write-path SQL support (CTAS, INSERT INTO, MERGE INTO, DELETE, DROP, ALTER RENAME) and a native dbt adapter plugin (dbt-sqe) to enable dbt Core as a transformation layer on top of SQE.

Motivation

dbt is the standard transformation tool for analytics engineering. Without dbt support, SQE is limited to read-only analytical queries. With it, SQE becomes a full data transformation platform: ingest via Polaris, transform via dbt, query via BI tools, all through the same engine with the same auth model.

What Changes

SQE Engine (Rust)

  1. Write path (sqe-catalog + sqe-coordinator):

    • CTAS: parse, then execute query, then write Parquet via iceberg-rust writer, then commit to Polaris
    • CREATE OR REPLACE: new snapshot replacing all data
    • INSERT INTO SELECT: append to existing table
    • MERGE INTO: read-modify-write cycle with Iceberg atomic commits
    • DELETE FROM: position delete or equality delete files
    • DROP TABLE: Polaris REST delete + optional file purge
    • ALTER TABLE RENAME: Polaris REST rename
  2. information_schema virtual schema (sqe-coordinator):

    • Virtual tables derived from Flight SQL metadata: information_schema.tables, information_schema.columns, information_schema.schemata
    • Registered as a special TableProvider that queries Polaris metadata
    • Respects per-user access (user only sees tables they can access)

dbt Adapter (Python)

  1. dbt-sqe Python package:
    • Connection via adbc_driver_flightsql.dbapi
    • Credential passthrough (username/password to Flight SQL handshake)
    • SQLAdapter subclass with Iceberg-aware materializations
    • Macros for: table, view, incremental (append, delete+insert, merge)
    • Seeds via batch INSERT
    • Snapshots via MERGE

Dependencies

  • Phase 1 (query engine + auth + Flight SQL): ✅ complete
  • Phase 2 (views + write path basics): ✅ complete
  • iceberg-rust write support (RisingWave fork with rewrite_files()): ✅ available

Success Criteria

  • dbt debug connects and validates (via ADBC Flight SQL)
  • dbt run with table materialization (CTAS)
  • dbt run with view materialization (CREATE VIEW)
  • dbt run with incremental (append via INSERT INTO)
  • dbt run with incremental (merge via MERGE INTO)
  • dbt run with incremental (delete+insert via DELETE + INSERT)
  • dbt seed loads CSV data (batch INSERT, 1000 rows/batch)
  • dbt test runs assertion queries
  • dbt docs generate produces catalog metadata
  • All dbt operations run as the authenticated user (OIDC passthrough)
  • dbt snapshot SCD Type 2 (needs snapshot materialization macro)
  • End-to-end validation with sample dbt project

Status

Implemented: April 2026. All core functionality working. dbt-sqe adapter is at adapters/dbt-sqe/. Remaining work is integration testing and the snapshot materialization.

Rollback Strategy

dbt-sqe is an independent Python package. SQE write-path additions are backwards-compatible. Existing read-only queries are unaffected.

================================================================================ FILE: openspec/changes/phase-2c-dbt/design.md

Design: Phase 2c, dbt Core Compatibility

dbt-sqe Adapter Architecture

dbt Core
  │
  ▼
dbt-sqe (Python package: dbt-sqe)
  │
  ├── SQEConnectionManager
  │     Uses: adbc_driver_flightsql.dbapi.connect()
  │     Auth:  username + password → Flight SQL handshake → Keycloak
  │     Returns: DB-API 2.0 connection + cursor
  │
  ├── SQEAdapter (extends SQLAdapter)
  │     Implements:
  │       - list_relations_without_caching()  → SHOW TABLES / info_schema
  │       - get_columns_in_relation()         → info_schema.columns
  │       - create_schema()                   → CREATE SCHEMA
  │       - drop_schema()                     → DROP SCHEMA
  │       - rename_relation()                 → ALTER TABLE RENAME
  │       - truncate_relation()               → DELETE FROM (no pred)
  │
  ├── SQEColumn (extends Column)
  │     Maps Arrow/Iceberg types → dbt column types
  │
  └── macros/
        ├── adapters.sql         → SQL generation overrides
        ├── materializations/
        │     ├── table.sql      → CREATE [OR REPLACE] TABLE AS
        │     ├── view.sql       → CREATE [OR REPLACE] VIEW AS
        │     └── incremental.sql → INSERT/MERGE/DELETE+INSERT
        └── catalog.sql          → metadata for dbt docs

Connection Manager

from adbc_driver_flightsql.dbapi import connect as flight_connect
from adbc_driver_manager import DatabaseOptions

class SQEConnectionManager(SQLConnectionManager):
    TYPE = "sqe"

    @classmethod
    def open(cls, connection):
        credentials = connection.credentials
        uri = f"grpc://{credentials.host}:{credentials.port}"

        handle = flight_connect(
            uri,
            db_kwargs={
                DatabaseOptions.USERNAME.value: credentials.user,
                DatabaseOptions.PASSWORD.value: credentials.password,
            },
        )
        connection.handle = handle
        connection.state = "open"
        return connection

    def cancel(self, connection):
        connection.handle.close()

    def execute(self, sql, auto_begin=False, fetch=False):
        cursor = self.get_thread_connection().handle.cursor()
        cursor.execute(sql)
        if fetch:
            # ADBC returns Arrow tables — convert to agate for dbt
            table = cursor.fetch_arrow_table()
            return self._arrow_to_agate(table)
        return cursor

information_schema Virtual Schema

SQE must respond to queries like:

SELECT table_catalog, table_schema, table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'finance'

Implementation in sqe-coordinator:

Register a virtual information_schema schema with TableProviders that pull metadata from the Polaris REST catalog:

#![allow(unused)]
fn main() {
/// Virtual table provider that resolves metadata from Polaris
struct InfoSchemaTablesProvider {
    catalog_client: Arc<CatalogClient>,
    session: Arc<Session>,  // for user-scoped access
}

impl TableProvider for InfoSchemaTablesProvider {
    fn schema(&self) -> SchemaRef {
        // Standard information_schema.tables columns:
        // table_catalog, table_schema, table_name, table_type
        Arc::new(Schema::new(vec![
            Field::new("table_catalog", DataType::Utf8, false),
            Field::new("table_schema", DataType::Utf8, false),
            Field::new("table_name", DataType::Utf8, false),
            Field::new("table_type", DataType::Utf8, false),
        ]))
    }

    async fn scan(&self, ...) -> Result<Arc<dyn ExecutionPlan>> {
        // Call Polaris REST: listNamespaces → listTables per namespace
        // Filter by user's access (Polaris does this via bearer token)
        // Return as Arrow RecordBatch
    }
}
}

Similar providers for:

  • information_schema.columns maps to Iceberg schema for column details
  • information_schema.schemata maps to Polaris namespaces

These benefit from the L1 catalog cache. Repeated metadata queries within a dbt run (which can be hundreds) are served from cache.

Write Path in SQE

CTAS Flow

CREATE TABLE finance.totals AS SELECT region, SUM(amount) FROM ...

1. Parse → detect CTAS statement
2. Execute the SELECT portion → get Arrow RecordBatches
3. Infer Iceberg schema from Arrow schema
4. Create table in Polaris: POST /v1/namespaces/finance/tables
     { name: "totals", schema: {...}, partition-spec: {...} }
5. Write RecordBatches as Parquet files via iceberg-rust DataFileWriter
     → fanout writer if partitioned (iceberg-rust 0.8.0)
     → upload to S3 with user's token
6. Commit snapshot to Polaris: POST /v1/tables/finance.totals/commits
     { adds: [data_file_1, data_file_2, ...] }

MERGE INTO Flow

MERGE INTO target USING source ON condition
  WHEN MATCHED THEN UPDATE SET ...
  WHEN NOT MATCHED THEN INSERT ...

1. Parse → detect MERGE statement
2. Scan target table (full or filtered by MERGE predicate)
3. Scan source (subquery or table)
4. Join on condition in DataFusion
5. Classify rows: matched-update, matched-delete, not-matched-insert
6. For updates/deletes: write position delete files for affected rows
7. For inserts + updates: write new data files with the new/updated rows
8. Commit atomically: add new data files + delete files in one snapshot

Key Design Decision: Copy-on-Write vs Merge-on-Read

For MERGE/DELETE, Iceberg supports two approaches:

  • Copy-on-Write (CoW): Rewrite entire data files excluding deleted rows
  • Merge-on-Read (MoR): Write small delete files, merge at read time

Implemented: Copy-on-Write via rewrite_files(). This was chosen because the RisingWave iceberg-rust fork provides a stable rewrite_files() API that atomically replaces old data files with rewritten ones. The full flow:

  1. Read affected data files
  2. Apply modifications (filter for DELETE, CASE WHEN for UPDATE, FULL OUTER JOIN for MERGE)
  3. Write new data files with modified contents
  4. Commit atomically: delete old files + add new files in one transaction

MoR with position deletes is planned for the future when upstream iceberg-rust supports it (Epic #2186, estimated Q3 2026). MoR would be more efficient for small deletes on large tables, but CoW is simpler and correct for all cases.

dbt Materializations

table materialization

-- dbt-sqe generates:
{% materialization table, adapter='sqe' %}
  {%- set existing = adapter.get_relation(this.database, this.schema, this.identifier) -%}

  {% if existing %}
    -- Atomic replacement via Iceberg
    {% call statement('main') %}
      CREATE OR REPLACE TABLE {{ this }} AS (
        {{ sql }}
      )
    {% endcall %}
  {% else %}
    {% call statement('main') %}
      CREATE TABLE {{ this }} AS (
        {{ sql }}
      )
    {% endcall %}
  {% endif %}

  {{ return({'relations': [this]}) }}
{% endmaterialization %}

incremental materialization (merge strategy)

{% materialization incremental, adapter='sqe' %}
  {%- set strategy = config.get('incremental_strategy', 'append') -%}
  {%- set unique_key = config.get('unique_key') -%}

  {% if strategy == 'append' %}
    INSERT INTO {{ this }} (
      {{ sql }}
    )

  {% elif strategy == 'merge' %}
    MERGE INTO {{ this }} AS DBT_INTERNAL_DEST
    USING ({{ sql }}) AS DBT_INTERNAL_SOURCE
    ON {{ unique_key_condition }}
    WHEN MATCHED THEN UPDATE SET {{ update_columns }}
    WHEN NOT MATCHED THEN INSERT {{ insert_columns }}

  {% elif strategy == 'delete+insert' %}
    DELETE FROM {{ this }}
    WHERE {{ unique_key }} IN (SELECT {{ unique_key }} FROM ({{ sql }}));

    INSERT INTO {{ this }} (
      {{ sql }}
    )
  {% endif %}
{% endmaterialization %}

view materialization

-- Already supported via Phase 2 Iceberg views
CREATE OR REPLACE VIEW {{ this }} AS (
  {{ sql }}
)

Type Mapping: Arrow to Iceberg to dbt

┌──────────────────┬──────────────────┬──────────────────┐ │ Arrow Type │ Iceberg Type │ dbt Type │ ├──────────────────┼──────────────────┼──────────────────┤ │ Utf8 │ string │ VARCHAR │ │ Int32 │ int │ INTEGER │ │ Int64 │ long │ BIGINT │ │ Float32 │ float │ FLOAT │ │ Float64 │ double │ DOUBLE │ │ Boolean │ boolean │ BOOLEAN │ │ Date32 │ date │ DATE │ │ TimestampMicro │ timestamptz │ TIMESTAMP │ │ Decimal128(p,s) │ decimal(p,s) │ NUMERIC(p,s) │ │ Binary │ binary │ BINARY │ │ Struct │ struct │ STRUCT (nested) │ │ List │ list │ ARRAY │ │ Map │ map │ MAP │ └──────────────────┴──────────────────┴──────────────────┘

Interaction with Caching (Phase 2b)

dbt runs are particularly cache-friendly:

  • Same tables referenced many times across models keep L1/L2/L3 hot
  • Sequential model execution means previous model’s output is cached for next
  • dbt test queries same tables as dbt run, giving L5 result cache hits
  • Metadata-heavy workflow uses information_schema backed by L1 catalog cache

Expected impact: a dbt run with 50 models that would take 10 minutes without caching could drop to 3-4 minutes with warm L2/L3/L4 caches.

Interaction with Security (Phase 5)

dbt runs as the authenticated user. Policy enforcement applies:

  • If analyst role can’t see column ssn, dbt models selecting * from that table won’t include ssn in the output
  • CTAS respects the user’s visible schema. The new table only contains columns the user can see
  • MERGE operates on the user’s view of the data

This is consistent and correct: dbt transforms what the user can see.

================================================================================ FILE: openspec/changes/phase-2c-dbt/tasks.md

Tasks: Phase 2c, dbt Core Compatibility

Phase 2c.1, Write Path DDL (sqe-sql + sqe-coordinator + sqe-catalog)

  • 2c.1.1 Parse CTAS: CREATE [OR REPLACE] TABLE … AS SELECT
  • 2c.1.2 Parse DROP TABLE [IF EXISTS]
  • 2c.1.3 Parse ALTER TABLE … RENAME TO
  • 2c.1.4 Parse CREATE SCHEMA / DROP SCHEMA
  • 2c.1.5 Implement CTAS execution: query, then infer schema, then create table, then write, then commit
  • 2c.1.6 Implement CREATE OR REPLACE TABLE: DROP IF EXISTS + CTAS
  • 2c.1.7 Implement DROP TABLE: Polaris REST catalog via iceberg-rust
  • 2c.1.8 Implement ALTER TABLE RENAME: Polaris REST catalog via iceberg-rust
  • 2c.1.9 Implement CREATE/DROP SCHEMA: Polaris namespace operations
  • 2c.1.10 Integration test: CTAS creates Iceberg table readable by subsequent SELECT
  • 2c.1.11 Integration test: CREATE OR REPLACE atomically swaps table contents

Phase 2c.2, Write Path DML (sqe-sql + sqe-coordinator + sqe-catalog)

  • 2c.2.1 Parse INSERT INTO … SELECT
  • 2c.2.2 Parse DELETE FROM … WHERE
  • 2c.2.3 Parse MERGE INTO … USING … ON … WHEN MATCHED/NOT MATCHED
  • 2c.2.4 Implement INSERT INTO: execute SELECT, then write new data files, then fast_append commit
  • 2c.2.5 Implement DELETE FROM: CoW via rewrite_files() (RisingWave iceberg-rust fork)
  • 2c.2.6 Implement UPDATE: CoW via rewrite_files() with CASE WHEN rewriting
  • 2c.2.7 Implement MERGE INTO: CoW via FULL OUTER JOIN + rewrite_files()
  • 2c.2.8 Integration test: INSERT INTO appends data correctly
  • 2c.2.9 Integration test: DELETE FROM removes matching rows
  • 2c.2.10 Integration test: MERGE INTO updates existing + inserts new

Phase 2c.3, information_schema (sqe-coordinator)

  • 2c.3.1 Implement InfoSchemaTablesProvider (virtual TableProvider)
  • 2c.3.2 Implement InfoSchemaColumnsProvider
  • 2c.3.3 Implement InfoSchemaSchemataProvider
  • 2c.3.4 Register information_schema as virtual schema per session
  • 2c.3.5 Integration test: SELECT * FROM information_schema.tables WHERE table_schema = ‘x’
  • 2c.3.6 Integration test: information_schema respects user access (different results per user)

Phase 2c.4, dbt-sqe Adapter (Python)

Location: adapters/dbt-sqe/

  • 2c.4.1 Scaffold dbt-sqe package
  • 2c.4.2 Implement SQEConnectionManager (ADBC Flight SQL connect)
  • 2c.4.3 Implement SQECredentials (host, port, user, password, database/catalog, schema)
  • 2c.4.4 Implement SQEAdapter (list_relations, get_columns, create/drop schema, rename)
  • 2c.4.5 Implement SQEColumn (Arrow to dbt type mapping)
  • 2c.4.6 Implement SQERelation (Iceberg table/view relation handling)
  • 2c.4.7 Implement table materialization macro
  • 2c.4.8 Implement view materialization macro
  • 2c.4.9 Implement incremental materialization: append strategy
  • 2c.4.10 Implement incremental materialization: delete+insert strategy
  • 2c.4.11 Implement incremental materialization: merge strategy
  • 2c.4.12 Implement seed macro (batch INSERT, 1000 rows per batch)
  • 2c.4.13 Implement catalog generation macro (for dbt docs)
  • 2c.4.14 Implement snapshot materialization (SCD Type 2 via MERGE)

Phase 2c.5, End-to-End dbt Testing

  • 2c.5.1 Create sample dbt project with staging + marts models
  • 2c.5.2 Test: dbt debug connects successfully
  • 2c.5.3 Test: dbt seed loads test CSV data
  • 2c.5.4 Test: dbt run with table materialization
  • 2c.5.5 Test: dbt run with view materialization
  • 2c.5.6 Test: dbt run with incremental (append)
  • 2c.5.7 Test: dbt run with incremental (merge): blocked on MERGE
  • 2c.5.8 Test: dbt run with incremental (delete+insert): blocked on DELETE
  • 2c.5.9 Test: dbt run --full-refresh with CREATE OR REPLACE
  • 2c.5.10 Test: dbt test runs assertion queries
  • 2c.5.11 Test: dbt docs generate produces catalog JSON
  • 2c.5.12 Test: dbt snapshot creates SCD Type 2 table: blocked on MERGE
  • 2c.5.13 Test: dbt run with different users sees policy-filtered results
  • 2c.5.14 Test: concurrent dbt runs from different users don’t conflict

Fine-grained policy: row filters, column masking, tag-based masking (next-steps notes)

Future phase, not yet built. Design notes for row-level filtering, column masking, and tag-based masking in SQE, driven by Apache Ranger, reaching rough parity with Snowflake’s row-access + masking + tag-masking. Pairs with the coarse Ranger access-control backend already shipped (catalog/table allow-deny via Polaris) and with docs/site/book/src/design-notes/s3vending.md.

Why this lives in SQE, not Polaris

Polaris + Ranger gives a COARSE allow/deny per catalog operation (LOAD_TABLE, CREATE_NAMESPACE, …). The Polaris service-def declares no rowFilterDef / dataMaskDef, and the Polaris authorizer reads only the boolean decision, so Polaris cannot enforce row filters or column masks even though the Ranger engine can compute them. Fine-grained enforcement has to happen in the query engine.

Two enforcement models:

  • Push/sync (Snowflake, closed engine). You cannot intercept Snowflake’s planner, so Privacera PolicySync translates Ranger policies into Snowflake native objects on a schedule: GRANT/REVOKE, CREATE ROW ACCESS POLICY, CREATE MASKING POLICY, object TAG + tag-based masking. Snowflake enforces natively at query time; Ranger is the source of truth and the compiler.
  • Pull/rewrite (SQE, open engine). SQE owns its LogicalPlan and already enforces by rewriting it: PolicyEnforcer::evaluate() runs between planning and optimization, injects row-filter Filter nodes above the scan, swaps columns for mask expressions, and DROPS denied columns entirely (PostgreSQL-RLS model, strictly more expressive than Snowflake’s mask-to-NULL workaround).

SQE should keep pull/rewrite. The work is not the model (it exists) but the policy VOCABULARY to express Snowflake-equivalent policies, plus a Ranger-backed policy source.

Which Ranger service-def (authoritative: ranger-fine-grained-service-type.md)

Fine-grained policies do NOT go on the polaris service (it has no dataMaskDef/rowFilterDef - coarse allow/deny only). Use the hive service-def (the service Apache Spark’s Kyuubi Ranger plugin reads) so SQE and Spark share one policy set, plus a linked tag service for tag-based masking. Key consequences that shape this phase:

  • hive resources are database -> table -> column (NO catalog level). SQE must flatten Iceberg catalog + namespace into the database string using the SAME convention Kyuubi uses, or cross-engine policies silently won’t match.
  • policyType integers: 0 = access, 1 = DATAMASK, 2 = ROWFILTER.
  • mask transformers in the service-def are Hive UDFs (mask, mask_show_last_n, mask_hash); SQE reimplements them as DataFusion UDFs or rewrites them.
  • pull everything in one call via the plugin DOWNLOAD endpoint (below).

Ranger policy type -> where it is enforced

Ranger policy typeServiceEnforcementStatus
resource access (catalog/ns/table allow-deny)polarisPolaris (embedded Ranger authorizer)shipped
row-filter (policyType 2)hiveSQE PlanRewriter (Filter above scan)this phase
data-mask (policyType 1)hiveSQE PlanRewriter (column -> mask expr)this phase
tag (mask/row-filter)tag linked to hiveSQE, via tag-resource associationsthis phase

A single Ranger policy store drives both: the coarse Polaris gate (already wired) and SQE’s fine-grained rewriter (new RangerStore: PolicyStore).

What SQE has today

  • sqe-policy/src/lib.rs: PolicyEnforcer, PolicyStore, ResolvedPolicy { row_filters: Vec<Expr>, column_masks, restricted_columns }, MaskType { Nullify, Redact(const), Hash, Custom(Expr) }.
  • sqe-policy/src/plan_rewriter.rs: the pull/rewrite enforcement point.
  • sqe-policy/src/sha256_udf.rs: the only registered policy mask UDF (HMAC-SHA256 for the Hash mask).
  • sqe-core/src/session.rs: SessionUser { username, roles: Vec<String> } (flat role list, no hierarchy / secondary roles).
  • OpaStore already implements PolicyStore (resolve row filters + masks from OPA) and is the template for a RangerStore.

What to add (the function/primitive vocabulary)

  1. Session-context SQL functions (the biggest gap). SQE advertises CURRENT_USER / CURRENT_SCHEMA in Flight SQL getSqlInfo metadata but does NOT register them as evaluable functions. Register, resolved per-session from SessionUser:

    • current_user(), current_role(), current_database(), current_schema()
    • is_role_in_session(role), current_available_roles() / current_secondary_roles() equivalents
    • Prerequisite: a richer role model. SessionUser.roles is a flat Vec<String> with no hierarchy / secondary-role notion; is_role_in_session needs sqe-auth to surface the full active + inherited role set into the SQL eval context. This is the real work behind the context functions.
  2. Mask types (extend MaskType so policies are declarative, not all Custom). Have: nullify, constant/redact, hash, custom-SQL. Add as first-class: partial/substring (e.g. show last 4) and regexp_replace. Each maps to a Snowflake masking CASE body (THEN val ELSE <transform>).

  3. Row-filter mapping-table idiom. The Filter-injection mechanism exists; add a first-class lookup-table pattern (an EXISTS / semi-join against a mapping table keyed on the session role), the idiom Snowflake row-access policies lean on. Row-filter expressions reference the session-context functions from (1).

  4. Tag-based masking (this is where tagging belongs). A tag store -> masking- policy binding -> auto-apply path: assign a mask to a tag once, tag a column, masking applies automatically and new matching columns inherit it. SQE’s natural tag source is Iceberg / Polaris column properties plus Ranger tag policies. Note: Ranger tag policies need a tag source; for the Polaris gate there is none today (no Atlas hook / tagsync for Polaris resources), but SQE can read Iceberg column properties directly as the tag source for its own masking, independent of the Polaris path.

Component to build

RangerStore: PolicyStore (new, modeled on OpaStore):

  • Reads a hive-type Ranger service (the one Spark uses), NOT the polaris service. Pull the whole bundle in one call: GET /service/plugins/policies/download/{serviceName} returns ServicePolicies = resource policies[] (access=0, datamask=1, rowfilter=2) + serviceDef (mask transformer templates, rowFilterDef) + tagPolicies + policyVersion (304 on unchanged -> cheap polling). The public-v2 /api/policy array is resource-only and insufficient.
  • resolve(user, table, namespace) -> ResolvedPolicy: flatten the Iceberg catalog/namespace/table to the hive database/table/column naming (match Kyuubi’s convention), select matching row-filter + data-mask (+ tag) items for the user, and return row_filters + column_masks + restricted_columns. Match on the user + SessionUser.roles DIRECTLY (SQE’s session roles come from the token, unlike the Polaris gate which needs Ranger role membership). Evaluation order: tag policies first, deny-overrides, then resource access -> mask -> row-filter. Translate filterExpr/valueExpr from Hive/Spark dialect to DataFusion; realize Hive mask transformers as DataFusion UDFs (see the mask table in ranger-fine-grained-service-type.md).
  • Wire it as a PolicyEngine::Ranger variant in config, feeding the existing PolicyEnforcer / PlanRewriter (which today runs passthrough).
  • Cache + fail-closed like OpaStore (use policyVersion for incremental refresh).

See ranger-fine-grained-service-type.md for the full service-type rationale, the mask-type mapping, the flattening sharp edge, and cross-engine sharing requirements.

Snowflake context functions to mirror (reference)

CURRENT_USER, CURRENT_ROLE, CURRENT_AVAILABLE_ROLES, CURRENT_SECONDARY_ROLES, IS_ROLE_IN_SESSION(role), INVOKER_ROLE, CURRENT_ACCOUNT, CURRENT_DATABASE, CURRENT_SCHEMA, POLICY_CONTEXT (test). The role-hierarchy ones (IS_ROLE_IN_SESSION) are what require the richer SessionUser role model.

Phase shape

Phase 2A (shipped, branch a feature branch). The full Ranger hive built-in mask vocabulary is implemented and wired. Steps 3, 4, and 6 from the original list are done:

  • MaskType extended with PartialMask { show_first, show_last, upper, lower, digit } and DateShowYear. RangerStore maps every standard dataMaskType string to the corresponding MaskType variant.
  • mask_partial DataFusion UDF realises the Hive char-level transformer (uppercase, lowercase, digit substitution chars; punctuation and non-ASCII pass through unchanged; Unicode scalar counting). The full hive set is now: MASK_NULL, MASK, MASK_SHOW_LAST_4, MASK_SHOW_FIRST_4, MASK_HASH, MASK_DATE_SHOW_YEAR, CUSTOM.
  • Quickstart polaris-ranger-keycloak updated: orders table gains an ssn VARCHAR column; a MASK_SHOW_LAST_4 policy seeds on that column for role engineer; test.sh section 5 proves 111-11-1111 becomes xxx-xx-1111 for bob (engineer) and stays raw for alice (analyst-only).

Phase 3a (shipped, branch a feature branch). Tag-based masking enforcement:

  • TagSource trait + NoopTagSource default + CacheTagSource production implementation. Tags stored as Iceberg sqe.column-tags table property.
  • PolicyStore::resolve_tags default no-op; RangerStore overrides to fetch tagPolicies from the Ranger bundle and return (tag_masks_by_tag, tag_filters, unmappable_tags).
  • PolicyPlanRewriter wired: calls tag_source.column_tags(catalog, full-namespace-vec, table) using the FULL namespace path (not last component), then resolve_tags, then merge_tag_masks. Precedence rules: policy.mask-precedence picks the contested-column winner (default tag, matching Spark/Kyuubi; resource for most-specific-wins), restricted stays restricted, unmappable tag fails closed unless a working resource mask is already in place.
  • Four executable integration tests prove: tag mask end-to-end, full-namespace identity (FakeTagSource capture), contested-column precedence in both modes, unmappable-tag fail-closed.
  • NOT live-demoed (quickstart stack drift); the executable tests are the proof.
  • Phase 3b (shipped): CUSTOM tag masks + cache invalidation on SET TBLPROPERTIES.

Phase 3b (shipped, branch a feature branch). CUSTOM tag mask support + cache invalidation:

  • TagMaskSpec enum (Ready(MaskType) or Custom(String)) replaces the raw MaskType in PolicyStore::resolve_tags return type. CUSTOM masks carry the raw {col} template; the rewriter substitutes the column name at merge time.
  • resolve_tag_policies in ranger_store.rs: CUSTOM tags with a value_expr now produce TagMaskSpec::Custom(template) instead of being marked unmappable. Tags with no value_expr remain unmappable (fail-closed, nothing to substitute).
  • merge_tag_masks in plan_rewriter.rs: TagMaskSpec::Custom(template) branches substitute {col} with the real column name, call parse_sql_predicate, produce MaskType::Custom(expr) on success, restrict column on parse failure (fail-closed).
  • set_table_properties in catalog_ops.rs: explicit session_catalog.invalidate_table call after commit_schema_update so a SET TBLPROPERTIES('sqe.column-tags'=...) is visible on the next query without waiting for TTL expiry. invalidate_policy_cache() also called in QueryHandler for defense-in-depth.
  • Two new tests cover: CUSTOM with valid expression applies MaskType::Custom (not restriction); CUSTOM with unparseable expression restricts the column.

Phase 2B (not yet started). Session-context SQL functions:

  1. sqe-auth / SessionUser: richer role model (active + inherited/secondary).
  2. Register current_user(), current_role(), current_available_roles() as evaluable scalar UDFs resolved per-session from SessionUser.

Phase 2C (not yet started). Cross-engine dynamic transformer + tag-based masking:

  1. Dynamic transformer configuration for arbitrary-N show-first/show-last masks (currently hard-coded to 4).
  2. Tag-based masking: Iceberg column-property tag source -> mask binding, plus Ranger tag policies. The tag source for the Polaris gate has no Atlas hook today, but SQE can read Iceberg column properties directly.

Sources

  • Snowflake row access policies: https://docs.snowflake.com/en/user-guide/security-row-intro
  • Snowflake masking policies: https://docs.snowflake.com/en/sql-reference/sql/create-masking-policy
  • Snowflake tag-based masking: https://docs.snowflake.com/en/user-guide/tag-based-masking-policies
  • Snowflake context functions: https://docs.snowflake.com/en/sql-reference/functions-context
  • Snowflake IS_ROLE_IN_SESSION: https://docs.snowflake.com/en/sql-reference/functions/is_role_in_session
  • Privacera PolicySync (Ranger -> Snowflake push model): https://docs.privacera.com/resources/design/access-management/integrations/privacera_policysync.html
  • Apache Ranger policy model (row-filter / data-mask / tag types): https://ranger.apache.org/blogs/policy_model.html

Catalog-based access control with Polaris + Apache Ranger

This is a reference for SQE’s ranger access-control backend. On this path, access control is catalog-based and enforced by Apache Polaris, not by SQE. SQE only writes policies and surfaces denials. It does not filter rows or mask columns here. That is the fine-grained path, covered separately (see the end of this document).

Overview

SQE supports several access-control backends for GRANT / REVOKE / SHOW GRANTS dispatch. The selector is access_control.backend in sqe.toml. The values are none (default), chameleon, polaris, and ranger, defined in sqe-core/src/config.rs (AccessControlBackend). This document covers the ranger backend.

The ranger backend assumes Polaris running its embedded Ranger authorizer (polaris.authorization.type=ranger). Two halves are at work:

  • Write path (SQE). SQE translates each GRANT / REVOKE into a call to the Ranger Admin REST API. SHOW GRANTS reads Ranger policies back. SQE never enforces anything itself on this path.
  • Enforcement path (Polaris). When SQE asks Polaris to load a table (carrying the user’s Keycloak token), Polaris asks Ranger whether the principal may perform the operation. An ungranted operation fails at Polaris.

The backend code is sqe-policy/src/grants/ranger.rs (RangerGrantBackend). The doc-comment on that file states the design directly: “Enforcement is delegated to Polaris’s embedded Ranger authorizer; this backend only writes/reads Ranger policies.”

Architecture and flow

SQE  --GRANT/REVOKE-->  Ranger Admin        (policies stored here)
SQE  --query+token-->   Polaris  --check-->  Ranger    (enforcement)

The write path is HTTP basic-auth to Ranger Admin, and which endpoint it uses depends on grant_authority:

statementadmin-role (default)ranger-delegate
GRANTPOST /service/public/v2/api/policy (create) or PUT .../policy/{id} (merge)POST /service/plugins/services/grant/polaris
REVOKEPUT .../policy/{id}, subtracting access typesPOST /service/plugins/services/revoke/polaris
SHOW GRANTSGET /service/public/v2/api/policy?serviceName=polarissame

The default goes through the authenticated policy API. Ranger declares the plugin grant and revoke endpoints security="none", which bypasses Spring Security, so basic auth sent there is never processed: measured on Ranger 2.8.0, that endpoint accepts a grant carrying no credentials at all, while the same credential-free request to the policy API is refused with 401. Ranger 2.9.0 stops serving those endpoints altogether unless ranger.admin.allow.unauthenticated.access is on.

ranger-delegate keeps the plugin endpoint because Ranger’s per-resource check on the grantor field IS the authority in that mode, and only that endpoint performs it. One predicate drives both the transport choice and enforces_grantor_authority(), so the coordinator never stands its admin gate down over a write that authorizes the REST user rather than the caller.

The policy API does not merge, which the plugin endpoint did server-side, so SQE performs the merge: read the policy for the resource, union or subtract this grantee’s access types, write it back. Reading the whole policy is what preserves denyPolicyItems and the provenance policyLabels that REVOKE narrows from.

Because the policy API records the authenticated REST user in createdBy rather than the grantor, the SQL caller is recorded separately in a chm-grantor: policy label, and SHOW GRANTS prefers that label over createdBy when reporting granted_by.

The last URL component (polaris) is the configured service_name. It is the only URL-interpolated value, and it is operator-controlled config, not user input.

The enforcement path runs entirely inside Polaris. SQE sends the query plus the user’s bearer token. Polaris resolves the principal, asks its embedded Ranger authorizer for a decision, and either serves the table metadata or refuses.

A denied table load surfaces as an explicit permission error. SQE propagates Polaris’ 403 instead of falling through DataFusion’s table/view lookup and rewriting the denial as “table not found”. A genuine catalog 404 remains a not-found error.

The polaris Ranger service-def

The Ranger service type used here is polaris, defined by quickstart/polaris-ranger-keycloak/ranger/servicedef-polaris.json. It is a coarse allow/deny service: it answers “may this user perform this operation on this resource?” per catalog operation.

  • Resource hierarchy. root -> catalog -> namespace -> table (the service-def also declares principal and policy resource levels). RangerGrantBackend writes the catalog, namespace, and table levels; see build_resource_map.
  • Access types. 69 Polaris-native access types, named with hyphens (table-data-read, namespace-create, catalog-content-manage, and so on). These are the verbs Polaris checks at enforcement.
  • No fine-grained constructs. The service-def declares no rowFilterDef and no dataMaskDef. Row filtering and column masking are not part of this service. They live on the separate frontend-query service read by SQE’s policy engine, named query in the quickstarts.

GRANT and REVOKE mapping

SQL privileges map to Ranger access types in map_sql_to_ranger_access (ranger.rs). A single SQL privilege expands to the full explicit set of access types the corresponding Polaris operations check. The mapping:

SQL privilegeRanger access typesResource level
SELECTtable-data-read, table-properties-read, table-listtable
INSERTtable-data-write plus the full snapshot/schema/properties commit set (22 types)table
DROPtable-droptable
CREATE TABLEtable-createnamespace
USAGEnamespace-list, namespace-properties-readnamespace
DROP SCHEMAnamespace-dropnamespace
CREATE SCHEMA / CREATEnamespace-createcatalog
ALL / ALL PRIVILEGEScatalog-content-managecatalog
anything elsethe value, lowercasedtable

Unknown privileges pass through lowercased, so an operator can name native Ranger access types directly in a GRANT statement.

A table grant alone does not make a table readable

GRANT SELECT ON cat.ns.tbl needs catalog-level discovery already in place to be readable through SQE, and the reason is on SQE’s side rather than Polaris’s.

Polaris will serve the table: a direct LOAD_TABLE carrying only the table-level grant returns 200. But SqeCatalogProvider::schema() answers only for a namespace present in its cached namespace list, and list_visible_namespace_names builds that list from two calls that must both succeed. list_namespaces needs LIST_NAMESPACES, authorized at the CATALOG level (Polaris does not use Ranger’s SELF_OR_DESCENDANTS matching, so a namespace-scoped namespace-list will not satisfy it). The visibility filter then probes each namespace with get_namespace, which is LOAD_NAMESPACE_METADATA and needs namespace-level namespace-properties-read; a 403 hides the namespace so ungranted names do not leak through SHOW SCHEMAS.

Either failure leaves an empty schema list, schema() returns None, and planning ends at table not found with LOAD_TABLE never attempted. Nothing in the SQE log shows a 403, because there is no denial to report.

A privilege therefore expands into a multi-level plan (build_grant_plan), outermost first:

GRANT SELECT ON cat.ns.tbl TO ROLE r;   -- writes THREE policies
LevelAccess type
catalognamespace-list
namespacenamespace-properties-read
tablethe privilege’s own set

GRANT USAGE ON DATABASE cat remains the way to write catalog discovery on its own: USAGE binds to the namespace level, and with no namespace named the resource map degrades to {root, catalog}, which is what LIST_NAMESPACES is authorized against.

Four properties of the expansion, each load-bearing:

Both the shape and the sets come from grant-profile.json, now at v5. Its SELECT is catalog:[namespace-list] | namespace:[namespace-properties-read] | table:[table-data-read], and the data-platform control plane generates its policies from the same file. SQE writing a different set for the same statement would make “who granted this, and does it mean the same thing” unanswerable, and there is a drift gate whose whole job is to keep the two in step.

The sets are not in the file, and that is on purpose. privileges ships seeds; access_types carries the implication graph, and SQE walks it at write time to produce what Polaris actually checks. Shipping finished sets would make the profile’s fixtures self-satisfying, this code asserting it read what it read, where today they compare SQE’s closure against one the platform computed independently. The closure is exactly what drifted before v4, when SQE’s hand-written WRITE_ACCESS carried table-properties-write, which table-data-write does not imply.

v5 folded that graph in from a second vendored file. servicedef-polaris.json is still the Ranger service DEFINITION, registered with Ranger Admin by the quickstarts, but it is no longer an input to planning.

The catalog level is a real widening, accepted rather than hidden. Its holder can enumerate every namespace NAME in the catalog, unrelated ones included, and that now happens on every table grant. Auto-adding it was initially refused here on exactly that ground, and the refusal was overturned: diverging from the contract both tools share is the worse failure, and the leak is names rather than data. Separate catalogs are the boundary when namespace names are themselves sensitive.

Outermost first. Ranger has no transaction spanning several calls. Outermost-first fails to “can list, nothing readable”, which is inert; innermost-first would fail to “has table access, table unreachable”, the exact symptom being removed. On a deepest-level failure the error says the outer grants were left in place.

Revoke releases the deepest level only. The catalog and namespace policies are shared with every other grant anyone holds in that catalog, so walking the plan backwards would strip discovery out from under unrelated grants: an outage dressed up as a narrow revoke. Traversal policies therefore accumulate and nothing cleans them up. That is the correct trade, and it is the position the platform takes too. Provenance labels are written at the deepest level only for the same reason: stamping shared plumbing with one grantee’s privilege would misrepresent it as privately owned.

The quickstart’s bootstrap seeds wildcard discovery for analyst and engineer, which is why a single GRANT SELECT looks sufficient there. A principal outside those roles still needs the catalog grant.

Verified on Polaris 1.7 with a clean database, one variable at a time: with catalog discovery and a table SELECT grant but no namespace visibility, the read failed table not found; adding ONLY namespace-properties-read at {catalog, namespace} returned rows. Full transcript in docs/internal/research/2026-08-02-catalog-traversal-gate.md.

The named scope must match the privilege’s level

The right-hand column is not advisory. It decides which keys go into the Ranger resource map, and the keys below that level are dropped. Naming an object deeper than the level a privilege binds to used to widen the grant silently:

GRANT ALL ON wh.sales.orders TO USER alice;

ALL binds to the catalog, so the namespace and table were dropped and the write landed as catalog-content-manage on wh. The statement named one table, reported success, and gave alice every table in the catalog. Nothing in the response distinguished it from the narrow grant that was asked for, and the operator reading SHOW GRANTS months later found a catalog policy nobody remembered writing.

SQE now refuses the statement and names both the level and the scope that would have been written:

Privilege 'ALL PRIVILEGES' binds to the catalog level, but the statement names
a namespace or table. The policy would apply to 'wh' and everything under it,
which is wider than the object named. Re-issue the statement against 'wh', or
name a privilege that binds to the object you meant.

The check is general rather than an ALL special case, because USAGE on a table and CREATE SCHEMA on a namespace widen through the same path. It applies to GRANT, REVOKE and DENY alike, so the three agree on what a statement’s scope means. A widened DENY over-restricts rather than over-grants, which is the safer direction, but locking a grantee out of a whole catalog when one table was named is no less surprising.

Grants written before this check exists are still catalog-wide. Revoking one needs the statement re-issued at the level it actually landed on, which is what the error text tells you.

Why the full explicit set

The Polaris embedded authorizer does not honor service-def implied-grants. A service-def can declare that table-data-write implies the commit verbs, but the embedded authorizer ignores those declarations. So SQE expands each privilege to every access type the operations will check. SELECT reads three types because a read through SQE loads the table then reads files. INSERT lists table-data-write plus every snapshot, schema, sort-order, partition-spec, and properties commit type, because a write loads the table and commits a new snapshot, which fans out into many fine-grained Polaris operations. The constants are READ_ACCESS and WRITE_ACCESS in ranger.rs.

Grantees: USER and ROLE only

grantee_to_fields splits the grantee into the Ranger request fields:

  • GRANT ... TO USER "alice" writes to the users array.
  • GRANT ... TO ROLE "analyst" writes to the roles array.
  • GRANT ... TO GROUP ... is rejected with NotImplemented. Polaris does not deliver groups to Ranger unless Ranger usersync runs, so the backend will not write a grant whose grantee it cannot confirm exists.

The write and read paths are deliberately asymmetric here, and the asymmetry is worth knowing before it surprises you. SQE will not WRITE a group grant, but it does ENFORCE one: a group-bound policy authored in the Ranger console is matched against the session’s groups on the fine-grained read path (ranger_store, pinned by group_bound_items_match_the_session_groups). Before that, such a policy was skipped outright, which meant a mask an operator could see in the Ranger UI quietly did nothing.

CHECK ACCESS does not resolve groups either, for the same reason the write path refuses them, so a grant held only through a group is invisible to introspection while still being enforced.

The request body is GrantRevokeRequest, serialized with Ranger’s exact JSON field names (accessTypes, delegateAdmin, enableAudit, replaceExistingPermissions, isRecursive). Audit is on; delegate-admin, replace-existing, and recursive are off.

All tables and future tables in a schema

GRANT SELECT ON ALL TABLES IN SCHEMA sales_wh.sales TO ROLE analyst grants the privilege across every table in the namespace. So does ON FUTURE TABLES IN SCHEMA. SQE translates both to a Ranger policy with a table wildcard (table = "*"). New tables created later in sales are covered automatically, with no follow-up grant.

The two forms are equivalent in SQE, which is one difference from Snowflake: Snowflake’s FUTURE grant applies only to objects created after the grant, and its ALL grant only to objects that already exist. Ranger has no future-only resource, so the wildcard necessarily covers both. Either statement means “every table in this schema, present and future.” Use a table-specific grant when you need to scope to a single existing table.

Do not confuse either form with GRANT ... ON SCHEMA, which stays a namespace-level resource and does not reach the tables inside it. Namespace SELECT is namespace-list plus namespace-properties-read and deliberately carries no table-data-read; Ranger does not widen a namespace policy to the tables beneath it. A namespace grant lets a role see that the schema and its tables exist, not read their rows.

Identifier validation

Catalog, namespace, table, and grantee names come from GRANT SQL and flow into the JSON resource map. validate_identifier rejects empty values and any value containing / ? # % \, whitespace, or control characters. A GRANT that needs no catalog is also rejected: the backend requires catalog.namespace.table form.

SHOW GRANTS and CHECK ACCESS read Ranger back

SHOW GRANTS calls fetch_policies, flattens each policy’s allow and deny items into rows (policies_to_entries), and filters by grantee or by resource prefix. The resource-prefix match is dot-boundary aware: SHOW GRANTS ON CATALOG "wh" matches wh and wh.sales.orders but never sibling catalogs like wharf.ns.t or wholesale (resource_matches_prefix).

CHECK ACCESS is best-effort introspection only. evaluate_access applies deny-overrides-allow against the fetched policies for a user and access type. Its own doc-comment is explicit: “The authoritative decision is Polaris enforcement; this is for CHECK ACCESS introspection only.” It does not account for tag policies, conditions, or wildcard resource matching beyond exact match and bare *.

It DOES resolve the target user’s Ranger roles, including nested ones. That is worth stating because it did not, and the failure was quiet. check_access passed an empty role list, under a comment claiming roles were unknown at this layer, when Ranger serves them at /service/public/v2/api/roles. Since role grants are the normal way to grant, the practical result was:

CHECK ACCESS SELECT ON sales_wh.acdemo.orders FOR USER "alice";
-- false | No matching grant for alice table-data-read on sales_wh.acdemo.orders

while SHOW GRANTS on the same table listed table-data-read for ROLE analyst, alice was a member, and alice was reading the table. The answer looked authoritative, so an auditor would conclude the table was closed while a user read from it. It now reports:

-- true | Allowed via ROLE 'analyst'

Membership follows nested roles (a role listing another role confers it), walked with a seen-set because Ranger does not prevent an operator creating a cycle.

Groups are not resolved. Ranger only knows a user’s groups when usersync runs, so a group-derived role would be a guess. A grant reachable only through a group does not appear in CHECK ACCESS, which keeps the answer conservative in the direction it already erred. A role-lookup failure says so in the reason rather than degrading to a confident “no”.

Identity model

This is the part the quickstart pins down through live testing, documented in quickstart/polaris-ranger-keycloak/OVERVIEW.md. The mapping has two halves, users and roles, handled differently.

Principals must pre-exist in Polaris. Polaris federates the principal from the Keycloak token: the principal name is preferred_username. But federation resolves an existing principal entity; it does not create one. Each user must be pre-created as a Polaris principal. The bootstrap creates alice, bob, carol, dave. A token for a principal that does not exist is rejected with 401 “Failed to resolve principal”. The token is a lookup key, not an identity source. This holds in external mode too, confirmed against Polaris source: DefaultAuthenticator is the only authenticator in Polaris 1.5, and it always looks the principal up in the metastore. See polaris-principal-provisioning.md for the full investigation. Eliminating per-user provisioning is not a config option; it would require a custom Authenticator bean.

Roles come from Ranger role membership. Polaris ignores the token’s realm roles. They lack Polaris’s expected PRINCIPAL_ROLE: prefix, so they are dropped during authentication. Polaris principal-roles cannot help either: the 1.5 Ranger authorizer leaves principal-role management operations unmapped, so creating or assigning them is always denied. The mapping that works is Ranger role membership. Polaris sends the username to Ranger; Ranger resolves that user’s roles from its own role store. In production this comes from Ranger usersync (LDAP/AD/SCIM). In the quickstart, ranger-setup sets it explicitly:

analyst   -> alice, bob, carol
engineer  -> bob, carol
sqe_admin -> carol

Groups are not forwarded by Polaris at all. The backend supports USER and ROLE grantees only.

The root="*" realm is required. A policy SQE writes must match the resource Polaris sends at enforcement. The Polaris service-def hierarchy is root -> catalog -> namespace -> table, and the root level carries a realm/context value. SQE controls it through [access_control.ranger] realm in sqe.toml. For this stack the resolved value is "*": every policy carries root = *, which matches the realm value Polaris sends. This is required. A {catalog:*} policy without root never matches Polaris’s checks, so a granted user would still be denied. A precise realm string can replace "*" for tighter scoping if you confirm the exact value Polaris sends (Ranger Admin audit tab or docker compose logs polaris) and restart SQE.

The LOAD_TABLE read gate. SQE reads parquet with its own configured S3 credentials. So once a user can load a table’s metadata it can read the data, and Polaris’s table-data-read (vended-credential) check never fires for this deployment. The effective read gate is LOAD_TABLE / table-properties-read, not credential vending. The quickstart uses that fact to make GRANT the visible gate: the baseline traverse set (catalog-list, catalog-properties-read, namespace-list, namespace-properties-read, table-list) deliberately omits table-properties-read, so GRANT SELECT is what actually lets a member load and read a table, and REVOKE takes it away.

Group bindings

Policy items bound to a GROUP are enforced, matched against the group memberships on the session.

Enterprise Ranger deployments usually bind policies to directory groups rather than naming users, with usersync mirroring the directory into Ranger. SQE previously matched only the username and the token roles and skipped group-bound items outright, so that whole class of policy silently did not apply. The session already carried the memberships; the matcher ignored them.

Groups come from the provider’s groups_claim, which is separate from roles_claim and unset by default. When it is unset the session carries no groups and a group-bound item still cannot match: SQE logs which knob fixes that at debug level rather than failing silently. Users, roles and groups are OR-ed, so a policy naming any of the three applies.

Views

GRANT SELECT ON VIEW cat.ns.v works, and so do ON ALL VIEWS IN SCHEMA and ON FUTURE VIEWS IN SCHEMA. Two facts shape how, both established against a live Polaris 1.6 rather than inferred from the service-def.

A view has no resource level of its own. The polaris service-def declares root -> catalog -> namespace -> table and no view. A view is addressed by putting its NAME in the table slot; only the access-type set differs. Granting view-properties-read + view-list on {catalog, namespace, table: <view>} is what lets a grantee load the view, so that is what SELECT ON VIEW emits.

A view is NOT a privilege boundary. SQE expands the view’s SQL and plans against the base tables, so the reader needs its own grant on those tables too. Granting only the view produces

Failed to plan view 'v' SQL: table 'cat.ns.orders' not found

which is the base-table denial surfacing through the view. This differs from Snowflake secure views and Databricks views, where the definer’s privileges apply and the reader needs nothing on the base table. There is no definer’s- rights mode here: a view cannot be used to expose a subset of a table to someone who may not read the table. Use a row filter or a column mask for that, which is the mechanism SQE does support.

Column masks and row filters DO still apply through a view, because the rewrite happens on the expanded scan. A view is therefore safe (it cannot launder a masked column) but not sufficient (it cannot stand in for a grant).

DENY

DENY <privilege> ON <object> TO <grantee> writes an explicit denial, which Ranger evaluates ahead of any allow.

It does NOT go through the grant endpoint. /services/grant writes allow items only and has no field for a denial, so DENY uses the policy API and merges a denyPolicyItems entry into the policy covering the resource.

It merges into an existing policy rather than creating its own, because Ranger forbids the alternative. Only one policy may exist per exact resource per service; a second is rejected with

Validation failure: error code[3010], reason[Another policy already exists for
matching resource: policy-name=[...], service=[polaris]]

So a dedicated deny policy is impossible, and the deny lands on whichever policy already covers that resource. Repeating the statement is idempotent: items are deduplicated on grantee plus access-type set, not on JSON equality, because Ranger echoes a stored item with every optional field populated (users: [], conditions: [], delegateAdmin) and a byte comparison appended a duplicate every time.

Two asymmetries with GRANT, both deliberate:

  • Not scoped to the caller’s delegate authority. The policy API authorizes the authenticated REST user and takes no grantor, so [auth] admin_roles is the only check. Ranger offers no grantor-scoped deny.
  • REVOKE clears a denial too. There is no UNDENY keyword; REVOKE removes the grant whether it was an allow or a deny, which is Unity Catalog’s behaviour. Without this DENY would be a one-way door, since the grant endpoint only touches allow items and undoing a denial would need console access. Matching is on grantee plus access-type set, so the revoke removes exactly what the equivalent DENY would have written.

SHOW GRANTS lists denials with effect = DENY alongside allows, so a denial is visible to the same audit path as everything else. DENY is also recorded in the audit log as a privilege change (AuditKind::Grant), not as an ordinary statement.

Who may grant: the caller, checked by Ranger

SQE sends the authenticated caller as the Ranger grantor, never its own service identity, and Ranger decides whether that caller may grant.

This is an authority check, not an audit field. Verified against a live Ranger 2.8: a POST to /service/plugins/services/grant/{service} carrying grantor: "dave" is refused with

HTTP 403 {"msgDesc":"User doesn't have necessary permission to grant access"}

even though the request authenticates with admin REST credentials. Ranger authorizes the named grantor. So passing the real caller makes grant authority resource-scoped (does this user hold delegate admin on THIS table?) rather than merely role-scoped, and Ranger’s audit record names the human instead of admin.

WITH GRANT OPTION maps to Ranger’s delegateAdmin, which is how that authority is handed on. Without it nobody except the principals seeded at bootstrap could ever grant.

The [auth] admin_roles gate on GRANT and REVOKE stays in place by default as defence in depth. The two checks answer different questions: the role gate is coarse and local (“may this session issue grant statements at all”), while Ranger’s is per-resource. The gate also still matters for the polaris access-control backend, which swaps the caller’s token for a service token (issue #204) and so has no equivalent check of its own.

Delegated grants: grant_authority

Both checks together mean a table owner holding WITH GRANT OPTION still cannot use it without an engine-wide admin role. [access_control] grant_authority decides which check applies:

[access_control]
backend = "ranger"
# admin-role      (default) require an [auth] admin_roles role, then Ranger
# ranger-delegate let Ranger's per-resource delegateAdmin be the only check
grant_authority = "ranger-delegate"

The default is admin-role, so an upgrade changes nobody’s deployment. Read the Ranger policies before switching: ranger-delegate widens who may issue grants to everyone holding delegateAdmin, and a wildcard discovery policy (catalog = *) written with delegateAdmin: true hands its roles the authority to grant those access types anywhere in the service. The quickstart’s analyst and engineer roles are exactly that shape.

ranger-delegate is only honoured for a backend that authorizes the caller (GrantBackend::enforces_grantor_authority). Asking for it against a backend that acts with SQE’s identity leaves the gate in place rather than removing the last check.

DENY ignores the setting entirely and always requires an admin role. See the two asymmetries above: the policy API authorizes the REST user and takes no grantor, so there is nothing finer to hand over to.

Delegate admin does not cascade upward

A GRANT on a table writes three policies, and Ranger authorizes each one separately against the grantor. Measured on Ranger 2.8, with a grantor holding delegateAdmin on cat.ns.tbl only:

RequestResult
grant on cat.ns.tbl200
grant on cat.ns403
grant on cat403
revoke on cat.ns.tbl200
revoke on cat403
grant table-data-write on cat.ns.tbl (outside their delegate set)403

The plan writes the catalog level FIRST, so a delegated grant would fail on its very first call. SQE therefore skips a traversal level the grantee already holds at that exact resource: Ranger merges access types, so re-POSTing a set already present changes nothing, and skipping it removes the only call the delegated grantor was not authorized to make. The level the statement NAMES is never skipped, because it may still add access types or delegateAdmin.

What follows from that is the real shape of delegated grants: an admin onboards a principal to a catalog and namespace once, and table owners manage their own tables from then on. A grantee with no discovery yet cannot be served by a delegated grant, and the error says so, naming the level that failed and the statements that fix it.

The check is deliberately exact-resource. A wildcard policy can cover the same target, but deciding that needs Ranger’s own matcher, and a wrong “already covered” would skip a level the grantee does not hold, leaving a grant that reports success and confers nothing. Being too cautious costs one redundant POST. A policy disabled in the console is treated as holding nothing: Ranger returns it with isEnabled: false and its items intact while enforcement ignores it.

One cost worth knowing before a large deployment: Ranger’s policy API has no by-resource query, so each lookup fetches the whole policy list for the service. A table GRANT now does that twice for the skip check plus once for the provenance label, where it used to do it once, and the fetch is linear in total policy count. Invisible at tens of policies, not at thousands.

Ownership, then, is delegateAdmin, and WITH GRANT OPTION is how it is handed on. SQE does not yet grant it automatically to whoever creates a table; that is tracked separately.

Migration note

This is a behaviour change for existing deployments. A caller who holds an SQE admin role but NOT delegateAdmin in Ranger could previously grant (every grant was performed as the Ranger admin user) and will now be refused 403.

Grant delegate authority to whoever should be able to grant. The quickstart does this at bootstrap: post_grant sends "delegateAdmin": true for the root user and the sqe_admin role, which is why carol can still grant after this change. For an existing stack, either add a Ranger policy giving the administrator delegate admin on the resources they manage, or grant it through SQL with WITH GRANT OPTION.

Configuration

The ranger access-control backend is configured with two TOML blocks. The Ranger Admin base URL is taken from [access_control] url, not from a field inside [access_control.ranger]. This matches the Polaris backend convention.

[access_control]
backend = "ranger"
url = "http://ranger-admin:6080"

[access_control.ranger]
service-name = "polaris"
admin-user = "admin"
admin-password = "rangerR0cks!"
# Polaris includes the `root` resource in every authorization request, so every
# Ranger policy SQE writes must carry a matching `root` value. "*" matches the
# realm Polaris sends (verified against this stack). Without it, GRANTs succeed
# but enforcement silently never matches.
realm = "*"

Field reference (RangerConfig in sqe-core/src/config.rs):

KeyMeaningDefault
access_control.urlRanger Admin base URL(none)
service-nameRanger service instance; must match Polaris polaris.authorization.ranger.service-namepolaris
admin-userRanger Admin user for HTTP basic authadmin
admin-passwordRanger Admin password (a secret)(empty)
realmthe Polaris root resource value; empty omits the root level(empty)
timeout-secsHTTP timeout for one Ranger Admin call30
accept-invalid-certsaccept self-signed TLS on Ranger Adminfalse

The admin password should be supplied by environment variable rather than written into the file:

SQE_ACCESS_CONTROL__RANGER__ADMIN_PASSWORD=...

Two different “realm” concepts appear in the same sqe.toml and should not be confused. The Keycloak realm (iceberg-ranger, in the [auth] token_url) is the OIDC realm. The [access_control.ranger] realm = "*" is the Polaris root resource value. They are unrelated.

Quickstart

The reference deployment is quickstart/polaris-ranger-keycloak/: Polaris 1.7 with its embedded Ranger authorizer, Apache Ranger 2.8, and Keycloak 26.5. The OVERVIEW.md there is the authoritative identity-model and enforcement reference.

test.sh proves the catalog-level path end to end:

  • A GRANT SELECT enables a query that was denied before the grant.
  • A REVOKE SELECT disables it again.
  • A Ranger DENY added to the same resource policy overrides the allow (deny-overrides-allow).
  • USER grants (GRANT SELECT ... TO USER "bob") and ROLE grants (GRANT SELECT ... TO ROLE "analyst") both work, resolved through Ranger role membership.
  • A user with no role (dave) is denied.
  • SHOW GRANTS ON sales_wh.sales.orders round-trips and lists the analyst and engineer grants written earlier.

GRANT / REVOKE are themselves gated behind an admin allowlist (access_control.admin_roles = ["sqe_admin"] in the quickstart sqe.toml), so only carol (who holds sqe_admin) can run them.

What this path does NOT do

This path is coarse. It answers one question: may this user load this table? It does not do any of the following.

  • No row filtering. It cannot restrict a query to a subset of rows.
  • No column masking. It cannot redact or null a column’s values.
  • No tag-based policy. The polaris service-def declares no rowFilterDef and no dataMaskDef.

Those are the fine-grained path, enforced by SQE itself at the query-plan layer by reading a separate Ranger service of servicedef type hive. SQE downloads those policies and rewrites the LogicalPlan before DataFusion optimization: row filters inject as Filter nodes above the TableScan, column masks replace column references with masking expressions. The two paths are independent, and a query must pass both: the Polaris gate (can the user load the table?) and SQE’s rewriter (what rows and columns may the user see?). Revoking the coarse SELECT grant still denies the query before any fine-grained check runs.

The fine-grained path is configured under [policy] engine = "ranger" with [policy.ranger] service-name, a separate setting from access_control.backend = "ranger". The default is hive; the quickstarts name the instance query, because nothing in the picture is a Hive metastore and the old name sent every reader looking for one. Only the instance name changed: the servicedef type stays hive, since Spark’s Kyuubi plugin is hardwired to the hive resource shape (database / table / column). For the fine-grained model see the “Fine-grained enforcement” section of quickstart/polaris-ranger-keycloak/OVERVIEW.md, the design notes in fine-grained-policy.md, and the service-type decision in ranger-fine-grained-service-type.md.

Two engines, two tiers: how Spark reaches the same gates

Spark runs against the same Polaris catalog and the same Ranger instance, and it is subject to the same object-level policies, with no engine code on SQE’s side. What makes that work is a credential choice, not an enforcement layer.

Polaris already runs its own Ranger plugin (polaris.authorization.type: ranger) keyed on the federated OIDC identity. So Spark’s Iceberg REST catalog is given a per-user Keycloak token, and Polaris authorizes the end user:

spark.sql.catalog.<c>.token=<the user's Keycloak JWT>
spark.sql.catalog.<c>.token-refresh-enabled=false

The second line is load-bearing. Left at its default, Iceberg exchanges the external JWT against Polaris’s own token endpoint and the identity silently reverts to the service account, at which point every access-control test passes for the wrong reason. Connecting as a service principal, which is the common Spark pattern, bypasses the object tier completely.

A per-user token governs ONLY the catalog it is attached to. Any other catalog configured for the same warehouse in that session is a SEPARATE identity, and the caller chooses which one by naming it. Measured: with spark.sql.catalog.sales_wh.credential set to a service account, a user denied on a table through his own catalog reads the same table through that alias in the same session.

The session cannot defend itself. Overriding the alias’s token with the user’s JWT does not help, because Iceberg prefers credential when both are set (measured). The fix is a deployment one: remove the service-account catalog, do not shadow it. Pinned by a_service_account_catalog_in_the_session_defeats_per_user_identity.

Identity then reaches the two tiers by different routes, and the asymmetry is the most important property of the arrangement:

Keycloak token (signature verified)   ->  Polaris  ->  `polaris` service   [object]
HADOOP_USER_NAME (asserted string)    ->  Kyuubi   ->  `query` / `tag`     [fine grained]

Why the frontend service carries a blanket allow

Kyuubi checks its own privilege BEFORE Polaris is consulted, and default-denies without a matching policyType-0 item:

AccessControlException: Permission denied: user [bob] does not have
  [select] privilege on [sales/orders/id]

SQE ignores policyType-0 entirely, so a grant that works in SQE fails in Spark and the failure looks like a Polaris bug. Object level belongs to Polaris, so the query service carries one deliberate blanket allow that makes Kyuubi defer, and holds nothing else beyond masks and row filters.

The item grants select, update, create, drop, alter, index, lock, read and write to group public on database=*/table=*/column=*. Every one of those access types has to be listed: Kyuubi checks update for INSERT and create for DDL, and a missing one short-circuits exactly as above.

It is written with Ranger’s grant API rather than as a self-documenting named policy, because creating a hive-type service makes Ranger auto-generate all - database, table, column over that exact resource signature, granted to admin and {OWNER} only. That policy owns the signature, so a named policy is refused:

Validation failure: error code[3010], reason[Another policy already exists for
matching resource: policy-name=[all - database, table, column]]

Every other wildcard shape is taken by a sibling auto policy. The grant API merges an item into the existing match instead, so the defer item appears as the group public item on all - database, table, column.

Two consequences, both requirements rather than notes.

Read out of context the item says “everyone may select everything”. It grants no data access, because Polaris still decides, and object_denial_survives_the_frontend_defer_policy exists to prove exactly that: with the item present and no Polaris grant, the read is still refused, by Polaris. Do not delete it to tighten security; Spark stops working and nothing is gained.

Any engine that reads the frontend service must also authorize through Polaris. An engine that trusts query alone would be wide open. That is a standing constraint on adding engines, not a property of the current two.

Testing it

make test-access-control-spark writes each grant through SQE’s GRANT statement and asserts it through Spark, so one grant path is checked against two engines. Every denial assertion names the tier it expects: a Kyuubi denial where Polaris was expected means the defer item went missing and the assertion never reached the tier under test.

Two traps are worth knowing before reading a result. Kyuubi caches the policy bundle on disk and refreshes on a 10s poll, so a spark-sql JVM started seconds after a policy change can still enforce the previous bundle. And ranger-spark-security.xml is a bind-mounted single file: editing it on the host with sed replaces the inode, breaks the mount, and leaves the container with FileNotFoundException, after which Kyuubi enforces nothing at all. Recover with docker compose up -d --force-recreate spark.

Versions

  • Apache Polaris 1.7.0 (embedded Ranger authorizer, Beta).
  • Apache Ranger 2.8.0 (required by the Polaris plugin; new embedded authorizer API).
  • Keycloak 26.5.

Fine-grained enforcement with Apache Ranger (row filters, column masks, tags)

This is a reference for SQE’s ranger policy backend: the fine-grained path where SQE itself enforces row-level filters, column masks, and tag-based masking by rewriting the query plan. It is separate from the catalog access-control path.

For the coarse, catalog-level path (where SQE translates GRANT/REVOKE into Ranger policies on the polaris service and Polaris enforces them, and SQE does no filtering of its own) see ranger-access-control.md. That document is the companion to this one. This document does not repeat it.

Overview

The two paths use two different Ranger services and two different config blocks.

  • Catalog path (ranger-access-control.md). [access_control] backend = "ranger". SQE writes the polaris service; Polaris enforces. Coarse allow/deny per catalog operation. SQE does not filter rows or mask columns.
  • Fine-grained path (this document). [policy] engine = "ranger". SQE reads the query service-def, the same service Apache Spark’s Kyuubi Ranger plugin reads, and enforces row filters and column masks in its own LogicalPlan rewriter, between planning and optimization.

The two are independent and both apply. A query must pass BOTH gates: the Polaris catalog gate (may this user load this table?) AND SQE’s fine-grained rewrite (what rows and columns may this user see?). Revoking the coarse SELECT grant denies the query at Polaris before any fine-grained check runs.

Why this lives in SQE and not Polaris: the polaris service-def declares no rowFilterDef and no dataMaskDef, and the Polaris authorizer reads only a boolean allow/deny. It cannot enforce row filters or column masks even though the Ranger engine can compute them. Fine-grained enforcement has to happen in the query engine. The full service-type rationale is in ranger-fine-grained-service-type.md; the design notes are in fine-grained-policy.md.

How it works

The store is RangerStore in sqe-policy/src/ranger_store.rs. The rewriter is PolicyPlanRewriter in sqe-policy/src/plan_rewriter.rs.

Ranger Admin  --download bundle-->  RangerStore (resolve)  -->  ResolvedPolicy
ResolvedPolicy  -->  PolicyPlanRewriter  -->  rewritten LogicalPlan  -->  optimizer

Namespace matching and the last-component fallback

resolve is keyed on the full dotted Iceberg namespace. A Ranger policy whose database resource is only the last component (finance) still matches tenant_a.finance and tenant_b.finance. That is a migration path from the old last-component lookup key, not the intended convention.

Over-matching is the safe direction for a mask or row filter: the policy keeps firing instead of silently disappearing. The cost is tenant collision. Two namespaces that share a last component cannot tell those policies apart. Rewrite the Ranger database value as the full dotted namespace (the same key Kyuubi uses) and the fallback no longer applies.

Download bundle

RangerStore::fetch_bundle calls one endpoint:

GET /service/plugins/policies/download/{service_name}

The {service_name} is hive by default (config policy.ranger.service-name); the quickstarts set it to query. The call uses HTTP basic auth with the configured admin user and password. The response is the full ServicePolicies JSON bundle: the resource policies[] (each carries a policyType: 0 = access, 1 = DATAMASK, 2 = ROWFILTER), and an optional nested tagPolicies block when a tag service is linked. This is the same bundle the JVM Ranger plugin downloads, which is why the policy set is shared with Spark/Kyuubi. The public-v2 /api/policy endpoint returns a flat resource-only array and is insufficient.

The bundle and the per-user ResolvedPolicy cache share policy.ranger.cache_ttl_secs (default 30s). GRANT / REVOKE / policy DDL through SQE call invalidate_policy_cache(), so the next query sees the edit. Edits made in Ranger Admin behind SQE’s back wait until the bundle TTL expires. When that download reports a new policyVersion, the resolved-policy cache is dropped too, so the Admin edit is visible on the next resolve instead of waiting out a second 30s. Operators who need the change sooner can hit the admin catalog-refresh hook.

Resolve

RangerStore::resolve(user, table, namespace) returns a ResolvedPolicy:

#![allow(unused)]
fn main() {
ResolvedPolicy {
    row_filters: Vec<Expr>,
    column_masks: HashMap<String, MaskType>,
    restricted_columns: Vec<String>,
}
}

Resolution is keyed on the user plus the user’s token roles. SQE matches policy items directly against SessionUser { username, roles } (see item_matches): a policy item applies if its users list contains the username OR its roles list intersects the user’s token roles. This differs from the catalog path: SQE’s session roles come from the token (realm_access.roles), so SQE matches the token roles directly and does NOT depend on Ranger role membership the way Polaris does.

Resource matching (policy_matches_table, resource_matches) compares the policy’s database and table resource values against the target. Only exact match and bare * are supported; Ranger glob patterns like orders* are not matched in this version. isExcludes inverts the match. The namespace is flattened to a hive database name by hive_database; the rewriter passes the LAST dotted component of the schema (so schema sales_wh.sales becomes database sales), matching the write path’s namespace().last() keying.

Rewrite

PolicyPlanRewriter::evaluate walks the plan, collects every TableScan, resolves a policy per scanned table, then rewrites top-down. For each scan with a non-empty policy it builds wrappers with LogicalPlanBuilder so injected expressions normalize against the real (qualified) scan schema:

  1. Row filters inject as Filter nodes above the TableScan. User predicates can push through these (same semantics as a user WHERE). The filter sits below the masking projection, so it is evaluated against stored values: masking a column that a row filter reads does not change which rows survive. Kyuubi orders these two the other way around, which is why scripts/access-control-parity-demo.sh pins the difference.
  2. Column masks replace the column reference in a projection with the masking expression, aliased back to the column’s qualified name. User predicates cannot push through a mask expression (the expression boundary blocks pushdown on the raw value, matching PostgreSQL RLS).
  3. Restricted columns are forced to NULL: the column stays in the output schema but every value becomes a typed NULL (restriction is a forced Nullify). SELECT * and any reference to the column resolve and return NULL; the raw value is never returned, and predicate pushdown on the real value is blocked. Restriction wins over a mask on the same column.

Fail-closed throughout

Every uncertain path denies rather than leaks.

  • A table reference that cannot be mapped to a policy key injects a lit(false) row filter (deny all rows). See resolve_policy_key returning None.
  • A policy resolution error (transport, parse, breaker open) injects a lit(false) row filter for that table.
  • An unparseable row-filter expression becomes lit(false) rather than being dropped.
  • An unsupported mask type restricts the column rather than returning it raw.
  • The download is guarded by a PolicyCircuitBreaker: repeated failures trip the breaker, and an open breaker returns an error, which the rewriter treats as deny-all.

Results are cached in a moka TTL cache keyed by username, namespace, table, and the sorted role list. The cache invalidates on invalidate_all (called when table properties change; see the tag section).

Mask vocabulary

SQE realizes the complete Ranger hive built-in mask set. map_mask in ranger_store.rs maps each dataMaskType string to an SQE MaskType. The char-class transformer is the sqe_mask_partial DataFusion UDF in sqe-policy/src/mask_udf.rs.

Ranger dataMaskTypeSQE MaskTypeEffect
MASK_NULLNullifyReplace the value with a typed NULL.
MASK_HASHHashHMAC-SHA256 hex digest (plain SHA-256 when no mask key is set).
MASKPartialMask { 0, 0, 'X', 'x', 'n' }Full redact: uppercase to X, lowercase to x, digit to n; punctuation and non-ASCII kept.
MASK_SHOW_LAST_4PartialMask { 0, 4, 'x', 'x', 'x' }Show the last 4 characters; mask the rest with x.
MASK_SHOW_FIRST_4PartialMask { 4, 0, 'x', 'x', 'x' }Show the first 4 characters; mask the rest with x.
MASK_DATE_SHOW_YEARDateShowYearTruncate a date to its year (date_trunc('year', col)); month and day zeroed.
CUSTOMCustom(Expr)Arbitrary SQL expression; see below.
MASK_NONE(no mask)Explicit exemption. The column is left visible and is not restricted. Place it first in Ranger to carve exceptions.

The character conventions match the hive serviceDef transformer templates. Full MASK uses X/x/n; the MASK_SHOW_* partial masks use x for every replaced character type. Counting is by Unicode scalar (chars), matching Hive. For 111-11-1111 with MASK_SHOW_LAST_4 the output is xxx-xx-1111.

CUSTOM masks carry a valueExpr with {col} as the column placeholder. map_mask substitutes the real column name into the template, then parses the result into a DataFusion Expr via parse_sql_predicate. A parse failure restricts the column (fail-closed). Any genuinely unknown dataMaskType also restricts the column.

How a mask becomes an expression at rewrite time is in apply_mask (plan_rewriter.rs): the masking expression is built to keep the column’s Arrow type (a Nullify on a BIGINT emits a typed Int64 NULL, not a Utf8 NULL), so downstream Filter, Join, and GroupBy operators see the shape they expect and a predicate cannot coerce both sides to Utf8 and leak masked rows.

Masking on the value of another column

A CUSTOM mask is an arbitrary SQL expression, and it can reference other columns of the same row, not only the column being masked. The Ranger valueExpr uses {col} for the masked column; any other bare column name resolves against the table’s scan schema.

Example: mask salary only for rows outside the HR department.

-- Ranger CUSTOM mask valueExpr on column `salary`:
CASE WHEN department = 'HR' THEN {col} ELSE '0' END

Limitation: only bare column names resolve. A qualified reference such as t.department fails to parse, and SQE fails closed by restricting the column (it is forced to NULL, not returned raw). Reference siblings by their bare name.

Role-conditional policy (session-context functions)

SQE registers five session-context scalar UDFs, defined in sqe-policy/src/session_udf.rs. Each bakes in the session’s SessionIdentity at construction time and is Volatility::Immutable, so DataFusion const-folds the call to a literal during logical optimization on the coordinator. The folded literal is what ships to workers; the function call never crosses the wire. This is what makes them distribution-safe.

FunctionReturns
current_user()the session username
is_role_in_session(role)true if role is in the session’s token roles
current_available_roles()the role set as a sorted JSON array string
current_database()the session database, or NULL
current_schema()the session schema, or NULL

is_role_in_session matches the FLAT token role list directly (membership works on unsorted input). These functions are usable in user SQL and inside Ranger-authored policy expressions (row filters and CUSTOM mask valueExpr).

One current limitation in policy expressions: RangerStore builds the resolution identity with database: None and schema: None (it does not hold the session warehouse). So inside a Ranger policy expression, current_user, is_role_in_session, and current_available_roles resolve correctly, but current_database() and current_schema() fold to NULL. In ordinary user SQL all five resolve fully. This is the documented MVP behavior.

Tag-based masking

Tag-based masking splits into two independently-stored halves. The decision is recorded in Ranger tag storage.

  1. The mask-per-tag RULE (“any column tagged PII is masked show-last-4”) lives in Apache Ranger as a tag-service policy, returned in the download bundle’s tagPolicies block. Shared with Spark/Kyuubi like resource policies.
  2. The tag-to-column ASSOCIATION (“column ssn has tag PII”) lives in the Iceberg/Polaris table property sqe.column-tags, a JSON object mapping column name to a list of tags. The mask RULE is shared with Spark/Kyuubi; the association is not yet, pending the Iceberg-to-Ranger tag sync.

Authoring column tags

Attach tags to columns with SET TAGS. SQE stores the association in the sqe.column-tags table property; the DDL writes that property for you.

ALTER TABLE sales.orders SET TAGS (email = ('PII', 'GDPR'), salary = ('PII'));

-- remove all tags on a column:
ALTER TABLE sales.orders UNSET TAGS (salary);

Snowflake’s column-tag syntax works too. The tag name becomes the label; SQE has no tag values, so the assigned value is ignored. ALTER COLUMN is accepted as a synonym for MODIFY COLUMN.

Authoring the tag policy: mask types carry a component prefix

A tag-service policy must name the mask type in component-qualified form:

"dataMaskInfo": { "dataMaskType": "hive:MASK_SHOW_LAST_4" }

Ranger’s tag service definition does not define bare mask names. It aggregates the mask types of every component it can decorate, so its dataMaskDef lists hive:MASK_SHOW_LAST_4, hive:CUSTOM, trino:MASK_NULL and so on. Ranger rejects a policy naming a bare type with HTTP 400.

SQE reads a hive-type service, so map_mask normalizes the hive: prefix and accepts either form. Another component’s prefix is deliberately left unmatched, which restricts the tagged column rather than applying a foreign engine’s policy.

This mattered in practice. Until 2026-07-31 the mapper matched bare names only, so every tag mask fell through to the unsupported arm and the tagged column was RESTRICTED instead of masked. Fail-closed, so no value ever leaked, but the feature was inert from the day it shipped. Nothing caught it because the harness of the day asserted the absence of raw digits, and a restricted column has no digits either. The regression test is mask_type_component_prefix_is_normalized plus the live-Ranger case tag_column_mask_applies_from_iceberg_property.

Tag row filters need a Ranger Admin property

A tag-service policy can carry a row filter as well as a mask (policyType 2 on the tag service), so one rule filters every table holding a column with the tag. SQE supports it, and Ranger ships the capability switched off:

<property>
  <name>ranger.servicedef.autopropagate.rowfilterdef.to.tag</name>
  <value>true</value>
</property>

in ranger-admin-site.xml. Ranger copies each component’s dataMaskDef into the tag service definition unconditionally, but copies its rowFilterDef only when that property is true (AbstractServiceStore, default false). This is not a version limitation and no upgrade changes it.

Without the property the tag service definition carries a populated dataMaskDef and an empty rowFilterDef, and the policy POST is rejected with

tag policy can specify values for one of the following resource sets:
 does not have any resource hierarchies

The message names resource hierarchies rather than the missing capability, so it reads like a malformed resource block. It is not: the resource block is fine and the definition simply cannot express a row filter.

One caveat if you patch the definition over REST rather than setting the property: Ranger’s own aggregate tag definition does not round-trip through Ranger’s validator. It carries a duplicate ozone:assume_role access type and elasticsearch implied grants naming access types the definition never declares, both of which must be pruned before the PUT is accepted.

ALTER TABLE sales.orders MODIFY COLUMN email SET TAG PII = 'true';
ALTER TABLE sales.orders MODIFY COLUMN email UNSET TAG GDPR;

SET TAGS merges: it changes only the columns you name and leaves the rest of the table’s tags in place. Tags within a column are unioned and deduped. UNSET TAGS (col) removes all tags on that column. The mask that a tag triggers still lives in the Ranger tagPolicy; SET TAGS only authors which columns carry which label.

Underneath, the association is one JSON value in the sqe.column-tags table property, mapping each column to its list of tags:

sqe.column-tags = {"email": ["PII", "GDPR"], "salary": ["PII"]}

The write goes through a Polaris updateProperties commit. After the commit SQE calls invalidate_table on the catalog and invalidate_policy_cache(), so the new tags are visible on the next query without waiting for the cache TTL.

The association lives in the Iceberg property that SQE reads. Until the separate Iceberg-to-Ranger tag sync lands, other engines (Spark/Kyuubi) do not see these column tags. The mask-per-tag rule in the Ranger tagPolicy is shared with those engines; the column-to-tag association is not yet.

Tags as table properties (rather than the Ranger tag store) win on four counts: they cover federated catalogs that Polaris cannot gate, they need no Atlas/tagsync deployment, they travel with the data through clone/replicate/rename, and SQE already reads table.metadata() on every scan. The full rationale is in ranger-tag-storage-decision.md.

Resolution and merge

At scan time the rewriter reads column-to-tags from the injected TagSource (sqe-policy/src/tag_source.rs; NoopTagSource by default, CacheTagSource in production). It passes the FULL namespace path (split on .), not the truncated last component, because the tag cache is keyed by the full table identity. The TagSource fails safe: any miss or unparseable metadata returns an empty map, since tags only ADD restrictions.

RangerStore::resolve_tags resolves the tag policies for the user’s roles and returns a three-tuple:

  • mask specs keyed by TAG name, as TagMaskSpec::Ready(MaskType) for a fully-resolved mask or TagMaskSpec::Custom(template) for a CUSTOM mask whose {col} placeholder must be substituted per column at merge time;
  • row filters that the matching tags triggered;
  • the set of tags whose mask could not be mapped (genuinely unsupported type).

merge_tag_masks in plan_rewriter.rs joins tags to columns and enforces a locked precedence contract:

  1. Restricted columns always win. A tag cannot un-restrict a column.
  2. Tag masks win over resource masks, by default. policy.mask-precedence selects this: tag (the default) matches the standard Ranger plugin order that Hive and Spark/Kyuubi implement, so one policy set renders one value in every engine. resource keeps the narrower most-specific-rule-wins reading SQE shipped earlier. Either way the column is masked, and either way an unmappable tag never strips or restricts a column that already has a working resource mask: replacing a readable masked value with NULL is not an improvement.
  3. Tag row filters are ANDed with resource row filters (most restrictive).
  4. Within a column, the first tag in stored order with a matching mask wins (deterministic, since col_tags preserves the parsed JSON order).
  5. Unmappable tags fail closed. A column whose only protection is an unmappable tag, and which has no resource mask, is RESTRICTED (dropped), mirroring the resource path’s behavior.
  6. CUSTOM tag masks are substituted and parsed. The {col} placeholder is replaced with the column name and parsed; on parse failure the column is restricted (fail-closed).

If the bundle fetch fails during resolve_tags, SQE returns a single lit(false) row filter (deny all rows), consistent with resolve().

How policies are changed

There are three authoring surfaces, one per layer.

  • Coarse catalog layer. SQL GRANT / REVOKE. The access-control backend writes the polaris Ranger service; Polaris enforces. See Ranger access control.
  • Fine-grained row filters and column masks. SQL CREATE OR REPLACE POLICY / DROP POLICY writes the hive Ranger service (row-filter policyType 2, data-mask policyType 1). SQE downloads and enforces them. The same policies enforce in Spark/Kyuubi. Ranger UI/REST remains an external authoring path.
  • Tag-to-column associations. ALTER TABLE ... SET TAGS / UNSET TAGS (the Snowflake MODIFY|ALTER COLUMN ... SET TAG forms work too). The DDL writes the sqe.column-tags table property. The mask-per-tag rule itself is a hive/tag service policy in Ranger.

Propagation delay, per surface

The SQL surfaces take effect immediately: GRANT, REVOKE, policy DDL and SET TAGS / UNSET TAGS flush the resolved-policy cache after the mutation commits, so the next query re-resolves (issue #207).

A policy authored in the Ranger UI or over REST does not, because SQE learns about it only on the next download. The resolved-policy cache holds for [policy.ranger] cache-ttl-secs, which bounds an over-permissive window: a user who queried the table before the edit keeps the old decision until their cache entry expires. Tightening a mask in the console is therefore eventually consistent, up to the TTL.

The tag path is exempt on the association side, since resolve_tags re-reads the column-to-tag map on every call. The tag RULE still comes from the cached bundle.

Lower cache-ttl-secs if prompt propagation of console-authored edits matters more than download load against Ranger Admin. The window is pinned at both edges by cache_ttl_bounds_policy_staleness.

Catalog path vs fine-grained path

Catalog pathFine-grained path
Config block[access_control] backend = "ranger"[policy] engine = "ranger"
Ranger servicepolarishive (+ linked tag)
Granularitycatalog / namespace / table allow-denyrow filters, column masks, restricted columns, tag masks
Authored viaSQL GRANT / REVOKESQL CREATE/DROP POLICY + ALTER TABLE SET TAGS (Ranger UI/REST also supported)
Enforced byPolaris embedded authorizerSQE PolicyPlanRewriter (plan rewrite)
Does SQE filter?No (write/read policies only)Yes (rewrites the plan)
Shared with Spark?No (Polaris-specific service)Yes (the query service Kyuubi reads)
Identity matchingRanger role membership (resolved by Polaris)token roles, matched directly
Documentranger-access-control.mdthis document

Both gates apply to every query. The catalog gate runs first at Polaris; the fine-grained rewrite runs in SQE on the loaded plan.

Configuration

The fine-grained path is configured under [policy], separate from [access_control]. Setting engine = "ranger" activates RangerStore.

[policy]
engine = "ranger"

[policy.ranger]
url = "http://ranger-admin:6080"
service-name = "query"
admin-user = "admin"
# Set via SQE_POLICY__RANGER__ADMIN_PASSWORD rather than in the file.
admin-password = ""
timeout-secs = 5
cache-ttl-secs = 30
cache-max-entries = 10000
accept-invalid-certs = false

Field reference (RangerPolicyConfig in sqe-core/src/config.rs):

KeyMeaningDefault
policy.enginepolicy backend selector; ranger activates this pathpassthrough
policy.ranger.urlRanger Admin base URL(empty)
service-namethe frontend-query Ranger instance to read; shared with Spark/Kyuubihive
admin-userRanger Admin user for HTTP basic authadmin
admin-passwordRanger Admin password (a secret)(empty)
timeout-secsHTTP timeout for one download call5
cache-ttl-secsresolved-policy cache TTL30
cache-max-entriesmax cached ResolvedPolicy entries10000
breaker-failure-thresholdconsecutive failures before the breaker opens(OPA default)
breaker-recovery-secshow long the breaker stays open before probing(OPA default)
accept-invalid-certsaccept self-signed TLS on Ranger Adminfalse

The two Ranger config blocks are distinct. [access_control.ranger] points at the polaris service for the write/enforce-at-Polaris catalog path. [policy.ranger] points at the query service for the SQE-side fine-grained path. They can target the same Ranger Admin host but read different services.

The reference deployment is quickstart/polaris-ranger-keycloak/. Its OVERVIEW.md has a “Fine-grained enforcement (SQE-side)” section that walks the live setup, and test.sh section 5 proves a MASK_NULL on orders.amount and a MASK_SHOW_LAST_4 on orders.ssn for role engineer: bob (engineer) sees xxx-xx-1111 and an empty amount; alice (analyst-only) sees the raw values.

For cross-engine parity with Apache Spark on the same Ranger setup, see sqe-spark-ranger-parity.md.

Related references:

Versions

  • Apache Polaris 1.7.0 (embedded Ranger authorizer, Beta).
  • Apache Ranger 2.8.0.
  • Keycloak 26.5.

Which Ranger service type for fine-grained policies (row/column/mask/tag) shared with Spark (research + decision)

Question: to do Snowflake-style row filtering, column masking, and tag-based masking in SQE by reading policies from Apache Ranger - in addition to the coarse catalog policies Polaris already enforces - and ideally share the SAME Ranger backend with Apache Spark, which Ranger SERVICE-DEF / type should we use?

Decision

  • Resource-based row-filter + column-mask: use the hive service-def (a.k.a. “Hadoop SQL”). It is the service Apache Spark’s Ranger plugin reads, so one policy set governs Spark AND SQE.
  • Tag-based masking (the Snowflake tag analog): add a tag service linked to that hive service. Engine-agnostic across Spark/Trino/SQE.
  • Keep the polaris service for the coarse catalog/table allow-deny gate (Polaris enforces it). Fine-grained is a SEPARATE service that SQE (and Spark) read and enforce themselves.
  • Do NOT put row/mask policies on the polaris service (it has no dataMaskDef/rowFilterDef), and do NOT invent a custom SQE service-def (it would not be shared with Spark).
ConcernRanger serviceEnforced by
catalog/table allow-denypolarisPolaris embedded authorizer (shipped)
row-filter + column-mask (per table/column)hive (shared with Spark)SQE + Spark, each in its own engine
tag-based masking/row-filter (PII everywhere)tag linked to the hive serviceSQE + Spark + Trino

Why hive

  • A service-def can host row-filter / data-mask policies only if it declares rowFilterDef + dataMaskDef. Built-in defs that have them: hive, trino, presto, nestedstructure. hdfs, hbase, and polaris do NOT.
  • The de-facto OSS Spark FGAC path is Apache Kyuubi’s Spark AuthZ plugin, which binds to a hive-type service (ranger.plugin.spark.service.name = a Ranger hive service; “reuses the hive service def”). There is no separate spark service-def. So sharing with Spark == SQE reads the same hive service.
  • trino has a richer resource model (catalog/schema/table/column, WITH a catalog level) that maps Iceberg more naturally, but Spark does not read the trino def, so choosing it breaks the share-with-Spark goal. Trade-off: hive = Spark parity (with namespace flattening, below); trino = clean Iceberg model, no Spark sharing.

Sharp edges (decide whether sharing actually works)

  1. Resource-name flattening. The hive def is database -> table -> column with NO catalog level. SQE must flatten Iceberg catalog + (multi-level) namespace into the database string using the SAME convention Kyuubi/Spark uses (two-part db.table; dotted namespace). If SQE emits db=ns and Spark emits db=catalog.ns, the same policy silently fails to match. Validate against Kyuubi’s actual resolved identifiers. This is the make-or-break detail.

  2. policyType integers: 0 = access, 1 = DATAMASK, 2 = ROWFILTER. (Note: data-mask is 1, row-filter is 2.)

  3. Mask transformers are Hive UDFs. The 8 built-in mask types and how SQE should realize them:

    Ranger mask typeEffectSQE realization
    MASK_NULLNULLemit NULL literal (-> existing MaskType::Nullify)
    MASK_NONEno mask (exemption)pass-through (place first to carve exceptions)
    CUSTOMvalueExpr with {col}parse the expr -> MaskType::Custom(Expr)
    MASK_DATE_SHOW_YEARkeep year, zero month/daymake_date(year(col),1,1) / date_trunc('year',col)
    MASKredact letters->x digits->nneeds a mask() UDF or char-class rewrite
    MASK_SHOW_LAST_4show last 4needs mask_show_last_n() (new partial-mask type)
    MASK_SHOW_FIRST_4show first 4needs mask_show_first_n() (new partial-mask type)
    MASK_HASHhash the valueMaskType::Hash (document MD5/SHA choice; Hive uses one)

    The transformer templates live in the service-def (in the download bundle), reference Hive function names, and use a {col} placeholder. SQE either implements equivalent UDFs or rewrites them into DataFusion expressions.

  4. Row-filter filterExpr is a SQL boolean string in Hive/Spark dialect (e.g. region in (select ... where userid = current_user())). SQE must translate it to its dialect and inject it as a Filter above the scan. It can reference current_user() and subqueries.

  5. Tagging Iceberg is manual today. No native OSS Atlas hook for Iceberg, so tag->resource associations come from Atlas+tagsync or manual Ranger tag REST. Tag-based is the most powerful but the heaviest operational lift.

How SQE consumes it (pull + evaluate locally, no JVM plugin)

  • One REST call: GET /service/plugins/policies/download/{serviceName} returns the full ServicePolicies bundle:
    • policies[] (resource: access=0, datamask=1, rowfilter=2),
    • serviceDef (resource hierarchy, accessTypes, dataMaskDef.maskTypes with transformer templates, rowFilterDef),
    • tagPolicies (.policies[] + .serviceDef) when a tag service is linked,
    • policyVersion (returns 304 when lastKnownVersion matches -> cheap polling). This is exactly what the JVM plugin downloads. The public-v2 /api/policy?serviceName= endpoint returns only a flat resource-policy array (no serviceDef, no tags) -> insufficient; use the download endpoint.
  • Tag-to-resource associations (which tags a table/column has) come from the tag store the RangerTagEnricher consumes (a separate download / tag REST), not the policy bundle. SQE needs them only for tag-based policies.
  • Identity (the caller’s groups/roles) is supplied by SQE at evaluation time. IMPORTANT distinction from the Polaris gate: SQE’s SessionUser.roles come from the user’s token (realm_access.roles), so SQE matches policy items on the token roles DIRECTLY - it does NOT depend on Ranger role membership the way Polaris does. (Polaris drops token roles; SQE does not.)
  • Evaluation order to replicate Ranger: tag policies first, deny-overrides, then resource access -> data-mask -> row-filter. Feed the result (ResolvedPolicy { row_filters, column_masks, restricted_columns }) to the existing PlanRewriter.

Cross-engine sharing requirements (author once, enforce everywhere)

For a single policy to apply in Spark AND SQE (AND Trino), all must line up:

  1. same service-def TYPE (all point at a hive-type service) OR a shared tag service;
  2. same service NAME (ranger.plugin.<engine>.service.name resolves to the same Ranger service);
  3. identical resource NAMING (the database/table/column strings each engine produces must match exactly - the flattening convention from sharp edge #1).

Trino is the exception: it reads its own trino service-def (catalog/schema/ table/column), so it does not auto-share hive policies; it shares via the tag service or a duplicated policy set.

Catalog federation (internal vs external catalogs)

Polaris “catalog federation” (an EXTERNAL/passthrough catalog proxying to a remote Iceberg REST / Hive / Glue catalog) changes where enforcement can happen, and it strengthens the case for engine-side fine-grained rather than weakening it.

What Polaris does (verified against 1.5.0 + main):

  • Polaris does NO fine-grained (row/col/mask) for ANY catalog; its Ranger plugin (polaris service-def) is allow/deny RBAC only.
  • For FEDERATED (passthrough) catalogs it is even coarser: the remote’s namespaces/tables are not persisted as Polaris entities, so path resolution falls back to the parent and enforces at CATALOG level only. Per-table RBAC on federated content needs ENABLE_SUB_CATALOG_RBAC_FOR_FEDERATED_CATALOGS (default OFF; the synthetic-entity/JIT path is still a TODO in 1.5).
  • Polaris->remote uses a service credential, not the end-user identity.

Why the hive-service (SQE-side) layer is the unifier: SQE applies row/col/mask in its plan rewriter keyed on TABLE IDENTITY, independent of Polaris’s authorizer and of whether the catalog is internal or federated. So it behaves identically for both. Coverage matrix:

coarse allow/denyfine-grained (row/col/mask)
internal catalogPolaris polaris serviceSQE hive service
federated catalogPolaris CATALOG-level only (per-table opt-in, off)SQE hive service (the ONLY fine-grained, and effectively the primary control)

Consequences:

  • Federated = SQE is load-bearing -> fail-closed is non-negotiable. If SQE cannot reach Ranger or resolve a policy for a federated table, deny/restrict, never pass through (Polaris will not catch it).
  • Wire-name mapping. SQE must map the (catalog, namespace, table) it sees ON THE WIRE - including the Polaris federated-catalog ALIAS, not the remote’s native name - to the hive database/table resource string, consistently with Spark. Policies are authored against the names SQE/Spark observe.
  • The two layers are decoupled by design: SQE’s fine-grained does not wait on Polaris federation authz maturing (issue #540 / the opt-in flag).

Effort estimate (rough, one engineer)

Phased; the OpaStore template + the existing PlanRewriter cut a lot of the work. Estimates are engineering rough-cuts, not commitments; the risk drivers below can move them.

  • Phase 1 - resource-based row-filter + column-mask, single-engine (~2-3 wk). RangerStore: PolicyStore (download-endpoint client + ServicePolicies parse + user/role matching + merge + cache/breaker + fail-closed, modeled on OpaStore); MaskType extended with partial/regex/date + the Hive-equivalent mask UDFs (mask, mask_show_last_n/first_n, mask_hash); filterExpr parse reusing OPA’s parser; PolicyEngine::Ranger config + wiring; unit tests + a row/mask demo on the existing quickstart. Demonstrable end of this phase.
  • Phase 2 - Spark-shared + session-context functions (~2-3 wk). Lock the Iceberg->hive database/table flattening to Kyuubi’s exact convention and validate a single policy enforces in BOTH Spark and SQE; register current_user()/current_role() (cheap) and is_role_in_session() (needs a richer SessionUser role model in sqe-auth - active + inherited/secondary roles - the bigger part); Hive->DataFusion dialect translation for filterExpr/valueExpr.
  • Phase 3 - tag-based masking (~2-3 wk + ops). Evaluate tagPolicies from the bundle + fetch tag-resource associations; a tag source for Iceberg (Atlas hook is absent in OSS -> manual tag REST or a custom hook). Heaviest, mostly operational.

So: a usable single-engine MVP in ~2-3 weeks; Spark-shared + context functions in ~1-1.5 months total; full Snowflake-parity incl. tags in ~1.5-2 months.

Risk drivers (can expand the estimate): cross-engine resource-name match with Kyuubi (integration-fiddly); faithful reimplementation of Hive mask UDF semantics; dialect translation of filterExpr; robustness of the existing PlanRewriter for arbitrary mask expressions + column swaps; and the sqe-auth role-model change behind is_role_in_session.

Net

Bind SQE’s fine-grained RangerStore to the same hive service Spark uses, flatten Iceberg names to database/table/column with Kyuubi’s convention, pull the download bundle, and evaluate row-filter + data-mask (+ tag policies) in the existing PlanRewriter. Layer a tag service for org-wide PII rules. This shares one Ranger backend across SQE and Spark with no policy duplication.

Sources

  • Kyuubi Spark AuthZ (uses hive service-def): https://kyuubi.readthedocs.io/en/master/security/authorization/spark/install.html
  • Ranger hive service-def (mask types, transformers, rowFilterDef): https://github.com/apache/ranger/blob/master/agents-common/src/main/resources/service-defs/ranger-servicedef-hive.json
  • Ranger trino service-def (catalog level): https://github.com/apache/ranger/blob/master/agents-common/src/main/resources/service-defs/ranger-servicedef-trino.json
  • Ranger polaris service-def (no mask/rowfilter): https://github.com/apache/ranger/blob/master/agents-common/src/main/resources/service-defs/ranger-servicedef-polaris.json
  • Ranger tag service-def + RANGER-1494 (tag masking): https://github.com/apache/ranger/blob/master/agents-common/src/main/resources/service-defs/ranger-servicedef-tag.json , https://issues.apache.org/jira/browse/RANGER-1494
  • ServicePolicies bundle + download endpoint: https://github.com/apache/ranger/blob/master/agents-common/src/main/java/org/apache/ranger/plugin/util/ServicePolicies.java
  • RangerPolicy policyType constants + row-filter/data-mask structs: https://github.com/apache/ranger/blob/master/agents-common/src/main/java/org/apache/ranger/plugin/model/RangerPolicy.java
  • Tag-based policies (Atlas + tagsync, enricher, linking): https://cwiki.apache.org/confluence/display/RANGER/Tag+Based+Policies
  • Hive row-filter/column-mask design: https://cwiki.apache.org/confluence/display/RANGER/Row-level+filtering+and+column-masking+using+Apache+Ranger+policies+in+Apache+Hive

Decision: where tag associations are stored (Phase 3 tag-based masking)

Status: DECIDED (2026-06-19). Scopes the storage layer for tag-based masking (the Snowflake tag-masking parity pillar). Pairs with ranger-fine-grained-service-type.md and fine-grained-policy.md.

The two halves of a tag system

Tags split into two independently-stored things. Conflating them is the usual mistake.

  1. The mask-per-tag RULE (“any column tagged PII -> mask show-last-4”).
  2. The tag-to-column ASSOCIATION (“column sales.customers.ssn has tag PII”).

Decision

  • Rule (1) lives in Apache Ranger. Ranger’s tag service holds tag-based mask / row-filter policies, and SQE’s RangerStore download bundle already returns tagPolicies. No change to the policy-source model: the same rules are shared with Spark/Kyuubi, exactly like our resource policies.

  • Association (2) lives in Iceberg/Polaris table metadata as a single namespaced table property sqe.column-tags, a JSON object mapping column name to a list of tags:

    sqe.column-tags = {"ssn": ["PII"], "amount": ["FINANCIAL"]}
    

    Stored in TableMetadata.properties (an arbitrary key/value map that Polaris persists and SQE already reads via table.metadata()). The user-facing surface is now ALTER TABLE ... SET TAGS / UNSET TAGS (with the Snowflake MODIFY|ALTER COLUMN ... SET TAG forms), not raw SET TBLPROPERTIES; the DDL writes this one property for you.

  • Cross-engine (Spark) tag enforcement is an OPTIONAL one-way sync, not a dependency: a Phase-3.1 job mirrors sqe.column-tags into Ranger’s tag store (/service/tags/...) so Kyuubi’s Ranger plugin honors the same column tags. SQE never depends on this sync being run.

Why Iceberg/Polaris properties for the association, not the Ranger tag store

Four deciding factors, in order:

  1. Federated catalogs. Per ranger-fine-grained-service-type.md, SQE’s engine-side enforcement is the ONLY fine-grained layer that covers external / federated catalogs (Polaris cannot gate those). Tags-as-table-properties work for ANY Iceberg table SQE can read, federated included. Populating Ranger’s tag store per federated resource (with exact name matching) is fragile and would leave federated tables untagged.

  2. No Atlas, no tagsync. Ranger tag associations normally come from Apache Atlas via tagsync. There is no Atlas for Polaris/Iceberg here, so a Ranger-only association store would sit empty unless something pushes to it. Iceberg properties need no Atlas and no tagsync.

  3. Tags travel with the data. Properties live in the table metadata, so they survive clone, replicate, and rename. Ranger associations are keyed by resource name and break on rename/move.

  4. SQE reads it natively. SQE already loads table.metadata() on every scan; reading one extra property is trivial. No new client, no new store.

The mask-per-tag RULE (the genuinely valuable shared-with-Spark part) still lives in Ranger, so the cross-engine policy story is preserved. Only the association source of truth moves to the data.

Tradeoff accepted

Spark/Kyuubi read tag ASSOCIATIONS from Ranger’s tag store, not from Iceberg properties. So out of the box, Spark will not honor sqe.column-tags-sourced tags. That is what the optional Iceberg -> Ranger sync (above) is for, and it is only needed when Spark must enforce the same column tags. For SQE-native governance (the common case, and the only option for federated catalogs), no sync is required.

Storage format detail

  • One property sqe.column-tags (a single JSON blob), not one property per column. Atomic to read/write, no per-column-property support needed (Iceberg per-column metadata beyond doc is not in the spec). Column doc is left for human descriptions.
  • Tag names are opaque strings that must match the resource/tag names used in Ranger tagPolicies.
  • iceberg-rust 0.8.0: TableMetadata::properties() -> &HashMap<String,String> for the read; the write goes through the catalog’s update-table-properties path (the same mechanism CTAS/ALTER use). Confirm the exact iceberg-rust write API at implementation time.

Resolution path at query time (Phase 3 build)

  1. On scan, read sqe.column-tags from the table metadata -> map column -> tags.
  2. From the RangerStore bundle tagPolicies, resolve the mask/row-filter for each tag that applies to the user’s roles (same matching as resource policies).
  3. Feed the resulting masks/filters into the existing PolicyEnforcer / PlanRewriter. Resource policies still win on conflict per Ranger ordering.

This reuses the entire enforcement path already shipped in Phase 1/2A; Phase 3 is the tag SOURCE + the tag-policy resolution, not new enforcement.

Operating note: tag row filters need a Ranger flag (validated 2026-07-31)

Both halves above work against a live Apache Ranger 2.8, proven end to end by crates/sqe-coordinator/tests/it/access_control_e2e.rs (make test-access-control). Two behaviours are worth knowing before you deploy this.

Tag mask types are component-qualified. Ranger’s tag service definition does not define bare mask names. It aggregates the mask types of every component that can be decorated, so the entries are hive:MASK_SHOW_LAST_4, hive:CUSTOM, trino:MASK_NULL and so on. SQE reads a hive-type service, so it accepts the bare form and the hive: form; a mask authored under another component’s prefix is deliberately left unmatched, which restricts the tagged column (fail-closed) rather than applying another engine’s policy.

Tag ROW FILTERS are off by default, and it is not a version limitation. Ranger propagates each component’s dataMaskDef into the tag service definition unconditionally, but propagates rowFilterDef only when Ranger Admin runs with

<property>
  <name>ranger.servicedef.autopropagate.rowfilterdef.to.tag</name>
  <value>true</value>
</property>

in ranger-admin-site.xml (AbstractServiceStore, default false). Without it the tag service definition carries a populated dataMaskDef and an empty rowFilterDef: {}, and Ranger rejects a tag row-filter policy with:

tag policy can specify values for one of the following resource sets:
does not have any resource hierarchies

Set the property, restart Ranger Admin, then re-save a component service definition so the propagation runs. Tag row filters then behave exactly like resource row filters in SQE: resolve_tag_policies returns them keyed by tag and the rewriter ANDs them above the scan.

The e2e suite cannot assume an operator-configured Ranger, so its fixture patches the capability in over the REST API instead (ranger_fixture::ensure_tag_rowfilter_support). That is a test-environment shortcut, reset by a Ranger upgrade or a volume wipe. Production deployments should use the property.

One trap if you ever PUT the tag service definition yourself: Ranger’s own aggregate does not round-trip through Ranger’s validator. On 2.8.0 with the stock component set it carries a duplicate ozone:assume_role access type (duplicate itemId 201209) and elasticsearch implied grants naming access types the definition never declares, so a verbatim re-submit is rejected. Deduplicate and prune those first.

SQE and Apache Spark: Ranger policy parity

This is a reference for how SQE compares to Apache Spark when both read the SAME Apache Ranger policies over the SAME Polaris catalog. The short version: a column mask authored once in Ranger produces byte-exact identical output in SQE and in standard Spark.

For how SQE enforces fine-grained policies internally, see ranger-fine-grained-enforcement.md. For the coarse catalog access-control path, see ranger-access-control.md. This document covers only the cross-engine parity result and its scope.

The validated result

With the same Ranger hive-service policy on the same Polaris catalog, SQE and standard Spark produce identical masked output. This was validated live (an earlier change).

The test runs the same query as the same user against both engines:

SELECT id, ssn FROM sales_wh.sales.orders

Run as bob, both engines return the same masked SSNs:

xxx-xx-1111
xxx-xx-2222
xxx-xx-3333

3 of 3 rows byte-exact across SQE and Spark.

Why the results agree

Both engines apply the mask through their OWN plan-rewrite layer, not a shared runtime.

  • SQE rewrites the LogicalPlan in its PolicyEnforcer / PolicyPlanRewriter before DataFusion optimization. See ranger-fine-grained-enforcement.md.
  • Spark rewrites its logical plan through Kyuubi’s Spark Authz plugin (RangerSparkExtension).

Both read the same hive Ranger service-def: the same policy items, the same dataMaskType strings, and the same transformer templates. SQE reimplements the Hive char-class transformer faithfully (uppercase to X, lowercase to x, digit to n for full MASK; x for every replaced character in the partial masks; Unicode-scalar counting). Because both engines start from the same policy and apply the same transformer semantics, the masked values match.

So you get the SAME catalog-level access control (Polaris enforcing the polaris Ranger service) AND the same query-level masking (each engine enforcing the hive Ranger service) across SQE and Spark. One policy set, two engines, the same answer.

Scope of the parity

Parity is validated for RESOURCE policies: named-column masks and named-table / named-column row filters on the query service. These are the policies both engines resolve by table and column name.

Tag-based masking is NOT cross-compared. The two engines source tag-to-column associations from different places:

  • Spark Authz reads tag associations from the Ranger / Atlas tag store.
  • SQE reads them from the Iceberg table property sqe.column-tags (see ranger-tag-storage-decision.md).

The mask-per-tag RULE is shared (both read tagPolicies from Ranger), but the ASSOCIATION source differs. Tag parity would require an Iceberg-to-Ranger tag sync to mirror sqe.column-tags into the Ranger tag store. That sync is optional and not part of this parity result.

Required Spark configuration

For Spark to resolve the injected Hive mask UDF that the Ranger transformer template names, Spark must run with the Hive catalog implementation so function resolution stays in the built-in / Hive function registry:

spark.sql.catalogImplementation=hive

Without it, Spark cannot resolve the mask function the transformer template references, and the masked query fails rather than matching SQE.

Quickstart and version matrix

The reference deployment is the polaris-ranger-keycloak quickstart with a spark service added plus a parity-test.sh that runs the masked SELECT id, ssn FROM sales_wh.sales.orders as bob against both engines and asserts the output is byte-exact across them.

Validated version matrix:

ComponentVersion
Apache Spark3.5.4
Iceberg Spark runtimeiceberg-spark-runtime-3.5_2.12-1.8.1
Kyuubi Spark Authzkyuubi-spark-authz_2.12-1.11.1
Scala2.12
Apache Ranger2.8
Apache Polaris1.5.0
Keycloak26.5

Spark 4 is not feasible off the shelf for this parity. The kyuubi-spark-authz_2.13 artifact is unpublished, so a Spark 4 (Scala 2.13) parity stack would need Kyuubi built from source.

S3 credential vending (next-steps notes)

Future phase, not yet built. Captures the research and the local-test decision so we can pick it up after the Ranger work. Design/brainstorm only at this point.

Goal

Replace the static shared S3 key with Polaris credential vending: when SQE loads a table, Polaris returns short-lived, minimally-scoped S3 credentials for that table, and SQE reads/writes the data with those creds. This makes per-user S3 access control real (the data path is gated, not just metadata) and removes the single broad key. Production object store is AWS S3 or NetApp StorageGRID.

How Polaris vends (mechanism)

  • Polaris calls AWS STS AssumeRole with a generated inline SESSION POLICY that scopes the temp creds to the table’s S3 prefix (read-only or read-write). The loadTable response (with header X-Iceberg-Access-Delegation: vended-credentials) carries s3.access-key-id / s3.secret-access-key / s3.session-token.
  • Catalog storageConfigInfo (S3) fields: roleArn (required), region, externalId, userArn, endpoint, endpointInternal, pathStyleAccess, and stsEndpoint (per-catalog STS endpoint override -> point at a non-AWS STS).
  • stsUnavailable: true tells Polaris to skip AssumeRole and pass static creds through (what our current Ranger quickstart uses, which is why vending is off). Known bug: apache/polaris#3742 (Polaris still tried to vend with NetApp S3 despite stsUnavailable: true).
  • SKIP_CREDENTIAL_SUBSCOPING_INDIRECTION (server flag, default false) is a test-only bypass that hands the server’s ambient creds to every client. Not for production; use per-catalog stsUnavailable for STS-less stores instead.

SQE state (what needs building)

  • WRITES already consume vended creds: INSERT/MERGE/DELETE use table.file_io(), which carries the loadTable vended credentials. Likely works end-to-end today.
  • READS discard them. The coordinator hardcodes s3_session_token: "" in every ScanTask and reads with the static [storage] key (crates/sqe-coordinator/src/query_handler.rs ~2277). Vending for reads is explicitly deferred (“Step 5 / Pluggable Catalogs”): the credential_refresh callback in sqe_server.rs returns None.
  • Worker side is READY: build_object_store_with_creds already applies .with_token(session_token) and there is a credential-refresh channel for mid-scan rotation (sqe-worker/src/executor.rs ~836, ~549).
  • The gap (the work): coordinator extracts s3.access-key-id/-secret/ -session-token from the loadTable response and puts them into ScanTask instead of the static config; wire the deferred vend_credentials refresh callback (reload table near token expiry, push fresh creds to workers).

Minimal S3 permissions (Polaris inline session policy)

  • Read cred: s3:GetObject, s3:GetObjectVersion on the table prefix; s3:ListBucket on the bucket with an s3:prefix StringLike condition scoped to the table prefix; s3:GetBucketLocation.
  • Write cred: the read set plus s3:PutObject and s3:DeleteObject (DeleteObject needed for merge-on-read position deletes / metadata cleanup).

Local test decision: two tiers

Enforcement is the object store’s contract (StorageGRID in prod), not SQE’s. SQE’s deliverable is CONSUMING vended creds. So split the test:

  • Flow tier (default, dev loop + CI): rustack (github.com/tyrchen/rustack). Rust LocalStack-compatible emulator, ~8 MB image, <1s start. Its AssumeRole uses POST form-body so it works with Polaris’s AWS Java SDK StsClient (does NOT hit the SeaweedFS failure mode), returns access-key/secret/session-token, and S3 tolerates the session token. Proves SQE extracts and uses vended creds end-to-end, provable by giving SQE NO static key so a read can only succeed via vended creds. Does NOT enforce session policies (confirmed in its own design spec and code: allow-all IAM sim stubs, bucket policy store-only, S3 maps any key to an empty secret). So it cannot prove “a read cred cannot write”.
  • Enforcement tier (real full end-to-end): Ceph RGW (or real StorageGRID). Ceph RADOS Gateway has real, enforced STS AssumeRole + session policies since Nautilus (2019), is the closest open-source analog to StorageGRID, and has a published Polaris+RGW STS walkthrough. ~2.5 GB RAM (single all-in-one quay.io/ceph/demo container, osd_memory_target tunable to ~1 GB). This tier proves a vended read cred genuinely cannot write or cross table prefixes.
  • Production: AWS S3 (native STS) or NetApp StorageGRID 12.0+ (added AssumeRole + session policies). Both enforce.

Rejected local options: SeaweedFS (STS present but broken with SDK clients: POST-body AssumeRole 500s, SigV4 ignores X-Amz-Security-Token); Garage (no STS / no IAM at all, only long-lived per-key-per-bucket keys); MinIO (excluded by request; it does support AssumeRole + session policies).

Phase shape (when we build it)

  1. SQE coordinator: extract vended creds from loadTable -> ScanTask; wire the refresh callback. Read-path is the work; write-path already vends.
  2. Quickstart quickstart/polaris-ranger-keycloak variant (or new) with rustack
    • Polaris stsEndpoint -> rustack STS, stsUnavailable: false, a role ARN, and SQE with NO static [storage] key. Test: read succeeds only via vended creds.
  3. Optional enforcement quickstart with Ceph RGW: prove read-cred-cannot-write.
  4. Combine with the Ranger backend: Ranger gates the catalog operation (LOAD_TABLE), Polaris vends a cred scoped to that table, the store enforces the data path. Defense in depth.

Sources

  • Polaris management spec (AwsStorageConfigInfo, stsEndpoint): https://raw.githubusercontent.com/apache/polaris/refs/heads/main/spec/polaris-management-service.yml
  • Polaris config reference (SKIP_CREDENTIAL_SUBSCOPING_INDIRECTION): https://polaris.apache.org/in-dev/unreleased/configuration/configuration-reference/
  • stsUnavailable + NetApp bug: https://github.com/apache/polaris/issues/3742
  • Ceph RGW + Polaris STS walkthrough: https://medium.com/@sharas2050/ceph-rgw-and-polaris-integration-using-sts-and-iam-roles-7e6012ed6bdd
  • Ceph STS: https://docs.ceph.com/en/latest/radosgw/STS/
  • StorageGRID 12.0 AssumeRole: https://docs.netapp.com/us-en/storagegrid/s3/use-access-policies.html
  • rustack: https://github.com/tyrchen/rustack (v0.9.x; STS design spec documents the no-enforcement posture)
  • SeaweedFS STS broken: https://github.com/seaweedfs/seaweedfs/discussions/8312
  • Garage (no IAM/STS): https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/

Polaris principals + external auth: do you need to pre-create them? (research)

Question investigated: with users managed only in Keycloak and Apache Polaris in external-auth mode (authorization delegated to Apache Ranger), can Polaris authenticate a user WITHOUT a Polaris principal entity, i.e. purely federated from the token? Tested empirically AND confirmed from source.

Answer (short)

No. Polaris always requires a principal ENTITY in its own metastore to authenticate, regardless of authentication.type (internal / mixed / external). The token is only a lookup key; the entity must exist. There is no config to turn this off; only a custom Authenticator (code) could.

BUT this is usually invisible: in the quickstarts it “just works” with Keycloak-only users because the bootstrap script auto-creates a Polaris principal per Keycloak user. Polaris still needs them; the automation creates them for you. So the right model is “auto-provision principals transparently,” not “no principals.”

Why it looked like it works without principals

The provisioning is hidden in automation. In this Ranger quickstart, quickstart/polaris-ranger-keycloak/polaris/bootstrap-data.sh does:

mkprincipal() { api POST "$MGMT/principals" "{\"principal\":{\"name\":\"$1\",\"type\":\"USER\"}}"; }
for u in alice bob carol dave; do mkprincipal "$u"; done

and the shared quickstart/_shared/polaris/bootstrap.sh creates root/adminuser/testuser the same way. The names match the Keycloak preferred_username, so when a Keycloak user’s token arrives, Polaris resolves it to the pre-created principal. You only touched Keycloak; the script touched Polaris. That is why the 13/13 Ranger test passes without anyone creating principals by hand.

The empirical test (external mode, no principals)

Config: polaris.authentication.type=external, the mkprincipal loop removed, a Keycloak-only user carol (federated, no Polaris principal), authorization via Ranger (carol is a member of the Ranger role sqe_admin). Result: 401 on every call. Raw Polaris log (in-container):

DefaultAuthenticator  Resolving principal for credentials:
  PolarisCredential{principalName=carol, principalRoles=[...analyst, engineer, sqe_admin...]}
DefaultAuthenticator  Failed to resolve principal from credentials=...   -> HTTP 401

The JWT verified fine and Ranger had carol’s roles; the failure is the Polaris principal LOOKUP (a metastore separate from Ranger). So Keycloak + Ranger alone is not enough. (Reverted afterward; the committed quickstart stays on mixed.)

Also: pure external mode disables the internal root token, so you cannot bootstrap the first principal (chicken-and-egg). The quickstart uses mixed so the bootstrap (internal token) can create principals, after which external Keycloak users authenticate against them.

Source confirmation (apache-polaris-1.5.0, unchanged on main)

  • runtime/service/.../auth/Authenticator.java is the interface; the ONLY implementation is DefaultAuthenticator (@Identifier("default")).
  • DefaultAuthenticator.resolvePrincipalEntity() calls metaStoreManager.findPrincipalById(...) / findPrincipalByName(...); if null it logs “Failed to resolve principal from credentials={}” and throws NotAuthorizedException. No branch synthesizes or auto-creates a principal.
  • Class javadoc: “it does not support federated principals that are not managed by Polaris.”
  • polaris.authentication.authenticator.type (AuthenticationRealmConfiguration, @WithDefault("default")) only accepts registered @Identifier strings, of which only default exists. Any other value needs a custom compiled bean.
  • The OIDC external path: OidcPolarisCredentialAugmentor -> DefaultPrincipalMapper extracts principalId/principalName from the claims (polaris.oidc.principal-mapper.{id,name}-claim-path) and hands a PolarisCredential to DefaultAuthenticator, which still does the metastore lookup. OIDC builds the credential; the entity must pre-exist.
  • No identity-federation feature flag exists (polaris.principal.* / polaris.authentication.federation.* do not exist; every “federation” key is CATALOG federation, unrelated).

Disposition

  • Provision a Polaris principal per user (no principal-roles needed: roles come via Ranger role membership). It can be fully automated and transparent:
    • quickstart/dev: the bootstrap script (as today).
    • production / data-platform BFF: create the Polaris principal when the BFF onboards a user (it already manages users + grants), so the operator manages identities only in Keycloak + Ranger.
  • Do not rely on pure-token federation; it is not supported by config.
  • Only way to eliminate provisioning entirely: write a custom Polaris Authenticator bean that auto-creates/synthesizes the principal from the token and set polaris.authentication.authenticator.type to its identifier. That is a code change / custom Polaris build (and an upstream-PR candidate), worth it only if removing the provisioning step is a hard requirement.
  • Identity model summary: quickstart/polaris-ranger-keycloak/OVERVIEW.md.
  • Ranger backend design: docs/internal/specs/2026-06-18-ranger-access-control-backend-design.md.
  • BFF OPA->Ranger migration (carries this finding): ../data-platform/docs/prompts/opa-to-ranger-migration-prompt.md.

Row-Level Write Operations: MERGE INTO, DELETE, UPDATE

Summary

Row-level write operations (MERGE INTO, DELETE FROM, UPDATE) are implemented for Iceberg tables via Copy-on-Write using the iceberg-rust fork vendored at vendor/iceberg-rust/, which provides the rewrite_files() transaction API. The fork tracks risingwavelabs/iceberg-rust and is rebased onto DataFusion 53.1 + Arrow 58 (see docs/site/blog/2026-04-14-datafusion-53-and-the-iceberg-fork.md).

Motivation

SQE needed row-level mutations to be a viable Trino replacement:

  • MERGE INTO: the most common pattern for incremental data pipelines (upserts). Required by dbt incremental models.
  • DELETE FROM: GDPR right-to-erasure, data corrections, partition cleanup.
  • UPDATE: in-place corrections without full table rewrites.

Current State (as of 2026-04-01)

What SQE Has

ComponentStatus
SQL parsing (MERGE, DELETE, UPDATE)✅ sqlparser handles all three
Statement classification & routingStatementKind::Merge, Delete, Update routed in classifier
Query execution (SELECT part of MERGE)✅ DataFusion handles the join/match logic
Append writes via FastAppendAction✅ Used by INSERT INTO and CTAS
DELETE FROM via CoWdelete_handler.rs, rewrite_files()
UPDATE via CoWupdate_handler.rs, rewrite_files()
MERGE INTO via CoWmerge_handler.rs, rewrite_files()
Schema inferenceQueryHandler::get_schema()
Per-session catalog with bearer tokenSessionCatalog with Polaris passthrough
Integration tests (all three operations)✅ Against Polaris + MinIO
TPC-C write benchmarks✅ 17/17 pass

Iceberg Dependency

Uses the iceberg-rust fork vendored at vendor/iceberg-rust/ (a risingwavelabs/iceberg-rust DF 53 + Arrow 58 rebase, ported in-tree to DataFusion 54) for rewrite_files() transaction support. When upstream iceberg-rust ships OverwriteAction (tracked in Epic #2186), the dependency can be migrated back to the official crate.

Future: Merge-on-Read

MoR with position deletes is not yet implemented. Upstream PRs to watch:

PRTitleStatusImpact
#2203RowDeltaAction for row-level modificationsActiveEnables MoR path
#2219Delta writer (position + equality delete writer)ActiveCombined writer for MoR
#1987Delete file support in SnapshotProducerActiveEnables committing delete files

MoR would reduce write amplification for write-heavy workloads but requires compaction to maintain read performance.

Design

Iceberg Delete Strategies

Iceberg v2 supports two strategies for row-level deletes:

Copy-on-Write (CoW):

  • Read affected data files entirely
  • Rewrite without the deleted/modified rows
  • Produce new data files + remove old ones via OverwriteAction
  • Pro: simple reads (no delete file reconciliation)
  • Con: write amplification (rewrites entire files even for single-row changes)

Merge-on-Read (MoR):

  • Write small position/equality delete files marking deleted rows
  • Readers reconcile deletes at scan time
  • Pro: fast writes (small delete files)
  • Con: read overhead (must merge deletes during scans)

SQE strategy: CoW implemented, MoR planned. CoW is simpler and is fully operational via the RisingWave fork’s rewrite_files(). MoR will be added when upstream ships the required primitives.

Architecture

SQL: MERGE INTO target USING source ON condition
     WHEN MATCHED THEN UPDATE SET ...
     WHEN NOT MATCHED THEN INSERT ...

                    ┌──────────────────┐
                    │   SQL Parser     │
                    │   (sqlparser)    │
                    └───────┬──────────┘
                            │ StatementKind::Merge
                            ▼
                    ┌──────────────────┐
                    │  QueryHandler    │
                    │  execute()       │
                    └───────┬──────────┘
                            │
                            ▼
              ┌─────────────────────────────┐
              │  MergeHandler               │
              │                             │
              │  1. Plan source + target     │
              │     via DataFusion           │
              │                             │
              │  2. Execute the join to      │
              │     produce matched/         │
              │     unmatched rows           │
              │                             │
              │  3. Classify each row:       │
              │     - matched → UPDATE/DEL   │
              │     - not matched → INSERT   │
              │                             │
              │  4. For CoW: rewrite         │
              │     affected data files      │
              │     without deleted rows,    │
              │     add new rows             │
              │                             │
              │  5. Commit via               │
              │     OverwriteAction          │
              └─────────────────────────────┘

DELETE FROM Implementation

DELETE FROM target WHERE condition

1. Scan target table metadata to find data files
2. For each data file that may contain matching rows:
   a. Read the file
   b. Apply the WHERE filter
   c. If all rows match → mark file for removal
   d. If partial match → rewrite file without matching rows (CoW)
3. Commit via OverwriteAction:
   - Remove old data files
   - Add rewritten data files (partial matches)

UPDATE Implementation

UPDATE target SET col = expr WHERE condition

1. Same as DELETE, but:
   a. Read affected files
   b. Apply WHERE filter
   c. For matching rows: apply SET expressions
   d. Rewrite file with modified rows (CoW)
2. Commit via OverwriteAction

MERGE INTO Implementation

MERGE INTO target USING source ON condition
  WHEN MATCHED AND <cond> THEN UPDATE SET ...
  WHEN MATCHED AND <cond> THEN DELETE
  WHEN NOT MATCHED THEN INSERT (cols) VALUES (vals)

1. Execute the join: DataFusion LEFT OUTER JOIN of source and target
2. For each result row, classify:
   - Matched + update condition → apply SET, write to new file
   - Matched + delete condition → omit from rewrite (delete)
   - Not matched → write as new INSERT row
3. For CoW: identify affected target data files, rewrite them
4. Commit via OverwriteAction:
   - Remove old data files (affected ones)
   - Add new data files (rewritten + inserted)

SQE Implementation

FileChange
Cargo.tomlVendored iceberg-rust fork at vendor/iceberg-rust/ (DF 53 + Arrow 58 rebase, ported in-tree to DataFusion 54)
crates/sqe-coordinator/src/merge_handler.rsMERGE INTO execution via CoW
crates/sqe-coordinator/src/delete_handler.rsDELETE FROM execution via CoW
crates/sqe-coordinator/src/update_handler.rsUPDATE execution via CoW
crates/sqe-coordinator/src/query_handler.rsRoutes Merge/Delete/Update to handlers
crates/sqe-coordinator/src/write_handler.rsShared CoW rewrite logic
crates/sqe-coordinator/src/lib.rsModules registered

Testing Strategy

Unit Tests (no external deps)

  • Parse and classify MERGE/DELETE/UPDATE statements
  • Row classification logic (matched/unmatched/insert/update/delete)
  • CoW rewrite logic with in-memory RecordBatches

Integration Tests (require Polaris + MinIO)

  • DELETE FROM with WHERE clause, then verify rows removed
  • DELETE FROM without WHERE, then verify table emptied
  • UPDATE SET, then verify values changed
  • MERGE with WHEN MATCHED THEN UPDATE, then verify upsert
  • MERGE with WHEN MATCHED THEN DELETE, then verify conditional delete
  • MERGE with WHEN NOT MATCHED THEN INSERT, then verify new rows added
  • Concurrent MERGE operations, then verify conflict detection
  • MERGE with schema mismatch, then verify error handling

dbt Compatibility Tests

  • dbt run with incremental materialization strategy maps to MERGE INTO
  • dbt run with delete+insert strategy maps to DELETE + INSERT
  • Verify dbt test passes after incremental runs

Acceptance Criteria

  • DELETE FROM table WHERE condition removes matching rows
  • DELETE FROM table removes all rows (empty table, metadata preserved)
  • UPDATE table SET col = expr WHERE condition modifies matching rows
  • MERGE INTO target USING source ON cond WHEN MATCHED THEN UPDATE ... works
  • MERGE INTO target USING source ON cond WHEN NOT MATCHED THEN INSERT ... works
  • MERGE INTO with multiple WHEN clauses works
  • All operations are atomic (commit or rollback, no partial state)
  • Bearer token passthrough works for all operations (no privilege escalation)
  • Audit log captures DELETE/UPDATE/MERGE operations
  • Metrics track row-level write operations
  • Works in single-node mode (distributed deferred)

Rollback Strategy

Each operation is a single Iceberg snapshot commit. Rollback = revert to previous snapshot via Polaris REST API. No partial state is possible. Iceberg’s snapshot isolation guarantees atomicity.

If the feature is unstable, the StatementKind::Merge/Delete arms in query_handler.rs can be reverted to NotImplemented in a single commit.

Timeline

All three operations (DELETE, UPDATE, MERGE INTO) were implemented using the RisingWave iceberg-rust fork rather than waiting for upstream. Implementation completed 2026-03-28.

Remaining Action Items

  • Watch upstream iceberg-rust Epic #2186 for OverwriteAction: migrate from RisingWave fork to official crate when available
  • Implement DELETE FROM (done)
  • Implement UPDATE (done)
  • Implement MERGE INTO (done)
  • Integration tests (done)
  • TPC-C write benchmarks (17/17 pass)

Iceberg Caching Strategy

Problem

SQE re-reads everything from Polaris and S3 on every query. Trino caches at 7 layers. On warm queries against the same tables, Trino is 2-3x faster because it skips redundant I/O. At SF0.01 (TPC-H): SQE total 14.2s vs Trino 7.1s.

Analysis: Trino’s 7 Caching Layers

LayerWhatDefaultCorrectness risk
1a. Iceberg Table cacheLoaded Table objects30s TTL, soft refsLow (TTL-bounded stale reads)
1b. REST ETag cacheHTTP conditional loadTable5min TTL, 100 entriesNone (server validates)
1c. Manifest content cacheRaw manifest bytesOFF by defaultNone (files immutable)
2a. Connector table cacheBaseTable per instance1000 entries, no TTLMedium (no expiry)
3. Coordinator memory cacheEntire small files2% heap, 1h TTLNone (files immutable)
4. Worker disk cacheAll file pages on SSDOFF, 7d TTL (Alluxio)None (files immutable)
JVM JITCompiled hot pathsAfter ~10k invocationsN/A

Key insight: Iceberg metadata and data files are immutable by spec. A manifest file at s3://bucket/table/metadata/snap-123-m0.avro never changes content. Caching by S3 path is inherently safe. Only the “which metadata file is current” pointer changes between snapshots.

SQE’s Current Caching

CacheWhatSizeTTLSafe?
FooterCacheParquet metadata (schema, row groups, stats)256MB moka LRUNone (immutable files)Yes
ResultCacheFull query results256MB, 5MB/entry300sYes (write-invalidation for SQE writes, 5min stale window for external writes)
OPA cachePolicy decisionsmokaConfigurableYes
JWKS cacheJWT validation keysmokaConfigurableYes

Missing: table metadata cache, manifest cache, coordinator file cache, HTTP ETag.

What to Build

Cache 1: Table Metadata Cache (highest priority)

What: Cache the Table object returned by SessionCatalog::load_table(). This avoids the Polaris REST round-trip (~30-200ms) for repeated queries to the same table.

Key: (warehouse, namespace, table_name) Value: iceberg::table::Table (contains metadata, schema, snapshots, partition spec) TTL: metadata_cache_ttl_secs config (default 30s, already in CatalogConfig) Max size: 1000 entries (matches Trino) Eviction: TTL expire-after-write + LRU when full Invalidation: On SQE’s own DDL/DML (DROP, ALTER, INSERT, DELETE, UPDATE, MERGE, CTAS) Implementation: moka async cache in SessionCatalog

Correctness: 30-second stale window for external writes. Acceptable for analytics. The metadata_cache_ttl_secs config exists but is not wired – this connects it.

Expected savings: 30-200ms per query per table.

Cache 2: Manifest File Cache (second priority)

What: Cache parsed manifest content (manifest entries with data file paths, column stats, partition values) by S3 path.

Key: S3 URI of manifest file (e.g., s3://warehouse/db/table/metadata/snap-123-m0.avro) Value: Parsed manifest entries (Vec<ManifestEntry>) TTL: None needed (files are immutable by Iceberg spec) Max size: 256MB (configurable, same as FooterCache) Eviction: Size-based LRU (moka weighted cache) Implementation: moka cache in IcebergScanExec, alongside existing FooterCache

Correctness: Zero risk. Iceberg manifest files are append-only write-once. A given S3 path always has the same content. New snapshots create new manifest files with new paths.

Expected savings: 50-200ms per query for tables with >10 manifests.

Cache 3: Coordinator File Cache (third priority)

What: Cache entire small files (metadata JSON, manifest lists, manifest files) in memory by S3 path.

Key: S3 URI Value: Raw bytes (Vec<u8>) Max size: 2% of configured memory limit (matches Trino default) Max file size: 8MB (skip files larger than this) TTL: 1h (matches Trino default) Implementation: moka weighted cache, integrated into the S3 I/O path

Correctness: Zero risk for Iceberg files (immutable). The TTL is a memory hygiene measure, not a correctness requirement.

Expected savings: Eliminates S3 round-trips for metadata files on warm queries. Combined with Cache 2, this means the scan planning path is fully cached after the first query.

Cache 5: HTTP ETag Support (low priority, high polish)

What: Send If-None-Match header with cached ETag when calling loadTable() on the Polaris REST catalog. If the table hasn’t changed, Polaris returns 304 Not Modified.

Key: (warehouse, namespace, table_name) Value: (ETag string, cached Table object) TTL: 5min (matches Trino’s REST table cache) Implementation: Modify SessionCatalog::load_table() to store and send ETags

Correctness: Perfect (server validates). Requires Polaris to support ETag headers on the loadTable endpoint.

Expected savings: ~10-30ms per query when table hasn’t changed (skip JSON parsing of unchanged response). Reduces Polaris server load.

What NOT to Build

Worker disk cache (Trino Layer 4): Requires Alluxio or similar, significant engineering effort (~2 weeks). Deferred to post-v1.0.

Query plan cache: Trino doesn’t have one either. DataFusion re-plans every query.

JIT equivalent: Rust AOT compilation means consistent performance. We trade JVM warmup advantage for cold-start advantage.

Implementation Order

Phase 1: Table metadata cache          ~2 hours    30-200ms savings
Phase 2: Manifest file cache           ~3 hours    50-200ms savings
Phase 3: Coordinator file cache        ~4 hours    eliminates remaining S3 round-trips
Phase 5: HTTP ETag support             ~3 hours    reduces Polaris load
                                       ─────────
                                       ~12 hours   expect 2-3x warm query improvement

Configuration

[catalog]
# Table metadata cache (avoids Polaris REST round-trip)
metadata_cache_ttl_secs = 30     # 0 = disabled. Default 30s.

# Manifest file cache (immutable files, safe to cache indefinitely)
manifest_cache_max_mb = 256      # 0 = disabled. Default 256MB.

# Coordinator file cache (small immutable files in memory)
file_cache_max_mb = 0            # 0 = disabled (default). Set to e.g. 512 for production.
file_cache_ttl_secs = 3600       # 1 hour default, matches Trino.
file_cache_max_file_mb = 8       # Skip files larger than this.

Metrics

Each cache exposes Prometheus metrics:

sqe_cache_hits_total{cache="table_metadata"}
sqe_cache_misses_total{cache="table_metadata"}
sqe_cache_evictions_total{cache="table_metadata"}
sqe_cache_size_bytes{cache="table_metadata"}

sqe_cache_hits_total{cache="manifest"}
sqe_cache_misses_total{cache="manifest"}
...

sqe_cache_hits_total{cache="file"}
...

Validation

After implementing each cache:

  1. Run BENCH_SCALE=0.01 ./scripts/benchmark-test.sh --compare-trino tpch
  2. Compare warm-query times vs Trino
  3. Verify row counts still match 22/22
  4. Run with external writer (Trino writes, SQE reads) to verify cache invalidation

Expected Results

ScenarioBeforeAfterImprovement
Cold TPC-H q01SQE 785ms, Trino 1846msSQE ~785ms (unchanged)SQE still 2.4x faster cold
Warm TPC-H q06SQE 557ms, Trino 113msSQE ~200msFrom 0.2x to ~0.6x Trino
Warm TPC-H totalSQE 14.2s, Trino 7.1sSQE ~8-9sFrom 0.5x to ~0.8x Trino

Appendix: Decimal Literal Precision Fix

Discovery

During value-level comparison of TPC-H q06, we found SQE and Trino return different results:

Query: SELECT sum(l_extendedprice * l_discount) FROM lineitem
       WHERE l_discount BETWEEN 0.06 - 0.01 AND 0.06 + 0.01

SQE:   40,723,667  (758 matching rows)
Trino: 68,170,401  (1138 matching rows)

Root cause

Enginetypeof(0.06)0.06 - 0.01Behavior
Trinodecimal(2,2)0.05 (exact)SQL standard
DataFusionFloat640.049999999999999996IEEE 754

Trino treats numeric literals as DECIMAL (exact arithmetic). DataFusion treats them as DOUBLE (floating-point). This causes BETWEEN predicates to exclude boundary values.

Fix

DataFusion has parse_float_as_decimal config (default false). Setting it to true makes 0.06 parse as Decimal128(2,2) instead of Float64, matching Trino/SQL standard behavior.

#![allow(unused)]
fn main() {
SessionConfig::new()
    .set_bool("datafusion.sql_parser.parse_float_as_decimal", true)
}

This is a one-line fix with enormous impact: all numeric predicates, arithmetic, and aggregates now use exact decimal arithmetic, matching Trino’s SQL standard compliance.

Change Data Capture (CDC) scans

SQE supports snapshot-range incremental reads over Iceberg tables. The feature covers the Phase 1 range scan described by iceberg-rust #2152: query the rows appended or removed between two snapshot ids. The full changelog view from iceberg-rust #1636 is deferred.

Syntax

SELECT *
FROM ns.t
FOR INCREMENTAL BETWEEN SNAPSHOT 100 AND SNAPSHOT 105;

The range is open-closed: 100 is excluded, 105 is included. Both must be integer snapshot ids. Branch names and tag names are not accepted here (use FOR VERSION AS OF for point-in-time pins).

Meta columns become visible when selected explicitly:

SELECT id, amount, _change_type, _change_ordinal, _commit_snapshot_id
FROM orders
FOR INCREMENTAL BETWEEN SNAPSHOT 100 AND SNAPSHOT 105;

Semantics

  • Range: (start, end]. Rows in data files added in any snapshot up to and including end, going back until (but not including) start.
  • Sentinel: start = 0 reads from the beginning of history.
  • Parent chain walk: only snapshots reachable from end count. Branch commits not on that chain are skipped.
  • Deletes: delete files added in the range are reconciled only against data files also in the range. A position-delete file added at snapshot 102 that targets a data file added at snapshot 90 is dropped. Equality deletes are retained.
  • Meta columns: _change_type is the literal string insert or delete. _change_ordinal is a per-snapshot sequence. _commit_snapshot_id is the snapshot that produced the change.

Error cases

  • Descending range (start > end): rejected at parse time.
  • Missing start or end snapshot id: rejected during resolve with the id named in the error.
  • Non-ancestor start: when start != 0 and no parent chain from end lands on start, the resolver rejects the query.
  • Meta columns referenced outside an incremental scan: the query fails with an error naming the requirement. The three meta columns are not regular table columns.

Intended dbt use case

The dbt-sqe adapter plans to expose an append_changes incremental strategy that stores the last-seen snapshot id in dbt state. On each run the adapter emits:

SELECT * FROM source FOR INCREMENTAL BETWEEN SNAPSHOT <last> AND SNAPSHOT <current>

and merges the result into the target table. The adapter work lives in a separate repository (dbt-sqe) and is tracked in Phase G tasks 8.14-8.15; the SQL engine side is complete as of this release.

Current limitations

  • Full coordinator wiring of the incremental parser + planner into a physical scan executor ships in a follow-up commit. The planner (sqe-catalog/ src/incremental_scan.rs) is reachable and unit-tested today; integration tests that stand up a real catalog plus S3 stack live in the next phase of work.
  • Equality deletes are not yet written by SQE. Range reads tolerate them when Spark or Trino writes them, but SQE’s own DELETE path still emits position deletes.
  • Changelog view (per-row deltas with _before/_after payloads) is not in scope for Phase G.

Write modes: Merge-on-Read vs Copy-on-Write

Iceberg tables can choose how DELETE, UPDATE, and MERGE statements persist changes. SQE honours the standard Iceberg table properties. Each DML kind has its own property so you can run DELETE as MoR but keep UPDATE as CoW on the same table.

Properties

PropertyScopeDefaultAccepted values
write.delete.modeDELETEcopy-on-writecopy-on-write, merge-on-read
write.update.modeUPDATEcopy-on-writecopy-on-write, merge-on-read
write.merge.modeMERGEcopy-on-writecopy-on-write, merge-on-read

The dispatcher is strict. Typos like "mor", "MoR", or "COPY-ON-WRITE" raise an error at DML time so silent mode mismatches do not happen.

Set the property on a new table:

CREATE TABLE ns.orders (
    id BIGINT,
    customer_id BIGINT,
    amount DECIMAL(18,2)
)
WITH (
    identifier_field_ids = 'id',
    'write.delete.mode' = 'merge-on-read',
    'write.update.mode' = 'merge-on-read',
    'write.merge.mode'  = 'merge-on-read'
);

Or toggle it on an existing table:

ALTER TABLE ns.orders
SET TBLPROPERTIES ('write.update.mode' = 'merge-on-read');

When each mode wins

Copy-on-Write (default)

CoW rewrites every data file that contains a matched row. One snapshot produces a clean set of parquet files with no delete-file layer for readers to merge.

Pick CoW when:

  • The UPDATE or DELETE touches most rows in each file (rewriting costs the same as reading and the read path stays simple).
  • You care about read latency on small tables and want to avoid the equality-delete merge overhead.
  • You write to the table through engines that have weak or no equality-delete read support.

Merge-on-Read

MoR keeps the old data files untouched and adds a small equality-delete file plus, for UPDATE and MERGE, a new data file with the replacement rows. Commit is atomic via RowDeltaAction.

Pick MoR when:

  • The statement touches a small fraction of a large table. The SF100 TPC-E trade_result_update_holding query updates thousands of rows per call in a partition with hundreds of millions of rows. CoW times out at 120 seconds because it rewrites every file; MoR only writes the matched rows.
  • You need snapshot-stable keys: rows added later that match the same equality keys are also excluded at scan time without a new delete file.
  • Writes arrive faster than compaction can run.

Mixed configuration

Setting only write.delete.mode = 'merge-on-read' gives you DELETE as MoR while UPDATE and MERGE still rewrite files. This is useful during rollout: enable the fastest lane first, check correctness across reader engines, then extend to UPDATE and MERGE.

Compatibility

MoR requires reader engines that understand position deletes and equality deletes.

  • Spark 4.1 with iceberg-spark-runtime 1.x: full support.
  • Trino 465 with trino-iceberg-connector: full support.
  • Spark 3.3 or older: V2 position-delete reads work; V3 position deletes need Spark 4.x. Equality deletes require Spark 3.1+.
  • DuckDB iceberg extension: position-delete reads land in the 2025 release; equality deletes are not yet supported.

system.rewrite_data_files is delete-aware. It reads each file group through the Iceberg scan, so position and equality deletes are applied during the rewrite: the compacted files hold only surviving rows and deleted rows never reappear. The output is pinned to the sequence number of the snapshot it read, so an equality delete another writer commits mid-compaction still applies to the compacted files. Fully-covered position delete files are dropped in the same commit; equality deletes are left to age out through expire_snapshots. Compaction is safe to run on Merge-on-Read tables without materializing their deletes first.

Primary keys

MoR UPDATE and MERGE need a primary key because the equality-delete file has to reference the old row by value. SQE reads the key from the table schema’s identifier-field-ids.

Without a PK the dispatcher falls back to CoW with a log entry rather than fail. MoR DELETE without a PK falls back to position deletes.

Performance expectations

The scripts/benchmark-mor-vs-cow.sh harness exercises a 1k-row UPDATE on a 1M-row table under each mode. The expected shape:

ModeDurationNew data filesRemoved data filesDelete files
copy-on-writesecondsone per file rewrittenall matched files0
merge-on-readmilliseconds1 new data file with 1k rows01 equality delete

Latest numbers land in benchmarks/results/mor-vs-cow-<timestamp>.json.

Known limitations

  • Native MERGE plan: DataFusion’s upcoming MERGE INTO plan (apache/datafusion#20746) is not yet available. SQE rewrites MERGE as a composition of DELETE + UPDATE + INSERT internally. The MoR version builds the row delta from that composition in one commit.
  • Snapshot conflict on concurrent writes: the RowDeltaAction calls validate_from_snapshot(S) and fails with a retryable conflict if another writer advances the snapshot first. Clients should re-read state and re-apply the delta.

Distributed compaction

Phase 4c fans CALL system.rewrite_data_files out to the worker fleet. The data flow below covers how a rewrite job is planned, dispatched, executed, and committed, and why commit authority never leaves the coordinator.

See Configuration for the [maintenance.distribution] config block (mode, min_workers, timeouts, partial_progress) and CALL procedures for the per-call distributed => 'auto'|'local'|'require' override.

Summary

A distributed rewrite job is still one atomic Iceberg commit. The coordinator, not the workers, owns that commit. Workers do the expensive part (reading data files, applying deletes, sorting, writing new Parquet) directly against S3, using their own S3 credentials and no catalog token at all. They report back a small, Avro-encoded description of what they wrote. The coordinator decodes those descriptions, re-checks the row-count invariant across the whole job, and commits a single RewriteFilesAction that swaps every old file for every new file at once.

The design goal: move compute and data-plane I/O to the fleet, keep every correctness-bearing decision, including the decision that the job succeeded at all, on the coordinator.

Data flow

CALL system.rewrite_data_files(..., distributed => 'auto')
        |
        v
COORDINATOR (sqe-coordinator::maintenance)
  1. load_table, pin snapshot_id + sequence_number
  2. plan_delete_aware_read + collect_live_delete_files
  3. bin-pack eligible files into groups
        |
        v  one signed CompactGroupRequest per group (Arrow Flight do_action)
        |
WORKER (sqe-worker::compaction::compact_file_group)
  4. verify_compaction_signature (HMAC over the exact wire bytes)
  5. StaticTable::from_metadata_file(metadata_location) -- S3 creds only,
     no catalog token
  6. assert current_snapshot_id == request.snapshot_id (snapshot pin)
  7. re-plan the delete-aware read locally, resolve this group's files
     (resurrection guard: any requested path missing from the re-plan
     fails loud instead of reading it blind)
  8. read + apply deletes + optional sort + write new Parquet -> S3
  9. Avro-encode the new DataFiles into CompactGroupResponse
        |
        v  Progress* then one Done frame (CompactGroupFrame)
        |
COORDINATOR (sqe-coordinator::compaction_dispatch + sqe-compaction::dispatch)
 10. decode_group_response against THIS table load's schema/partition
     type/spec id/format version
 11. aggregate_group_outcomes: re-check added_rows <= removed_rows across
     the WHOLE job (each worker already checked its own group)
 12. Transaction::rewrite_files(): add every new DataFile, delete every
     old data file + covered position delete, commit ONE snapshot

Every group in a job is dispatched independently and can land on a different worker. The job either commits every group’s output in one transaction or commits nothing: a group that exhausts its retries fails the whole job before any commit is attempted, and any group still in flight when that happens is simply abandoned (its output becomes an orphan, see below).

Why workers re-plan instead of receiving a serialized scan plan

The coordinator’s read plan (plan_delete_aware_read) produces FileScanTasks with fields that are not meant to survive serialization (iceberg-rust marks some #[serde(skip)]). Sending a coordinator-built plan over the wire and expecting a worker to resume it would silently drop those fields.

Instead, the worker receives only what it needs to redo the planning step itself: metadata_location, snapshot_id, and the list of data file paths in its group. It loads a catalog-free StaticTable from that metadata location, asserts the table’s current snapshot still matches snapshot_id (the coordinator’s plan is only valid against the snapshot it read), then calls the same plan_delete_aware_read the coordinator used to resolve which delete files apply to which data file. This duplicates a small amount of manifest I/O per worker but means the worker’s delete accounting is independently derived, not trusted verbatim from the coordinator, matching the same resurrection guard rewrite_group already enforces on the local path.

Trust boundary: workers get S3 credentials, never a catalog token

A worker executing compact_file_group builds its FileIO from the S3Conn embedded in the request (endpoint, region, access key, secret key, session token, path-style, allow-http): the coordinator’s own static storage credentials, the same ones it would use for a local rewrite, not a per-caller vended credential. Nothing in the request carries a Polaris/catalog token, and nothing in the worker’s compaction path calls the catalog. The worker cannot list namespaces, cannot look up other tables, and cannot commit anything: StaticTable never talks to a catalog, and the worker’s compact_file_group handler returns its CompactGroupResponse over Flight instead of writing to sqe_system/Polaris directly.

The blast radius of a compromised or buggy worker is therefore bounded to “can read/write objects the coordinator’s S3 credentials can reach,” not “can commit arbitrary changes to the catalog.” Commit authority is a single-writer property of the coordinator, by construction, not by convention.

The request body carries live S3 credentials. CompactGroupRequest is signed (HMAC-SHA256 over the exact wire bytes, verified by the worker) so a request cannot be forged or tampered with, but the signature does not encrypt the body. The Arrow Flight channel the coordinator opens to a worker for do_action("compact_file_group") is the same channel used for scan-fragment dispatch and credential pushes; in production that channel must run over TLS, exactly like the coordinator’s own client-facing Flight SQL listener. Deploy worker Flight endpoints behind TLS termination (or a TLS-terminated mesh) before enabling distributed compaction outside a trusted, single-tenant network.

Coordinator moves kilobytes, workers move the data

Bytes read from data files, deletes applied, and bytes written for the rewritten Parquet output all flow directly between a worker and S3. The coordinator never streams a row of table data for a distributed job. What crosses the coordinator-worker Flight channel is metadata: one CompactGroupRequest per group (paths, a snapshot id, S3 credentials, a sort spec) out, and one CompactGroupResponse per group (Avro-encoded DataFile entries, row/byte counts, uploaded paths) back. A job compacting gigabytes of Parquet moves on the order of kilobytes of metadata through the coordinator, the same shape as the existing distributed scan path (coordinator hands out signed tickets, workers read Parquet from S3 directly), applied to the write side.

Placement and retries

The coordinator places groups largest-first across healthy workers, capped at max_inflight_groups_per_worker per worker (sqe_compaction::dispatch::place_groups_largest_first). A group that fails on one worker is retried on a different healthy worker (group_attempts, default 2); the failing worker is only marked unhealthy for a transport-class failure (connection refused, timeout, a connection that goes silent mid-stream), never for an application-level failure the worker deliberately returned (resurrection guard, a delete-accounting mismatch, a bad signature). An application-level failure is evidence about the group or the request, and would fail identically on any other worker, so it must not take a healthy worker out of the fleet for every other job. A group that has failed on every currently-healthy worker, or has exhausted every attempt, fails the whole job.

Continuous dispatch pipelining

Dispatch keeps every healthy worker filled up to max_inflight_groups_per_worker at all times. As soon as any in-flight group resolves, whether it succeeds or needs a retry attempt on a different worker, the freed slot is refilled immediately from the pending queue instead of waiting for every other group in the same wave to finish first. Earlier dispatch ran in waves: compute a batch of assignments, spawn all of them, wait for the whole batch to drain, then compute the next one, so a worker that finished its one group early sat idle until every sibling group in that wave also finished. The pure refill decision (next_group_assignment in sqe_compaction::dispatch) is unit-tested for largest-group-first priority, cap enforcement, exclusion-set fallthrough, no double-assignment, and no assignment once every worker is saturated.

Pipelining changes only scheduling. Per-group retry on a different worker (group_attempts), the transport-vs-application failure classification, the stall guard, and the aggregate-then-commit step described above are unchanged: pipelining decides which worker gets the next group and when, not what happens once a group’s output comes back. A job compacting a given set of groups produces the same committed files whether dispatch ran the old wave-based scheduler or the new continuous one; only the wall-clock time to get there changes, because a fast worker no longer waits on a slow sibling in the same wave before picking up its next group.

Live progress and the heartbeat timeout

Before this change, a worker’s compact_file_group action computed an entire group, the delete-applying read, optional sort, and rolling write, before it emitted anything at all: Progress and Done arrived back-to-back once the whole rewrite had already finished. Under that scheme group_heartbeat_timeout_secs only ever bounded the wait for a frame once the worker had already produced its first one; a worker wedged mid-compute produced no frames at all and was only caught by the much coarser group_timeout_secs, the end-to-end bound on the whole dispatch attempt (default 3600s).

Workers now emit a Progress frame every PROGRESS_INTERVAL_BATCHES record batches (a fixed internal constant, currently 8) processed during the write loop, across every write path: plain rewrite, sort, and z-order. The coordinator’s per-frame wait, group_heartbeat_timeout_secs, resets on every frame it receives, so a fresh frame arrives well inside that window as long as the worker keeps making forward progress. group_heartbeat_timeout_secs is therefore a meaningful mid-compute liveness bound now, not just a frame-delivery one: a worker that stalls partway through, a wedged read or a hung write, stops producing frames and is caught here. Its group is retried on a different healthy worker, exactly like any other retryable dispatch failure, up to group_attempts.

PROGRESS_INTERVAL_BATCHES is not an operator-facing knob; the only tuning surface is group_heartbeat_timeout_secs itself, sized to however long an operator is willing to wait between progress signals before treating a worker as stalled. Output is unchanged: only frames were added to the wire protocol, and the data files a worker writes are identical to before.

Commit-conflict retries and orphaned worker output

A concurrent writer that commits between the coordinator’s read and its RewriteFilesAction commit produces a retryable conflict, exactly like the local (non-distributed) rewrite path. When that conflict actually surfaces as an Err, the coordinator re-plans the job from scratch (fresh snapshot, fresh groups) and re-dispatches every group again, rather than trying to patch the stale attempt against the new snapshot.

That last sentence has a load-bearing qualifier: when it surfaces as an Err. The vendored iceberg::transaction::Transaction::commit (vendor/iceberg-rust/crates/iceberg/src/transaction/mod.rs, do_commit) silently reloads the table and REPLAYS the same RewriteFilesAction unchanged whenever it detects its base is stale, and only returns an Err to the coordinator once its own internal commit-retry budget (commit.retry.* table properties) is exhausted. A single, one-shot concurrent writer is absorbed entirely inside that call – the coordinator never sees an Err for it, so the “re-plan from scratch” retry described above never runs. RewriteFilesAction has no equivalent of upstream Iceberg’s validateNoNewDeletesForDataFiles check, so if that one concurrent writer landed a position delete on one of the files this job is compacting, the silently-replayed commit can still resurrect the deleted rows – see the “Partial-progress commits” section below for how partial_progress batches guard against this specifically, and note that the DEFAULT (non-batched) path is exposed to the identical window, closing it only requires the missing vendor-side validation.

The prior attempt’s workers may already have written Parquet files to S3 before a conflict was detected (whether that conflict surfaced to the coordinator or was absorbed internally by Transaction::commit). Any files an actual re-plan produced but did not end up committing are never referenced by any commit, so they become orphans. That is an accepted trade-off, not a bug: reclaiming them is CALL system.remove_orphan_files’s job, on its normal age-thresholded sweep, the same mechanism that already reclaims orphaned output from other failure paths. Correctness comes from never committing a stale plan; cleanup of writes that turned out to be unneeded is a separate, deliberately decoupled concern.

Partial-progress commits (opt-in)

[maintenance.distribution] partial_progress (default false) lets a distributed rewrite commit its successful groups in batches instead of holding everything until the whole job finishes. Off, the job behaves exactly as before: commit_eligible_groups treats every eligible group as a single batch, so a terminal failure anywhere still commits nothing.

On, eligible groups are chunked into batches of partial_progress_batch (default 10), and each batch commits as its own Transaction::rewrite_files(), in the identical sequence the single-commit path already uses: the sequence number pinned once at plan time (seq_at_start), never advanced between batches, the same set_check_file_existence(true) gate, the same snapshot-property stamp, and the same added-rows-not-exceeding-removed-rows invariant, checked over just that batch’s own files.

Correctness does not depend on batching being disjoint by luck. eligible_groups partitions every input data file into exactly one group up front, and batching only chunks that partition further, so no data file, and no position-delete file (each has exactly one referenced_data_file), ever appears in two batches. After batch K commits, batch K+1’s input files are still exactly where they were: check_file_existence on K+1’s commit finds them, unless a concurrent external writer removed one, exactly the same conflict it already catches on the non-batched path. Pinning every batch to the same seq_at_start, rather than to whichever snapshot each batch actually lands on, keeps a concurrent equality delete from dodging a later batch’s rewritten rows.

That handles equality deletes. Position deletes need a second guard, because pinning seq_at_start does nothing for them: a position delete matches by (file_path, position), not by sequence number, and once a batch’s input file is replaced by its compacted output the position delete’s path matches nothing at all. If a concurrent writer lands a position delete on one of THIS batch’s own input files after the batch’s worker output was produced but before it commits, that output does not reflect the delete – and, per the previous section, Transaction::commit can silently replay that stale output rather than surfacing a catchable Err. So for every batch after the first (committed_batches > 0), the coordinator does not simply wait for a retryable Err: before every commit attempt it reloads the table and checks whether any position delete now references one of this batch’s own input files that it did not already know about. If so, it re-dispatches the same groups against the reloaded snapshot – so the worker output actually reflects the delete – before ever attempting to commit, instead of committing what it already has. This closes the realistic single-race window for batches after the first. A sub-millisecond gap remains between the coordinator’s own reload and Transaction::commit’s internal one (a delete landing in exactly that gap); closing it needs the missing vendor-side validateNoNewDeletesForDataFiles-equivalent check, which is out of scope here. The first batch shares that same residual, unclosed window – see the caveat in the previous section.

A retryable commit conflict that the pre-commit check above did not already preempt – an unrelated concurrent writer, or Transaction:: commit’s own internal retry budget exhausted by sustained contention – is still retried for any batch after the first: the coordinator reloads the table and RE-DISPATCHES the same groups against the reload before recommitting, up to the same retry budget the outer job-level retry uses. It does not recommit the prior attempt’s worker output unchanged (an earlier version of this logic did, which is what let the position-delete race above resurrect rows in the first place). The first batch’s failure is not retried at this inner layer at all; it bubbles up for the outer rewrite_data_files_distributed loop to handle by re-planning and re-dispatching the whole job, exactly as it did before partial_progress existed – with the same “may be silently absorbed by Transaction::commit first” caveat from the previous section. Only a batch failure that is not retryable, or one whose retries are exhausted, after at least one batch has already committed, is terminal: the already-committed batches are never rolled back, and the job reports status = "partial" in sqe_system.maintenance_log with the partial byte/file/row counts and the error that ended it, instead of failing the job outright.

partial_progress trades a larger commit-conflict surface, N commits instead of one, each independently racing concurrent writers, for incremental durability on very large tables, where losing an entire multi-hour job to one late group failure is expensive. It is opt-in for that reason: the default keeps the simpler, fully atomic guarantee, the whole job commits or none of it does, for tables where a single conflict-driven re-plan is cheap enough to just re-run.

Multi-coordinator HA: the lease is an efficiency layer, not a correctness mechanism

Phase 4d adds a multi-coordinator HA lease (crates/sqe-coordinator/src/maintenance_lease.rs) so that when more than one coordinator runs the maintenance scheduler (maintenance.scheduler.lease = "catalog", see Configuration), only one of them dispatches a rewrite for a given table in a given tick. The lease is a row appended to sqe_system.maintenance_log, claimed with an Iceberg optimistic-concurrency commit (Transaction::rewrite_files().delete_files([current_claim]).add_data_files([new_claim]).set_check_file_existence(true)) immediately before the scheduler dispatches the one expensive step of a tick, the rewrite itself, and released the same way after. A coordinator that finds the lease already held by a live holder skips its tick for that table rather than attempting the rewrite at all.

State the invariant plainly: this lease is an efficiency layer, not a correctness mechanism. Correctness against two coordinators double- compacting the same table is already established above, in “Commit- conflict retries”: the coordinator that owns a rewrite job commits it with Transaction::rewrite_files(), pinned to the snapshot it read and gated by set_check_file_existence(true). If two coordinators ever plan and dispatch a rewrite for the same table at the same time, whether because the lease was never configured (lease = "none"), a lease operation failed and the scheduler proceeded unleased rather than blocking an otherwise-eligible compaction, or a lease was legitimately stolen mid-job, exactly one commit still wins. The loser’s commit hits a file-existence check against files the winner already deleted, fails as a non-retryable conflict, and that coordinator’s job ends having modified nothing. Nothing about the lease’s presence, absence, or failure changes this outcome. The lease exists only to stop a second coordinator from paying for the loser’s redundant scan, delete-apply, and re-encode work when Iceberg would have discarded that work anyway.

A holder crash mid-job is the same guarantee working the other way. Because the rewrite is one atomic Iceberg commit, a coordinator that dies mid-scan or mid-write has committed nothing: the table is untouched. The lease it held simply outlives it until lease_ttl_secs (default 300s) elapses, at which point the next coordinator to check the lease finds the claim expired and steals it. No cleanup or reconciliation step is required against the table itself; the only “recovery” is the next coordinator picking the table back up on its own next due tick. (The lease’s very first claim for a brand-new table has no existing lease row to compare-and-swap against and uses an unprotected append instead, so two coordinators racing that one first-ever claim can both succeed; every claim after that is fully exclusive. That first-claim race is a documented, accepted gap in the lease’s own exclusivity, not a gap in table correctness: the Iceberg commit above still decides the table outcome regardless.)

What stays the same as the local path

The worker-side rewrite calls the exact same primitive the coordinator’s local path uses: sqe_compaction::rewrite::rewrite_group. Delete application, the sort/z-order path, Parquet compression, and the post-delete row cross-check are not reimplemented for the distributed case, they are the same audited code, given a worker’s inputs (S3 credentials instead of a catalog session, one pre-selected group instead of the coordinator’s full bin-packed set) instead of the coordinator’s. The commit itself, Transaction::rewrite_files() with set_enable_delete_filter_manager(true), set_check_file_existence(true), and the sequence-number pin, is line-for-line the same sequence the local path already runs.

Runtime filter pushdown into the Iceberg scan

Engineering log for the work that closes most of the SF10 TPC-H gap to Trino on lineitem-heavy join queries (q06, q14, q15, q16, q17, q20). Pairs with upstream issue apache/iceberg-rust#2376.

Update 2026-07-05. Parts of this log are historical. The resolution below says with_dynamic_predicate stays intentionally absent from SQE’s IcebergScanExec; since MR #220 the current code registers it by default, with a seal-wait (runtime_filters.wait_ms) and the #132 clustering-skip gate. Measurements on the current code, plus the SSB root-cause verdict this log fed into, live in docs/evidence/perf/ssb-sf10-root-cause-sf100-prep.md. Treat the code as authoritative where the two disagree.

Problem

DataFusion 53 has a runtime / dynamic filter pushdown path: a HashJoinExec build side emits a DynamicFilterPhysicalExpr (initially lit(true), sealed once the build completes), and the framework walks the plan to push that filter into the probe-side scan. The probe scan then uses the filter to skip Parquet row groups, consult bloom filters, and avoid reading data that can’t possibly match.

Until the work documented here, none of that reached our Iceberg scan. The vendored IcebergTableScan left gather_filters_for_pushdown and handle_child_pushdown_result at the ExecutionPlan defaults, which reject every parent filter. So the runtime filter ended up sitting above the scan as a FilterExec and only ran AFTER the data had been decoded. No Parquet-level pruning at all.

The visible symptom: at TPC-H SF10, lineitem-heavy joins ran 3-5x slower than Trino on the same data:

q06  SQE 9546  vs Trino 1217   0.13x
q08  SQE 9262  vs Trino 1070   0.12x
q14  SQE 8139  vs Trino 1664   0.20x
q15  SQE 10715 vs Trino 2546   0.24x

What we shipped

Three fix branches, in order:

branchcommitwhat it does
fix/bench-tpch-decimal-typesmergedBench generator was emitting Float64 for TPC-H money/quantity columns even though the schema said DECIMAL(15, 2). Float64 SUM is non-associative, which broke q15’s total_revenue = MAX(total_revenue) equality compare (the row-count flipped between runs). Fixing the generator made q15 deterministic and incidentally simplified DataFusion’s type coercion.
fix/bench-tpcc-decimal-typesmergedSame shape on the TPC-C generator (8 columns, varying precisions).
a feature branchopen MRThe actual runtime filter pushdown. Two layered commits described below.

Path B: post-batch runtime filtering (commit dd300b3)

vendor/iceberg-rust/crates/integrations/datafusion/src/physical_plan/scan.rs

  • IcebergTableScan gained a runtime_filters: Vec<Arc<dyn PhysicalExpr>> field.
  • gather_filters_for_pushdown returns an empty FilterDescription (leaf with no children).
  • handle_child_pushdown_result clones self with the parent filters appended and reports PushedDown::Yes per filter, so the framework drops the wrapping FilterExec.
  • execute() wraps the iceberg-rust output stream with a per-batch PhysicalExpr::evaluate + arrow::compute::filter_record_batch. The DynamicFilterPhysicalExpr starts as lit(true) (no-op while the build side is loading) and becomes selective once the build accumulator is sealed, so the filter kicks in mid-stream without restarting the scan.

Bench delta vs the post-DECIMAL baseline:

SF1SF10
total-21.3% (18,384 -> 14,464 ms)-9.4% (163,858 -> 148,511 ms)
matched22/2222/22

Big per-query wins at SF1: q20 -45%, q07 -36%, q15 -35%, q06 -30%, q14 -28%.

Path B-2: per-task scan-time pruning (commit c564a89)

Path B filters AFTER the row group is decoded. To reach Parquet row-group skipping, the dynamic predicate has to participate in the reader’s existing static-predicate pruning paths. We extended iceberg-rust with a Trino-style “sample once per file scan task” hook.

vendor/iceberg-rust/crates/iceberg/src/expr/dynamic.rs (new):

#![allow(unused)]
fn main() {
pub trait DynamicPredicate: Send + Sync + Debug {
    fn current(&self) -> Option<Predicate>;
}
}

vendor/iceberg-rust/crates/iceberg/src/scan/mod.rs: TableScanBuilder::with_dynamic_predicate(...) plumbs the trait through TableScan to ArrowReaderBuilder.

vendor/iceberg-rust/crates/iceberg/src/arrow/reader.rs: at the start of process_file_scan_task we sample dp.current(), bind the result to the task schema, and 3-way AND with the static predicate and the equality-delete predicate. The combined predicate flows into the reader’s existing row-group min/max, page-index, and RowFilter paths unchanged.

vendor/iceberg-rust/crates/integrations/datafusion/src/physical_plan/physical_to_predicate.rs (new): minimal physical -> iceberg-Predicate converter for the expression shapes HashJoinExec’s enable_dynamic_filter_pushdown produces:

inputoutput
DynamicFilterPhysicalExpr (wrapper)unwrap to current(), recurse
Literal(Boolean(true))None (build hasn’t run yet)
BinaryExpr(col cmp lit) for Eq/NotEq/Lt/LtEq/Gt/GtEqPredicate::Binary
BinaryExpr(And)best-effort AND of two sides
BinaryExpr(Or)both sides must translate (else None)
InListExpr(col, [literals])Predicate::Set
anything elseNone (per-batch evaluator from Path B handles it)

scan.rs::RuntimeFiltersDynamicPredicate wraps the existing runtime_filters Vec and exposes them via the trait. execute() constructs the bridge and hands it to the iceberg-rust scan_builder.

Bench delta vs Path B alone:

SF1SF10
total+3.7% (~noise)-3.3% (148,511 -> 143,590 ms)
vs 4/21 baseline (SF10)n/a-12.4%

SF10 per-query wins from scan-time pruning (Path B + B-2 vs 4/21 baseline):

q06   9546 -> 4660  -51%
q07  14290 -> 9799  -31%
q14   8139 -> 5458  -33%   (canonical target query)
q15  10715 -> 8997  -16%
q16   1084 ->  563  -48%
q17   6196 -> 5168  -17%
q20   6118 -> 5173  -15%

q18, which had regressed under an earlier bloom-filter experiment, is back to baseline (17,640 -> 17,392 ms, -1.4%). That regression was a bloom-only artifact; Path B-2 doesn’t touch it.

What we tried and reverted

After Path B-2, five SF10 queries regressed vs Path B alone (q02 +22%, q04 +11%, q08 +18%, q09 +11%, q11 +16%). All five share a shape: multi-join chains above one big scan, so every HashJoinExec emits a runtime filter, and the leaf scan absorbs all of them. Per-task sampling cost compounds.

We tried five follow-up fixes across four fresh attempts. All five made things worse and got reverted. Each failed for a different reason; cataloguing them here so the next person doesn’t repeat the same mistakes.

The fourth failure (cap=200 below) is the most informative. It revealed that SF10 has a ~5-7% run-to-run noise floor, which is larger than every effect size we were trying to optimize. Without multi-run statistics or a more sensitive measurement, we cannot distinguish “the fix helped” from “this run got lucky.”

Failed attempt 1: IN-list size cap

The fix: in convert_in_list, return None when the IN-list has more than 4096 values. q04 at SF10’s runtime filter is l_orderkey IN (~580K orderkeys), which is expensive to construct as Predicate::Set and to bind per task.

Why it backfired: queries with multiple runtime filters keep paying construction cost on the smaller filters while losing the row-group pruning the big filter was actually delivering. Worst-of-both-worlds. q04 went +24% rather than recovering.

Failed attempt 2: Arc::ptr_eq cache

The fix: cache the converted Predicate keyed on Arc::as_ptr of the inner expression returned by DynamicFilterPhysicalExpr::current(), so we skip retranslation when the filter hasn’t changed between tasks. Backed by a std::sync::Mutex.

Why it backfired: DynamicFilterPhysicalExpr::current() calls remap_children, which in the join-pushdown path returns a freshly-built Arc<dyn PhysicalExpr> each call (column indices get remapped into the probe schema). So Arc::ptr_eq never matched, the cache never hit, and the only observable effect was Mutex contention across the scan’s 11 concurrent FileScanTask processors.

Net SF10 result with both fixes: 150,693 ms. slower than Path B (148,511 ms) and Path B-2 (143,590 ms). q14 went +35%, q16 +91%, q01 +24%.

Failed attempt 3: OnceLock first-success cache

The fix: replace the Mutex<Vec<...>> cache with a std::sync::OnceLock<Predicate> that fills on the first call returning a non-None predicate and is read lock-free thereafter. The intent: avoid both the Mutex contention from attempt 2 and the conversion cost on every per-task call. The trade-off was acknowledged up front: lock in the FIRST snapshot we observe.

Why it backfired (a new failure mode, distinct from attempts 1-2): multi-filter scans (q04, q06, q08, q09) have several DynamicFilterPhysicalExpr runtime filters that seal at different times as the upstream hash joins finish their builds. The first task that observes ANY filter sealed converts to a Predicate that AND-combines whatever sealed so far. The OnceLock then locks that partial predicate in for the rest of the scan, so later tasks miss the additional pruning that would have come from filters sealing later. q04 got +34%, q06 +74%, q16 +42% relative to Path B-2 alone.

Net SF10 result: 150,811 ms (worse than Path B-2’s 143,590 ms by ~5%, and even slightly worse than Path B alone’s 148,511 ms).

The takeaway for any future cache-based attempt: caching only works if all dynamic filters in the bundle have sealed by the time you populate the cache. A single OnceLock keyed on “first non-None” is therefore unsafe for multi-filter scans. A correct cache needs either (a) per-filter OnceLock slots indexed by stable filter identity, or (b) a sentinel that says “all filters in this bundle are sealed” before the cache fills. Both depend on iceberg-rust / DataFusion exposing a “is this filter sealed?” predicate, which is the upstream API ask in issue 2376.

Failed attempt 4: Trino-aligned IN-list cap at 200

The fix: cap convert_in_list at 200 values, matching iceberg-rust’s own IN_PREDICATE_LIMIT constant (defined identically in row_group_metrics_evaluator.rs, manifest_evaluator.rs, and inclusive_metrics_evaluator.rs). Above 200 values the iceberg-rust evaluator unconditionally returns ROW_GROUP_MIGHT_MATCH, so any larger Predicate::Set we hand it has the conversion + binding cost amortized across zero pruning benefit. The intent: align with upstream’s threshold to never do work the reader will discard.

This was a different failure mode from attempt 1 (which used 4096): attempt 1 sat in the dead zone (200-4096 values: full converter cost, zero pruning), whereas attempt 4 was placed exactly at the upstream boundary so we should never waste work.

Why it backfired (the smoking-gun moment): SF10 result was 151,635 ms versus Path B-2’s 143,590 ms, a 5.6% regression. But look at what regressed:

qjoin?Path B-2cap=200delta
q06none4,6606,612+42%
q03none8,15310,514+29%
q20yes5,1736,579+27%
q05yes6,7008,101+21%

q06 has zero joins, so no runtime filters reach its scan, so the cap setting is literally a no-op for that query. It still moved +42%. The variance band of SF10 itself is wider than the effect we’re trying to measure.

Cross-checking SF10 totals across functionally-equivalent runs:

datecode statetotal
4/27Path B alone148,511
4/27Path B-2 (final)143,590
4/27Path B-2 + OnceLock150,811
4/28Path B-2 + cap=200151,635

The spread is ±5-7% with no single run-to-run difference attributable to a code change. SF10’s noise sources include cold object-cache state on every regenerate, Trino’s per-run JIT warm-up, OS page-cache variance across the 60M-row lineitem, and Polaris’s age-since-restart affecting catalog response latency.

The takeaway: at SF10, a single bench run can’t reliably measure effects below 7%. The Path B-2 baseline already has 22/22 match and −12.4% vs the 4/21 baseline; that’s signal we know is real because it sits well above the noise band. Anything inside ±5% needs either a multi-run confidence interval (5+ runs, 2 hours), a focused single- query EXPLAIN ANALYZE, or a smaller deterministic benchmark.

Failed attempt 5: bound-predicate cache (post-microbench)

The fix: add a current_bound(schema, case_sensitive) -> Option<BoundPredicate> method to the DynamicPredicate trait, plumb the reader through it, and override in RuntimeFiltersDynamicPredicate with a per-filter sealed-state cache backed by a Mutex<Option<CachedBound>>. The microbenchmark (commit 5eeb00c) showed Predicate::bind is the dominant per-task cost (~61 ms at N=580K, 3x the conversion cost), so caching the bound result rather than the unbound one targets the right layer.

The design supposedly avoided the partial-seal trap from attempt 3 by treating each filter independently: walk every filter, contribute the ones whose DynamicFilterPhysicalExpr inner is no longer lit(true), mark fully_sealed only when every filter passed the check this round, and short-circuit subsequent calls to a lock-free clone of the cached combined BoundPredicate.

Why it backfired (despite being microbench-correct): SF10 result was 162,589 ms vs Path B-2’s 143,590 ms (+13.2%). q11 -28% and q12 -15% showed the cache works when filters seal early, but q05 +73%, q20 +40%, q01/q03 +33%, q22 +30% all regressed because:

  1. Multi-join queries seal filters at staggered times. Until the last filter seals, every task hits the slow path: walk every filter, convert each sealed one, AND them, bind once. That work is the same as Path B-2 plus a Mutex round-trip.

  2. The Mutex contends across the scan’s concurrent tasks. Every slow-path call grabs the Mutex twice (read for fast path, write to update). With ~12 concurrent FileScanTask processors per scan, the lock serializes them. This is the same failure mode as attempt 2; the lock-free fast path only kicks in after fully_sealed, which can be late in queries with deep join chains.

  3. The microbench measured single-threaded steady-state cost; the real workload pays Mutex acquisition cost on every call. The bench predicted ~80 ms saved per task; the lock contention burnt most of that and added some.

This was the most disappointing failure because the microbench data strongly suggested it should work. The lesson: targeting the right layer (bind) is necessary but not sufficient. Without an upstream signal that lets us know when filters are sealed (so the cache can populate exactly once at the right moment), every Mutex-based design hits this contention floor.

Failed attempt 6: SQE-side unconditional with_dynamic_predicate (2026-05-03)

The fix: after an earlier change (6a03124) brought Path B-2 to SQE’s own IcebergScanExec via the gather_filters_for_pushdown override, the natural next step was to call with_dynamic_predicate on the iceberg-rust scan builder from SQE’s streaming path too. The wiring mirrors what the vendored IcebergTableScan already does: build a RuntimeFiltersDynamicPredicate from the resolved pushed_down_filters and pass it via sb.with_dynamic_predicate(dp) right next to the existing sb.with_filter(static_pred) call. The runtime filter then participates in iceberg-rust’s row-group min/max, page-index, and post-decode RowFilter passes alongside the static predicate.

Why it backfired (recovering known ground): SSB SF1 has single-file lineorder with high-cardinality unsorted join keys (lo_custkey, lo_suppkey). Row-group min/max evaluator runs across the file’s row groups but every row group spans the full key range, so no row group is excluded. Pure conversion plus bind cost is paid 26 times across the suite (once per FileScanTask reaching lineorder), with zero pruning benefit.

Bisect on a single day, same docker stack:

SSB SF1 configurationSQE totalWiring fires
Bisect (no wiring at all)6,647 ms0x
Unconditional wiring (run 1)9,454 ms26x
Unconditional wiring (run 2)9,086 ms26x

Net SF1 regression: ~+37%. The same failure mode as attempts 1-5: the per-task pruning surface charges a fixed cost per filter and only recovers it when the filter shape matches the data layout. SSB join-key filters at SF1 satisfy neither condition.

Trace evidence the wiring fired correctly (so this is genuinely the data-shape problem, not a bug):

target=sqe_catalog::iceberg_scan
  IcebergScanExec: wired runtime filters into iceberg-rust DynamicPredicate
    table=ssb_sf1.lineorder  count=1  files=1

Failed attempt 7: SQE-side gated with_dynamic_predicate (2026-05-03)

The fix: gate attempt 6 on file_entries.len() > 1, mirroring the existing file-level pruning gate. SF1 single-file lineorder skips the wiring (post-decode filter still applies). SF10+ multi-file lineorder activates it.

#![allow(unused)]
fn main() {
if !pushed_down_filters.is_empty() && file_entries.len() > 1 {
    let dp = RuntimeFiltersDynamicPredicate::new(pushed_down_filters.clone());
    sb = sb.with_dynamic_predicate(dp);
}
}

Why it backfired (the gate is too coarse): SF1 is preserved cleanly (7,412 ms gated, 6,647 ms bisect, gate fires 0x). But SF10 reproduces the per-class TPC-H Path B-2 pattern from attempts 1-5:

SSB SF10 qApr 21 baselineToday (gated)DeltaClass
q1.15,1793,656-29%1 dim, sorted-column probe (lo_orderdate)
q1.24,3593,819-12%same shape
q1.33,8842,740-29%same shape
q3.44,9354,318-13%0-row dim, AlwaysFalse short-circuit
q3.16,1218,972+47%3 dim, high-card key probe
q3.25,0807,071+39%same
q3.34,2538,269+94%same, worst case
q4.27,63413,556+78%4 dim, high-card key probe
q4.37,38910,755+46%same
TOT76,05989,855+18.1%net regression

The gate catches “is there file-level pruning potential”, but does not differentiate by filter shape (whether iceberg-rust’s row-group evaluator can use the predicate at all) or column layout (whether differentiated min/max stats exist on the probe column). The gate fires on q3.x and q4.x where neither factor is favourable, paying the per-task cost without earning pruning.

The pattern is identical to TPC-H attempts 1-5: queries with one dominant filter on a clustered column win, multi-join queries with high-cardinality probe columns lose. SSB SF10 confirms what the TPC-H work already established. The per-task surface is structurally mixed without an additional gating mechanism (filter shape + column layout).

Resolution

All seven attempts reverted; SQE’s IcebergScanExec stays at the post-decode-filter-only path on main. The vendored IcebergTableScan keeps Path B-2 (working as designed for the TPC-H mix). Postmortem comment posted to apache/iceberg-rust#2376 with API recommendations for upstream:

  1. Expose a cheap monotonic version (generation) on DynamicFilterPhysicalExpr so consumers can cache without relying on Arc::ptr_eq, OR
  2. Have the DynamicPredicate trait return an opaque (Predicate, version) so the reader can manage its own cache key.

Either is small and forward-compatible. Both unblock the multi-filter staggered-seal class.

Bench timeline at a glance

Chronological inflection points of the runtime-filter work, grouped by what changed in the codebase. Each row is a single bench run; the totals are exact (not averaged) so the noise floor (±5-7% at SF10, ±5-10% at SF1) is visible inline.

TPC-H SF10

DateCode stateSQE totalMatchNotes
4/20Pre-runtime-filter, broken q15125,600 ms9/22quoted DECIMAL bug active
4/21Pre-DECIMAL Float64 baseline163,858 ms6/22q15 broken, q07/q14 etc fail
4/27Path B (post-batch only)148,511 ms22/22-9.4% on TOTAL, fixes correctness
4/27Path B + B-2 (per-task DP)143,590 ms17/22additional -3.3%; current main
4/27+ IN-list cap=4096reverted-q04 +24%, others mixed
4/27+ Arc::ptr_eq cache150,693 ms-Mutex contention, q14 +35%, q16 +91%
4/27+ OnceLock first-success150,811 ms-partial-seal trap, q06 +74%, q16 +42%
4/28+ cap=200 (Trino-aligned)151,635 ms-noise floor visible: q06 (no joins) +42%
4/29+ bound-predicate cache162,589 ms-+13.2%, Mutex contention
4/30Path B + B-2 (cleaned)matches 143,590-all attempts reverted

SSB SF1

DateCode stateSQE totalNotes
4/14pre-perf baseline7,554 msearly benchmark stack
4/15+ small-file fast path6,208 msfirst wins
4/16+ dynamic filter pushdown6,191 msDF 53 dynamic filter wired
4/20+ parallel small-file fast path6,552 mswithin noise
4/30+ Phase O+ catalog dispatch6,908 mswithin noise
5/1post an earlier change (Path B-2 wiring SSB)8,410 msSSB-only run, cold Trino
5/3bisect (no wiring)6,647 mstoday’s stable baseline
5/3unconditional wiring (run 1)9,454 ms+37% regression
5/3unconditional wiring (run 2)9,086 ms+34% regression
5/3gated wiring (file_count > 1)7,412 mswithin noise (gate fires 0x at SF1)

SSB SF10

DateCode stateSQE totalNotes
4/20pre-runtime-filter92,300 msfirst SF10 SSB run
4/21clean baseline76,059 msreference baseline for today
5/3gated wiring (today)89,855 ms+18% net, mixed per-query

Per-query effect at TPC-H SF10 (cumulative pre-B vs B+B-2)

The work that landed on main. Negative percentages = faster.

qPre-BB+B-2DeltaClass
q018,4047,180-15%full lineitem scan, group-by
q021,3391,587+19%5-way join, NOT LIKE on a column the converter can’t translate
q039,3698,153-13%mktsegment + 3-way join
q045,3704,711-12%orderdate range + EXISTS subquery
q057,9306,700-16%regional 6-way join, low-card region keys
q069,5464,660-51%1 table, sorted-column range filter (canonical win)
q0714,2909,436-34%2 selective hash joins, lineitem clustered
q089,2628,045-13%regional brand share
q097,0586,982-1%flat
q109,41910,203+8%LEFT JOIN noise
q117561,114+47%tiny query, all shuffled keys, overhead dominant
q128,8157,767-12%shipmode filter
q133,6063,185-12%left outer join NOT LIKE
q148,1395,458-33%1 join, 1 month range filter (canonical win)
q1510,7158,997-16%CTE plus revenue threshold
q16578563-3%tiny, flat
q175,4095,168-4%flat
q1817,64017,646+0%dominated by full scan
q198,3329,428+13%OR-of-AND on multiple columns (translation failure)
q206,3915,173-19%semi-join chain, selective key filters
q2110,67310,684+0%flat
q22817750-8%small
TOT163,858143,590-12.4%

Five clean wins in the win signature class (q06, q07, q14, q15, q20). Three regressions in the loss signature class (q02, q11, q19). Eleven queries in the noise band. The +12.4% total is real because the wins are large enough (q06 -51%, q07 -34%, q14 -33%) to dominate the noise and the small regressions.

Per-query effect at SSB SF10 (Apr 21 baseline vs today gated wiring)

The data behind the +18.1% total today. Same shape pattern as TPC-H.

qApr 21TodayDeltaClass
q1.15,1793,656-29%1 dim, sorted column (mirrors q06/q14)
q1.24,3593,819-12%same
q1.33,8842,740-29%same
q2.15,3746,175+15%3 dim, varied selectivity
q2.24,9325,490+11%same
q2.35,9285,975+1%same
q3.16,1218,972+47%3 dim, high-card key probe (mirrors q11)
q3.25,0807,071+39%same
q3.34,2538,269+94%same, worst regression
q3.44,9354,318-13%0-row dim, AlwaysFalse
q4.110,9919,059-18%4 dim, date-range dominant
q4.27,63413,556+78%4 dim, high-card key probe
q4.37,38910,755+46%same
TOT76,05989,855+18%

The cross-suite mapping is one-for-one. SSB q1.x ≈ TPC-H q06/q14 (sorted column wins). SSB q3.x ≈ TPC-H q11 (high-card probe loses). SSB q3.4 ≈ TPC-H q15’s AlwaysFalse-ish path (selective short-circuit wins). 4-dim queries (q4.x) regress more than 3-dim because each extra filter compounds the overhead.

Cross-suite reading: what the SSB and TPC-H data add up to

The per-query tables above (TPC-H SF10 cumulative effect, SSB SF10 gated wiring) line up class-for-class:

ClassTPC-H SF10SSB SF10
1 dim, sorted-column probe (clean win)q06 -51%, q07 -34%, q14 -33%q1.1 -29%, q1.3 -29%
Selective short-circuit / AlwaysFalseq15 -16%q3.4 -13%
Multi-dim, high-cardinality key probe (clean loss)q11 +47%, q19 +13%, q02 +19%q3.1 +47%, q3.3 +94%, q4.2 +78%
Tiny query, overhead-dominatedq11 (756 ms baseline)q3.x at SF1 (sub-second)

The mixed bag is not noise and not an implementation bug. It is structural and reproduces across two benchmarks. The SF10 SSB run explains why the with_dynamic_predicate call is intentionally absent from SQE’s IcebergScanExec on main even though the machinery exists in the vendored crate: the author of c564a89 already knew the regression class would dominate on the SSB mix. SQE’s scan path stays at post-batch filter-only because that matches the realistic SQL workloads we run.

Why mixed results: a per-query walkthrough

The cross-suite data shows clearly that the wiring helps some queries and hurts others. The why is not “noise” or “implementation bugs”: it is a small set of compounding mechanical reasons. Each is illustrated with a query that exhibits it.

1. IN_PREDICATE_LIMIT = 200 in iceberg-rust’s evaluators

The row-group + manifest + inclusive metric evaluators all bail to MIGHT_MATCH when an IN-list has more than 200 values. The Predicate::Set we built is fully evaluated before that bail-out: field reference resolution, type binding, conversion cost. All of that is paid; none of it produces pruning.

SSB q3.3 hits this hard. The customer build emits ~8K custkey values for c_city IN ('UNITED KI1','UNITED KI5') (8K customers from 30K total whose city matches). 8K is 40x the cap. Conversion + bind run, evaluator returns MIGHT_MATCH, no row group skipped. With 3 dim joins all paying this cost per FileScanTask, the overhead compounds linearly.

-- q3.3
FROM lineorder, dim_date, customer, supplier
WHERE lo_custkey = c_custkey
  AND lo_suppkey = s_suppkey
  AND lo_orderdate = d_datekey
  AND (c_city = 'UNITED KI1' OR c_city = 'UNITED KI5')      -- ~8K custkeys
  AND (s_city = 'UNITED KI1' OR s_city = 'UNITED KI5')      -- ~5 suppkeys
  AND d_year BETWEEN 1992 AND 1997                          -- 2191 datekeys

q3.4 has the identical join structure but d_yearmonth = 'Dec1997' narrows the date filter to ~30 days. The narrower date dimension fits under the 200 cap AND happens to align with lineorder’s natural orderdate clustering, so it wins (-13%). Same query, different filter selectivity, opposite outcome.

2. Probe-side data layout (clustered vs shuffled)

lineorder is naturally written ordered by lo_orderdate. Files that arrive into the table ship in chronological order and Iceberg preserves that. Row groups within a file therefore have differentiated min/max on lo_orderdate: a 1993 row group has min=19930101, max=19931231, a 1994 row group has 1994 bounds, etc.

Date-range filters are bounds (BETWEEN, >, <), not IN-lists. Bounds against differentiated min/max prune cleanly: a 1993 filter against a 1995 row group is provably outside the range, drop the row group.

lo_custkey, lo_suppkey, lo_partkey are shuffled across files because customer/supplier/part are dim tables joined into fact rows. Every row group has min ≈ 1 and max ≈ N for these. The evaluator runs but cannot find a bound that excludes the row group. We pay the cost; nothing prunes.

SSB q1.1 wins (-29%) on this:

-- q1.1 (1 dim join, date filter only)
FROM lineorder, dim_date
WHERE lo_orderdate = d_datekey
  AND d_year = 1993
  ...

The dim_date build emits 365 datekeys for d_year=1993. DataFusion recognises this as a contiguous range and emits the dynamic filter as bounds (l_orderdate BETWEEN 19930101 AND 19931231) rather than an IN-list. Bounds against a sorted-column row group min/max = clean prune. Most non-1993 row groups skip entirely.

SSB q3.1 loses (+47%) with the same orderdate column AND a 6-year date range filter (so date pruning is weaker), PLUS two shuffled-key joins:

-- q3.1 (3 dim joins, two on shuffled keys)
WHERE lo_custkey = c_custkey  -- shuffled
  AND lo_suppkey = s_suppkey  -- shuffled
  AND lo_orderdate = d_datekey  -- sorted, but date range is 6 years
  ...

Net effect: weaker date pruning + zero key-column pruning + cost on 3 filters per task = -47% slower.

3. Filter shape: bounds vs IN-list

DataFusion’s hash join chooses between two dynamic-filter shapes based on build size:

  • Small build: emit InListExpr(probe_col, [build_keys...]). Translates to Predicate::Set for iceberg-rust. Subject to the 200-element cap.
  • Build is a contiguous numeric range: emit BinaryExpr(probe_col >= min AND probe_col <= max). Translates to Predicate::Binary bounds. Always evaluated; no cap.

Bounds work against any column with differentiated row-group min/max (sorted or partitioned). IN-list shapes only prune when the probe column has clusters of contiguous values.

TPC-H q11 illustrates the cost side without the benefit side:

-- q11 (2 hash joins, ALL on shuffled keys)
FROM partsupp, supplier, nation
WHERE ps_suppkey = s_suppkey
  AND s_nationkey = n_nationkey
  AND n_name = 'GERMANY'
HAVING SUM(...) > (subquery scans the same tables again)

s_nationkey build for n_name=‘GERMANY’ = 1 nationkey, well under the cap. Bind succeeds. But partsupp is the probe and is unsorted by suppkey across its files. Min/max evaluator runs, can’t prune. Total query is small (756 ms baseline); per-task overhead becomes a visible fraction. Net +47% regression.

4. Multi-filter staggered sealing

HashJoinExec build sides complete at different wall-clock times. The earliest dim that finishes building seals its dynamic filter first; later dims seal later. Tasks that start scanning while only some filters are sealed see a partial predicate.

Concretely: when the per-task dp.current() is called early in the scan, it returns Some(predicate) only for the dims whose builds have completed. Tasks that call later get a more selective predicate. Iceberg-rust does not re-call dp.current() for a task once the file is open, so early-task tasks miss the late-sealing filters entirely.

SSB q4.2 (+78%) is the canonical example:

-- q4.2 (4 dim joins, all sealing at different times)
FROM lineorder, dim_date, customer, supplier, part
WHERE lo_custkey = c_custkey
  AND lo_suppkey = s_suppkey
  AND lo_partkey = p_partkey
  AND lo_orderdate = d_datekey
  AND c_region = 'AMERICA'
  AND s_region = 'AMERICA'
  ...

Four dims build in parallel. supplier (smallest) seals first, then maybe date, then customer, then part. Lineorder scan tasks ramp up as soon as the first build is ready. The probe side does most of its decoding while only 1-2 of 4 filters have sealed. By the time all 4 are sealed, the scan is mostly done. The runtime filter never gets to apply with full selectivity at the scan layer.

This is the same failure mode that killed the OnceLock cache (failed attempt 3).

5. OR-branch translation failures

iceberg-rust’s predicate AST supports Or, but convert_physical_filters_to_predicate requires both sides of an OR to translate. If one side has a shape it doesn’t understand (e.g., a complex BinaryExpr nested inside, or a non-supported literal type), the whole OR returns None.

TPC-H q19 is the classic OR-of-AND query:

WHERE
  (p_partkey = l_partkey AND p_brand = 'Brand#12'
   AND p_container IN ('SM CASE','SM BOX',...) AND ...)
  OR (p_partkey = l_partkey AND p_brand = 'Brand#23'
      AND p_container IN ('MED BAG','MED BOX',...) AND ...)
  OR (p_partkey = l_partkey AND p_brand = 'Brand#34'
      AND p_container IN ('LG CASE','LG BOX',...) AND ...)

DataFusion may emit a single dynamic filter combining all three branches, or one per branch with union semantics. Either way, the converter has to walk every leaf and translate. Any leaf failing to translate kills the whole branch. q19 +13% regressed because we walked the tree, failed somewhere, and returned None. We paid the walk cost for nothing.

6. Small-query overhead amortization

Per-task DynamicPredicate sampling has a fixed cost: walk the runtime filters, downcast each, build the iceberg Predicate, bind to the file schema. The microbench (commit 5eeb00c) measured this at ~80 ms per task at N=580K IN-list size; less at smaller sizes but never zero.

For tiny queries (q11 at 756 ms baseline, q22 at 817 ms, q3.1 at ~6s) the per-task overhead is a visible fraction of total wall-clock. For large queries (q07 at 14 s, q15 at 10 s) the overhead amortizes across more useful work.

This is why noise is so visible at SF1 (most queries < 1 s) and muted at SF10 (most queries 5-15 s). It is also why the failed attempt 4 (cap=200) measured +42% on q06 even though q06 has zero joins: the noise band is wider than the effect on small queries.

7. The signature of a clean win

A query wins from with_dynamic_predicate when all of these are true:

  1. The probe-side column is naturally clustered or sorted.
  2. The build side is small enough (<200 values) to land as a real IN-list, OR the dim filter is contiguous (date range, region IN) so DataFusion emits bounds.
  3. The total query has > ~5 s of decode work for the per-task overhead to amortize against.
  4. There is one dominant filter, or one filter that seals first and is selective on its own.

TPC-H q06, q14, q07, q20 all match this signature. SSB q1.1, q1.2, q1.3 do too. q3.4 wins via the AlwaysFalse short-circuit (special case of #2 with build size = 0).

8. The signature of a clean loss

A query loses when:

  1. Multiple dim joins with shuffled probe-side columns (custkey, suppkey, partkey).
  2. At least one dim build is large (> 200 values) so its Predicate::Set is wasted conversion.
  3. Total query is small (< 5 s) so per-task overhead dominates.

TPC-H q11, q19, q02 match this. SSB q3.1, q3.2, q3.3 do too. The worst offenders are the 4-dim queries (q4.2, q4.3) which compound all three.

Core issue and smart solutions

Reduced to one line: SQE pays per-task eval cost on every filter, regardless of whether that filter can actually prune row groups for the data shape it targets.

Everything else falls out of that. The per-task overhead is fixed. The pruning benefit varies by query shape. So the cost-benefit goes positive or negative based on factors the wiring decision never considers.

The 5 failed attempts tried to reduce per-task cost via caching (Arc::ptr_eq, OnceLock, bound-cache). All hit Mutex contention or partial-seal traps. The cache attempts were solving the wrong problem. Caching reduces the per-task cost when the cost is paid. The real fix is don’t pay the cost when the filter can’t help.

Five candidate paths, ranked by depth of fix:

A. Read Parquet bloom filters in iceberg-rust’s row-group evaluator

Deepest fix. Today the evaluator reads only min/max statistics. Bloom filters can prune high-cardinality unsorted columns (custkey, suppkey, partkey) where min/max can’t.

Concretely: when the evaluator sees Predicate::Set(col, values) and min/max returns MIGHT_MATCH, fall through to the column’s Parquet bloom filter chunk. Hash each value, check membership. If no value hits the bloom, the row group provably has no matching rows. Skip.

We already write Parquet bloom filters on join-key columns when write.parquet.bloom-filter-columns is set (matrix-f, commits eb95e72, 9172dc3). The data is on disk. The reader does not consult it.

This is what Trino does internally and why Trino wins on q3.x and q4.x where we lose. It composes with everything else and addresses the root cause for the entire regression class.

Cost: vendored iceberg-rust patch. Bounded scope. Plumbing into the existing row_group_metrics_evaluator path.

B. Predicate-shape-aware wiring (practical, shippable next)

Translate the runtime filter once at scan-builder time, inspect the resulting Predicate, then wire to with_dynamic_predicate only when the shape can prune:

#![allow(unused)]
fn main() {
let dp = RuntimeFiltersDynamicPredicate::new(filters);
match dp.current() {
    Some(Predicate::AlwaysFalse) => sb = sb.with_dynamic_predicate(dp), // free win
    Some(Predicate::Binary(_))   => sb = sb.with_dynamic_predicate(dp), // bounds prune sorted cols
    Some(Predicate::Set(_, vals)) if vals.len() <= 200 => {
        sb = sb.with_dynamic_predicate(dp);                              // under iceberg-rust IN_PREDICATE_LIMIT
    }
    _ => { /* skip wiring; post-decode filter still applies */ }
}
}

This avoids the IN_PREDICATE_LIMIT trap (cause #1), the OR-translation failure trap (cause #5), and never pays the eval cost when the predicate can’t translate to a prunable shape.

Open issue: at scan-builder time the build side may not have sealed yet. current() returns None or AlwaysTrue and we skip wiring. Later when the build seals, the predicate is prunable but the wiring decision is already made.

Mitigation: keep the SQE post-decode filter as the safety net. Net effect: B is strictly additive on top of Path B, no SF1 regression, real wins on the queries where the predicate translates to a pruneable shape.

Bounded scope, ~50 lines in iceberg_scan.rs.

C. Column-layout-aware wiring (catches the data shape)

At scan startup, inspect manifest data: for each column referenced by a runtime-filter target, compute the variance of per-file min/max ranges:

  • High variance (different files have different ranges) means the column is clustered. Wire predicates targeting it.
  • Low variance (every file has roughly the full range) means the column is shuffled. Skip wiring.

Catches lo_orderdate is sorted vs lo_custkey is shuffled structurally, regardless of filter shape. Combine with B: wire only when both the predicate shape is prunable AND the probe column has differentiated stats.

Cost: one-time per scan. Manifest data already loaded. Cheap.

D. Don’t double-evaluate

When wiring with_dynamic_predicate, iceberg-rust’s RowFilter applies the predicate post-decode. SQE’s per-batch loop also applies it post-decode. The same predicate runs twice on the same surviving rows.

Track which predicates were handed to iceberg-rust. Skip those in the SQE post-decode loop. Apply only the predicates that didn’t translate.

Independent of A/B/C, removes a small redundant cost. Worth a few percent on queries where multiple filters translate.

E. Plan-time cardinality estimation (orthogonal)

Pre-evaluate constant dim filters at plan time using tracked Iceberg metadata. If the result is provably empty (no manifest entries match the filter), replace the join subtree with EmptyRelation. Captures q3.4 / q2.2 / q2.3 SSB and any TPC-DS empty-result query.

Doesn’t touch the per-task surface. Pure plan-time logic. Bounded scope.

B first because it is the smallest change with the cleanest signal: ship predicate-shape gating, watch the bench. Should reclaim the q3.4 / q1.1 wins without the q3.3 / q4.2 regressions. If B alone nets positive on SSB SF10, the per-task surface is done for this round.

D as a follow-up cleanup. Doing the same predicate twice is wasted work once we know about it.

E in parallel. Solves the 0-row dim case fundamentally. Composes additively with B (B catches prunable shapes that aren’t 0-row, E catches 0-row before the scan even starts).

A as a focused follow-up MR after B + D + E land. Deepest fix but touches the upstream evaluator. Better measured against a clean baseline.

C as a refinement of B once A and E are in place. C only helps in cases B doesn’t already catch.

The thing all 5 failed attempts missed: the cost reduction was always inside the per-task path. The smart move is to not enter the per-task path at all when the predicate can’t be prunable. B + C do that. A makes more predicates prunable.

How to reproduce

# SF1 - quick smoke (~5 min)
BENCH_SCALE=1 ./scripts/benchmark-test.sh --compare-trino tpch

# SF10 - real-world signal (~25-40 min)
BENCH_SCALE=10 ./scripts/benchmark-test.sh --compare-trino tpch

Result JSONs land in benchmarks/results/. Compare against historical baselines:

# All TPC-H SF10 results, oldest first
ls -tr benchmarks/results/compare-tpch-sf10-*.json

The relevant baselines for this work:

date / fileSQE totallabel
compare-tpch-sf10-2026-04-21T11:32:08.json163,858msPre-DECIMAL Float64 baseline
compare-tpch-sf10-2026-04-27T18:38:53.json148,511msPath B (post-batch only)
compare-tpch-sf10-2026-04-27T19:19:28.json143,590msPath B + B-2 (current head)

Open follow-ups

itemsizepriority
Multi-join queries (q08, q09, q04) still regress slightly vs Path Bmedium. needs upstream generation API or equivalent cache keymedium
Decimal Datums not yet handled in the physical convertersmall. extend scalar_to_datum once iceberg::spec::Datum::decimal accepts a raw i128 + (precision, scale)low for TPC-H, blocks decimal-keyed hash joins
q15 CTE re-scan independent of Path B-2requires DataFusion CTE materialization (DF 53 has none); SQE-level rewrite alternative is fragilelow
Trino bench reliability at SF10container OOM / timeout on q18+ in some runs; not a SQE buglow

Negative result: bloom-on-write does not compose with Path B-2

A side branch (a feature branch, commit f022619) explored writing Parquet bloom filters on TPC-H/SSB join-key columns at data-generation time. The hypothesis was that blooms would prune row groups when the runtime filter could not, and the two would compose multiplicatively.

The actual numbers, measured before and after Path B-2 landed:

  • SF1: bloom-on-write was already a regression on its own (+24% slower) because at SF1 the per-row-group bloom evaluation overhead exceeded the prune benefit. DataFusion only consults blooms for literal equality predicates, not for the build side of a hash join, and TPC-H has no literal predicates on join keys.
  • SF10 with Path B alone (pre-B-2): bloom-on-write recovered the SF1 cost and produced -7.5% wins on q06 / q07 / q14 because larger row groups tipped the cost-benefit toward bloom pruning.
  • SF10 with Path B-2: bloom-on-write regressed by +25.9s when layered on top. Path B-2’s runtime filter already prunes the row groups the bloom would address; the bloom adds eval overhead with no incremental benefit.

The takeaway is that bloom-on-write and runtime filter pushdown target the same row-group pruning surface for join keys. Path B-2’s runtime filter is more selective: it carries actual min/max bounds or in-list literals from the build side, and arrives at the reader through the same DynamicPredicate machinery the static predicate uses. Adding a parallel bloom probe burns CPU on row groups Path B-2 has already pruned.

The branch is deliberately unmerged. The matrix bloom-filters:v2/v3 cells are still full because the per-table bloom write path is correct end-to-end (verified by the writer_props_emit_bloom_filter_in_parquet_footer test in sqe-catalog/src/parquet_writer_config.rs): users who ask for blooms via write.parquet.bloom-filter-columns get them. The negative result here is specifically about forcing blooms on join keys at bench-data-generation time, which is a benchmark-stack choice rather than a property of the engine’s bloom support.

When blooms still help (and the per-table path covers):

  • Literal predicates on bloomed columns at scan time (WHERE bloomed_col = 5)
  • Point-lookup workloads with skewed value distributions where column min/max stats provide a wide range
  • IN-list filters with a small constant set on a bloomed column

When blooms do not help (the bench-bloom-on-write path):

  • Hash join build-side filtering on join keys (Path B-2 covers it)
  • Range scans on dense integer columns (min/max stats already do this for free)
  • Anything where the runtime filter or static predicate has already pruned the row group

SSB SF1 trace investigation

Why SSB at scale factor 1 sits at 0.50-0.85x vs Trino while every other suite ships in the 1-7x range. Captured 2026-04-30 against tpch-sf1-flight-2026-04-30T16:25:01.json baselines using the new phase-level rows in EXPLAIN ANALYZE (an earlier change).

Setup

The EXPLAIN ANALYZE output now prefixes per-operator metrics with five phase rows:

 step  operation                                              elapsed_ms
 -5    [phase] parse + logical plan                                  X
 -4    [phase] policy evaluate                                       X
 -3    [phase] physical plan                                         X
 -2    [phase] execute (per-op detail below)                         X
 -1    [phase] framework overhead (parse + plan + policy + result)   X

Combined with BENCH_DEBUG=1 printing the result rows from sqe-bench (an earlier change), we can see exactly where each query spends its time.

What the trace shows

Five SSB queries patched to EXPLAIN ANALYZE, run through the live bench harness:

Queryparse+planpolicyphysicalexecuteframeworkresult rows
q1.1 (cold)20.20.023.1585.723.31
q2.20.400.0011.8470.82.20
q3.20.420.0011.9432.02.4600
q3.30.460.0012.0417.52.40
q4.10.520.0012.1651.02.735

Two findings up front:

  1. q1.1 (the first query) shows a real DataFusion warmup: ~20 ms parse
    • ~23 ms framework overhead. From q2.2 onward the parse+plan drops to < 0.5 ms and framework overhead to ~2 ms. The warmup amortizes to ~3 ms / query across the suite.
  2. The execute phase is 90+% of every query’s wall time. The framework overhead a plan cache could shave is ~0.5 ms / query.

So the SSB SF1 floor is not framework cost. Plan-cache and parse optimizations save < 1 ms / query.

Per-operator breakdown

Per-query, focusing on the lineorder scan (the fact table):

Querylineorder rows scannedscan elapsed_computefirst join outputjoin elapsed_compute
q1.1786,156 (date-pruned)189.8112,2922.9
q2.26,000,000 (full)127.96,000,00041.9
q3.26,000,000 (full)135.65,145,01038.0
q3.36,000,000 (full, but result is 0)118.15,145,01041.9
q4.16,000,000 (full)166.96,000,00046.9

q1.1 is fastest because its WHERE lo_orderdate BETWEEN 19940101 AND 19940131 is a literal range filter on lineorder. SQE’s static predicate pushdown reduces the scan from 6M to 786K rows. Every other query scans all 6M lineorder rows even when the result is zero (q2.2, q3.3, q3.4 all return 0 rows after dim filters that match nothing).

The expectation was that runtime filter pushdown (Path B-2, runtime-filter-pushdown.md) would prune lineorder via dim build-side filtering. The trace shows it does not.

Why Path B-2 did not engage

Added temporary eprintln! traces to convert_physical and convert_in_list in vendor/iceberg-rust/crates/integrations/datafusion/src/physical_plan/physical_to_predicate.rs and ran the full SSB suite. Zero invocations. The dynamic predicate code path was never reached on any SSB SF1 query. The ground truth: DataFusion was not pushing runtime filters down to SQE’s IcebergScanExec at all. Why is covered in the root cause subsection below.

Debugging Path B-2 going forward

SQE’s IcebergScanExec (the production scan node, in sqe-catalog/src/iceberg_scan.rs) emits a tracing::debug! line in handle_child_pushdown_result so future investigators can see whether DataFusion is offering filters to the scan and how many of them are dynamic:

RUST_LOG="info,sqe_catalog=debug" \
  BENCH_SCALE=1 ./scripts/benchmark-test.sh ssb

The relevant log fields:

target=sqe_catalog::iceberg_scan
  IcebergScanExec::handle_child_pushdown_result
    table=...  parent_filter_count=N  dynamic_filter_count=N

The vendored IcebergTableScan in iceberg-rust emits equivalent logs under target=iceberg_datafusion::physical_plan::scan, but SQE queries hit the SQE node, not the vendored one. Use the iceberg_datafusion=debug filter only when investigating direct uses of the vendored crate.

If parent_filter_count = 0 on every call, DataFusion never offered a runtime filter to this scan: an intermediate node in the plan blocked pushdown, or the cost-model rule decided this join was not worth a dynamic filter, or, as it turned out for SSB SF1, the scan itself failed to declare itself a filter-absorbing leaf via gather_filters_for_pushdown.

If parent_filter_count > 0 but dynamic_filter_count = 0, the parent forwarded only static filters (already handled at plan time) and there is no runtime filter to honor.

If dynamic_filter_count > 0 but the bench shows no scan reduction, the runtime filter is reaching the scan but pruning is not happening. Two reasons that might be: the dynamic filter is still at its lit(true) placeholder when the scan executes (normal for the first batches), or the iceberg row-group/file pruning is not honoring it. Check physical_to_predicate.rs::convert_physical and the file_entries.len() > 1 gate in iceberg_scan.rs::execute_partition_inner next.

Root cause: missing gather_filters_for_pushdown override

The hypotheses above were all wrong. The dynamic filter never reached the scan because SQE’s IcebergScanExec (in sqe-catalog/src/iceberg_scan.rs) was missing the gather_filters_for_pushdown override.

The default ExecutionPlan::gather_filters_for_pushdown returns FilterDescription::all_unsupported(...). That tells DataFusion’s filter-pushdown rule “this node does not support any of these filters.” The optimizer then abandons the dynamic filter, and handle_child_pushdown_result is never called. Path B-2 silently no-ops.

Adding the override (returning FilterDescription::new(), the leaf-scan convention used by the vendored IcebergTableScan in iceberg-rust) tells DataFusion the scan absorbs filters. With debug logging on, lineorder now shows:

target=sqe_catalog::iceberg_scan
  IcebergScanExec::handle_child_pushdown_result
    table=lineorder  parent_filter_count=1  dynamic_filter_count=1

The dynamic filter from the dim build side now reaches the scan.

The vendored IcebergTableScan already had the correct override; SQE’s reimplementation simply did not. None of the three earlier hypotheses (intermediate RepartitionExec, missing DynamicFilterPhysicalExpr, cost-model rejection) was right.

Why SSB SF1 still does not see a wall-clock improvement

The fix engages Path B-2 correctly but does not move the SSB SF1 floor. Two reasons:

  1. SSB SF1 lineorder fits in one Parquet file. SQE’s file-level pruning is gated by file_entries.len() > 1 in iceberg_scan.rs: it only attempts to skip files when there is more than one to choose between. Single-file tables fall through to a full scan.

  2. SQE’s IcebergScanExec does no row-group level pruning with the dynamic filter. Even if the file-level gate were lifted, it would either keep or skip the entire 6 M-row file. To get sub-file pruning, the runtime filter would need to be evaluated against per-row-group min/max from the Parquet footer.

Expected to pay off at SF10+ where lineorder spans multiple files and a selective dim build side can skip whole files. Track row-group level dynamic-filter pruning as a follow-up.

What the empty-IN-list fix changes

While investigating, found a real but minor correctness issue in convert_in_list: when the IN-list is empty (which would happen with a zero-row build side that did get to push a runtime filter), the converter returned None instead of Predicate::AlwaysFalse. That means even if Path B-2 fired with an empty build, the lineorder scan would not be pruned. The fix emits AlwaysFalse for empty IN-lists so iceberg’s metrics evaluator can prune every data file.

This fix is correct in principle but does not help SSB SF1, because Path B-2 does not fire at all. Kept as a small correctness improvement for any future case where DataFusion does propagate an empty IN-list down to a leaf scan.

Where the SSB SF1 floor actually lives

elapsed_compute (per-operator CPU time) sums to 30-50% less than the execute-phase wall clock. The gap is asynchronous I/O wait: S3 GETs for Parquet data files. Per query this is roughly 100-200 ms.

For 0-row queries (q2.2, q3.3, q3.4), the wasted work is:

  • 6 M lineorder rows scanned and decoded
  • Joined against a 0-row dim, produces 0 rows
  • Aggregation runs over 0 rows (~0 ms)

If DataFusion short-circuited the entire join subtree to EmptyExec the moment it knew one build side was empty, the lineorder scan could be skipped. DataFusion does this at the join level (the HashJoinExec itself returns immediately when build is empty), but the lineorder probe-side scan has already been started by then. The cost is paid before the join discovers it has no work.

Candidate optimizations, ranked by trace evidence

  1. Plan-time cardinality estimation for dim filters that resolve to constant predicates. Pre-evaluate the dim filter at plan time when the filter is a constant IN-list or equality on a column with tracked min/max. If 0 files survive metrics-based pruning, replace the join subtree with EmptyRelation. Helps q3.3, q3.4, q2.2 specifically. Estimated savings: ~120 ms x 3 queries = ~360 ms across SSB SF1.

  2. Better join reordering for star-schema selectivity. q3.3 chose lineorder × supplier first (build = 2192 rows, output = 5.1 M) instead of lineorder × customer first (build = 0 rows, output = 0). SQE has star_schema_reorder enabled by default; investigate why it picked the wrong order on this query shape. Helps any star-schema query with one highly-selective dim. Estimated savings: ~160 ms / query when the optimizer flips the order correctly.

  3. Row-group level dynamic-filter pruning in IcebergScanExec. Path B-2 now engages (the gather_filters_for_pushdown fix lands the runtime filter on the scan) but SF1 still scans 6 M rows because lineorder is one file and SQE only prunes at file level. Wire the resolved dynamic filter through PruningPredicate over per-row-group min/max from the Parquet footer, the way DataFusion does for static filters in ParquetExec. Lifts the file_entries.len() > 1 gate as well so single-file tables can still benefit. Estimated savings: matches the SF10+ Path B-2 numbers when the dim build is selective.

  4. Investigate Path B-2 at SF10. Path B-2 is now wired up but the SSB SF1 cardinality is too small to demonstrate it. Re-run SSB at SF10 to confirm the fix actually reduces lineorder scans on queries with selective dim builds.

  5. Plan cache. Production hygiene; adds ~0.5 ms / query benefit on repeated SQL. Not the SF1 fix.

The trace work itself (an earlier change, !122) is the foundation: every future SSB optimization can be measured against the phase rows.

Quack Protocol Reference (as of DuckDB extension v1.5-variegata)

Reference notes for implementing a Quack-compatible server and client in Rust. Extracted from the duckdb/duckdb-quack source (MIT, ~356 commits, May 2026) and the DuckDB v1.5.2+ release. Cross-checked against the announcement post and duckdb-quack’s own docs/usage.md.

The Quack protocol is pre-release and the DuckDB project plans to stabilise it for v2.0 in September 2026. Treat this document as a snapshot, not a stable contract.

Status of upstream documentation

The upstream documentation has two surfaces with mismatched naming:

  • README + source code: quack_serve, quack_stop, quack: URI scheme, HTTP endpoint POST /quack, content type application/vnd.duckdb.
  • duckdb-quack docs/usage.md and FAQ: rpc_start, rpc_stop, POST /rpc, MIME type application/duckdb.

The source is authoritative. The rpc_* doc names appear to be an older or aspirational naming. We follow the source.

The FAQ states “Quack uses HTTP v2.0”. The source uses httplib (a small C++ HTTP/1.1 library) with keep_alive_max_count(128). We treat the wire as HTTP/1.1 with keep-alive, not HTTP/2.

Transport

FieldValue
ProtocolHTTP/1.1, keep-alive enabled
Default port9494
URI schemequack:host[:port] (HTTPS by default for non-localhost, plain HTTP for localhost)
EndpointPOST /quack
Content-Type (request and response)application/vnd.duckdb
TLSOptional. Server generates self-signed cert via quack_generate_keys(). Production deployments expected to terminate TLS at a reverse proxy.
CORSServer returns Access-Control-Allow-Origin: * on OPTIONS /quack and on every response

There is also a root path that returns a plain-text identification string:

GET / HTTP/1.1

HTTP/1.1 200 OK
Content-Type: text/plain

This is a DuckDB Quack RPC endpoint. Use ATTACH 'quack:...' to connect here.

Useful for sniffing whether a host speaks Quack.

Wire format

Every request body and every response body is a serialised QuackMessage. The serializer is DuckDB’s BinarySerializer with SerializationCompatibility::FromIndex(7). This is the same code path DuckDB uses for its Write-Ahead Log files.

Each message on the wire is:

[ serialized MessageHeader (BinarySerializer Begin/End block) ]
[ serialized message body  (BinarySerializer Begin/End block) ]

BinarySerializer uses field-tagged encoding. Every field has a numeric ID, a type, and a value. Optional fields can be omitted. The schema (with stable field IDs) is captured in src/include/quack_message.json in the upstream repo and reproduced below for stability.

Message header

Field IDNameTypeNotes
1typeMessageType (enum)See message types below
2connection_idstringServer-assigned, returned in CONNECTION_RESPONSE
3client_query_idoptional_idx (u64)Monotonic per-client query ID for log correlation

MessageType is an enum encoded as idx_t:

INVALID = 0
CONNECTION_REQUEST = 1
CONNECTION_RESPONSE = 2
PREPARE_REQUEST = 3
PREPARE_RESPONSE = 4
FETCH_REQUEST = 5
FETCH_RESPONSE = 6
APPEND_REQUEST = 7
SUCCESS_RESPONSE = 8
DISCONNECT_MESSAGE = 9
ERROR_RESPONSE = 10

The exact wire ordering of enum tags depends on DuckDB’s EnumUtil; do not hard-code numeric values. Always go through the named enum.

Message bodies

ConnectionRequest

Initial handshake. Sent once per connection.

FieldTypeNotes
1 auth_stringstringBearer token. Server’s auth function decides validity
2 client_duckdb_versionstringe.g. "v1.5.2"
3 client_platformstringe.g. "osx_arm64"
4 min_supported_quack_versionidx_tclient min
5 max_supported_quack_versionidx_tclient max

ConnectionResponse

FieldTypeNotes
1 server_duckdb_versionstring
2 server_platformstring
3 quack_versionidx_tCurrently 1

Header carries the server-assigned connection_id; clients echo it in subsequent requests.

PrepareRequest

FieldTypeNotes
1 sql_querystringRaw SQL

PrepareResponse

FieldTypeNotes
1 result_typesvector<LogicalType>Per-column DuckDB type
2 result_namesvector<string>Column names
3 needs_more_fetchboolIf true, client must follow up with FETCH_REQUEST using result_uuid
4 resultsvector<DataChunkWrapper>Optional first batch of rows
5 result_uuidhugeint_tServer-side handle for follow-up fetches

The server may inline the entire result if it fits; otherwise it returns a result_uuid and the client pulls more via FETCH_REQUEST.

FetchRequest

FieldTypeNotes
1 uuidhugeint_tResult handle from PrepareResponse

FetchResponse

FieldTypeNotes
1 resultsvector<DataChunkWrapper>Batched chunks
2 batch_indexoptional_idxSequence number for ordering

AppendRequest

Bulk insert from client to server.

FieldTypeNotes
1 schema_namestringTarget schema
2 table_namestringTarget table
3 append_chunkDataChunkWrapperRow data

SuccessResponse

Empty body. Used to acknowledge DisconnectMessage, AppendRequest, etc.

DisconnectMessage

Empty body. Client signals end of session. Server responds with SuccessResponse and closes the connection.

ErrorResponse

FieldTypeNotes
1 messagestringRaw error message

DataChunk wire format

Results travel as DataChunkWrapper, which serialises one DuckDB DataChunk (vectorised columnar batch). The wrapper has a single field:

Field IDNameType
300chunkDataChunk

A DataChunk is DuckDB’s native columnar batch type. Its serialisation includes:

  • Number of columns
  • Per-column LogicalType (recursive for nested types)
  • Per-column Vector data (validity bitmap + data buffer + optional dictionary/auxiliary buffers)

This is not Arrow IPC. DuckDB has its own columnar layout. The two formats are not interchangeable without conversion.

For SQE to read these, we either:

  1. Link libduckdb and let DuckDB’s C++ code deserialise into a DataChunk, then convert to Arrow inside our process; or
  2. Reimplement DuckDB’s BinarySerializer and DataChunk::Serialize semantics in Rust.

Option 1 ties us to a specific DuckDB version but gets correctness for free. Option 2 is purer Rust but the maintenance cost tracks DuckDB releases. Decision recorded in openspec/changes/duckdb-quack-protocol-support/design.md (Open Questions section).

Authentication

The server’s quack_authentication_function (default quack_check_token) is a SQL scalar function with signature (sid VARCHAR, token VARCHAR) -> BOOLEAN. The default implementation compares the token against quack_default_token.

Users can plug their own auth by registering a scalar function with that signature and pointing the setting at it.

The token travels in ConnectionRequestMessage.auth_string. There is no separate Auth frame. Once ConnectionResponse returns, the connection is authenticated for the lifetime of that connection.

Per-query authorisation: quack_authorization_function is (sid VARCHAR, query VARCHAR) -> BOOLEAN. Default allows everything. Called server-side before executing each PrepareRequest.

Pushdown semantics

The server supports the following pushdowns when a client ATTACHes and then scans a remote table:

  • Projection pushdown: only requested columns are returned
  • Filter pushdown: constant comparisons (=, <, >, <=, >=, <>), IS NULL, IS NOT NULL, IN (...), and AND/OR combinations

Filters are evaluated server-side. Other predicates (function calls, joins) execute on the client.

For SQE-as-server: the SQL the client sends is already the filtered/projected SQL. We do not need to extract pushdowns from a separate field. The SQL string carries everything.

Logging

The extension registers two log types:

  • quack log: structured per-message (message_type, connection_id, client_query_id, query, duration_ms, error)
  • HTTP log: per-request URL + status

For SQE compatibility, we should emit equivalent structured logs from the server crate.

Compatibility matrix

Server quack_versionClient min..maxBehaviour
1min<=1<=maxOK
1min>1Server returns ErrorResponse
Future Nclient max < NServer should downgrade if possible; otherwise reject

Current quack_version = 1. The protocol is expected to bump versions before v2.0 stabilisation.

Things SQE will need to handle differently from DuckDB

  • Iceberg-backed catalogs: DuckDB Quack assumes its own catalog. Our Attach returns SQE’s Iceberg catalog tree. DuckDB clients see Iceberg namespaces as schemas.
  • OIDC tokens vs static tokens: the auth function receives an opaque string. We treat it as an OIDC bearer and validate via sqe-auth. Bare static tokens are still accepted if sqe-auth is configured for them.
  • Result format: SQE’s existing query engine produces Arrow RecordBatch. We must convert each RecordBatch to a DuckDB DataChunk before serialising. This conversion is non-trivial but tractable (both are columnar, both have validity bitmaps).
  • Policy enforcement: server-side SQL goes through sqe-policy SQL-text rewriter (see openspec/changes/duckdb-quack-protocol-support/design.md) before reaching the planner.

References

  • Upstream repo: https://github.com/duckdb/duckdb-quack (MIT)
  • Announcement: https://duckdb.org/2026/05/12/quack-remote-protocol
  • DuckDB docs (overview): https://duckdb.org/docs/current/quack/overview
  • FAQ: https://duckdb.org/quack/faq
  • Local reference clone: /tmp/duckdb-quack-src/ (during research; delete after Phase 1)

Trino Client Compatibility

BI-tool compatibility (Metabase, Superset, JDBC) fixed 2026-06 (issues #1, #4, #5, #6, #327, #345 and the catalog-enumeration blocker) and verified live against a Polaris + Keycloak + Ranger stack on 2026-07-02 (see Live verification below). Original curl protocol matrix: 2026-04-10 against SQE v0.15.0. SQE Trino HTTP endpoint: http://localhost:8080

Benchmark Comparison: SQE vs Trino 465 (SF0.01, same Polaris + S3)

Historical snapshot from v0.15.0 (2026-04) at SF0.01. Superseded by the current SF1/SF10 baselines in the project benchmark results; kept here for the original client-compat context, not as a current performance claim.

BenchmarkMatchedSQETrinoWinnerNotes
TPC-H (22)22/227.6s8.7sSQESQE faster on analytical queries
SSB (13)13/134.2s5.1sSQESQE faster on star schema
TPC-DS (99)92/9940.1s41.4sSQENear-parity, 6 row diffs from ORDER BY tiebreaking
TPC-C (17)15/174.0s3.8sTrino2 DML row count diffs
TPC-E (18)17/185.0s3.7sTrino1 BothFailed (correlated subquery in SET)
TPC-BB (10)0/101.4s0.2sN/ABoth fail (Trino catalog namespace mismatch)
ClickBench (43)41/4312.7s3.9sTrinoSimple scans favor JVM JIT
Total200/22274.9s67.0sMixedSQE wins analytical, Trino wins simple scans

Key finding: SQE beats Trino on complex analytical queries (TPC-H, SSB, TPC-DS) thanks to:

  • No JVM startup overhead (Rust AOT compilation)
  • Efficient Iceberg scan planning with predicate pushdown
  • DECIMAL precision (matching SQL standard, fixed Apr 10)

Trino wins on simple single-table scans (ClickBench) due to JVM JIT compilation advantage on hot paths.

Correctness: DECIMAL literal fix ensures 0.06 - 0.01 = 0.05 (exact), not 0.049999999999999996 (IEEE 754 rounding). This was a critical correctness fix that changed TPC-H q06 results from wrong (40.7M) to correct (68.2M).

Summary

ClientVersionConnectBrowseQueryPaginateStatus
curl (Trino HTTP)n/a⏭️✅ 26/28 tests pass
Trino wire protocol (JDBC/SQLAlchemy)465✅ full handshake driven live 2026-07-02 (see below)
Metabaserecent✅ JDBC protocol path verified live; real client drove the original bug discovery
dbt-trino1.9.x✅ exercised end-to-end in the remote test setup (native dbt-sqe adapter is the primary path)
Superset (SQLAlchemy)4.x⏭️✅ SQLAlchemy reflection + query paths verified live via the shared wire protocol; not run inside a Superset GUI
DBeaver (Trino)24.x⏭️✅ same JDBC metadata paths verified live; not run inside the DBeaver GUI
trino-cli476⏭️✅ official Trino CLI 476 ran SHOW/DESCRIBE/typed queries live 2026-07-02 over the TLS route

Rating: ✅ works | ⚠️ partial (with workaround) | ❌ broken | ⏭️ not tested

The BI-tool fixes landed in 2026-06 after pointing a real Metabase at the endpoint and watching the metadata handshake fail: the PREPARE the parser rejected (JDBC could not connect), the two-column SHOW TABLES that collapsed every table into its namespace, the catalogs never enumerated, the quoted identifiers DESCRIBE/SHOW COLUMNS never matched, and the timestamp(6) type signature the JDBC driver refused to parse. Each surfaced as a silent zero (0 tables, 0 columns) or an “invalid response,” never a server error.

The summary matrix above is the current status. The per-client checklists further down are the original v0.15.0 (2026-04) test templates, kept for the case-by-case detail; their unchecked boxes predate the 2026-06 fixes and are not a current record of what works.

Live verification (2026-07-02)

Drove the Trino client protocol (what the JDBC driver and the SQLAlchemy dialect issue on the wire) against a live SQE stack: the data-platform quickstart with Polaris, Keycloak (iceberg realm), and Ranger, catalog main_warehouse, authenticated with Basic auth exchanged for an OIDC token. Every step of the BI handshake passed:

StepQueryResult
Connect gatePREPARE st FROM SELECT 1FINISHED, X-Trino-Added-Prepare header set, no parse error
Parameterized queryPREPARE p FROM ... WHERE order_id = ? then EXECUTE p USING 'o-01'FINISHED with data (a type-mismatched literal is correctly rejected)
SHOW CATALOGSsingle Catalog column
SHOW SCHEMASsingle Schema column
SHOW TABLES FROM "s"single Table column, bare names
Quoted DESCRIBEDESCRIBE "cat"."schema"."table"column rows returned, no error
Quoted SHOW COLUMNScolumn rows returned
SQLAlchemy reflectionSELECT column_name, data_type FROM information_schema.columns WHERE ...Trino type names (varchar), unqualified information_schema resolves to the session catalog
Typed timestampdate_trunc('month', CAST(now() AS timestamp))timestamp(6) with rawType: "timestamp" and precision in arguments; value normalized to 6 fractional digits
Computed aggregatecount(*)type bigint, value rendered as a JSON number
Time-series chartdate_trunc('quarter', ...) GROUP BY 1timestamp(6) + bigint, correct rows
Pagination83,521-row result, follow nextUri84 pages of 1000 rows (521 on the last), exact row count, RUNNING -> FINISHED; the max_result_rows guard rejects oversized results cleanly

A type-mismatched predicate (for example WHERE varchar_col = 1) used to return errorName: EXECUTION_FAILED, errorType: INTERNAL_ERROR, and the raw DataInvalid => Can't convert datum ... string, which told a BI client the engine was broken rather than the query. That now classifies as TYPE_MISMATCH (USER_ERROR) with the DataInvalid => wrapper stripped. Genuinely bad SQL already surfaced well (Invalid function 'frobnicate'. Did you mean 'truncate'?). The remaining error-detail work is the generic-message cases noted below.

Cross-checked with the official Trino CLI 476 (the same client protocol the JDBC driver uses) pointed at the TLS route https://localhost/v1/ (nginx proxies it to sqe:8080; password auth over the wire requires TLS): SHOW SCHEMAS, SHOW TABLES, quoted three-part DESCRIBE (rendered as Trino’s Column | Type | Extra | Comment), and a date_trunc('month', ...) , count(*) query all returned correctly, with timestamps normalized to 6 fractional digits.

Trino HTTP v1/statement Protocol (curl)

Tested: 2026-04-08 via curl -X POST http://localhost:28080/v1/statement with Bearer token auth.

Connection & Metadata:

  • SHOW CATALOGS returns results (1 row)
  • SHOW SCHEMAS IN <catalog> returns results (2 rows: default, information_schema)
  • SELECT 1 succeeds
  • SELECT 1+1 AS result succeeds with column alias

Trino Date/Time Functions (compat UDFs):

  • now(): returns current timestamp
  • year(CAST('2024-01-15' AS DATE)): returns 2024
  • month(CAST('2024-03-15' AS DATE)): returns 3
  • day_of_week(CAST('2024-01-15' AS DATE)): returns day number
  • date_format(now(), '%Y-%m-%d'): MySQL format codes work
  • date_trunc('month', ...): native DataFusion

String Functions:

  • upper(concat('hello', ' ', 'world')): HELLO WORLD
  • length('hello'): 5
  • substr('hello world', 1, 5): hello
  • replace('hello', 'l', 'r'): herro
  • trim(' hello '): hello

Conditional / Type:

  • CASE WHEN 1=1 THEN 'yes' ELSE 'no' END: yes
  • COALESCE(NULL, 42): 42
  • NULLIF(1, 1): NULL
  • GREATEST(1,2,3), LEAST(1,2,3): 3, 1
  • typeof(42): Int64
  • TRY_CAST('abc' AS INTEGER): NULL

Math:

  • abs(-5), sqrt(16.0): 5, 4.0
  • round(3.14159, 2): 3.14
  • pi(): 3.14159…
  • random(): random float

JSON:

  • json_format('{"a":1}'): formatted JSON string

Known failures:

  • VALUES clause: SELECT count(*) FROM (VALUES 1,2,3) AS t(x) works (a bare-VALUES pre-parse rewrite landed in #315; inline VALUES sources are covered by tests)
  • Error detail: bad SQL returns generic Query execution failed instead of the underlying parse error message
  • Missing table: correctly returns error with table name: table 'test_warehouse.default.nonexistent_table' not found

Results: 26/28 pass. Core SQL functions, metadata, and Trino compat UDFs all work correctly over the Trino HTTP protocol.

trino-cli

Version tested: ⏭️ not yet tested Command:

# Connect to SQE's Trino HTTP endpoint
trino --server http://localhost:8080 --user admin --catalog iceberg --schema tpch_sf1

Test cases:

  • Connection succeeds
  • SHOW CATALOGS returns results
  • SHOW SCHEMAS returns results
  • SHOW TABLES returns results
  • SELECT * FROM orders LIMIT 10 returns data
  • SELECT count(*) FROM orders returns correct count
  • Large result set pagination works (>1000 rows)
  • DESCRIBE orders works
  • Error messages display correctly for bad SQL
  • \q / Ctrl+D exits cleanly

Results: Requires trino-cli binary Known issues: To be tested

Trino JDBC Driver

Version tested: not recorded Connection URL: jdbc:trino://localhost:8080/iceberg/tpch_sf1

Test cases:

  • DriverManager.getConnection() succeeds
  • DatabaseMetaData.getCatalogs() returns results
  • DatabaseMetaData.getSchemas() returns results
  • DatabaseMetaData.getTables() returns results
  • DatabaseMetaData.getColumns() returns column metadata
  • Statement.executeQuery() returns ResultSet
  • ResultSet iteration works for all data types
  • Large result sets paginate correctly
  • PreparedStatement works (if supported)
  • Connection pooling (HikariCP) works

Results: To be filled after testing Known issues: To be filled after testing

DBeaver (Trino JDBC)

Version tested: not recorded

Test cases:

  • Create Trino connection in DBeaver
  • Schema browser shows catalogs, then schemas, then tables
  • Column metadata displays correctly
  • Query editor runs SELECT queries
  • Result grid displays data correctly
  • Data export (CSV, SQL) works
  • ER diagram generation works (if tables have relationships)

Results: To be filled after testing Known issues: To be filled after testing

Superset (Trino SQLAlchemy)

Version tested: not recorded

Test cases:

  • Add database connection with trino://admin@localhost:8080/iceberg/tpch_sf1
  • Test connection succeeds
  • Table list populates
  • Create chart from table data
  • SQL Lab query execution works
  • Result pagination works

Results: To be filled after testing Known issues: To be filled after testing

dbt-trino

Version tested: not recorded

Test cases:

  • dbt debug connects successfully
  • dbt run executes models
  • Table materialization works
  • View materialization works
  • Incremental materialization works
  • dbt test runs schema tests
  • Compare output with native dbt-sqe adapter

Results: To be filled after testing Known issues: To be filled after testing

Common Issues & Workarounds

Authentication: SQE’s Trino HTTP endpoint requires a Bearer token (OAuth2). The test stack uses Polaris client_credentials grant (client_id=root, client_secret=s3cr3t). The live stack uses Keycloak OIDC with OPA-enforced authorization.

Catalog context: Set the session catalog with the X-Trino-Catalog header (or use fully-qualified catalog.schema.table names). Both the SELECT and SHOW paths honor the session catalog and auto-discover it, so a BI tool syncing against a session catalog sees the same tables the query editor does.

Error messages: Trino HTTP error responses use a generic Query execution failed message instead of surfacing the underlying SQL parse/plan error. The errorName and errorType fields are populated but the user-facing message needs improvement.

DESCRIBE: DESCRIBE <table> and SHOW COLUMNS FROM <table> both work and resolve double-quoted identifiers ("catalog"."schema"."table"). DESCRIBE OUTPUT / DESCRIBE INPUT on a prepared statement are handled for JDBC PreparedStatement metadata calls.

HuggingFace glob expansion: research notes

Goal: support SELECT * FROM 'hf://datasets/foo/bar@~parquet/**/*.parquet' so DuckDB-style glob URLs work end-to-end through SQE.

What blocks globs today

After V12.1 (this MR), the SQL rewriter resolves the hf:// URL to its HTTPS form and DataFusion’s enable_url_table() builds a ListingTable against that URL. For globs to expand, two layers must agree:

  1. object_store::list(prefix) on the underlying store. DataFusion’s ListingTableUrl::list_all_files calls list to enumerate files matching the glob. The default HttpStore from object_store::http cannot enumerate; HTTP has no standard directory-listing protocol. The store returns an empty iterator (or an error) and DataFusion sees “no files match”.

  2. Glob parsing. DataFusion accepts **/*.ext syntax in ListingTableUrl and dispatches to list_all_files. That part already works; it’s the upstream list that returns nothing.

So the gap is: HuggingFace HTTPS URLs need a working list() implementation. HuggingFace’s tree API gives us exactly that, just not through WebDAV.

HuggingFace tree API

GET https://huggingface.co/api/datasets/<owner>/<name>/tree/<branch>?recursive=true

Returns JSON:

[
  {"type": "file", "path": "default/train/0000.parquet", "size": 12345, "oid": "sha"},
  {"type": "file", "path": "default/train/0001.parquet", "size": 67890, "oid": "sha"},
  {"type": "directory", "path": "default/test", "oid": null}
]

For models and spaces the prefix changes: /api/models/<owner>/<name>/tree/<branch> and /api/spaces/<owner>/<name>/tree/<branch>. The <branch> segment accepts URL-encoded refs, including refs%2Fconvert%2Fparquet for the auto-generated parquet view.

Auth: anonymous for public datasets. Private datasets need Authorization: Bearer $HF_TOKEN.

Rate limits: HuggingFace publishes 1000 requests / 5 minutes per IP for the API. Tree calls are cached server-side; consecutive calls for the same dataset are cheap.

Three approaches

Option A: SQL pre-rewriter expands globs

Extend rewrite_hf_urls_in_sql to detect glob characters and replace the query with a UNION of resolved URLs.

-- Input
SELECT col FROM 'hf://datasets/foo/bar@~parquet/**/*.parquet';

-- Rewritten
SELECT col FROM (
  SELECT * FROM 'https://huggingface.co/datasets/foo/bar/resolve/refs%2Fconvert%2Fparquet/default/train/0000.parquet'
  UNION ALL
  SELECT * FROM 'https://huggingface.co/datasets/foo/bar/resolve/refs%2Fconvert%2Fparquet/default/train/0001.parquet'
);

Pros:

  • No new TableProvider; reuses the same V12 SQL-rewrite hook.
  • Works for both URL-table auto-detect (SELECT * FROM 'hf://...') and TVFs (read_parquet('hf://...')).
  • Easy to test deterministically: fake the HTTP client.

Cons:

  • N HTTPS calls become N ListingTable entries. Each opens a separate Parquet reader. DataFusion’s ParquetExec already handles multi-file scans, but the SQL rewrite introduces them as a UNION instead, which is less efficient (the planner sees them as separate sources, not one table).
  • Glob expansion happens at SQL parse time, which means the file list gets baked into the query text. Subsequent runs that re-fetch a stale list would not see new files. Cache invalidation left to the user.
  • The rewritten SQL gets very long (a 1000-file dataset becomes 1000 UNION arms).

Verdict: workable for small datasets, brittle at scale. Not recommended as the primary path.

Option B: Custom HfObjectStore with working list()

Wrap HuggingFace’s tree API in an object_store::ObjectStore implementation. DataFusion’s ListingTable then uses the default glob path with no other changes.

#![allow(unused)]
fn main() {
pub struct HfObjectStore {
    inner_http: Arc<dyn ObjectStore>,  // for actual file reads
    api_client: reqwest::Client,        // for tree API listing
    base: String,                       // "https://huggingface.co"
}

#[async_trait]
impl ObjectStore for HfObjectStore {
    async fn list(&self, prefix: Option<&Path>) -> ... {
        // 1. Parse `prefix` -> (owner, name, branch, in-repo path)
        // 2. GET /api/<kind>/<owner>/<name>/tree/<branch>?recursive=true
        // 3. Filter results by in-repo path prefix
        // 4. Yield ObjectMeta entries
    }

    async fn get(&self, location: &Path) -> ... {
        // Delegate to inner_http after rewriting hf:// to https://
        self.inner_http.get(location).await
    }

    // ... head, list_with_delimiter, etc.
}
}

Pros:

  • Single TableProvider handles arbitrary globs at any depth.
  • No SQL surgery. The user’s exact URL flows through unchanged.
  • Lists once per query, not once per parse. Stat caching can live in the store.
  • Works for read_parquet, read_csv, read_json, and URL-table auto-detect uniformly.

Cons:

  • More code: ~400 lines for the store impl, plus tests.
  • Needs registration with the lazy registry: LazyHttpObjectStoreRegistry would gain a branch for hf:// schemes that builds an HfObjectStore instead of a plain HttpStore.
  • Has to handle pagination if HF caps results (the tree endpoint returns up to 1000 entries per call; pagination via ?cursor= per HF Hub API docs).

Verdict: the right architecture. Aligns with how V10 handles HTTPS via a lazy-built store. Worth building.

Option C: DataFusion TableFactory for hf://

Register a custom TableFactory that DataFusion’s DynamicFileCatalog calls when it sees 'hf://...'. The factory:

  1. Parses the URL
  2. Calls HF tree API directly
  3. Builds a ListingTable with the expanded file list

Pros:

  • Cleaner separation: hf:// handling lives in one place.
  • No object_store gymnastics.

Cons:

  • DynamicFileCatalog’s extension API (per DataFusion 53) is private. register_factory is not public. Would require either a fork patch or upstreaming.
  • Doesn’t help the TVF path (read_parquet('hf://**/*.parquet')); that goes through different machinery.

Verdict: blocked on upstream API exposure. Skip.

Implement Option B. Concretely:

  1. New module sqe-catalog/src/hf_object_store.rs (~300-400 lines).
  2. Implements ObjectStore trait. Constructor: HfObjectStore::new(repo_kind, owner, name, branch, http_client).
  3. get, get_range, head: rewrite Path against the resolved HTTPS form, delegate to inner HttpStore.
  4. list, list_with_delimiter: call HF tree API, parse JSON, filter by prefix, yield ObjectMeta.
  5. LazyHttpObjectStoreRegistry::get_store learns to detect hf:// scheme and build an HfObjectStore instead of an HttpStore. Cache per (repo_kind, owner, name, branch).
  6. Tests:
    • Unit tests with wiremock faking the tree API: glob expansion, prefix filtering, pagination, branch refs with slashes (refs/convert/parquet).
    • Integration test against a known small HuggingFace dataset (#[ignore] so CI does not hit network unprompted).

SQL changes after Option B lands

The V12 SQL rewriter (this MR) translates hf:// to https://huggingface.co/... so DataFusion sees an https URL. With Option B, we want DataFusion to see the original hf:// URL so it picks the HfObjectStore from the registry.

Two cleanup options:

  • Stop rewriting hf:// in SQL when Option B lands. The HfObjectStore handles the URL natively. Remove rewrite_hf_urls_in_sql. The TVFs keep their internal rewrite_hf_path_in_place (they need the resolved URL because they manually call register_http_store_if_needed).
  • Keep both paths. SQL rewrite stays as a fallback when LazyHttpObjectStoreRegistry is not active (e.g., a harness-only test context).

Recommendation: drop the SQL rewrite once Option B lands. The lazy registry is the natural integration point; keeping both is dual maintenance.

Effort estimate

TaskLinesEffort
hf_object_store.rs~350M
LazyHttpObjectStoreRegistry integration~50S
Tests (wiremock + ignored network)~250M
rewrite_hf_urls_in_sql removal~30S
Docs~50S

Roughly a 1-2 day MR. Slot as V12.2 once V12 (this MR) and V12.1 (the @~parquet extension landing in this same commit) merge.

What we are NOT doing

  • Caching the tree response across queries. First query pays the API call; if the user runs the same glob twice in the same session, the second query hits the server again. Adding a TTL cache is a small follow-up.
  • HF_TOKEN env var pickup for private datasets. V10 documented this as deferred. Public datasets work today; private datasets need an explicit auth provider, which lands separately.
  • DuckDB’s ?ext=parquet shortcut. Their httpfs accepts hf://...?ext=parquet to skip the glob and let HF route to whichever file matches. We do not implement this; the auto-generated ~parquet view + glob is the equivalent path.

References

  • HuggingFace Hub API: https://huggingface.co/docs/hub/api
  • DuckDB hf:// extension blog: https://duckdb.org/2024/05/29/access-150k-plus-datasets-from-hugging-face-with-duckdb
  • DataFusion ListingTableUrl: https://datafusion.apache.org/library-user-guide/working-with-data-sources/datasource.html
  • object_store ObjectStore trait: https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html