blob: 1b24e652f72f7a45318700ba30b94860a5603e22 [file] [view]
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Table Compaction
Rust supports small-file compaction for ordinary append tables, normal-file
compaction for Data Evolution tables, and sorted-run compaction for primary-key
tables with fixed or dynamic buckets. `CompactBuilder` creates
a `CompactPlanner`, a `CompactExecutor`, and a `TableCommit`. Planning and
execution do not publish snapshots; `commit_compact` atomically publishes the
selected results in a `COMPACT` snapshot. DataFusion SQL runs this flow through
[`CALL sys.compact`](sql.md#compact), and Python through
[`table.new_compact_builder()`](python-binding.md#compaction).
The builder selects a dedicated append, primary-key or Data Evolution planner
for the table mode. They share snapshot capture and the public plan/task types;
each planner owns its file eligibility, grouping and task-selection rules.
```rust
let builder = table.new_compact_builder();
let planner = builder.new_planner()?;
let executor = builder.new_executor()?;
let committer = builder.new_commit()?;
let plan = planner.plan().await?;
let mut results = Vec::new();
for task in plan.tasks() {
results.push(executor.execute(task).await?);
}
let metrics = committer.commit_compact(results).await?;
println!("Compacted {} files into {}", metrics.files_removed, metrics.files_added);
```
Writers also run one synchronous compaction round after a successful data commit.
`write-only` defaults to `false`; set it to `true` to leave compaction to a
dedicated job. The legacy `write.compaction-skip` alias is accepted, with
`write-only` taking precedence. Automatic compaction uses the existing planner's
thresholds and selects eligible files from the latest snapshot on the same branch,
restricted to the partitions and buckets changed by this write. It uses the current
schema even when the writer retains a historical schema, including schemas resolved
through a REST catalog. Primary-key writes honor normal sorted-run count and size
triggers. Lookup, first-row and deletion-vector compaction, or an explicit
`compaction.force-up-level-0=true`, can advance L0 files below those triggers.
Overwrite compaction is pinned to its own committed data snapshot, so suppressing
overwrite changelogs cannot consume a later append's pending lookup events.
As in Java, overwriting partitions being consumed by streaming readers is unsupported;
lookup changelogs do not describe the overwrite operation itself.
Automatic tasks share one captured snapshot and bucket-grouped source/index
metadata per round. Automatic index fields also share captured source metadata;
native builds only plan files overlapping missing or selected merge ranges.
Covered fields can still consolidate runs when their thresholds are met.
Captured metadata is released after the round. Publication and retries still
validate current sources and index
membership, and manual task execution captures fresh inputs on every call.
Automatic lookup compaction supports the default `lookup-compact=radical` policy.
`gentle` requires writer-lifetime trigger intervals and is explicitly unsupported
here; use `write-only=true` and schedule dedicated compaction separately.
An empty plan publishes no extra snapshot. Append, overwrite and state-dependent
data commits use this behavior; empty commits and truncation do not trigger it.
`write.compaction.parallelism` bounds concurrent buckets within one automatic
round (default `4`, positive integer; `1` runs serially). Tasks in the same
partition and bucket run in order. The commit waits for the complete round and
publishes all successful results together. On execution failure, queued buckets
are skipped, started buckets finish, and their unpublished outputs are cleaned up.
Normal commits pass their actual data snapshot and touched bucket set directly
to compaction; only recovery reconstructs them from stored snapshots and manifests.
Table modes unsupported by the compaction API retain their write behavior without
automatic compaction. Automatic lookup, first-row and deletion-vector compaction
use the configured catalog cache when available, otherwise a temporary disk cache
owned by the writer's compaction work. `lookup.cache-max-disk-size` bounds that
temporary cache when configured. Temporary files remain alive until the last cache
reader is released. Explicit manual compaction still requires
`local-cache.enabled=true` on the catalog/FileIO for these modes.
For plain partial-update and aggregation tables, an unsupported lossless retract
rewrite also leaves the raw source files in place. I/O, resource and data errors,
and lookup changelog failures, are still returned to the caller.
Data publication and compaction are separate transactions. A compaction failure
leaves the data committed and returns an error retaining the underlying cause.
For recoverable writes, use a stable commit user and caller-provided identifier,
then retry with `filter_and_commit_with_identifier` (or `filter_and_commit`) for
append, or `overwrite_with_identifier` for overwrite.
Recovery finishes an interrupted compaction round without repeating the data
commit or a completed automatic `COMPACT` snapshot. A single round can leave
additional work for a later write or a dedicated compaction job. This option does
not schedule background work or expire snapshots.
## Automatic global index maintenance
With `write-only=false` (the default), writes synchronously maintain declared
global indexes after automatic compaction. An empty compaction plan still allows
index maintenance. `write-only=true` leaves both jobs to dedicated operators.
Rust, Python, and DataFusion writes share this behavior. Tables without index
declarations retain their existing write behavior; no separate index switch is
required and no background job is scheduled.
Primary-key and append/Data Evolution tables share the same configuration:
`global-index.<type>.index-columns` declares comma-separated columns, and
`fields.<column>.index-options` supplies a JSON object of field parameters.
Supported types are `btree`, `bitmap`, `multivalue`, `fm`, `full-text`,
`ivf-flat`, `ivf-sq`, `ivf-pq`, `ivf-rq`, and `diskann`:
```rust
let options = std::collections::HashMap::from([
("row-tracking.enabled".into(), "true".into()),
("bucket".into(), "-1".into()),
("write-only".into(), "false".into()),
("global-index.btree.index-columns".into(), "id,age".into()),
("global-index.full-text.index-columns".into(), "text".into()),
(
"fields.text.index-options".into(),
r#"{"lower-case":true}"#.into(),
),
("global-index.diskann.index-columns".into(), "embedding".into()),
(
"fields.embedding.index-options".into(),
r#"{"dimension":768,"distance.metric":"cosine"}"#.into(),
),
]);
```
Maintenance reuses the global-index builders and their table, column-type,
and native writer restrictions. Tables must have row tracking enabled, no
primary key, and `bucket=-1`. Data evolution and the query-side
`global-index.enabled` option are not prerequisites for building indexes.
Full-text builds additionally reject deletion-vector tables. Declared columns
are trimmed; empty names, duplicates, unknown fields, and assigning multiple
families to one column are rejected before data publication. Each listed column
has its own index. Use the explicit build API for composite BTree indexes.
Update the lists and per-column option keys together with schema renames or drops.
`write-only=true` skips automatic maintenance. Shared declarations and native
parameters are validated when the corresponding index path is used; existing
Rust PK schema prerequisites still apply.
Short JSON keys are qualified with the implementation prefix: `block-size`
becomes `btree-index.block-size`, and vector `nlist` becomes `ivf-pq.nlist`.
String, boolean and numeric values are accepted; full-text arrays such as
`stop-words` use the native semicolon-separated encoding. The shared JSON
overrides table implementation defaults. Vector metrics default to
`inner_product`, and accepted metric aliases resolve consistently for builds,
indexed reads, raw scoring and refinement.
Native reads, builds and searches use the shared Rust resolver directly. Python
forwards the original schema options and explicit query/route options, including
field JSON and opaque parameters. Single, batch and hybrid builders use ordinary
options for declarations and parameters. Full-text analyzers remain scoped to
their schema columns, including JSON-encoded flat collection values.
Existing valid index metadata determines the vector implementation. PK scans
collect supported payloads for the column and discard stale source-file sets
before readers select the algorithm. All valid payloads, including completed
empty segments, must use one algorithm. With none left, readers resolve the
configured declaration for raw fallback. Type, parameters and metric come from
one captured result, reused for raw fallback, refinement and distributed tasks.
Captured raw splits retain their typed `index_type` metadata. Readers leave an
unspecified query metric unset so global index metadata can supply it; PK readers
retain Java's table metric validation.
The option aliases `index_type`, `index-type`, `vector.index-type` and
`fields.<column>.index-type` no longer select vector implementations. Use the
shared declaration, the retained PK declaration, or the explicit builder API.
The native training parameter `index.type` and typed index metadata fields are
unchanged.
Compatibility and precedence follow Java's shared configuration resolver:
- Declarations prefer `global-index.<type>.index-columns`, then the DLF singular
`global-index.<type>.index-column`, then legacy `pk-<family>.index.columns`.
An explicitly empty preferred declaration is invalid rather than falling back.
- Field JSON prefers `fields.<column>.index-options`, then the existing Rust
`fields.<column>.global-index.<family>.index-options`, then legacy
`fields.<column>.pk-<family>.index.options`. Shared JSON replaces legacy JSON;
it can override defaults, while non-metric legacy conflicts remain errors.
Vector metric aliases are resolved together after JSON merging, so field JSON
overrides legacy metric properties and case-only aliases stay compatible.
- Legacy `pk-vector` declarations still select the algorithm through
`fields.<column>.pk-vector.index.type`, only for columns named by
`pk-vector.index.columns`. The field type alone does not select an
implementation. The former Rust
`global-index.vector` declaration and field algorithm/metric keys remain aliases.
That older Rust declaration retains its metric/default priority over flat
aliases until replaced by a concrete declaration or shared field JSON.
- DLF implementation parameters such as `ivf-pq.dimension` and flat field
overrides such as `fields.embedding.dimension`, `distance.metric`, `nlist` and
`pq.m` remain supported. Shared vector JSON overrides those flat parameters.
`fields.<column>.global-index.row-count-per-shard` overrides the shard size
for that column. Morax scheduling switches do not control Rust maintenance.
The configuration is shared; table modes retain their validation and lifecycle.
PK indexes are maintained with data compaction, while append/DE indexes retain
global row-ID coverage and the incremental/consolidation policy below.
Upgrade readers and writers before switching PK tables to shared-only keys.
During a mixed-version rollout, retain the legacy declarations and field values
alongside the shared keys and keep their effective definitions aligned; older
clients still depend on the legacy PK namespace. Existing stored options do not
need migration for the updated resolver.
Explicit single-column builds also use these declarations as defaults; explicit
builder options take precedence over them.
Native vector builds can pass the complete schema option map. Unchanged entries
retain table precedence; changed entries remain procedure overrides. Standalone builder
option maps still accept native parameter aliases. Captured shards retain the
original schema and option map for worker execution.
FULL/DETAIL raw fallback uses the same per-column parameters as automatic
builds, including vector metric defaults and full-text tokenization. A declared
full-text column can use raw fallback before its first index is published.
Each round captures the current schema and one source snapshot. Missing row-ID
coverage is built as independent index files, retaining compatible existing
payloads. Adjacent index ranges form runs within one partition, bucket and row-ID
shard; logical row-ID holes are separate groups. The UniversalCompaction run
selector is shared with PK data compaction. Global index runs use logical row
counts as their size measure, so new ranges can participate in selection before
encoding; PK data runs continue to use bytes and their existing level rules.
Index merging has independent options with the same defaults as PK compaction:
| Option | Default | Meaning |
| --- | --- | --- |
| `global-index.num-sorted-run.compaction-trigger` | `5` | Run count at which size-based selection becomes eligible; must be positive. |
| `global-index.compaction.size-ratio` | `1` | Percentage tolerance when extending a merge to the next run; must be nonnegative. |
| `global-index.compaction.max-size-amplification-percent` | `200` | Select all runs when newer runs exceed this percentage of the oldest run; must be nonnegative. |
These options can also appear, fully qualified, in per-column `index-options`
JSON and override the corresponding table-level index policy. They do not change
the table's data-compaction options. Above the run-count limit, the selector
also makes progress when adjacent run sizes differ too much
for the normal size ratio. A single run is never rebuilt just to compact itself.
For example, with a 1000-row-ID shard, an existing `[0, 599]` index and a new
`[600, 799]` batch stay as two files below the trigger. Once enough adjacent runs
accumulate, selected ranges are rebuilt together from current data and sidecar
values. A large older run can remain untouched while smaller tail runs merge.
The implementation rebuilds selected data ranges rather than concatenating old
native index payloads. A physical compaction preserving row IDs and values can
retain global payloads. Shard-boundary changes, removed rows and incompatible
vector definitions retire affected payloads immediately. Additions and retired
references publish together; historical snapshots retain their old files.
Scalar and full-text payload reuse also checks the indexed field's type. New file
names record the actual builder schema ID; this remains valid after its source
snapshot expires and permits current-schema builds over older data. Java opens
the manifest's file name unchanged, so native and manifest formats are unchanged.
Legacy names require a complete schema history with an unchanged field ID and
type. Missing history or incompatible types cause atomic retirement and rebuild;
scalar layout-option changes alone retain compatible runs. Full-text type
changes, such as a shorter VARCHAR width, rebuild tokens from current logical
text. Full-text runs also require an equivalent parsed native configuration;
analyzer changes rebuild coverage, while explicit defaults preserve compatible
leaves. Missing or invalid analyzer metadata is rebuilt conservatively.
Vector training still uses the actual non-NULL count. If a small or sparse run
cannot satisfy its native training configuration, such as a size-per-vector
budget, maintenance may retry once with complete pending connected groups. This
keeps the declared algorithm, metric and budget. I/O and training execution
errors do not trigger this expansion. All-NULL vector builds retain Java's
existing empty-build behavior: they publish no zero-byte DE index marker.
Manual `commit_compact` also maintains declared indexes when `write-only=false`.
An empty maintenance plan publishes no snapshot. Empty data commits, index-only
commits, and truncation do not trigger a separate round. Indexed-column updates
retain `global-index.column-update-action`: use `DROP_PARTITION_INDEX` to allow
the data update and automatically rebuild the affected coverage. Changing a
vector algorithm, distance metric or dimension retires all incompatible payloads
for that column and publishes their replacements together. Other vector training
parameters alone do not rebuild unchanged coverage; changing shard boundaries
can regroup it.
Source vectors must satisfy a changed dimension. If they do not, maintenance
fails without publishing mixed-dimension payloads; fix the source values and
retry maintenance with the same originating commit identity.
Missing coverage is processed synchronously without a minimum-row or time
delay; index consolidation follows the run thresholds above.
A concurrent snapshot or schema change causes private outputs to be discarded
and coverage to be replanned, bounded by `commit.max-retries` and
`commit.timeout`. A stable identity derived from the originating data commit
makes repeated recovery idempotent. Uncertain publication outcomes retain
submitted files and check for an already published index snapshot before
retrying. Data, compaction, and index publication are separate transactions:
global row IDs must be assigned before indexing. An index failure leaves the
data committed and returns an error preserving the
cause. Retry append with `filter_and_commit_with_identifier` or
`filter_and_commit`, or overwrite with `overwrite_with_identifier`, using the
same commit user and identifier. Recovery resumes indexing even when automatic
compaction already completed. For an interrupted manual compaction, retry the
same prepared results and commit user through `commit_compact`.
## Compaction planning
Ordinary append and PK compaction use `target-file-size` as the output size target.
For unaware append tables (`bucket=-1`), task selection follows the batch packing rules in Java's
[`AppendCompactCoordinator`](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/append/AppendCompactCoordinator.java):
- Candidate files are strictly smaller than `target-file-size` multiplied by
`compaction.small-file-ratio` (default 0.7). This avoids repeatedly rewriting
output files that are slightly below the target after compression.
- Within each partition and bucket, candidates are sorted by file size.
Each file contributes `file_size + source.split.open-file-cost` to the task
weight. The open cost defaults to 4 MiB.
- A task is emitted once it contains at least two files and its weight reaches
twice `target-file-size`. A remaining group is emitted only when it has at
least `compaction.min.file-num` files (default 5, minimum 2).
There is no per-task file-count cap. An underfilled tail remains available for
planning against a later snapshot. Each plan independently captures a snapshot;
the API does not maintain a streaming coordinator's aging state.
Unordered bucketed append tables (`bucket>0`, `bucket-append-ordered=false`)
use Java's `BucketedAppendCompactManager` best-effort selection instead:
files are visited in minimum-sequence order, and a window is selected once it
reaches `compaction.min.file-num`. Before that count is reached, a window whose
physical bytes reach twice `target-file-size` drops its first file and keeps
sliding. The unaware-table small-file filter and open cost do not apply.
Ordered bucketed append compaction is unsupported: it requires sequence counters
that the ordinary Rust append writer does not yet maintain.
Ordinary read splits use a separate strategy, matching Java's
[`AppendOnlySplitGenerator`](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/table/source/AppendOnlySplitGenerator.java):
sort by minimum sequence number, then pack up to `source.split.target-size`
using `max(file_size, source.split.open-file-cost)` per file. Read split boundaries
do not determine compaction tasks, and changing `source.split.target-size` does
not change compaction grouping.
File sizes are approximate targets; normal writer rolling and
`target-file-row-num` still apply. An empty plan or result list publishes no
snapshot. Use `with_partition_filter(predicate)` to restrict planning to
partitions and `with_resources(context)` to share read/write memory budgets.
`CompactPlan`, `CompactTask` and `CompactResult` support Serde serialization.
Workers create their own executor with a table handle and local resources;
the task fixes the input snapshot, schema and output settings. Applications
can schedule independent tasks concurrently and submit completed subsets.
Results from different plans, duplicate tasks and overlapping inputs are
rejected. Executors stream input batches instead of collecting the whole task.
Concurrent appends can commit alongside compaction. A rewrite or overwrite of
an input file, a metadata change to that file, or a table schema change causes
the compaction commit to fail. Create a new plan after such a conflict.
PK commits also reject overlapping key ranges within a positive level, including
ranges introduced by a concurrent compaction. Overlapping L0 appends are allowed.
If the latest snapshot is below the plan's captured snapshot after a rollback,
commit rejects the uncommitted plan; create a new plan.
Retrying the same plan and task subset with the same `commit_user` is
idempotent, including when result order changes. Recovery searches only snapshots
created after the plan's captured snapshot. To retry after restarting
the coordinator, configure `with_commit_user(...)` with the original value
and retain the serialized results. A commit error can mean publication is
uncertain: retain submitted files rather than deleting them on error.
To abandon results that were never submitted, such as after a later task fails,
pass them to `abort_compact`. It attempts every prepared file and returns an
error naming the files it could not delete.
Append compaction preserves duplicate and null rows. Original files remain readable
through old snapshots, and append incremental scans skip `COMPACT` snapshots.
The removed-file metric counts manifest removals, not physical deletion;
snapshot expiration and storage reclamation are separate maintenance work.
## Primary-key compaction
Primary-key compaction maintains declared source-backed indexes using the same
`global-index.<type>.index-columns` and `fields.<column>.index-options` options
shown above. New code uses the shared `CoreOptions` index resolvers; PK-specific
option accessors are internal implementation details.
Existing Java `pk-<family>.index.columns` declarations, field JSON and metric
properties remain compatibility aliases. The legacy vector configuration pairs
`pk-vector.index.columns` with `fields.<column>.pk-vector.index.type`; new
configuration selects the implementation directly in
`global-index.<type>.index-columns`. No new PK-specific configuration API is
provided.
Declarations are unset by default. With `write-only=false`, the normal automatic
compaction round maintains them. Explicit compaction also maintains them with
`write-only=true`, for dedicated compactors. This uses the same native writers
as global indexes. PK vector and full-text search retain their existing
deletion-vector and search-mode requirements.
Each payload covers the complete compacted file set for one partition, bucket,
positive level and column. Source files are sorted by name; payload ordinals
concatenate their physical rows, preserving NULL and tombstone positions.
Each native build projects its indexed column rather than decoding every value
column. As in Java, vector builds exclude captured DV positions and lookup
deletions discovered by the current compaction, while keeping their physical
positions in the source frame. Selection precedes schema casts; bounded NULL
batches fill ordinal gaps for the shared native writer. Scalar and full-text
builds retain physical rows.
Unchanged coverage is retained. Changed coverage and incompatible vector
definitions replace the previous references in the same COMPACT snapshot as
data, changelogs and deletion vectors. Old payload files remain for historical
snapshots. L0 files remain outside these indexes and use the existing read path.
All-NULL vector levels retain an explicit empty segment, as in Java, so later
maintenance reuses their coverage without rescanning or opening a native index.
Vector payload row counts count indexed vectors; source ranges retain all physical positions.
Commit validates the complete source set and captured index membership before
publishing. Concurrent L0 appends and unrelated index changes can be rebased;
changes to a maintained unit require a new plan.
For newly built vector payloads, captured deleted positions must remain deleted
at commit. Concurrent DV additions and container repacking can be rebased;
restoring positions, for example through rollback-as-latest, requires re-execution.
A failed build cleans its private outputs. A failed or uncertain commit retains
prepared outputs for retry with the same results and commit user. Older serialized results lacking
index maintenance must be re-executed before first publication when indexes
need maintenance; already published results remain recoverable without their
original source snapshot.
As in Java, declared indexed columns cannot be renamed, dropped or have their
type changed while the declaration remains in effect.
If a declaration is removed to change a scalar column's type, retained files with
different source types use the normal read path until rewritten. Maintenance and
reads share this codec check, including when a type is changed back later.
The same API compacts primary-key tables with `deduplicate`,
`first-row`, `partial-update` or `aggregation` merge engines. Changelog producers
`none`, `input` and `lookup` are supported; `first-row` supports `none` or `lookup`.
For `deduplicate` with `none`, `input` or `lookup`, and non-DV `first-row` with
`none` or `lookup`, the planner chooses incremental sorted runs using Java's
universal-compaction size and count policy. Each L0 file is a
run; all files at one positive level form a single run. Normal size and run-count
triggers take precedence. If they select no work, explicit compaction forces
progress on every L0 run and absorbs adjacent runs according to their sizes. A
large higher-level run can remain in place while smaller runs are rewritten.
Without L0, positive-level maintenance requires the configured run-count and
size triggers. A single positive run needs no rewrite, even when it spans
several files.
The policy uses `num-sorted-run.compaction-trigger` (default `5`), `num-levels`
(default trigger plus one), `compaction.size-ratio` (default `1` percent), and
`compaction.max-size-amplification-percent` (default `200`). When selected runs
reach the oldest run, output goes to the highest configured or existing level;
otherwise it goes immediately below the next unselected run. Explicit full
compaction is not exposed. Without deletion vectors, `deduplicate` outputs retain winning retracts, so older values
in untouched files do not reappear after a delete or out-of-order update.
For `first-row`, both producers require actual catalog/FileIO local-cache.
The executor looks up keys in retained higher-level files. Existing accepted keys
stay in those files and produce neither a new data row nor a changelog event.
Selected positive-level rows still participate in the output. The `lookup`
producer emits only newly accepted keys, preserving their first add's row kind
and sequence. If every input key already exists, compaction can retire its
L0 inputs without creating a data or changelog file.
With deletion vectors, `deduplicate` supports the same incremental selection for
`none`, `input` and `lookup`. All three require actual catalog/FileIO local-cache.
Lookup Files preserve unfiltered physical row positions; captured deletion vectors
filter selected inputs and history before merging. A history hit moves its winning
state into the output and marks the original position deleted, even when an
out-of-order input loses. Winning retracts are omitted from data after changelog
generation, so a DELETE may publish a changelog without a new data file.
Commit atomically unions historical row deletions with the latest DV indexes and
retires rewritten inputs' ranges, preserving unrelated ranges and HASH indexes.
It rejects changed input masks, removed lookup targets and concurrently deleted
hit positions; unrelated new DV bits can be merged. Uncommitted serialized DV results must
retain their physical deletion metadata, including an empty list when appropriate.
Each output file is limited to 4,294,967,296 physical rows. Migrating files written
without DV is unsupported, including enabling DV through temporary table options.
Non-DV `partial-update` and `aggregation` with `lookup` use the same incremental
policy and require actual catalog/FileIO local-cache. The executor inserts the
nearest retained positive-level state into each changed key's merge, in sequence
order. The output contains the materialized state; lookup scans use only the
nearest positive-level state, so retained older levels are not aggregated again.
With `none` or `input`, these engines use incremental selection only when every
value field can be merged again independently of row order: `min`, `max`,
`bool_and`, `bool_or`, or integer `sum` without checked overflow. Aggregation must
have `aggregation.remove-record-on-delete=false` and no global sequence fields.
Partial update must use `ignore-delete=true`, no global sequence fields, and
protect every value field with a sequence group and one of these aggregate
functions. Each group's ordering fields retain its winning sequence and must
not also be protected fields. These modes merge only selected inputs, leaving
retained history to ordinary query merging, and do not require local-cache.
Other `none`/`input` configurations retain their previous selection: every active
file in the selected bucket participates, and output uses the highest input level
or level 1 for all-L0 inputs. This preserves sparse overwrite and order-sensitive
state such as `first_value`, finite floating-point summation, checked-overflow
behavior and reset-on-delete semantics. Existing retract/state restrictions still
apply. Append size and count options do not control PK selection.
Tasks capture their selected runs and output level. Executors validate the task
against the captured snapshot. Committers independently recompute the selection
from that snapshot and validate the files being removed and the output levels.
Older serialized incremental tasks without an output level must be replanned
before execution. Prepared results can still be published if their file changes
match the captured selection; already committed results remain recoverable
through the existing idempotent retry path.
Compaction uses each engine's existing merge function and persists its merge
state, including original sequence metadata and synthesized DELETE records:
- `deduplicate` retains the winning record, including retracts that must still
suppress later out-of-order values.
- `first-row` retains the earliest record. Retracts are rejected unless
`ignore-delete=true`. Its normal batch reader still skips uncompacted L0
files; compaction makes the selected records visible above L0.
- `partial-update` combines sparse columns using the configured sequence
groups and field aggregators. Delete payloads and group sequence values are
preserved so subsequent sparse updates can continue from that state.
- `aggregation` writes the engine's field accumulators. With `none` or `input`,
singleton records remain unreduced, including retracts; compound DELETE states
are preserved rather than being discarded as they are by the query reader.
The existing engine rules for ignored deletes, unsupported retracts and
whole-record deletion also apply during compaction. If all records are
legitimately ignored, compaction can remove the selected files without
creating an output file. Key bounds, sequence bounds, delete counts and file
indexes are regenerated, using metadata statistics options for the output level.
Compound `partial-update` DELETE states with field aggregators are rejected.
For both reducer engines, compound groups containing retracts are also rejected
when they use `first_value`, `first_non_null_value`, `collect`, `listagg`,
`nested_update` or `nested_partial_update`. These states may carry initialization
flags or raw DELETE payloads that cannot be reconstructed from one output row,
even when that row is an INSERT. Execution fails without publishing or deleting
the source files.
Partial-update retracts ignored by `ignore-delete=true` do not trigger this
restriction.
The engine's logical merged state remains unchanged. Physical record counts and
`rows_written` can decrease as older versions are removed; the latter includes
retained retract records. Append and input-changelog incremental reads do not
emit new logical events for a compaction snapshot.
PK rewrites stream ordered physical KV batches into the same low-level file
writer used by ordinary PK writes. Sequence allocation and merge buffering stay
in the ordinary write path. Key bounds, sequence bounds, statistics and indexes
are collected incrementally without retaining all rows of an output file.
`write-buffer-size` flushes format buffers. Parquet, ORC, Avro and ROW keep the
same output file open across these flushes. Formats that retain their pending
rows after flushing (Mosaic/Vortex) close the file at the buffer threshold to
preserve bounded memory. Files also roll when the format's approximate byte
count reaches `target-file-size` or the row count reaches `target-file-row-num`.
Output chunks are materialized at row limits to avoid retaining and repeatedly
charging the entire source batch for small slices.
The merge currently supports
up to 256 overlapping sorted-run inputs, matching the query reader's limit.
There is no multipass merge yet; too many overlapping runs fail without
publishing a snapshot. The captured snapshot must remain available until each
task subset is successfully committed; retries of an already committed subset remain
idempotent after its read snapshot expires.
## Lookup changelog producer
Set `changelog-producer=lookup` and run the same compaction API to publish data
and changelog files atomically in a `COMPACT` snapshot. Ordinary writes produce
L0 data files; changelogs become available when those files are compacted.
Batch incremental `Changelog` / `Auto` scans and streaming follow-up scans read
the resulting changelog manifest. `Delta` scans continue to skip compaction.
For each key, the lowest positive level is its latest materialized history.
Older positive levels already contributed to that state and are not reduced
again. Compaction merges this history with the captured L0 records and emits
changes only for keys with L0 input:
- No live history and a live result: `INSERT`.
- Live history and a deleted result: `DELETE` carrying the history's values.
- Live history and a live result: `UPDATE_BEFORE` followed by `UPDATE_AFTER`.
- No live history and no live result: no changelog.
Before images retain the historical sequence metadata; after images carry the
merged result's metadata. `first-row` emits the first accepted add for previously
unseen keys, preserving its `INSERT` or `UPDATE_AFTER` kind. It considers all
input levels when choosing the earliest record. `partial-update` and `aggregation`
always run the reducer in lookup mode, including singleton retracts, as Java does.
For example, lookup compaction turns a lone `DELETE(value=5)` with `sum` into
live state `-5` when `aggregation.remove-record-on-delete=false`; an ordinary
query still omits that singleton retract before compaction. Existing state
preservation restrictions still apply. History selection precedes merging.
Java-compatible options have these defaults:
| Option | Default | Behavior |
| --- | --- | --- |
| `changelog-producer.row-deduplicate` | `false` | When true, skip update pairs whose user values are equal. |
| `changelog-producer.row-deduplicate-ignore-fields` | unset | Comma-separated fields excluded from that comparison. |
| `changelog-producer.ignore-update-before` | `false` | Omit `UPDATE_BEFORE` events. |
| `changelog-producer.ignore-delete` | `false` | Omit `DELETE` events. |
With default options, even an unchanged value produces an update pair when its
key has L0 input. Ignoring changelog events does not change compacted table data.
`changelog-file.format`, `changelog-file.compression`, `changelog-file.prefix`
and changelog statistics options apply independently of data-file settings.
Changelog files roll at the ordinary size and row limits. `CompactResult` exposes
`changelog_files()`; `CompactMetrics` reports their file, row and byte counts in
`changelog_files_added`, `changelog_rows_written` and `changelog_bytes_written`.
The existing data metrics continue to count data files and rows only.
For `deduplicate`, unselected higher-level history is read through point lookups
in disposable local Lookup Files. Each file stores ordered keys and complete KV
rows, including row kinds and sequence metadata. The first access builds it by
streaming the source through the normal schema-evolution decoder. Later keys and
executors reuse it; a missing or corrupt cache block causes a bounded rebuild
instead of being treated as a missing key. Only selected runs are rewritten.
Lookup selects candidate files by key range, retaining range pruning across
schema changes that preserve the primary-key field IDs, order and types. The
reader retains one bounded parsed SST index and one decoded IPC batch.
Failed or cancelled builds clean up their unpublished cache blocks.
The executor streams both outputs and aborts both writers on execution failure.
Prepared results include both file lists for serialization, subset commits and
idempotent retries. Commit validation checks that selected inputs remain valid
and that files in each positive level have disjoint key ranges, matching Java's
LSM conflict checks. Concurrent L0 appends and changes to unselected files are
allowed when these checks pass. New L0 files remain eligible for a later
compaction and produce no premature events.
The executor requires an actual catalog/FileIO cache (`local-cache.enabled=true`);
setting the flag in table properties alone does not attach a cache.
Deduplicate Lookup Files use that cache's memory, disk or shared-disk backend,
namespace, eviction policy and `local-cache.max-size` budget. Set `local-cache.dir` to keep
them on disk. They do not require `data` in `local-cache.whitelist`.
`lookup.cache-max-disk-size` is rejected; use `local-cache.max-size` instead.
For deduplicate lookup caching, `lookup.cache-max-memory-size` defaults to
`256 MiB` per executor and bounds working components such as input batches,
IPC payloads and SST indexes. Oversized inputs or indexes,
or a cache unable to retain the generated file, fail execution without publishing
a snapshot. Reduce `read.batch-size` for oversized input batches or increase the
appropriate budget. Cache storage is charged separately to `local-cache.max-size`.
`lookup.cache-spill-compression` accepts `none`, `zstd` (default), `lz4` or `lzo`.
## Dynamic buckets
Dynamic bucket compaction (`bucket=-1`) rewrites each existing partition/bucket
without changing key assignments. HASH index files and their complete assignment
history, including deleted keys, are retained. A concurrent append may replace
the HASH index; compaction carries forward the latest manifest at commit time.
Cross-partition dynamic buckets are also supported: subsequent writers rebuild
their key-location index from the compacted data as usual. Compaction does not
rebalance buckets or reclaim HASH index capacity.
## Data Evolution compaction
The same API compacts normal structured files in unaware append tables with
`data-evolution.enabled=true` and `row-tracking.enabled=true`, following Java's
`DataEvolutionCompactCoordinator` and `DataEvolutionNormalCompactTask`.
Within each partition, planning keeps every version of an aligned row-ID group
together, including shadowed column files. Adjacent groups are packed using
`sum(max(file_size, source.split.open-file-cost))` as the weight. A bin is drained
when its weight strictly exceeds `target-file-size`; a group that alone exceeds
the target is isolated. A row-ID gap also drains the bin. A drained bin produces
a task only when it contains at least `compaction.min.file-num` physical files.
The ordinary append small-file ratio does not apply.
Data Evolution permits `compaction.min.file-num=1` to force a single-file
rewrite, for example to materialize the current schema.
Each task streams the latest normal-column values into one file covering the
same contiguous row-ID range. Like Java's default normal compaction, write-time
size and row limits do not roll this output; `target-file-size` guides planning.
The minimum and maximum input sequence numbers and per-column maximum sequence
numbers are retained. Blob and Vector files remain attached to their stable row
IDs and are not rewritten by these tasks.
Deleted physical slots are retained. When deletion vectors are enabled, commit
preparation moves their positions from the previous normal anchor files to the
new files. This uses the latest snapshot on every commit attempt, preserving
concurrent deletions and vectors belonging to untouched groups. Global indexes
remain valid because row IDs and values are preserved. Historical snapshots
continue to reference their original data and deletion vectors.
If compaction publishes before a prepared DELETE, that DELETE fails when it
references replaced anchor files or DV indexes. Prepare the DELETE again from
the latest snapshot; replaying the same stale messages does not rebase it.
Concurrent normal-column writes in a selected row-ID range cause commit to fail;
plan again from the latest snapshot. Disjoint appends can commit alongside the
rewrite. Subsets, serialization and idempotent retries use the same compaction
API as append and PK tables. `rows_written` counts physical slots, including
deleted rows; it counts overlapping column versions only once.
Changing storage options does not migrate values in existing files. If selected
normal files still provide a field that the current schema stores in dedicated
files, compaction is rejected before writing or publishing output. Migrate those
historical inline values first; otherwise omitting the field would lose data.
With deletion vectors enabled, one output file is limited to 4,294,967,296
physical row slots so every local position remains deletable by the current
Rust writer. Wider rewrites are rejected before execution; 64-bit deletion-vector
writing is not yet supported.
Dedicated Blob/Vector compaction, splitting large normal files, and materializing
deletions into reassigned row IDs are not provided. Setting
`data-evolution.compaction.split-large-files=true` or
`data-evolution.compaction.rewrite-row-ids=true` is rejected.
## Current scope
The `full-compaction` changelog producer is unsupported. Primary-key deletion-vector
compaction supports `deduplicate` with `none`, `input` or `lookup`.
Outside Data Evolution mode, row tracking and dedicated
blob/vector storage are unsupported. Query authorization, rowkind fields and
postpone buckets are unsupported in all modes.
Ordered bucketed append, append incremental clustering, primary-key clustering
override (`pk-clustering-override`) and record-level expiration
(`record-level.expire-time`) are also unsupported.
Snapshot index manifests may contain dynamic-bucket HASH indexes and primary-key
deletion vectors and source-backed PK indexes, or deletion vectors and global
indexes in Data Evolution mode.
Embedded and sidecar file indexes are regenerated according to the captured
table options. The automatic behavior also applies to language bindings using the
table committer. The manual compaction API and background compaction scheduling
are not yet exposed through language bindings.