Reject a lossy codec on columns backing keys and indexes
Project: ClickHouse/ClickHouse | Language: C++ | Concepts: data-structures, error-handling, testing-strategy | Difficulty: mid
Link: https://github.com/ClickHouse/ClickHouse/pull/114531
The problem
ClickHouse allows users to specify compression codecs for table columns, including "lossy" codecs like SZ3 that reduce precision. If a lossy codec is applied to a column used in a sorting key or index, it can corrupt the sort order or index integrity, leading to incorrect query results. Previously, there was no guard against this misconfiguration.
Why it's non-trivial
A naive fix might only check for lossy codecs directly on the column, missing cases where:
- Multiple codecs are stacked (e.g.,
CODEC(SZ3, LZ4)) - The codec is applied to a complex type (e.g., arrays or tuples), where lossiness might affect only some substreams
- The column is used in a key expression, not just directly as a sort key
- DDL operations like
ALTERorATTACHcould bypass the check if not handled everywhere
The approach
1. Add a method to detect lossy compression in stacked codecs
A new method is added to CompressionCodecMultiple to report if any of the stacked codecs are lossy:
+bool CompressionCodecMultiple::isLossyCompression() const
+{
+ for (const auto & codec : codecs)
+ if (codec->isLossyCompression())
+ return true;
+ return false;
+}
This loops through each codec in the stack and returns true if any are lossy, ensuring that combinations like CODEC(SZ3, LZ4) are correctly flagged.
2. Expose the new method in the codec interface
The header is updated to declare the new method:
+ bool isLossyCompression() const override;
This ensures the method is available for codec objects wherever they're used.
3. Validate key columns at table definition and alteration
The main check is inserted into MergeTreeData::checkProperties, which is called during table creation and alteration:
+ /// Check that the table sorting column is not compressed by a lossy codec (e.g. SZ3). Lossy codecs
+ /// lose precision and may destroy the sorting. Not on attach, so a table stored by an earlier version
+ /// stays loadable.
+ if (!attach)
+ {
+ /// Collecting the sort key columns.
+ for (const auto & name : new_metadata.getColumnsRequiredForSortingKey())
+ {
+ const auto column = new_metadata.columns.tryGetColumnDescription(
+ GetColumnsOptions(GetColumnsOptions::AllPhysical), name);
+ if (!column || !column->codec)
+ continue;
+
+ /// A codec applies to `Array(Float64)` through its float substream, not through the outer type,
+ /// so lossiness is resolved per substream the way the part writer resolves it.
+ bool is_lossy = false;
+ ISerialization::StreamCallback callback = [&](const auto & substream_path)
+ {
+ if (is_lossy || !ISerialization::isSpecialCompressionAllowed(substream_path))
+ return;
+ is_lossy = CompressionCodecFactory::instance()
+ .get(column->codec, substream_path.back().data.type.get())->isLossyCompression();
+ };
+ column->type->getDefaultSerialization()->enumerateStreams(callback, column->type);
+
+ if (is_lossy)
+ throw Exception(ErrorCodes::BAD_ARGUMENTS,
+ "Column {} is used in the sorting key, so it cannot be compressed with a lossy codec",
+ backQuoteIfNeed(name));
+ }
+ }
This block:
- Skips the check when attaching existing tables (for compatibility)
- Iterates over all columns required for the sorting key
- For each, checks all relevant substreams (e.g., for arrays/tuples) for lossy codecs
- Throws an error if any lossy codec is found, blocking the operation
4. Add regression tests to cover both accepted and rejected cases
New SQL and reference output files are added:
+SELECT 'CREATE, rejected';
+
+CREATE TABLE tab (i Float64 CODEC(SZ3('ALGO_INTERP_LORENZO', 'REL', 0.01)), f Int64)
+ENGINE = MergeTree ORDER BY i; -- { serverError BAD_ARGUMENTS }
...
+SELECT 'CREATE, accepted';
+
+CREATE TABLE tab (k Int64, i Float64 CODEC(SZ3('ALGO_INTERP_LORENZO', 'REL', 0.01)))
+ENGINE = MergeTree ORDER BY k;
+DROP TABLE tab;
...
+SELECT 'ALTER, rejected';
+
+CREATE TABLE tab (k Int64, i Float64) ENGINE = MergeTree ORDER BY (k, i);
+ALTER TABLE tab MODIFY COLUMN i Float64 CODEC(SZ3('ALGO_INTERP_LORENZO', 'REL', 0.01)); -- { serverError BAD_ARGUMENTS }
...
+SELECT 'ALTER, accepted';
+
+ALTER TABLE tab ADD COLUMN z Float64 CODEC(SZ3('ALGO_INTERP_LORENZO', 'REL', 0.01));
+SELECT count() FROM system.columns WHERE database = currentDatabase() AND table = 'tab';
+
+DROP TABLE tab;
These tests ensure that attempts to use lossy codecs on key columns are rejected, while allowed uses (e.g., on non-key columns) succeed.
From the review thread
AI Review:
- The simplified check is column-granular, not key-substream-granular. Once a key expression mentions a column, any lossy substream on that column causes rejection, even when the key reads only an exact structural substream. Safe keys such as
ORDER BY length(arr)onArray(Float64) CODEC(SZ3(...))become impossible to create, and unrelatedALTERs on such pre-existing tables will now fail too.
Suggested fix: drive the validation from the exact analyzed key dependencies (including subcolumns/substreams) instead of enumerating every codec-bearing substream on the whole storage column.
Internal second-model review:
The trigger list is the mechanism that closes a measuredADD COLUMNbypass through implicit minmax indexes, and a refactor could drop an entry with the test staying green. All four remaining entries are now pinned by a distinct case, each verified by deleting the entry, rebuilding and running the real test.
Takeaways
- Validate invariants at all entry points: When enforcing a new constraint (like disallowing lossy codecs on key columns), ensure it is checked during every relevant DDL operation (CREATE, ALTER, etc.) to prevent bypasses.
- Test all trigger paths independently: Each code path that should trigger the invariant must be covered by a dedicated test case, not just in combination with others.
- Handle complex types and codec stacking: When validating column properties, account for codecs stacked together and for codecs applied to substreams of complex types (like arrays or tuples), not just the top-level column type.
- Preserve backward compatibility: When tightening validation, allow legacy tables to load (e.g., skip checks on ATTACH/restore), but block new misconfigurations.