Skip to content

chore: Improve Virtual Column Block Meta Generation - #20348

Open
b41sh wants to merge 3 commits into
databendlabs:mainfrom
b41sh:feat-virtual-column-recluster
Open

chore: Improve Virtual Column Block Meta Generation#20348
b41sh wants to merge 3 commits into
databendlabs:mainfrom
b41sh:feat-virtual-column-recluster

Conversation

@b41sh

@b41sh b41sh commented Aug 21, 2026

Copy link
Copy Markdown
Member

I hereby agree to the terms of the CLA available at: https://docs.databend.com/dev/policies/cla/

Summary

This PR improves the metadata management, generation lifecycle, and read efficiency of Fuse virtual columns.

Changes

  • Persist segment-level virtual column schemas

    • Records the virtual column schema in segment metadata.
    • Provides a consistent mapping between logical JSON paths and the physical virtual columns stored by blocks in a segment.
    • Allows readers to resolve virtual column paths without relying on the current table schema or reopening sidecar files.
  • Generate virtual column data during rewrite operations

    • Virtual column sidecars are no longer generated during ordinary inserts and writes.
    • Sidecars are generated when running OPTIMIZE TABLE ... COMPACT or reclustering.
    • This allows compaction and reclustering to choose a consistent virtual column layout across their input blocks and avoids adding sidecar generation overhead to regular writes.
  • Replace the table-level virtual schema with segment-local schemas

    • Deprecates TableMeta.virtual_schema and stops maintaining a global virtual column schema at the table level.
    • Stores virtual column schemas in SegmentInfo.summary.virtual_segment_schema, allows each segment to use a layout derived from its own JSON path distribution
    • Keeps virtual column IDs and logical-to-physical path mappings local to each segment.
  • Read virtual column metadata from BlockMeta

    • Persists the required virtual column file metadata in BlockMeta.
    • Readers can determine virtual column locations, physical columns, offsets, sizes, and data types directly from block and segment metadata.
    • This replaces Parquet footer reads from virtual column sidecar files in the normal read path, reducing additional metadata I/O and improving pruning efficiency.
  • Support range pruning for virtual columns

    • Extends RangeIndex to consume statistics from virtual columns.
    • Resolves logical JSON paths through segment-local virtual column schemas and maps them to the corresponding physical virtual columns.
    • Uses virtual column min, max and null-count statistics to prune blocks before reading source Variant data.
  • Add diagnostic table functions for virtual columns schemas, block metadata, sidecar files, and simulated generation results. These functions make it easier to inspect how JSON paths are classified, verify that blocks in a segment use a consistent layout, diagnose pruning behavior, and estimate virtual sidecar sizes before rebuilding them.

    • fuse_virtual_column_segment_schema displays the segment-local mapping between canonical JSON paths and virtual column IDs.
    • fuse_virtual_column_block_meta displays virtual column metadata for each block, resolved against its segment-local schema, including physical types, offsets, sizes, statistics, and shared-path information.
    • fuse_virtual_column_parquet_meta reads virtual sidecar Parquet footer metadata and displays the direct and shared physical columns stored in each sidecar file.
    • fuse_virtual_column_build simulates virtual column generation for a block or an entire segment without modifying table data. It supports an optional max_direct_columns argument for evaluating different direct/shared layouts.

fixes: #[Link the issue here]

Tests

  • Unit Test
  • Logic Test
  • Benchmark Test
  • No Test - Explain why

Type of change

  • Bug Fix (non-breaking change which fixes an issue)
  • New Feature (non-breaking change which adds functionality)
  • Breaking Change (fix or feature that could cause existing functionality not to work as expected)
  • Documentation Update
  • Refactoring
  • Performance Improvement
  • Other (please describe):

AI assistance

  • AI usage: An AI coding agent implemented the Virtual Column segment metadata refactoring
  • Responsible human: @b41sh
  • The responsible human has read every line of this diff and can explain each change

This change is Reviewable

@github-actions github-actions Bot added the pr-chore this PR only has small changes that no need to record, like coding styles. label Aug 21, 2026
@b41sh
b41sh force-pushed the feat-virtual-column-recluster branch 2 times, most recently from 3f741ad to 821760a Compare August 31, 2026 01:45
@b41sh
b41sh marked this pull request as ready for review September 2, 2026 03:41
@b41sh
b41sh requested a review from zhang2014 September 2, 2026 03:41

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1bccb680e3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/query/sql/src/planner/binder/bind_context.rs Outdated
Comment thread src/query/storages/system/src/virtual_columns_table.rs Outdated
Comment thread src/query/storages/system/src/virtual_columns_table.rs Outdated
Comment thread src/query/storages/system/src/virtual_columns_table.rs Outdated
@b41sh
b41sh force-pushed the feat-virtual-column-recluster branch from 1bccb68 to 6a1ee17 Compare September 2, 2026 16:18

