Skip to content

Support the Vortex file format - #112950

Open
alexey-milovidov wants to merge 9 commits into
masterfrom
vortex-format
Open

Support the Vortex file format#112950
alexey-milovidov wants to merge 9 commits into
masterfrom
vortex-format

Conversation

@alexey-milovidov

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

Copy link
Copy Markdown
Member

Closes: #87327
Related: ClickHouse/rust_vendor#74

Changelog category (leave one):

  • New Feature

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

Added support for reading and writing the Vortex columnar file format (the Vortex input and output format). This closes #87327.

Documentation entry for user-facing changes

The implementation uses the Rust vortex crate (v0.83.0) through a new C FFI crate rust/workspace/vortex (_ch_rust_vortex), following the same pattern as prql and polyglot. Data crosses the FFI boundary through the Arrow C Data Interface and is converted with the same ArrowColumnToCHColumn/CHColumnToArrowColumn code as the Arrow format. IO is delegated back to ClickHouse through callbacks: reads go through ClickHouse's own read buffers (range reads for seekable inputs, whole-file buffering otherwise), and the produced file is streamed into the output buffer. All work is driven by a single-threaded runtime on the calling thread — the library spawns no threads, and Rust panics are caught at the FFI boundary and turned into exceptions.

Features:

  • Reading with projection pushdown: only the columns used by the query are read from the file.
  • Schema inference and count()-only queries answered from file metadata without reading data.
  • Writing with the library's default adaptive compression (BtrBlocks-style cascading encodings + zstd), including a valid empty file for empty results.
  • Graceful errors on malformed and truncated files (fuzzer-friendly: no aborts, Rust panics become exceptions).

Limitations (documented in docs/reference/formats/Vortex.mdx):

  • Map, Int128/UInt128/Int256/UInt256, IPv6, and Interval columns cannot be written (no corresponding Vortex type).
  • String and FixedString are written as Vortex Binary (ClickHouse strings are arbitrary bytes, while Vortex requires Utf8 to be valid UTF-8).
  • The format is disabled in MSan builds: the MSan-instrumented library (with origin tracking) is so large that linking unit_tests_dbms overflows the 2 GiB R_X86_64_PC32 relocation range (same approach as wasmtime and delta-kernel-rs).
  • Reading and writing are single-threaded in this first version. On a 10M-row test table, reads with projection are on par with Parquet (~0.5 s), while writes are noticeably slower (the adaptive compressor samples many encodings per column) — parallelism can be added later.

The 127 new vendored Rust crates are added in ClickHouse/rust_vendor#74 (the contrib/rust_vendor submodule is bumped to that branch).

Add `Vortex` input and output formats for reading and writing Vortex
files (https://github.com/vortex-data/vortex), an extensible columnar
file format for compressed Apache Arrow-compatible data.

The implementation uses the Rust `vortex` crate through a new C FFI
crate `rust/workspace/vortex` (`_ch_rust_vortex`). Data crosses the
FFI boundary through the Arrow C Data Interface and is converted with
the same `ArrowColumnToCHColumn`/`CHColumnToArrowColumn` code as the
`Arrow` format. IO is delegated back to ClickHouse through callbacks,
so reads go through ClickHouse's own buffers (with range reads for
seekable inputs and whole-file buffering otherwise), and writes stream
into the output buffer. All work is driven by a single-threaded
runtime on the calling thread: the library spawns no threads.

The reader supports projection pushdown (only the requested columns
are read), count-only queries from file metadata, and schema
inference. The writer uses the library's default adaptive compression
(BtrBlocks-style cascading encodings plus zstd).

Closes #87327

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov
alexey-milovidov requested a review from a team as a code owner August 1, 2026 21:39
@mintlify

mintlify Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
ClickHouse-docs 🟢 Ready View Preview Aug 1, 2026, 10:03 PM

@clickhouse-gh

clickhouse-gh Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [e5634f1]

Summary:


AI Review

Summary

This PR adds Vortex input/output format support through a Rust FFI layer and the existing Arrow conversion bridge. The overall shape is reasonable, but one advertised surface is still broken: Null/Nothing is documented as supported even though the current write path and schema-inference path do not handle it.

Findings
  • ⚠️ Majors
    • [src/Processors/Formats/Impl/VortexBlockOutputFormat.cpp:92] The new format still delegates writing to the generic CHColumnToArrowColumn mapper, and that mapper has no TypeIndex::Nothing branch in either getArrowType or fillArrowArray. That makes SELECT NULL FORMAT Vortex fail with UNKNOWN_TYPE instead of producing a Null field. On the read side, VortexSchemaReader::readSchema calls arrowSchemaToCHHeader with allow_arrow_null_type = false, so DESC file(...) on a Vortex Null field also throws instead of inferring Nothing.
    • Suggested fix: either add explicit Nothing/Nullable(Nothing) mapping to Arrow Null on write and enable arrow::Type::NA schema inference for Vortex, or remove the Null row from the supported-types contract and add a focused stateless test for the chosen behavior.
