Add pcodec (PCO) compression codec by alexey-milovidov · Pull Request #106222 · ClickHouse/ClickHouse · GitHub
Skip to content

Add pcodec (PCO) compression codec#106222

Open
alexey-milovidov wants to merge 41 commits into
masterfrom
add-pcodec-codec
Open

Add pcodec (PCO) compression codec#106222
alexey-milovidov wants to merge 41 commits into
masterfrom
add-pcodec-codec

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Jun 1, 2026

Copy link
Copy Markdown
Member

Adds pcodec (pco) as a new ClickHouse compression codec PCO. pcodec is a lossless codec specialized for fixed-width numeric sequences (it typically beats Gorilla/FPC/ZSTD on ratio for numeric columns at comparable speed).

Rather than linking the Rust crate, this is a native C++ reimplementation, which fits ClickHouse's all-C++ codec tradition (Gorilla/FPC/ALP/T64) and allows future ClickHouse-style runtime CPU dispatch of the hot loops.

The codec is bit-for-bit wire-format compatible with the reference pcodec v1.0.2 (format major 4 / minor 1, standalone version 3). This gives .pco interop and a free cross-validation oracle: streams produced by this codec are decoded byte-identically by the reference Rust implementation, and vice versa. This was used in both directions during development.

Supported types are all fixed-width numerics via their underlying integer/float width: Int8..Int64, UInt8..UInt64, Float32/Float64, and the types backed by them (Date, DateTime, Decimal32/Decimal64, IPv4, Enum, ...). The full decode path is implemented (Classic/IntMult/FloatMult/FloatQuant/Dict modes; None/Consecutive/Lookback/Conv1 delta; interleaved 4-way tANS). The encode path auto-selects Classic/IntMult/FloatQuant modes and a consecutive delta order via cheap sampling, only switching away from Classic when it reduces the estimated size. The encoder additionally guarantees no expansion: a chunk whose chosen configuration would exceed the raw data size (plus small framing) is re-encoded with a trivial single-bin configuration, so getMaxCompressedDataSize (the per-block buffer CompressedWriteBuffer reserves) is tight.

The codec is gated behind allow_experimental_codecs for the first release. Because it needs the column type, it can only be specified per column: the untyped MergeTree compression settings (marks_compression_codec, primary_key_compression_codec, default_compression_codec) reject it (and experimental codecs in general) at validation time.

A stateless test (04306_pco_codec) round-trips every supported numeric type (per-element verification, edge cases, and codec chaining such as CODEC(Delta, PCO)). A gtest (gtest_pcodec_fixtures.cpp) additionally decodes reference-generated .pco fixtures for the decoder-only modes (FloatMult, Dict, Lookback, Conv1), which the encoder round-trip cannot reach.

Changelog category (leave one):

  • Experimental Feature

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Added a new experimental compression codec PCO (a native port of pcodec), specialized for fixed-width numeric columns. It is wire-format compatible with pcodec .pco streams and is enabled with allow_experimental_codecs.

Documentation entry for user-facing changes:

  • Documentation is written (the PCO codec is documented in docs/en/sql-reference/statements/create/table.md).

alexey-milovidov and others added 2 commits June 1, 2026 07:59
pcodec (`pco`) is a lossless codec specialized for fixed-width numeric
sequences. This adds a native C++ reimplementation as a new ClickHouse
compression codec `PCO`, registered under method byte `0x9d`.

The implementation is bit-for-bit wire-format compatible with the
reference pcodec v1.0.2 (format major 4 / minor 1, standalone version 3):
ClickHouse can decode `.pco` streams produced elsewhere, and streams
produced by this codec are read back byte-identically by the reference
Rust decoder. This compatibility was used as a cross-validation oracle in
both directions during development.

The codec supports all fixed-width numeric ClickHouse types via their
underlying integer/float width (1/2/4/8 bytes): `Int8`..`Int64`,
`UInt8`..`UInt64`, `Float32`/`Float64`, and the types backed by them
(`Date`, `DateTime`, `Decimal32/64`, `IPv4`, `Enum`, etc.). It implements
the full decode path (Classic / IntMult / FloatMult / FloatQuant / Dict
modes; None / Consecutive / Lookback delta encodings; interleaved 4-way
tANS) and an auto-selecting encode path (Classic / IntMult / FloatQuant
modes and consecutive delta, chosen by cheap sampling so the ratio never
regresses below the Classic guarantee).

The codec is gated behind `allow_experimental_codecs` for now.

The library lives in a self-contained `src/Compression/Pcodec/`
sub-directory; the codec entry point is `CompressionCodecPco`. The
on-disk block stores a 2-byte header (element width + partial-tail byte
count) followed by a raw partial-value tail (as in `Gorilla`/`FPC`) and a
complete standalone `.pco` payload, which the encoder writes directly
into ClickHouse's destination buffer.

A stateless test (`04303_pco_codec`) round-trips every supported numeric
type (including per-element verification, edge cases, and codec chaining
such as `CODEC(Delta, PCO)`), and the documentation section for `PCO` is
added under `docs/en/sql-reference/statements/create/table.md`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	src/Compression/CompressionFactory.cpp
@clickhouse-gh

clickhouse-gh Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