@zhang2014 zhang2014 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.

Went through the whole diff. The segment-local schema / id remapping / footer fallback logic looks right to me, and I did not find a path that reads wrong data. Three inline comments below: the first one (enter_column_ref type rewrite) is the one I think needs a change before merge, the other two are perf suggestions on the insert path and the block-meta pruner.

Comment on lines +45 to +59
fn enter_column_ref(&mut self, column: &ColumnRef<String>) -> RewriteResult {
let Some(data_type) = self
.column_types
.and_then(|column_types| column_types.get(&column.id))
else {
return Ok(None);
};
if data_type == &column.data_type {
return Ok(None);
}

let mut column = column.clone();
column.data_type = data_type.clone();
Ok(Some(column.into()))
}

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.

This rewrite can leave input_domains inconsistent with the expression that is actually folded, and I believe that panics.

The ref is rewritten to the physical type here, but the parent is only re-type-checked when it is a FunctionCall (check_function in enter_function_call). When that re-check fails, the Err(_) => Ok(None) branch keeps the original call with the Nullable(Variant) column ref — while RangeIndex::apply has already inserted a Nullable(<physical>) domain for that name into input_domains (range_index.rs L136-137).

ConstantFolder::fold_with_domain then feeds a Domain::Number/Domain::String into a Variant-typed calc_domain, and those unwrap the downcast (TypedUnaryCalcDomain::domain_eval / EraseCalcDomainGeneric*Arg::domain_eval do VariantType::try_downcast_domain(..).unwrap()).

Shape that triggers it: any Variant-only function applied directly to a virtual column ref, no Cast in between, on a block whose stats for that path are typed:

WHERE json_typeof(v['a']) = 'string'   -- likewise as_string(v['a']), is_string(v['a']), json_array_length(v['a']) ...

check_function("json_typeof", [Nullable(UInt64)]) fails (there is no Number -> Variant auto-cast rule, only Variant -> X), the original expr is kept, the folder sees a UInt64 domain for a Variant ref and panics.

Two possible fixes:

  1. Only rewrite a ref whose direct parent is a Cast/TryCast — that is the only shape that benefits (is_true(TRY_CAST(v['a'] AS UInt8 NULL) = 10)), and it is the shape the planner emits for v['a'] = 10.
  2. Or keep the rewrite but only inject the typed domain for refs that were actually rewritten; anything left as Nullable(Variant) must keep Domain::full(&ty).

A test next to test_range_index_prunes_try_cast_virtual_column_by_physical_type with json_typeof("v.a") = 'string' and UInt64 stats should reproduce it (I could not run it locally because the jsonb git rev fetch kept timing out, so please double check).

Comment on lines +82 to +87
let path = jsonb::keypath::KeyPaths {
paths: key_paths.to_vec(),
}
.to_owned()
.to_canonical_path();
*self.source_paths[source_index].entry(path).or_default() += 1;

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.

perf: this is now on the regular write path — StreamBlockProperties/BlockBuilder create a JsonPathStatisticsBuilder for every MutationKind other than Recluster/Compact/Refresh when enable_virtual_column = true, and observe_path runs once per scalar leaf per row.

Per leaf it does key_paths.to_vec() + .to_owned() (one String per path segment) + .to_canonical_path() (another String), and entry(path) needs the owned String even when the path already exists — so it is 3+ allocations per leaf for a map that is almost always a hit.

VirtualColumnBuilder::extract_virtual_values already handles the same problem with a hash-first lookup (SipHasher24 over key_paths -> StackHashMap<u128, usize>) and only builds the owned path the first time it is seen. Reusing that here (hash -> index into Vec<(String, u64)>) would make the common path allocation-free.

Minor: max_path_statistics is only applied in finalize, so source_paths can grow unbounded within a block. Refusing new keys once the cap is reached (and setting path_statistics_complete = false) would bound memory as well.

Comment on lines +333 to +337
column.paths.iter().any(|item| {
item.path
.strip_prefix(prefix)
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
})

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.

nit/perf: paths is sorted by canonical path, so this can be a partition_point for prefix + "." / prefix + "[" instead of strip_prefix over every path. try_prune_from_block_meta calls it once per (virtual field x block); since the schema is shared by all blocks of a segment, the answer could also be computed once per segment and reused across blocks.

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

Labels

pr-chore this PR only has small changes that no need to record, like coding styles.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants