Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Parquet schema cache ignores `input_format_parquet_detect_variant_by_structure`

Open Beginner friendly
#124,986 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
Half a day
Newbie friendliness
66/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
cpp
Domain
databases

Research direction

Start with the Parquet getter for the schema-cache key in src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp (around lines 644-661), then compare it with the setting read in src/Processors/Formats/Impl/Parquet/SchemaConverter.cpp near line 777. The existing test 04757_schema_inference_cache_key_settings_parquet.sh is the place to add a case. Done means that running the two setting orders on the same file returns Dynamic for detect=1 and Tuple for detect=0 regardless of which query runs first.

Written by the indexing model from the issue text.

Description

ai_p2 comp-parquet-reader-v3
Describe what's wrong
  • Schema cache for Parquet files does not key on input_format_parquet_detect_variant_by_structure.
  • Whichever query infers the schema first fixes the type as Dynamic or Tuple for all later queries on that file.
  • Expected each query to get the type matching its own setting; instead the second query reuses the first query's cached type.
  • Can return raw blob values typed Tuple(value String, metadata String) instead of decoded values typed Dynamic.

Root cause: getKeysForSchemaCache keys the schema cache on FormatFactory::getAdditionalInfoForSchemaCache, but the Parquet getter in ParquetV3BlockInputFormat.cpp omits detect_variant_by_structure from the key format string, even though SchemaConverter::processSubtreeDynamic uses that setting to choose between Dynamic and Tuple.

Analysis details (evidence, affected locations, impact)

Why we believe this is a bug: getKeysForSchemaCache (ReadSchemaUtils.cpp:599) keys the cache on FormatFactory::getAdditionalInfoForSchemaCache; the Parquet getter (ParquetV3BlockInputFormat.cpp:644-661) lists enable_json_parsing, local_time_as_utc, allow_geoparquet_parser, skip_columns_with_unsupported_types etc., but not the new setting that SchemaConverter::processSubtreeDynamic reads at SchemaConverter.cpp:777 to choose Dynamic over Tuple.

Affected locations:

Impact: On one server, queries with different compatibility (< 26.10) or explicit values of the setting read the same Parquet file with each other's inferred schema: Spark variant columns come back as Tuple of raw blobs under default settings, or as Dynamic when the user disabled detection. Exercised with file(); the cache is shared by all users of the server.

Does it reproduce on most recent release?

Confirmed on master; not checked on a release.

How to reproduce

Reproduce with two file() queries against the same Parquet fixture using opposite values of the setting, with the file's mtime older than the first query's run.

# no-fasttest: needs Parquet.

# Each carrier below is probed in BOTH orders, because an order-insensitive probe cannot
# detect a missing cache-key field: whichever query runs first decides the cached type.
# Every order gets its OWN file so the two orders never share a cache entry.
#
# The fixtures are aged with `touch -d`: SchemaCache::tryGetImpl drops an entry when the
# source's mtime is >= the entry's registration time, and both are whole seconds, so a file
# written in the same second as the first query is re-inferred and nothing is cached.

T=test_repro
AGE="2000-01-01 00:00:00"

# --- enable_nullable_tuple_type -----------------------------------------------
# Decides whether an OPTIONAL group with an all-REQUIRED subtree is inferred as Nullable(Tuple(...))
# or as a plain Tuple(...), so each pair must report the type its own query asks for, whichever ran
# first. A stale entry also changes the value read back: the plain Tuple has nowhere to put a
# struct-level NULL and returns a tuple of NULLs instead.
clickhouse-local -q "
    SET enable_nullable_tuple_type = 1;
    SELECT * FROM values('p Nullable(Tuple(a UInt8, b UInt8))', tuple(1, 2), NULL)
    INTO OUTFILE '${T}_nt_a.parquet' TRUNCATE FORMAT Parquet"
cp "${T}_nt_a.parquet" "${T}_nt_b.parquet"
touch -d "$AGE" "${T}"_nt_*.parquet
echo "-- Parquet nullable_tuple, nt=1 first"
clickhouse-local -m -q "
    DESC file('${T}_nt_a.parquet', 'Parquet') SETTINGS enable_nullable_tuple_type = 1;
    DESC file('${T}_nt_a.parquet', 'Parquet') SETTINGS enable_nullable_tuple_type = 0;" | cut -f2
echo "-- Parquet nullable_tuple, nt=0 first"
clickhouse-local -m -q "
    DESC file('${T}_nt_b.parquet', 'Parquet') SETTINGS enable_nullable_tuple_type = 0;
    DESC file('${T}_nt_b.parquet', 'Parquet') SETTINGS enable_nullable_tuple_type = 1;" | cut -f2
echo "-- Parquet nullable_tuple value, nt=1 first"
clickhouse-local -m -q "
    SELECT isNull(p) FROM file('${T}_nt_a.parquet', 'Parquet') ORDER BY 1 SETTINGS enable_nullable_tuple_type = 1;
    SELECT isNull(p) FROM file('${T}_nt_a.parquet', 'Parquet') ORDER BY 1 SETTINGS enable_nullable_tuple_type = 0;"

# --- input_format_parquet_allow_geoparquet_parser -----------------------------------------
# Decides whether a GeoParquet geometry column is inferred as a geo type or as its raw String
# representation, so each pair must report LineString for the =1 query and Nullable(String)
# for the =0 query, whichever ran first.
for suffix in a b; do cp "$CUR_DIR"/data_parquet/03445_geoparquet_null_linestring.parquet "${T}_geo_${suffix}.parquet"; done
touch -d "$AGE" "${T}"_geo_*.parquet
echo "-- Parquet allow_geoparquet_parser, geo=1 first"
clickhouse-local -m -q "
    DESC file('${T}_geo_a.parquet', 'Parquet') SETTINGS input_format_parquet_allow_geoparquet_parser = 1;
    DESC file('${T}_geo_a.parquet', 'Parquet') SETTINGS input_format_parquet_allow_geoparquet_parser = 0;" | awk -F'\t' '$1 == "geometry" {print $2}'
echo "-- Parquet allow_geoparquet_parser, geo=0 first"
clickhouse-local -m -q "
    DESC file('${T}_geo_b.parquet', 'Parquet') SETTINGS input_format_parquet_allow_geoparquet_parser = 0;
    DESC file('${T}_geo_b.parquet', 'Parquet') SETTINGS input_format_parquet_allow_geoparquet_parser = 1;" | awk -F'\t' '$1 == "geometry" {print $2}'

# --- input_format_parquet_skip_columns_with_unsupported_types_in_schema_inference ---------
# Decides whether a column of an unsupported type is dropped or the file is rejected, so the
# permissive query must not let a later strict query skip the exception. Only this direction is a
# carrier: the strict query throws, and a throwing inference caches nothing.
# The data file has one VARIANT-typed column `u`, which is a valid Parquet logical type that is not
# implemented here, and one supported Int32 column `id`.
cp "$CUR_DIR"/data_parquet/parquet_variant_logical_type.parquet "${T}_unsup_a.parquet"
touch -d "$AGE" "${T}"_unsup_*.parquet
echo "-- Parquet skip_columns_with_unsupported_types, skip=1 first then strict must throw"
clickhouse-local -m -q "
    DESC file('${T}_unsup_a.parquet', 'Parquet') SETTINGS input_format_parquet_skip_columns_with_unsupported_types_in_schema_inference = 1 FORMAT Null;
    DESC file('${T}_unsup_a.parquet', 'Parquet') FORMAT Null;" \
    2>&1 | grep -c INCORRECT_DATA
echo "-- Parquet skip_columns_with_unsupported_types, strict alone throws (control)"
clickhouse-local -q "DESC file('${T}_unsup_a.parquet', 'Parquet') FORMAT Null" \
    2>&1 | grep -c INCORRECT_DATA
# Without this the arm above would also pass if nothing had been cached at all: the strict query
# throws either way. This shows the permissive query really did leave an entry, keyed on its value.
echo "-- Parquet skip_columns_with_unsupported_types, the permissive entry exists and is keyed"
clickhouse-local -m -q "
    DESC file('${T}_unsup_a.parquet', 'Parquet') SETTINGS input_format_parquet_skip_columns_with_unsupported_types_in_schema_inference = 1 FORMAT Null;
    SELECT count(), extract(additional_format_info, 'skip_columns_with_unsupported_types=\w+')
    FROM system.schema_inference_cache WHERE format = 'Parquet' GROUP BY 2 ORDER BY 2;"
# An entry being present still does not prove a later query read it: with cache reads bypassed every
# query re-infers and rewrites the same entry. Repeating one query at unchanged settings must hit.
echo "-- Parquet skip_columns_with_unsupported_types, a repeated query hits the cache"
clickhouse-local -m -q "
    DESC file('${T}_unsup_a.parquet', 'Parquet') SETTINGS input_format_parquet_skip_columns_with_unsupported_types_in_schema_inference = 1 FORMAT Null;
    DESC file('${T}_unsup_a.parquet', 'Parquet') SETTINGS input_format_parquet_skip_columns_with_unsupported_types_in_schema_inference = 1 FORMAT Null;
    SELECT value > 0 FROM system.events WHERE event = 'SchemaInferenceCacheSchemaHits';"

# --- input_format_parquet_detect_variant_by_structure ----------------------------------
# Decides whether an unannotated group with only `metadata` and `value` BYTE_ARRAY fields (how
# Spark 4.0 writes a variant column) is inferred as Dynamic or as Tuple, so each pair must report
# the type its own query asks for, whichever ran first.
for suffix in a b; do cp "$CUR_DIR"/data_parquet/04928_variant_spark.parquet "${T}_var_${suffix}.parquet"; done
touch -d "$AGE" "${T}"_var_*.parquet
echo "-- Parquet detect_variant_by_structure, detect=1 first"
clickhouse-local -m -q "
    DESC file('${T}_var_a.parquet', 'Parquet') SETTINGS input_format_parquet_detect_variant_by_structure = 1, print_pretty_type_names = 0;
    DESC file('${T}_var_a.parquet', 'Parquet') SETTINGS input_format_parquet_detect_variant_by_structure = 0, print_pretty_type_names = 0;" | awk -F'\t' '$1 == "v" {print $2}'
echo "-- Parquet detect_variant_by_structure, detect=0 first"
clickhouse-local -m -q "
    DESC file('${T}_var_b.parquet', 'Parquet') SETTINGS input_format_parquet_detect_variant_by_structure = 0, print_pretty_type_names = 0;
    DESC file('${T}_var_b.parquet', 'Parquet') SETTINGS input_format_parquet_detect_variant_by_structure = 1, print_pretty_type_names = 0;" | awk -F'\t' '$1 == "v" {print $2}'

rm -f "${T}"_*

Note: an automated re-run of this exact block on current master (38bc12ded730) did not show the failure; the analyst's run did (outputs below). Environment or ordering may matter.

Expected behavior

Each query reading the file should get Dynamic or Tuple according to its own value of input_format_parquet_detect_variant_by_structure.

Expected output of the reproducer above:

-- Parquet detect_variant_by_structure, detect=1 first
Dynamic
Tuple(value String, metadata String)
-- Parquet detect_variant_by_structure, detect=0 first
Tuple(value String, metadata String)
Dynamic
Error message and/or stacktrace

The first query's inferred type is cached and returned to a later query with the opposite setting, on the same server across users.

Actual output of the reproducer above:

-- Parquet detect_variant_by_structure, detect=1 first
Dynamic
Dynamic
-- Parquet detect_variant_by_structure, detect=0 first
Tuple(value String, metadata String)
Tuple(value String, metadata String)
Suggested fix

Add detect_variant_by_structure={} / settings.parquet.detect_variant_by_structure to the Parquet getter, and the two-order case to 04757_schema_inference_cache_key_settings_parquet.sh (test_sql_content).

Additional context

Same pattern as #123194 (found by: lexical, vector; vector: cosine distance 0.23 (agrees with the lexical leg)).

Found during automated review of PR #117106; whether that PR introduced it could not be established, so nobody is tagged. Severity P2 · Finding h_pr117106_001

Dominant language
C++
Stars
50.3k
Forks
9.1k
Avg merge
22h 15m
Merged PRs (30d)
448

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from ClickHouse/ClickHouse

All issues in ClickHouse/ClickHouse

Similar issues

More C++ issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.