Final Verdict
  • Status: ⚠️ Request changes
  • Minimum required actions: align the Null/Nothing contract with the implementation by either supporting it end-to-end or documenting/rejecting it consistently, and add a focused test for that behavior.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.80% 78.80% +0.00%

Changed lines: Changed C/C++ lines covered: 296/353 (83.85%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added pr-feature Pull request with new product feature submodule changed At least one submodule changed in this PR. labels Aug 1, 2026
Comment thread src/Processors/Formats/Impl/VortexBlockInputFormat.cpp Outdated
Comment thread src/Processors/Formats/Impl/VortexBlockOutputFormat.cpp
# Conflicts:
#	src/Formats/registerFormats.cpp
Address the AI review of #112950 and the `arm_tidy` build failure.

- `VortexBlockInputFormat::prepareReader` pruned the scanned columns to the
  exact top-level names of the requested header. A header column addressing a
  subcolumn, such as `t.a`, dropped the parent field `t` from the scan, and
  `ArrowColumnToCHColumn` then silently filled the column with default values.
  Keep `Nested::extractTableName` of every header column, the same way the
  `ArrowIPC` reader does. Covered by a new case in `04669_vortex_format`.

- `VortexBlockOutputFormat` did not override `resetFormatterImpl`, so a
  formatter reused by `MessageQueueSink` kept the finished Rust writer and the
  next message failed with `writer is already finished`. Free the writer and
  the conversion state on reset, like `Arrow`, `ORC`, and `Parquet` do.

- Value-initialize the `ArrowArray` and `ArrowSchema` C structures, fixing the
  `cppcoreguidelines-pro-type-member-init` errors in `Build (arm_tidy)`:
  https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112950&sha=d4debff97a3088dde960cc32e1d0ce8af1c997b6&name_0=PR&name_1=Build%20%28arm_tidy%29
alexey-milovidov and others added 2 commits August 4, 2026 02:53
The MSan build failed to link unit_tests_dbms with relocation
R_X86_64_PC32 out of range errors: the MSan-instrumented Vortex Rust
library (with origin tracking) adds so much code that the binary
exceeds the 2 GiB range of the small code model. Disable Vortex under
MSan, following the precedent of wasmtime and delta-kernel-rs.

Consequently, exclude Vortex from the format enumeration in
02187_async_inserts_all_formats (its availability now varies by build,
so it cannot appear in the static reference; this also fixes the
reference mismatch this PR introduced there), and tag
04669_vortex_format with no-msan.

CI: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112950&sha=645a6771def9e396863d6401ca67230109b220dd

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Master removed the legacy Docusaurus tree (docs/en), which conflicted with
the Vortex doc added there. The Vortex format page is ported to the Mintlify
tree: docs/reference/formats/Vortex.mdx (body matches the embedded
documentation registered via FormatFactory::setDocumentation), a row in
docs/reference/formats/index.mdx, and an entry in docs/reference/navigation.json.
@alexey-milovidov alexey-milovidov added the pr-autogenerated-docs PR that regenerates docs artifacts from source; exempt from the autogenerated-region edit guard label Aug 4, 2026
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 @groeneai, investigate the two Hung check failed, possible deadlock found stress-test failures and provide a fix in a separate PR (if one is already in progress, link it here):

  • Stress test (amd_msan): a fuzzer query is stuck in ActionsDAG::evaluatePartialResult / ActionsDAG::updateHeader while building an ExpressionStep in the planner — uninterruptible CPU-bound expression evaluation, possibly the same class as Uninterruptible CPU-bound function evaluation over a large value #112203.
  • Stress test (arm_release): a fuzzer query over merge(REGEXP('.+'), '.*') joined to a constant subquery is stuck in ReadFromMerge::filterTablesAndCreateChildrenPlans during QueryPlan::optimize (optimizePrimaryKeyConditionAndLimitapplyFilters), single-threaded, tens of seconds in plan optimization over the stress database's many tables.

Both hangs are unrelated to this PR: the hung queries do not involve the Vortex format at all, and the amd_msan binary does not even include it (Vortex is disabled under MSan).

@groeneai

groeneai commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Both hangs are the same defect, and it is not the one the amd_msan half is described as. Neither is caused by this PR.

amd_msan is not ActionsDAG::evaluatePartialResult. The hung query is

SELECT * FROM one AS A INNER JOIN system.one AS one ON A.dummy = one.dummy
             INNER JOIN merge('^') AS two ON A.dummy = two.dummy

and its stack (hung_check.log, thread 5193) is:

AnalysisTableExpressionData::ensureColumnMembershipSetsArePopulated
IdentifierResolver::tryResolveIdentifierFromTableExpression
... QueryAnalysisPass::run
ReadFromMerge::getModifiedQueryInfo
ReadFromMerge::createChildrenPlans          StorageMerge.cpp:849
ReadFromMerge::filterTablesAndCreateChildrenPlans
ReadFromMerge::applyFilters                 StorageMerge.cpp:1790
QueryPlanOptimizations::optimizePrimaryKeyConditionAndLimit
QueryPlan::optimize

The evaluatePartialResult / updateHeader / ExpressionStep frames do appear in that artifact, but in the Top messages not matching their format strings section, as the stack of a completed sleepEachRow exception (Code: 44. The argument of function sleepEachRow must be constant) raised on an unrelated HTTPHandler query. The reason they look like the hung query is that the CI report only renders the last 32 KiB of hung_check.log, and the hung row lives in the excluded head: 84% of the amd_msan log and 91% of the arm_release log are outside that window, and ReadFromMerge appears 0 times inside it. Reading the artifact directly instead of the report body is what separates the two.

So both rows are ReadFromMerge child-plan construction under QueryPlan::optimize, differing only in the leaf: amd_msan in ensureColumnMembershipSetsArePopulated, arm_release in FunctionFactory::tryGet under resolveFunction.

Cost vs interruptibility are two separate things, and only the first has a fix.

Cost: your #113140 is the right fix for it, and the amd_msan leaf is precisely the function its comment names as being rebuilt once per identifier. That accounts for the wall time.

Interruptibility: #113140 does not address it. Its 87 added lines contain no cancellation check (0 occurrences of isCancelled, checkTimeLimit, QueryStatus, ProcessList), and StorageMerge.cpp has none anywhere in its 2046 lines. Both rows show is_cancelled: 1 with max_execution_time = 10, yet ran 1757 s and 3994 s single-threaded. That is the gap: cancellation is observed between pipeline steps, and ReadFromMerge::applyFilters builds every child plan inside one optimizePrimaryKeyConditionAndLimit call with no checkpoint, so a cancelled query cannot leave. #113140 shrinks the window a lot but does not close it; a wide enough merge still exceeds any timeout uninterruptibly.

The phase counters confirm the hang is in the still-running phase rather than anything already finished: recorded phases sum to 6.6 s of 1757 s (amd_msan) and 29.4 s of 3994 s (arm_release), because QueryPlanOptimizeMicroseconds is only committed when its scope exits.

I am authoring the cancellation-checkpoint fix and will link the PR here when it opens. #112203 is a different mechanism (uninterruptible evaluation of one large value); this is uninterruptible plan construction, closer to #113003 and covered in general by #47272.

Unrelated to this PR, confirmed rather than assumed. Your MSan argument holds: rust/workspace/CMakeLists.txt in this PR sets ENABLE_VORTEX OFF when SANITIZE STREQUAL "memory", so the amd_msan binary cannot contain the format. For arm_release the diff touches 23 files, none of them under Storages/, Analyzer/, Planner/ or QueryPlan/, so it cannot appear on this stack; neither hung query references the format, and both shapes are recorded on other PRs.

Comment thread src/Processors/Formats/Impl/VortexBlockOutputFormat.cpp
The `Vortex` output format inherited the generic `CHColumnToArrowColumn`
mapping that serializes `DateTime` as plain Arrow `UINT32`, so the
temporal type was silently lost on round-trip: `DESC file(...)` of a
written file inferred `UInt32`.

Add an `output_datetime_as_timestamp` setting to `CHColumnToArrowColumn`
(following the existing `output_date_as_uint16` pattern) that exports
`DateTime` as Arrow `TIMESTAMP` with second precision and the column's
time zone, and enable it for `Vortex`. Such files are read back as
`DateTime64(0)`. `Arrow` and `Parquet` keep the old behavior.

Update the format documentation accordingly and add a test with
schema-inferred readback of `DateTime`, including `Nullable` and
`Array` nesting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Fixing PR for the ReadFromMerge::createChildrenPlans plan-time cancellation hung check on my
CI finish ledger: #113415 (external, open).

I had a fix for this on a local branch, but @alexey-milovidov opened #113415 with the identical
guard at the identical call site, so I dropped mine rather than open a duplicate. My analysis and
the one coverage gap I measured are in
#113415 (comment).

arrow_settings.output_fixed_string_as_fixed_byte_array = false;
/// Write `DateTime` as `vortex.timestamp` with second precision instead of the generic `U32`,
/// so the temporal type is preserved on round-trip (it is read back as `DateTime64(0)`).
arrow_settings.output_datetime_as_timestamp = true;

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.

Vortex still inherits the generic CHColumnToArrowColumn mapping for IPv4, which emits plain Arrow UINT32. On the read side ArrowColumnToCHColumn only reconstructs IPv4 when it gets an explicit type hint (or the ORC-specific INT32 carrier), so schema-inferred Vortex readback still turns SELECT toIPv4(...) FORMAT Vortex into UInt32 on DESC file(...) / plain SELECT * FROM file(...).

This needs a Vortex-specific logical mapping for IPv4 (or an explicit rejection + documented limitation) plus a small stateless inferred-schema test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🕵 Vortex has no logical type for IP addresses, so a lossless Vortex-specific mapping is not possible — IPv4 goes through the generic Arrow UINT32 carrier, exactly like in Parquet and Arrow (where schema inference also reads it back as UInt32). Rejecting the write would make Vortex stricter than the sibling formats for no benefit, so I took the documented-limitation route: the format documentation now states that IPv4 is written as U32, that inference reads it back as UInt32, and shows the explicit-schema way to get IPv4 back (SELECT * FROM file('data.vortex', Vortex, 'ip IPv4')). Added the stateless test 04812_vortex_ipv4.sh covering inference (UInt32/Nullable(UInt32)), the plain and explicit-schema round trips (values verified), and the documented IPv6 write rejection.

alexey-milovidov and others added 2 commits August 6, 2026 07:41
…ex` format

Vortex has no type for IP addresses, so `IPv4` columns are written through the
generic Arrow `UINT32` carrier (the same as in `Parquet` and `Arrow`), and schema
inference reads them back as `UInt32`. Document this explicitly, including the
explicit-schema way to read the column back as `IPv4`, and add a stateless test
covering inference, both round trips, and the documented `IPv6` write rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 CI triage for the reds on a56a056dafe (all unrelated to this PR; origin/master has been merged in and CI restarted on e5634f18090):

arrow_settings.output_datetime_as_timestamp = true;

ch_column_to_arrow_column
= std::make_unique<CHColumnToArrowColumn>(getPort(PortKind::Main).getHeader(), "Vortex", arrow_settings);

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.

Vortex now inherits the generic CHColumnToArrowColumn mapping here, but that mapper still has no TypeIndex::Nothing handling in either getArrowType or fillArrowArray. The advertised Null/Nothing support therefore does not actually work: SELECT NULL FORMAT Vortex (or any Nullable(Nothing) field) still throws UNKNOWN_TYPE, and a Null field cannot be schema-inferred on read either because VortexSchemaReader goes through arrowSchemaToCHHeader with allow_arrow_null_type = false.

Upstream vortex-arrow does support Arrow DataType::Null, so this needs either explicit Nothing/Nullable(Nothing) support on both paths or the supported-types contract/docs pared back, plus a focused stateless test for the chosen behavior.

@groeneai

groeneai commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The AST fuzzer STID 2270-2caa failure on a56a056dafe is covered by a fix I already have open: #113469

I confirmed that on your exact query rather than by inference. A pre-fix binary reproduces your log line byte for byte (Expected 0, found 1, empty Header:, Chunk: Nullable(size = 2, UInt8(size = 2), UInt8(size = 2)), aborting in RemoteSource::prepare); the branch head returns the 8 correct rows and no error. Both arms asserted by build id.

Mechanism: cume_dist() OVER () references no column of the table expression, and defaultValueOfArgumentType(k) is constant-folded, so the outer query reads nothing from the shard. prepareBuildQueryPlanForTableExpression then synthesizes one column purely to learn the row count, and the window function is deferred to the initiator along with the projection that would have consumed that column, so nothing keeps it and it is pruned. The initiator's mergeable header ends up empty while the shard still streams the surviving Nullable(UInt8) constant, hence 1 column where 0 are expected.

The crash is the visible half. When no constant survives there is no error and rows are silently dropped instead: over two shards of 1000 rows, SELECT count(*) OVER () FROM remote(...) returned 1000 rows pre-fix, and 0 with prefer_localhost_replica = 0, against 2000 correct. The locally-read shard survives, every remote one is lost.

SAMPLE, QUALIFY, the four shards, and remoteSecure rather than remote are all incidental: dropping each of them still aborts pre-fix. What is load-bearing is more than one shard plus a window function referencing no column of the table. count(c0) OVER (), a single shard, and enable_analyzer = 0 are all clean pre-fix, so the fix is scoped to the analyzer path.

113469 covers this carrier; I am not claiming it clears the whole message class. Agreed that 96656 and 103695 are the same family with different stack IDs. One caveat on keying: within this family a single STID pools more than one query shape, so the shape identifies a carrier more reliably than the STID does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-autogenerated-docs PR that regenerates docs artifacts from source; exempt from the autogenerated-region edit guard pr-feature Pull request with new product feature submodule changed At least one submodule changed in this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature request] please support read and write vortex columnar file format

2 participants