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
| Procedure | Origin | Required args | Optional args | Notes |
|---|---|---|---|---|
system.rewrite_data_files | sqe-sql + sqe-coordinator | table => '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 => true | Compacts 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_snapshots | sqe-sql + sqe-coordinator | table => 'ns.t' | older_than => TIMESTAMP, retain_last => N | Drops 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_files | sqe-sql + sqe-coordinator | table => 'ns.t' | older_than => TIMESTAMP | Deletes 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_manifests | sqe-sql + sqe-coordinator | table => 'ns.t' | - | Consolidates many small manifest files into fewer larger ones. Speeds up planning on large tables. |
system.suggest_bloom_filter_columns | sqe-sql + sqe-coordinator | table => 'ns.t' | history_limit => N | SQE-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_health | sqe-sql + sqe-coordinator | table => '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_cache | sqe-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
| Procedure | SQE | Trino + Iceberg | Spark + Iceberg | DuckDB |
|---|---|---|---|---|
| Compact small files | CALL system.rewrite_data_files(...) | ALTER TABLE t EXECUTE optimize | CALL t.system.rewrite_data_files(...) | - |
| Expire old snapshots | CALL system.expire_snapshots(...) | ALTER TABLE t EXECUTE expire_snapshots(...) | CALL t.system.expire_snapshots(...) | - |
| Remove orphans | CALL system.remove_orphan_files(...) | ALTER TABLE t EXECUTE remove_orphan_files(...) | CALL t.system.remove_orphan_files(...) | - |
| Rewrite manifests | CALL system.rewrite_manifests(...) | ALTER TABLE t EXECUTE optimize_manifests | CALL t.system.rewrite_manifests(...) | - |
| Suggest bloom filters | CALL 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_filescounts live data files below[maintenance.compaction].target_file_size_bytes(default 512 MiB).eligible_groups/est_rewrite_bytesreport pure bin-pack debt: groups that meetmin_input_fileson file count alone.delete_heavy_filesis a separate signal, files with at leastdelete_file_thresholddelete files applying to them. A laterrewrite_data_files(delete_file_threshold => N)call rewrites the union of both sets, so treat the two counts as additive, noteligible_groupsalready including delete-heavy files.last_compaction_snapshot_msis alwaysNULL. Active-mode compactions do stampsqe.maintenance.job-id/principal/triggeronto the snapshot they commit (see thesystem.rewrite_data_filesnote above), buttable_healthdoes 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_enabledreflects thesqe.maintenance.enabledtable 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_manifestsneedMODIFY(writes new files, commits a snapshot).system.expire_snapshots,system.remove_orphan_filesneedMODIFYandDROP(alters retention, deletes files).system.suggest_bloom_filter_columnsis read-only against query history;SELECTon the table is enough.system.table_healthis read-only against the table’s live metadata;SELECTon the table is enough. It bypasses the write-privilege gate entirely, unlike every other procedure in this table.system.refresh_catalog_cacheis self-scoped (it refreshes only the caller’s own session view) and bypasses the write-privilege gate, likesystem.table_health. A global flush across all sessions is intentionally not a SQL procedure: use the admin-gatedPOST /api/v1/catalogs/refreshendpoint 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_fileswith noolder_thanuses the 3-day default, which is conservative against compaction or COPY jobs in flight. Override witholder_thanonly after confirming no concurrent writers.expire_snapshotsis 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_filesrewrites entire data files, not row groups. Two consecutive calls can churn the same files; rely on themin_input_filesfloor (default 5) to keep churn bounded.rewrite_data_filesis 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 viaexpire_snapshots. It groups files per partition, so partitioned tables consolidate within each partition.rewrite_data_filesretries 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_filesto 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
conflictorretry.rewrite_data_filesalready 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.