@clickhouse-gh clickhouse-gh Bot added the pr-experimental Experimental Feature label Jun 1, 2026
Comment thread src/Compression/Pcodec/LatentDecoder.h
Comment thread src/Compression/Pcodec/BitReader.h
The `src/Compression` style lint forbids raw `std::vector` in favor of a
memory-tracked alternative. Route the codec's internal buffers through a
`PcoArray<T>` facade over `DB::PODArray` (so allocations use ClickHouse's
memory-tracking allocator). The facade restores the `std::vector` semantics
the codec relies on that plain `PODArray` lacks (copyable; value-initializing
`resize` and sized constructor), so it is a drop-in.

The standalone cross-validation harnesses compile these headers without the
ClickHouse runtime, so under `PCODEC_STANDALONE` the container falls back to
`std::vector`; because the facade mirrors `std::vector` semantics exactly, the
production (`PODArray`) and oracle (`std::vector`) builds behave identically,
keeping the reference-`.pco` cross-check faithful to the production path.

The two `std::unordered_map` usages in IntMult detection (cold path) keep
`std::vector` semantics via the sanctioned `STYLE_CHECK_ALLOW_STD_CONTAINERS`
marker.

Also add the `database = currentDatabase()` condition to the `system.parts`
queries in `04303_pco_codec.sql` (required by the test style check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/Compression/Pcodec/StandaloneDecoder.h
Comment thread src/Compression/Pcodec/StandaloneEncoder.h Outdated
Comment thread src/Compression/Pcodec/LatentDecoder.h
Comment thread src/Compression/Pcodec/LatentDecoder.h
`system.codecs` now lists the `PCO` codec, so the existing
`01222_system_codecs` test reference needs the new row (method byte 157,
experimental) and the updated codec count (14 -> 15).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/Compression/CompressionCodecPco.cpp Outdated
alexey-milovidov and others added 3 commits June 3, 2026 02:11
Addresses the review findings on the new PCO (pcodec) codec:

- `LatentDecoder.h`: fix `Conv1` delta inversion to output the reconstructed
  batch `residuals[order..order+len)` instead of the carried-in delta state
  `residuals[0..len)`, which produced shifted/stale values for reference `.pco`
  streams using `Conv1`.
- `StandaloneDecoder.h`: reject `Conv1` for 64-bit latent types (the signed
  accumulator widening is lossy and unsupported), bound the `Lookback` window
  against the chunk size before `initPage` allocates scratch, and reject
  `FloatQuant` `quant_k` larger than the float type precision so the join shifts
  can never reach undefined behavior on a malformed stream.
- `BitReader.h`: bounds-check `readUint`, `readBool`, and `readAlignedBytes`
  against `unpadded_bit_size` so truncated/malformed metadata fails closed
  instead of being decoded from the padding area. The hot batch loops are
  bounded once per batch via `checkInBounds` plus `DECODE_BATCH_OVERSHOOT` of
  readable slack in the source buffer.
- `Metadata.h`: bound the `Dict` length against the remaining stream size before
  resizing, avoiding a large allocation driven by a tiny malformed stream.
- `CompressionCodecPco.cpp`: decode straight into `dest` only when the output
  pointer is aligned for the element type (otherwise use aligned scratch), since
  `ICompressionCodec::decompress` does not guarantee alignment; enlarge the
  padded decode buffer to `DECODE_BATCH_OVERSHOOT`.
- `StandaloneEncoder.h`: split blocks larger than `MAX_ENTRIES` values into
  multiple chunks instead of truncating the 24-bit per-chunk count and writing
  an unreadable stream; update the size bounds accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… test

After merging master, two follow-ups:

- `src/Common/TargetSpecific.h` dropped the combined `MULTITARGET_FUNCTION_X86_V4_V3`
  multi-target macro (it now provides only the V4 variant). Define the V4+V3+default
  expansion the pcodec hot decode loops rely on locally in `LatentDecoder.h`, in terms
  of the per-arch attribute macros the header still exposes, so the codec keeps its
  AVX2 (`x86-64-v3`) and AVX-512 (`x86-64-v4`) specializations.
- Renumber the stateless test `04303_pco_codec` to `04306_pco_codec` (master added
  several `04303_*`/`04304_*` tests) and reduce its row counts so it no longer trips
  the `Test runs too long` limit on the MSan flaky check, while still spanning many
  decode batches across every supported numeric type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Compression/Pcodec/LatentDecoder.h Outdated
Comment thread src/Compression/Pcodec/LatentDecoder.h Outdated
Comment thread src/Compression/Pcodec/Metadata.h
Comment thread src/Compression/Pcodec/LatentDecoder.h Outdated
Comment thread src/Compression/CompressionCodecPco.cpp Outdated
Comment thread src/Compression/Pcodec/StandaloneDecoder.h
Comment thread src/Compression/Pcodec/Metadata.h
Comment thread tests/queries/0_stateless/04306_pco_codec.sql
Comment thread docs/en/sql-reference/statements/create/table.md Outdated
Comment thread src/Compression/CompressionCodecPco.cpp
Comment thread src/Compression/CompressionCodecPco.cpp
alexey-milovidov and others added 3 commits June 5, 2026 08:32
The commit adapting the codec to master's `TargetSpecific.h` referenced
`X86_64_V3_FUNCTION_SPECIFIC_ATTRIBUTE`, but that header only exposes a
per-function attribute macro for the V4 target — there is no V3 one. On
x86-64 (`ENABLE_MULTITARGET_CODE`), the `_x86_64_v3` specialization of the
hot decode loops therefore failed to declare, while `dispatchReadOffsets`
still called it, breaking the build with `use of undeclared identifier
'pcoReadOffsetsImpl_x86_64_v3'` (Fast test).

Define `X86_64_V3_FUNCTION_SPECIFIC_ATTRIBUTE` locally in the codec header,
matching the `arch=x86-64-v3` target that the header's
`BEGIN_X86_64_V3_SPECIFIC_CODE` block uses, guarded by `#ifndef` so it
composes if the shared header gains the macro later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several correctness and fail-close hardening fixes raised in review:

- `updateHash` now folds in `data_bytes_size` and the pcodec number-type
  byte. `PCO` is type-dependent, but the hash previously covered only the
  `PCO(level)` AST. Compact parts group substreams by codec hash, so a
  `UInt32`, `Int64` and `Float64` column all using `CODEC(PCO)` could share
  one codec object and encode later streams with the wrong width/type.
  (`Int64` and `Float64` share a width but differ in type byte, so both are
  needed — mirrors `Gorilla`'s `data_bytes_size` hashing.)

- `getMaxCompressedDataSize` is computed in 64-bit and fails closed when the
  bound exceeds `UInt32`. The old `UInt32` expression could wrap for wide
  1-byte blocks and under-reserve the destination buffer.

- `decodeStandalone` validates that each chunk's number-type width matches
  the codec element width. The decompress path picks the aligned/scratch
  output from the outer width, so a malformed block with a wider inner type
  (outer width 4 wrapping an `F64`/`U64` chunk) could write 8-byte stores
  through a 4-byte-aligned pointer.

- Reject newer wrapped format minors (`4.x` with `x > 1`); we only decode
  through `4.1`, and a newer minor may change metadata semantics.

- Reject corrupt `Lookback` distance `0` (would read the slot being written)
  and `Classic`/other bins whose `lower + max-offset` overflows the latent
  type (would wrap in the offset add instead of reporting corrupt data).

- Docs: `PCO` supports `Decimal32`/`Decimal64` only (not `Decimal128`/
  `Decimal256`), and it is the embedded standalone payload — not the whole
  compressed block — that is wire-compatible with reference `.pco`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Addressed the AI Review Request changes verdict (and the corresponding clickhouse-gh inline thread on CompressionCodecPco.cpp), whose required action was to make PCO decompression fail closed on invalid block headers and add focused coverage.

325b5041778doDecompressData now validates the stored header instead of silently recomputing/ignoring it:

  • W must be one of 1/2/4/8;
  • the stored leading-byte count B must equal uncompressed_size mod W (which also enforces B < W);
  • the early return for the partial-only case (uncompressed_size < W) is removed, so the trailing standalone .pco stream is always decoded and required to produce exactly the expected bytes — a body such as [W=8, B=0, four raw bytes, no standalone stream] is now rejected.

Valid streams are unaffected: the encoder always writes a supported W, B = uncompressed_size mod W, and a standalone stream (header-only for the n == 0 case, which decodeStandalone decodes to 0 bytes). Added gtest_pcodec_malformed_block covering an unsupported W, a mismatched B, and a partial-only body with its standalone stream stripped off. Verified CompressionCodecPco.cpp and the new test compile clean (-fsyntax-only -Werror); the empty-stream / stripped-stream behavior was confirmed against the standalone encoder/decoder sources.

Also merged latest master (was 258 behind) — textually clean, the only PR-touched file master also changed is MergeTreeSettings.cpp, with no interaction; the net PCO diff is preserved. No test renumbering needed (test_numbers_check is gap-based and 04306/04337 are dense within master's range; the PCO filenames are unique).

All CI reds on the previous commit are unrelated flakes:

  • Stateless Segmentation fault / Server died / 03714_queries_escaping_3 — a master-wide flaky read-path crash in MergeTreeRangeReader::startReadingChaingetTotalBytesInColumns, reproducing on master itself (pull_request_number = 0) and on 6 different PRs on 2026-06-29 alone (108786, 107567, 108673, 104816, 108565, 106222); no compression code in the stack. Tracked as "Server died" Server died #107487.
  • Stress (amd_tsan, arm_tsan) Hung check failed — the chronic master-wide hung-check flake Hung check failed, possible deadlock found #107941.
  • Stress (arm_debug) Bad cast … ColumnNullable to ColumnVector<unsigned long> in AggregateFunctionSum — a recurring AST-fuzzer flake seen across 7 separate days on different PRs (06-05/17/18/20/22/24/29), unrelated to compression.

The higher-level design questions in @rienath's review (whether the niche ratio win justifies a new codec, and the C++-port maintenance burden vs. linking the Rust crate) are left for the author's decision — they are not blocking and not actionable here.

Comment thread src/Compression/CompressionFactoryAdditions.cpp

@rienath rienath left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Friendly reminder: #106222 (review)

A codec-only `ALTER TABLE ... MODIFY COLUMN x CODEC(PCO)` (where the column
type is not restated) leaves `command.data_type` null. `AlterCommands::validate`
passed that null type straight into `validateCodecAndGetPreprocessedAST`, which
then took the untyped-codec path and rejected `PCO` with "requires the column
type to compress and can only be specified for a column" -- even though
`AlterCommand::apply` later falls back to the existing `column.type`.

As a result, a numeric column created with `CODEC(PCO)` could not get `PCO`
added later via the normal codec-only `ALTER`, even with
`allow_experimental_codecs = 1`.

Mirror the apply-time fallback in the validate path: use the existing column
type when `command.data_type` is absent. The experimental gate and the
fixed-width-numeric requirement are still enforced (both surface as
`BAD_ARGUMENTS`).

Add `04489_pco_codec_alter_modify_column` covering the codec-only ALTER, the
data round-trip after re-encoding, the codec chain, the experimental gate, and
the unsupported-type rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Pushed e73c49f0970 addressing the AI Review Request changes verdict (and the corresponding clickhouse-gh inline thread on CompressionFactoryAdditions.cpp): a codec-only ALTER TABLE ... MODIFY COLUMN x CODEC(PCO) was rejected as an untyped codec.

AlterCommands::validate passed command.data_type (null when the type is not restated) straight into validateCodecAndGetPreprocessedAST, so the validation took the untyped-codec branch and threw "Codec PCO requires the column type to compress and can only be specified for a column" — even though AlterCommand::apply falls back to the existing column.type. The fix mirrors that apply-time fallback in the validate path (command.data_type ? command.data_type : all_columns.get(column_name).type). The experimental gate and the fixed-width-numeric requirement are still enforced (both BAD_ARGUMENTS).

New test 04489_pco_codec_alter_modify_column covers the codec-only ALTER, the data round-trip after OPTIMIZE ... FINAL re-encodes parts, the CODEC(Delta, PCO) chain, the experimental gate, and the unsupported-type rejection. Built clean (ARM) and verified 04489, 04306_pco_codec, 04337_pco_codec_untyped_settings, 01061_alter_codec_with_type, and 00804_test_alter_compression_codecs all pass via clickhouse-test.

The sole remaining CI red — Stress test (arm_tsan), Hung check failed, possible deadlock found — is the chronic master-wide hung-check flake #107941 (no compression or ALTER code in any stack frame); the CI summary itself links the same issue.

No master merge: behind 195 but the merge-base is from 2026-06-29 23:39Z (~17h ago) and the only red is the unrelated flake above.

Comment thread src/Compression/CompressionCodecPco.cpp
`estimateCompressionRatio('<codec>')(column)` builds the requested codec
through the typed `CompressionCodecFactory::get` path, which does not
enforce the `allow_experimental_codecs` gate. This let an experimental
codec such as `PCO` be exercised to compress real data with the setting
off, bypassing the gate applied to column codecs and the untyped
MergeTree compression settings.

Resolve `allow_experimental_codecs` from the query settings at aggregate
creation time and enforce it in `getCodecOrDefault`, right where the
codec is constructed (and immediately before it is used to compress).
`CompressionCodecMultiple::isExperimental` already surfaces an inner
experimental codec, so codec chains wrapping `PCO` (e.g. `PCO, ZSTD` or
`Delta, PCO`) are rejected too. The check fails closed when settings are
unavailable.

The gate is enforced at construction rather than at parse time on
purpose: an empty input never constructs the codec, so the existing
behavior of `estimateCompressionRatio` on an empty table (it returns 0
without resolving the codec) is preserved.

New test `04490_estimate_compression_ratio_pco_experimental_gate`:
`allow_experimental_codecs = 0` rejects `PCO` (and chains wrapping it)
with `BAD_ARGUMENTS`, while non-experimental codecs and the default
codec keep working; `allow_experimental_codecs = 1` allows `PCO` on
supported numeric types.

Addresses the AI Review "Request changes" verdict on
#106222

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Pushed 8a758cf3df8 addressing the AI Review Request changes verdict (and the corresponding clickhouse-gh inline thread): estimateCompressionRatio('<codec>')(column) could instantiate an experimental codec such as PCO through the typed CompressionCodecFactory::get path without checking allow_experimental_codecs, letting it be exercised to compress real data with the gate off.

Fix: resolve allow_experimental_codecs from the query settings at aggregate creation time and enforce it in getCodecOrDefault, right where the codec is constructed (and immediately before it is used to compress). CompressionCodecMultiple::isExperimental already surfaces an inner experimental codec, so chains wrapping PCO (PCO, ZSTD, Delta, PCO) are rejected too; the check fails closed if the settings are unavailable. The gate is enforced at construction, not at parse time, so the existing behavior of estimateCompressionRatio on an empty input — it returns 0 without resolving the codec — is preserved (03363_estimate_compression_ratio_validation depends on that).

New test 04490_estimate_compression_ratio_pco_experimental_gate: allow_experimental_codecs = 0 rejects PCO and chains wrapping it with BAD_ARGUMENTS while non-experimental codecs and the default codec keep working; allow_experimental_codecs = 1 allows PCO on supported numeric types. Built clean (ARM, no warnings) and verified 04490, 04306_pco_codec, 04337_pco_codec_untyped_settings, 04489_pco_codec_alter_modify_column, 03363_estimate_compression_ratio_validation, 03363_estimate_compression_bug91415, 03364_estimate_compression_ratio, 04053, 04267, 01061_alter_codec_with_type, and 00804_test_alter_compression_codecs all pass via clickhouse-test.

The two remaining CI reds — Stress test (amd_tsan) and Stress test (arm_tsan), both Hung check failed, possible deadlock found — are the chronic master-wide hung-check flake #107941 (the CI summary links the same issue); the stack frames are generic AST-fuzzer query analysis with no compression code, and this PR touches nothing under that path.

No master merge: the PR is MERGEABLE with no conflicts, the merge-base is from 2026-06-29 (~21h ago, well under a week), and the only reds are the unrelated flake above (which fails on master too).

Comment thread src/Compression/CompressionCodecPco.cpp
Comment thread src/Compression/CompressionCodecPco.cpp
alexey-milovidov and others added 3 commits July 1, 2026 05:13
…oder

The `IntMult` join in the PCO standalone decoder reconstructs a value as
`primary * base + secondary`. For a sub-32-bit latent (`U16`/`I16`, whose
latent type `L` is `uint16_t`) both operands are promoted to signed `int` by
the usual arithmetic conversions, so a malformed stream with `primary` and
`base` both large (e.g. `65535 * 65535`) overflows `int` before the cast back
to `L` — undefined behavior that a sanitizer build traps on, rather than the
defined modular reconstruction the format specifies.

A well-formed `IntMult` stream always satisfies `primary * base + secondary`
equal to the in-range value, so the product never exceeds the latent width;
only a malformed stream (whose metadata lies) reaches the overflow. This is the
same integer-promotion hazard already handled in `decodeConv1`, which computes
its products in a wider unsigned accumulator.

Do the join in an explicitly unsigned accumulator at least 32 bits wide, then
cast back to `L`. Valid streams produce identical results (their product fits);
only malformed input differs, now wrapping modularly instead of invoking UB.

Adds a regression that decodes a hand-built malformed `IntMult` stream with
`primary = base = 65535`; under a sanitizer build the previous code trapped on
the signed overflow.

Addresses the AI Review Block verdict on commit 8a758cf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`decodeStandalone` stops when it reads the `MAGIC_TERMINATION_BYTE` and returns
only the number of output bytes. The `CompressionCodecPco` block wrapper then
only checks that the produced byte count equals the expected size, so a block
body such as `[W][B][raw][valid .pco][garbage]` was accepted as a valid `0x9d`
frame: the trailing bytes after the terminator were silently ignored. For
externally supplied compressed frames that weakens the fail-closed contract and
leaves the block body non-canonical, even though the spec says the rest of the
block is the standalone `.pco` stream.

`readAlignedBytes` leaves the reader byte-aligned, so `byteIdx()` is the exact
number of consumed bytes; a canonical stream (the reference encoder writes the
terminator last) consumes exactly `src_len`. Reject any stream that consumes
fewer bytes than that, so trailing bytes fail closed.

Adds a malformed-block regression that appends bytes after a valid `PCO` body
and expects decompression to fail.

Addresses the AI Review Block verdict on commit 8a758cf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The new `PCO` default-codec restrictions missed the server-wide `<compression>`
selector. With no table-level `default_compression_codec` or `RECOMPRESS` TTL
codec, `MergeTreeData::getCompressionCodecForPart` falls back to
`Context::chooseCompressionCodec`, and `CompressionCodecSelector::choose`
constructs the configured method with `factory.get(family_name, level)` — a path
that receives no column type and performs no `allow_experimental_codecs` check
and no `requiresColumnTypeToCompress` rejection. The part writer later
re-resolves the stored `CODEC(PCO)` description with each column type, so numeric
columns could be written with the experimental `PCO` codec from server config,
even though the SQL/untyped codec settings (`default_compression_codec`,
`marks_compression_codec`, `primary_key_compression_codec`) reject the same
codec at validation time.

Validate each `<compression>` case at config load: reject a codec that requires
the column type to compress, or is experimental, mirroring the rejection the
untyped codec settings already apply. This never rejects an ordinary codec
(`lz4`, `zstd`, ...); it fails closed at load rather than at the first part
write.

Adds a gtest covering `<method>pco</method>` (type-dependent), `<method>alp</method>`
(experimental), and ordinary codecs.

Addresses the AI Review Block verdict on commit 8a758cf.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Addressed the AI Review Block verdict on 8a758cf3 (its three minimum required actions), pushed fb4b8d5817c, 41c1e605c1f, c9272448255.

  • fb4b8d5817cIntMult sub-word reconstruction UB. The join primary * base + secondary was evaluated in signed int for sub-32-bit latents (U16/I16, L = uint16_t), so a malformed stream with both factors large (65535 * 65535) overflowed int before the cast back to L. A well-formed IntMult stream can never reach this (primary * base + secondary equals the in-range value), so only malformed input hits it — the same integer-promotion hazard already handled in decodeConv1. The join now runs in an unsigned accumulator at least 32 bits wide, then casts back to L: valid streams are byte-identical, malformed input wraps modularly instead of invoking UB. This also makes the "well-defined unsigned wrapping" premise from the still-open StandaloneDecoder.h range-validation thread actually hold; the semantic per-element range validation there remains deferred as noted (that thread is about rejecting fabricated-but-defined values, which is a separate decision).

  • 41c1e605c1f — trailing bytes after the standalone stream. decodeStandalone now verifies the reader consumed exactly src_len after the termination byte, so [W][B][raw][valid .pco][garbage] fails closed instead of being silently accepted by the wrapper's byte-count check alone.

  • c9272448255 — server-wide <compression> selector gate. CompressionCodecSelector now rejects a configured codec that requiresColumnTypeToCompress or isExperimental at config load, mirroring the untyped default_compression_codec / marks_compression_codec / primary_key_compression_codec validation, so <compression><case><method>pco</method> (and alp) can no longer bypass the gate and be re-resolved per-column by the part writer. Ordinary codecs (lz4, zstd) are unaffected.

Focused regressions added: IntMultSubWordReconstructionNoOverflow (decodes a hand-built malformed IntMult stream — a sanitizer build trapped on the previous code), RejectsTrailingBytesAfterStandaloneStream, and gtest_compression_codec_selector.cpp (type-dependent, experimental, and ordinary cases). Verified out of the ClickHouse build with a standalone PCODEC_STANDALONE harness under -fsanitize=undefined,address: the pre-fix IntMult arithmetic traps (signed integer overflow: 65535 * 65535 cannot be represented in type 'int'), the fixed decoder produces the defined wrap (1) and round-trips valid data, and trailing-byte tampering is rejected.

No master merge: MERGEABLE, merge-base is from 2026-06-29 (~1.3 days, well under a week), and the PR-touched files are new to this PR.

Comment thread src/Storages/MergeTree/MergeTreeSettings.cpp
…on ATTACH

The untyped MergeTree compression settings (`default_compression_codec`,
`marks_compression_codec`, `primary_key_compression_codec`) are validated
against the experimental / column-type-requiring codec gate only inside
`MergeTreeSettingsImpl::sanityCheck`, which `MergeTreeData` runs for
`LoadingStrictnessLevel::CREATE` only. A user could therefore run

    ATTACH TABLE t ... ENGINE = MergeTree ... SETTINGS default_compression_codec = 'PCO'

with `allow_experimental_codecs = 0`: the setting was loaded, `sanityCheck`
was skipped, and `getCompressionCodecForPart` later resolved the string, so
writes used the typed experimental `PCO` codec with the gate off.

Extract the codec-settings check into `checkCompressionCodecSettings` and run
it for `mode <= LoadingStrictnessLevel::ATTACH` (user `ATTACH`, and
`SECONDARY_CREATE` for RESTORE / DatabaseReplicated internal queries), while
`FORCE_ATTACH` (server startup) and `FORCE_RESTORE` keep skipping it so
already-loaded tables are never rejected. Add ATTACH regression cases to
`04337_pco_codec_untyped_settings`.

Addresses the AI Review "Request changes" blocker on
#106222

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Addressed the AI Review Request changes verdict on c9272448 — pushed a7d83200a42.

Blocker (MergeTreeSettings.cppATTACH gate bypass): fixed.
The untyped compression settings (default_compression_codec, marks_compression_codec, primary_key_compression_codec) were validated only inside MergeTreeSettingsImpl::sanityCheck, which MergeTreeData runs for LoadingStrictnessLevel::CREATE only — so ATTACH TABLE ... ENGINE = MergeTree ... SETTINGS default_compression_codec = 'PCO' slipped the gate with allow_experimental_codecs = 0. I extracted the check into checkCompressionCodecSettings and now run it for mode <= LoadingStrictnessLevel::ATTACH, i.e. user ATTACH and SECONDARY_CREATE (RESTORE / DatabaseReplicated internal queries); FORCE_ATTACH (server startup) and FORCE_RESTORE keep skipping it so already-loaded tables are never rejected. (Inline thread resolved.)

Test: added. 04337_pco_codec_untyped_settings now covers the full-form ATTACH ... UUID ... SETTINGS default_compression_codec = 'PCO' with allow_experimental_codecs = 0, a ZSTD(1), PCO chain, marks_compression_codec = 'PCO', plus a positive short-form re-attach with an ordinary codec (confirming the common re-attach path still works). It needs no-ordinary-database/no-replicated-database/no-shared-merge-tree because the full-form ATTACH requires an explicit table UUID (cf. 04046, 04159).

Major (StandaloneDecoder.h:250FloatQuant/IntMult semantic range validation): left for your decision.
This is the still-open thread you marked "leaving open for a decision". The bot's latest reply (2026-07-01) adds a new reachability argument that predates your last note there: it claims a client can send a checksummed 0x9d frame via the decompress=1 / CompressedReadBuffer path so the PCO decoder is reached without allow_experimental_codecs, making this a current fail-closed boundary rather than only a future raw-.pco-import concern. I did not implement the per-element guards: after the earlier IntMult accumulator fix the arithmetic is well-defined (no UB / OOB — malformed input yields wrong values, not memory unsafety), the guards add per-element branches to the hot decode loop, and the reference decoder doesn't validate these invariants either — so the correctness-vs-throughput tradeoff on this experimental codec is yours to make. Flagging the new argument so the call is informed; happy to add the conservative checked-arithmetic guards if you want them.

… collides in the flaky check)

The full-form `ATTACH TABLE ... UUID '<literal>' ... SETTINGS default_compression_codec = 'PCO'`
cases use a fixed table UUID (required for the full-form `ATTACH` syntax under the Atomic database
engine; cf. 04046, 04159). A table UUID is server-global, so when the flaky check runs the same
test in several parallel workers, two concurrent `ATTACH`es with the same UUID race: one registers
the UUID mapping and the other fails with `TABLE_ALREADY_EXISTS` before reaching the codec-setting
gate, instead of the expected `BAD_ARGUMENTS`.

The single-query failing path already releases the reserved UUID (via `TemporaryLockForUUIDDirectory`),
so a sequential run is unaffected — only concurrent runs of this test collide. Mark the test
`no-parallel`, matching the sibling test `04159_unique_key_ttl_rejected`, which uses a fixed `ATTACH`
UUID for the same reason.

Flaky-check failures (amd_tsan/amd_debug/amd_asan_ubsan):
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=106222&sha=a7d83200a42f3edd541b595943980370165fb9bf&name_0=PR&name_1=Stateless%20tests%20%28amd_tsan%2C%20flaky%20check%29

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Pushed c452ea51c3e: marked 04337_pco_codec_untyped_settings as no-parallel.

The only real, PR-caused CI failure was that test failing the flaky check (amd_tsan 6×, amd_debug 2×, amd_asan_ubsan 1×). Its full-form ATTACH TABLE ... UUID '<literal>' cases use a fixed, server-global table UUID (required for the full-form ATTACH syntax under the Atomic engine), so when the flaky check runs the same test in parallel workers, two concurrent ATTACHes race on the same UUID and one fails with TABLE_ALREADY_EXISTS before reaching the codec-setting gate, instead of the expected BAD_ARGUMENTS. The single-query failing path already releases the reserved UUID via TemporaryLockForUUIDDirectory, so a sequential run is fine — only concurrency collides. no-parallel matches the sibling test 04159_unique_key_ttl_rejected, which uses a fixed ATTACH UUID for the same reason.

The remaining red — AST fuzzer (amd_debug) Logical error: Inconsistent AST formatting … (STID: 1941-26fa) on CREATE TABLE … (rk Array(Tuple(Array(Float64), Float64))) ENGINE = Memory — is unrelated to this PR (no codec content) and is a known, tracked flake: #108372. The same STID fires across many unrelated PRs and on master (Stress test / AST fuzzer / BuzzHouse).

Comment thread src/Interpreters/TemporaryDataOnDisk.cpp
`getCodec` for `temporary_files_codec` (used for external sort/aggregation
spill files) only rejected codecs that `requiresColumnTypeToCompress` (e.g.
the experimental `PCO`). An experimental codec that does not require a column
type, such as `ALP`, still passed through and was used for temporary files
even with `allow_experimental_codecs = 0`.

`temporary_files_codec` is an untyped compression setting, so it bypasses the
per-column `allow_experimental_codecs` validation. Reject experimental codecs
here as well, matching the gate already enforced for the `<compression>`
config selector and the untyped MergeTree compression settings
(`marks_compression_codec`, ...); experimental codecs can only be specified
per column.

Extends `04337_pco_codec_untyped_settings` with an `ALP` case (single codec
and inside a chain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Pushed 5114dc28353 addressing the second AI Review Request changes finding (temporary_files_codec, TemporaryDataOnDisk.cpp).

getCodec for temporary_files_codec only rejected codecs that requiresColumnTypeToCompress (e.g. the experimental PCO). An experimental codec that does not require a column type — ALP — still passed through and could be used for external sort/aggregation spill files even with allow_experimental_codecs = 0. Now the experimental-codec gate is enforced here too, matching the <compression> config selector and the untyped MergeTree compression settings (marks_compression_codec, ...); experimental codecs can only be specified per column. 04337_pco_codec_untyped_settings gets an ALP case (single codec and inside a chain).

The other finding — FloatQuant / IntMult decomposed-latent range validation in StandaloneDecoder.h — is being discussed in its inline thread.

The standalone `.pco` decoder reconstructed `IntMult` and `FloatQuant` values
without validating the mode-specific decomposition invariants. Because
`CompressedReadBufferBase` dispatches external compressed frames by method byte
alone, a checksummed `0x9d` (`PCO`) frame reaches this decoder without
`allow_experimental_codecs`, so a malformed stream could silently decode to
fabricated values instead of raising a decompression exception.

Validate the decomposition in the join, matching the encoder in
`Modes.h::splitForMode`, and throw on violation:
  - `IntMult`: `base != 0`, `secondary < base`, and `primary * base + secondary`
    fits the latent width (checked with the overflow builtins so the check
    itself stays defined for sub-word latents).
  - `FloatQuant`: `primary` occupies at most `latentBits - k` bits and the
    remainder `m` fits in `k` bits.

These checks never reject a valid stream: they are the exact inverse of the
encoder's split. Extend `gtest_pcodec_encode_bounds` and
`gtest_pcodec_malformed_block` (the latter reaching the guard through
`codec->decompress`, i.e. the shared compressed-frame reader) with malformed
`IntMult`/`FloatQuant` regressions, plus well-formed baselines that still
round-trip.

Addresses the AI Review finding on `StandaloneDecoder.h`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Pushed a86a41f89c2 addressing the remaining AI Review Request changes finding — the FloatQuant / IntMult decomposed-latent range validation in StandaloneDecoder.h.

The bot's follow-up was correct that this is a current fail-closed boundary, not only a future raw-.pco concern: CompressedReadBufferBase::readHeaderAndGetCodecAndSize dispatches an external frame by its method byte alone (CompressionCodecFactory::instance().get(method)), so a checksummed 0x9d frame reaches this decoder without allow_experimental_codecs. A malformed IntMult / FloatQuant chunk would then decode to fabricated values by wrapping modularly instead of raising a decompression exception.

The join now validates the decomposition against the encoder's split (Modes.h::splitForMode) and throws on violation:

  • IntMult: base != 0, secondary < base, and primary * base + secondary fits the latent width.
  • FloatQuant: primary fits in latentBits - k bits and the remainder m <= (1 << k) - 1.

These are the exact inverse of the encoder's split, so they never reject a valid stream. New regressions: gtest_pcodec_malformed_block runs a malformed IntMult block through codec->decompress (the shared compressed-frame reader), and gtest_pcodec_encode_bounds covers malformed IntMult/FloatQuant across all latent widths, each with a well-formed baseline that still round-trips. Verified locally: all three touched TUs compile clean under -Weverything -Werror, and a standalone run of the decoder guards passes all 13 valid-decodes-vs-malformed-throws checks.

The higher-level review points from @rienath (whether to add the codec at all, and the maintainability of the hand-ported C++ rewrite vs. linking the Rust crate) remain open for your decision.

The `Build (arm_tidy)` check failed compiling the two gtest files added
in the previous commit (`gtest_pcodec_malformed_block.cpp` and
`gtest_pcodec_encode_bounds.cpp`): `clang-tidy`'s
`bugprone-argument-comment`, run with `-warnings-as-errors`, rejected
inline argument comments whose names did not match the callee's
parameter names — `/*base=*/`/`/*primary=*/` on `buildIntMultStandaloneU32`
(params `base_latent`/`primary_latent`) and `/*primary=*/`/`/*m=*/` on
`buildFloatQuantStream` (params `primary_latent`/`secondary_latent`).

`bugprone-argument-comment` is a `clang-tidy`-only check, not a compiler
warning, so it is not caught by a plain `-Weverything -Werror` compile;
it only runs in the tidy build.

Rename the argument comments to match the parameter names exactly. This
is a comment-only change with no behavioral effect.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=106222&sha=a86a41f89c22f922a954f6049531af2a4ee3473d&name_0=PR&name_1=Build%20%28arm_tidy%29

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Pushed 399aef3e027 to fix the only real, PR-caused CI failure: Build (arm_tidy).

The two gtest files added in a86a41f89c2 failed clang-tidy's bugprone-argument-comment (run with -warnings-as-errors) — 18 inline argument comments whose names did not match the callee's parameter names:

  • buildIntMultStandaloneU32(/*base=*/…, /*primary=*/…) — params are base_latent / primary_latent
  • buildFloatQuantStream(/*k=*/…, /*primary=*/…, /*m=*/…) — params are primary_latent / secondary_latent

This is why the local -Weverything -Werror compile passed but CI failed: bugprone-argument-comment is a clang-tidy-only check, not a compiler warning, so it only runs in the tidy build. The fix renames the argument comments to match the parameter names exactly — comment-only, no behavioral change.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=106222&sha=a86a41f89c22f922a954f6049531af2a4ee3473d&name_0=PR&name_1=Build%20%28arm_tidy%29


return std::make_shared<CompressionCodecPco>(data_bytes_size, pco_type_byte, level);
};
factory.registerCompressionCodecWithType("PCO", method_code, codec_builder);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registering PCO here still leaves one user-reachable path outside the experimental gate. CompressedReadBufferBase::readHeaderAndGetCodecAndSize accepts external framed input by method byte alone, and both HTTP decompress=1 and compressed TCP queries go through that path with external_data = true; there is no allow_experimental_codecs check there.

Because the PCO block wrapper is self-describing (W plus the inner standalone type byte) and W = 1 / U8 can represent arbitrary bytes, a client can already use 0x9d as request-body transport compression with allow_experimental_codecs = 0. That breaks the rollout contract behind isExperimental() and exposes the new decoder on production servers by default.

Please reject experimental codecs in the external-data CompressedReadBuffer path (or otherwise keep 0x9d out of externally accepted framed codecs), so the gate actually covers all user-reachable entry points.

@alexey-milovidov

Copy link
Copy Markdown
Member Author

The Build (arm_tidy) fix from 399aef3e is green — all 18 builds pass and there are no test failures on the current head; CI is re-running the remaining stages normally.

On the new AI Review blocker (CompressionCodecPco.cpp:358 — registering PCO makes method byte 0x9d decodable on the external-data path with allow_experimental_codecs = 0): leaving this design call to you, since it looks like something you've already decided rather than a fresh regression:

  • Same pattern as the existing experimental codec. ALP (0x9c, also isExperimental() == true) is registered globally in CompressionFactory.cpp next to PCO and is reachable through the identical external_data = true path (HTTPHandler decompress=1 and direct-client TCPHandler), also without an allow_experimental_codecs check. So PCO follows the established convention here rather than opening a new class of exposure; the bot's suggested external_data-side isExperimental() rejection would be a cross-cutting change to CompressedReadBufferBase that also alters ALP's behavior.
  • This is the documented contract. The NativeFormat spec update already states that the transport emits only NONE/LZ4/ZSTD but the shared CompressedReadBuffer accepts any registered method byte on input.
  • Mitigation already chosen. The 399aef3e-era hardening (fail-closed IntMult/FloatQuant decomposition validation via the inverse of Modes.h::splitForMode, plus the malformed-block gtests) is exactly the "decoder is robust on untrusted transport input" approach for this path.

Scope note: external_data = true is set only for transport input; stored MergeTree part reads use external_data = false, so a block there would be transport-only and would not affect reading PCO-compressed parts either way. No code change made — deferring the accept-vs-reject-on-transport decision (and @rienath's higher-level "should we add this codec / C++ vs Rust" question) to you.

@clickhouse-gh

clickhouse-gh Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 85.50% 85.50% +0.00%
Functions 92.60% 92.60% +0.00%
Branches 77.70% 77.70% +0.00%

Changed lines: Changed C/C++ lines covered: 2005/2181 (91.93%) · Uncovered code

Full report · Diff report

@alexey-milovidov

Copy link
Copy Markdown
Member Author

@alexey-milovidov alexey-milovidov added the hold Skip for auto-updating. label Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hold Skip for auto-updating. pr-experimental Experimental Feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants