Skip to content

Respect two inference-affecting settings in the schema inference cache key - #113533

Open
groeneai wants to merge 9 commits into
ClickHouse:masterfrom
groeneai:groeneai/fix-schema-inference-cache-key-settings
Open

Respect two inference-affecting settings in the schema inference cache key#113533
groeneai wants to merge 9 commits into
ClickHouse:masterfrom
groeneai:groeneai/fix-schema-inference-cache-key-settings

Conversation

@groeneai

@groeneai groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

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

Fixed the inferred column type depending on the order of previous queries for a source whose schema is cached. Settings that change an inferred type were missing from the schema inference cache key, so the first query's value decided the type every later query saw: input_format_try_infer_exponent_floats, max_parser_depth, input_format_json_infer_array_of_dynamic_from_array_of_different_types, and for Parquet also input_format_parquet_local_time_as_utc, input_format_parquet_allow_geoparquet_parser, input_format_parquet_skip_columns_with_unsupported_types_in_schema_inference and schema_inference_make_json_columns_nullable. Also registers the missing cache key getter for Form and makes the Template getter use its own row format's escaping rule.

Description

The key's additional_format_info component is a maintained allow-list, and settings that change an
inferred type were never added, so for a cached source the first query's value decides the type every
later query sees.

With 1e5 in a TSV file, input_format_try_infer_exponent_floats = 1 infers Float64 and = 0
infers String, and in either order both queries return the first query's type. max_parser_depth
is affected in the permissive direction only: a low limit that should throw TOO_DEEP_RECURSION
succeeds once a high-limit query warmed it.

Three formats cannot inherit the fix from those two functions and are fixed at their own getter.
Form registered no getter, so its additional_format_info was empty and every keyed setting
collided. Parquet's getter omitted max_parser_depth, which its schema converter throws on, plus
local_time_as_utc, allow_geoparquet_parser,
skip_columns_with_unsupported_types_in_schema_inference and
schema_inference_make_json_columns_nullable; Template's getter passed format_regexp_escaping_rule instead
of its row format's own rule, keying the wrong arm.

Placement follows where each setting is verdict-changing: max_parser_depth in the all-formats
string, the exponent setting in the Escaped/Raw, CSV and a new Quoted arm but not in JSON, and
infer_array_of_dynamic_from_array_of_different_types in the JSON arm, where it selects
Array(Dynamic) or an unnamed Tuple for a heterogeneous array.

Cached schemas are re-inferred once per source after upgrade; the cache is in-memory and
process-local, so this is a warm-up cost, as in 8a88ad5dc4d24d7. PRs #104816, #110766 and #102499 conflict textually only.

…e key

The schema inference cache key's additional_format_info component is an
explicitly maintained allow-list of settings, appended to by hand whenever a
setting is found to change an inferred type. Two such settings were never
added, so for a source whose schema is cached the setting value of the first
query decides the type every later query sees.

input_format_try_infer_exponent_floats selects the float parser in tryReadFloat
(SchemaInferenceUtils.cpp:1013-1018): with it off on a non-JSON escaping rule,
tryReadFloatTextExtNoExponent stops at the exponent, has_fractional stays
false, and the field completes to String instead of Float64. max_parser_depth
is read by the generic tryInferDataTypeForSingleFieldImpl (:1425), so over the
limit inference throws TOO_DEEP_RECURSION.

The consequence is user-visible in both channels: a TSV file holding 1e5 is
inferred as Float64 or String depending only on which setting value ran first,
and the poisoned query returns 100000 where a cold query returns 1e5. For
max_parser_depth only the permissive direction is reachable, because a throwing
inference caches nothing: a low limit that should throw silently succeeds once
a high-limit query has warmed the cache.

Placement follows where each setting is verdict-changing rather than symmetry.
max_parser_depth goes in getAdditionalFormatInfoForAllRowBasedFormats because
the check sits in the generic single-field entry point, and because BSONEachRow
and MsgPack call only that function while enforcing the setting themselves. The
exponent setting goes in the Escaped/Raw and CSV arms of
getAdditionalFormatInfoByEscapingRule plus a new Quoted arm reached by Values
and MySQLDump. It is deliberately absent from the JSON arm: tryReadFloat
short-circuits it there via is_json, so it cannot change a JSON verdict, and
the new test asserts JSON keeps a single cache entry for both values so a later
change cannot add it for symmetry.

Three formats cannot inherit from those two functions and are fixed at their
own getter. Form registered no cache getter at all, so its
additional_format_info was the empty string and every keyed setting collided;
it now registers one for the Raw rule its schema reader actually infers with.
Parquet's getter omitted max_parser_depth, which Parquet/SchemaConverter.cpp
reads and throws on; ORC already keys on that setting, and is the precedent.
Template's getter looped over its row format's escaping rules but passed
settings.regexp.escaping_rule, a Regexp setting, so it keyed on the wrong arm.

precise_float_parsing and allow_number_leading_zeros, also named in the
original report, are deliberately not added. The former was measured to change
neither the inferred type nor the read value, and inference isolates itself
from it. The latter is not a declared setting: its only writer hardcodes it on
a hive-local FormatSettings whose consumers never reach a schema cache
function, so no value of it can appear in a key. Parquet's getter still omits
four other settings its inference path reads; those were not measured for
order-dependence and are left alone, so this change does not make the cache key
complete.

Growing the key invalidates cached entries once per source after upgrade. The
cache is in-memory and process-local, so this is a warm-up re-inference rather
than a compatibility break, matching commit 8a88ad5, which changed the
same string with no migration. The Template fix additionally corrects keys for
users whose format_regexp_escaping_rule differs from their row format's rules.
No setting default changes, so no SettingsChangesHistory entry is due.

The new test probes both orders per carrier because an order-insensitive test
cannot detect this class. Its fixtures are aged with touch: SchemaCache
discards an entry when the source's mtime is at least its registration time and
both are whole seconds, so a fixture written in the same second as the first
query is re-inferred and nothing is cached.
@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review

Two independent reviews of this change: my own cold read of the resulting code (done before
reading the implementation's evidence, so the carriers were enumerated from source rather than
from the plan), and a separate second-model pass over the same tree. The second-model pass
returned zero findings. Its coverage note: all modified functions, the full Form file, the
cache lifecycle and API, the sibling getter registrations and inference paths, the complete new
test and reference, and the existing cache tests.

My own pass found no blocker and no major in the code. Three items, with verdicts:

⚠️ The posted validation-gate table understated two counts. Corrected before posting.
Row (f) claimed "All 14 getAdditionalFormatInfoByEscapingRule call sites now pass the rule
their reader infers with". Measured on this branch there are 15 (the other two occurrences of
the name are the definition and the header declaration). 14 is the count before this change:
git show origin/master:src/Processors/Formats/Impl/FormRowInputFormat.cpp has zero occurrences,
so the new Form getter is itself the 15th. Row (a)'s "Nine arms" was the reproduce-gate's
carrier count, not the test's arm count (the test has 23 labelled arms: 13 behavioural, 5
controls, 5 key-string rows). Both rows are now stated correctly. No code or test change was
involved and the PR body carried neither number.

💡 Five formats' cache keys still omit max_parser_depth, and that is correct. Arrow,
ArrowStream, Avro/AvroConfluent, CapnProto and ProtobufList/Protobuf register getters that do not
mention it. Each scores zero occurrences of the setting in its own source file and has no
depth-bounded schema recursion, so it cannot change their inferred schema. Every format that does
read the setting is now keyed on it: BSONEachRow and MsgPack inherit it from the all-formats
string they already call, ORC passed it already, Parquet gains it here, and the text formats get
it from the same all-formats string. Verified, not assumed: recorded so a later reader need not
redo the enumeration.

💡 The unordered reads of system.schema_inference_cache in the new test are safe.
StorageFile::getSchemaCache returns a function-local static, so the cache is per process; the
test runs 18 separate clickhouse-local invocations, and each unordered read is preceded by
exactly one DESC, so it returns at most one row. The only two-row read already carries
ORDER BY ALL.

Independently re-derived while reviewing, each reproducing exactly: the exponent setting is
verdict-changing for exactly the Escaped/Raw, CSV and Quoted rules and inert for JSON
(tryReadFloat short-circuits on is_json, and the two is_json = false public entry points are
reached only from the non-JSON rules and from a read-path caller in JSONExtractTree, never from a
schema-cache path); None and XML cannot produce a cached schema at all, because inference
throws BAD_ARGUMENTS for them; the four remaining Parquet settings named as out of scope are
exactly four once reachability at inference time is taken into account (two more are read only
behind a sample_block that inference passes as null, and two others reach the decoder rather than
the inferred type); the cited precedent commit has the same fix shape, the same reference churn and
no migration; the two named neighbouring PRs each append their own setting to the same allow-list
and perform none of this change's operations. All eight touched files are byte-identical to current
master despite 407 commits of drift, and the change contains no submodule updates.

@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author
Validation gate (a–i)
# Question Answer
a Deterministic repro? Yes. Nine carriers, each 5/5 deterministic, plus a cache-off control per exponent arm that discriminates. ⚠️ The fixture must be aged (touch -d): SchemaCache::tryGetImpl evicts an entry when the source's mtime is >= its registration time and both are whole seconds, so a just-written file re-infers and nothing caches. An unaged fixture reproduces nothing and would have made the whole test pass with the fix reverted.
b Root cause? additional_format_info is a hand-maintained allow-list; input_format_try_infer_exponent_floats (parser choice, SchemaInferenceUtils.cpp:1013-1018) and max_parser_depth (generic single-field entry point, :1425) were never added, so a cached source's type is decided by the first query's settings. Three formats additionally cannot inherit from those two functions: Form (no getter registered), Parquet (own getter, omits depth), Template (calls the function with settings.regexp.escaping_rule instead of its own loop variable).
c Fix matches root cause? Yes. Each setting is added at the scope where it is verdict-changing, and the three non-inheriting carriers are fixed at their own getter. No widened bound, no defensive guard, no band-aid.
d Test intent preserved / new tests? New 04757_schema_inference_cache_key_settings.sh: both orders per carrier, the value channel, per-setting key rows, and a JSON control. The four existing tests keep every assertion; only the key string grows, verified by normalising away the new fields and diffing against the pre-change file (all four CLEAN).
e Both directions? Yes, on two binaries with SELECT lower(buildId()) asserted against readelf -n before every verdict. Pre-fix: TSV Float64,Float64 / String,String, value 100000 instead of 1e5, Form key empty, warm low-depth queries silently succeed. Post-fix: every arm matches its cache-off control and the low-depth queries throw.
f General across code paths? Carrier audit re-run with a validated probe (it must report ORC/CSV/Parquet as having a getter, 1/1/1, so a zero means something). Seven formats still register no getter; each scores 0 for escaping-rule inference and 0 for max_parser_depth, so none is a carrier. All 15 getAdditionalFormatInfoByEscapingRule call sites now pass the rule their reader infers with (14 before this PR, plus the new Form one). No fifth carrier.
g Generalizes across inputs? Covered per escaping rule (Escaped/Raw, CSV, Quoted), per format family (row-based text, Form, Template, columnar Parquet), JSON and non-JSON for depth, and both directions of each setting. JSON is asserted insensitive on purpose. Only the permissive direction of the depth arm exists: a throwing inference caches nothing (count() = 0 after a lone failing DESC).
h Backward compatible? No setting default changes, so no SettingsChangesHistory entry is due. The key grows, so cached schemas are re-inferred once per source after upgrade; the cache is in-memory and process-local, matching the precedent 8a88ad5dc4d24d7 (same string, no migration). The Template hunk additionally corrects keys for users whose format_regexp_escaping_rule differs from their row format's rules.
i Invariants preserved? The new Quoted case was checked for fallthrough (every arm ends in an explicit break); the Form getter passes the same rule its reader infers with; no header, ABI, include or lifetime change.

Mutation matrix. Eight mutants, each built with the Build ID asserted to move and the server
identity re-asserted. Every one reddens its own arm:

id mutation reddened
M1 drop max_parser_depth from the all-formats string JSON + TSV depth arms and the TSV key row; the Parquet arm stayed green
M2 drop exponent from Escaped/Raw TSV type + value arms, Form arms, TSV key row
M3 drop exponent from CSV CSV arms, Template arms
M4 drop the new Quoted arm Values arms only
M5 also add exponent to the JSON arm the JSON one-entry arm only
M6 revert the Form getter Form arms and the Form key row
M7 drop max_parser_depth from the Parquet getter Parquet arms only
M8 revert the Template argument Template arms and the Template key row

M1 and M7 are independently pinned, as required: neither reddens the other's arm. M2 cross-trips
Form because Form keys through the Raw arm; only M6 is Form's sole detector. M5 initially
did not redden this test: a type-based JSON control is vacuous for it, because adding the field
changes only the key. A one-cache-entry assertion was added, and M5 now reddens that arm and only
that arm. Restoring the fix returned the Build ID exactly to its recorded value, which proves
both that the mutants ran different binaries and that the shipped tree is what was measured.

Runs. 50 randomized runs of the new test and of each of the four touched tests:
250/250 OK, 0 FAIL. The broader schema_inference family has 11 failures that reproduce
identically on the pristine base binary (missing s3_conn named collection, missing test.hits,
CANNOT_STAT on user_files staging), environmental to a single-node sandbox, not caused by this
change.

Deliberately out of scope. Parquet's getter also omits parquet.allow_geoparquet_parser,
schema_inference_make_json_columns_nullable,
parquet.skip_columns_with_unsupported_types_in_schema_inference and parquet.local_time_as_utc
(Parquet/SchemaConverter.cpp:47, :389, :411, :1140). These were not measured for
order-dependence and are not touched here, so this PR does not make the cache key complete.
ORC needs no change: it already keys on max_parser_depth.

@groeneai

groeneai commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

cc @vitlibar @scanhex12, could you review this? The schema inference cache key is a hand-maintained allow-list of settings, and input_format_try_infer_exponent_floats and max_parser_depth were never added to it, so for a cached source the first query's setting value decides the inferred type every later query sees; Form registered no key getter at all and Parquet's omitted the depth setting.

@alexey-milovidov alexey-milovidov added the can be tested Allows running workflows for external contributors label Aug 5, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [fe3502f]

Summary:


AI Review

Summary

This PR fixes schema-inference cache key collisions by hashing the settings that actually change inferred types for row-based text formats, Form, Template, Parquet, and JSON mixed-array inference, and it adds focused regression coverage for the order-dependent cases. I re-checked the current fe3502f3 diff against the touched inference paths and the prior review threads, and I did not find any remaining correctness, compatibility, or safety issues in the patch itself.

Missing context / blind spots
  • ⚠️ The available Praktika data for this run only includes Build profile diff; I did not have broader stateless or integration job results to cross-check the new shell test and Parquet fixtures.
Final Verdict

✅ No new blockers or majors found in the current PR head. The previously reported cache-key omissions are fixed in the current code.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.80% -0.10%
Branches 78.70% 78.70% +0.00%

Changed lines: Changed C/C++ lines covered: 79/81 (97.53%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 5, 2026
Comment thread src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp Outdated
…t_parser

Both settings change the type schema inference produces for Parquet, so a
schema cached under one value was reused for the other and the first query's
value decided the type every later query saw.

input_format_parquet_local_time_as_utc selects the timezone of the inferred
DateTime64 for a timestamp column that is not adjusted to UTC
(Parquet/SchemaConverter.cpp:1140). With the cache warm this also changes the
value read back, not only the type name: on the not_utc.parquet fixture a
DESC at =1 followed by a read at =0 returns 2023-01-01 12:00:00 where reading
with the cache disabled returns 13:00:00.

input_format_parquet_allow_geoparquet_parser decides whether a GeoParquet
geometry column is inferred as a geo type or as its raw String representation
(SchemaConverter.cpp:47), so on 03445_geoparquet_null_linestring.parquet a
poisoned entry reports LineString where Nullable(String) is correct, or the
reverse.

The sibling native ORC reader already keys skip_columns_with_unsupported_types
for the same reason (NativeORCBlockInputFormat.cpp:3001, added in
e88e3c6 with regression test 04514).

Reported by the AI reviewer on ClickHouse#113533.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp Outdated
input_format_parquet_skip_columns_with_unsupported_types_in_schema_inference decides whether a
column whose Parquet type is not implemented is dropped from the inferred schema or the file is
rejected, but it was absent from the Parquet schema-inference cache key. A source whose schema was
cached by a permissive query was therefore reused by a later strict query, which silently got the
reduced schema instead of the exception it should have raised.

Measured on the fixture added here, a Parquet file with a VARIANT-typed column, which is a valid
logical type the reader does not implement. With the cache disabled the verdict flips: at 1 the
column is dropped and DESC reports only `id`, at 0 inference throws INCORRECT_DATA. With the cache
enabled and the permissive query first, the following strict query returned the cached schema in
5 of 5 runs, and the key was byte-identical at both setting values. Only this direction is a
carrier, because the strict query throws and a throwing inference caches nothing.

The native ORC reader already keys its equivalent setting and 04514 is its regression test, so this
brings Parquet in line with it. schema_inference_make_json_columns_nullable also reaches an inferred
type from this getter but is deliberately left alone: PR 102499 adds that same field to this same
format-info string.

Test arms cover the poisoning order and a control asserting that the strict query throws on its own,
plus the cache-key string itself. Dropping the new field again reddens those two arms and no other.
The order arm greps for the exception the strict query must raise, which it also raises when nothing
was cached at all: measured, the arm still reports 1 with schema_inference_use_cache_for_file = 0.
Reading the entry back shows the permissive query left one, keyed on its own setting value, so the
strict query bypassed a cache entry rather than finding none.

Dropping the field from the getter now reddens this arm too, and still no arm of another setting.
…llable

schema_inference_make_json_columns_nullable decides whether a required JSON column is inferred as
JSON or Nullable(JSON), but it was absent from the Parquet schema-inference cache key, so a cached
source returned whichever the first query had inferred.

Measured on the fixture added here, a Parquet file with a required JSON column. With the cache
disabled the type flips between JSON and Nullable(JSON). With the cache enabled the second query
returned the first query's type in 3 of 3 runs in both orders. The column must be required: an
optional one is nullable at either value, so it cannot show the difference.

PR 102499 adds this same field to this same format-info string. Waiting for it was the earlier plan
here, but it has been open since April across 176 files and already conflicts with this branch in
this file, so this key stays incomplete for as long as that PR takes. The conflict is a one-line
resolution either way and is not avoided by leaving the setting out.

Dropping either of the last two fields from the getter reddens only that setting's own arms, so both
are pinned independently.
An entry being present does not prove a later query read it: with schema_inference_use_cache_for_file
off, every query re-infers the correct type and rewrites the same entry, so the whole test still
passes. Measured, SchemaInferenceCacheSchemaHits is 1 with cache reads live and 0 with them bypassed,
so repeating one query at unchanged settings and requiring a hit closes that gap.
@clickhouse-gh

clickhouse-gh Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing fe3502f3a with master 7a9ada8cf (stripped binary size, per-symbol sizes and ThinLTO time; object sizes against the warmup build of b0f84f4ac; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 689.45 MiB 686.33 MiB -3.12 MiB (-0.45%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Compile time of recompiled translation units

10 translation units recompiled, 26 s compile time in total, 9 of them have a recent master baseline.

Job report

00900_long_parquet_load_2 sweeps tests/queries/0_stateless/data_parquet/ with
find and loads every file it sees, so the two fixtures this PR added there were
picked up by it.

parquet_variant_logical_type.parquet carries a VARIANT logical type on a leaf
column, which the reader rejects with INCORRECT_DATA by design; that is what the
04757 skip_columns_with_unsupported_types arm needs. Loading it in 00900 prints
that exception into stdout, which clickhouse-test reports as "having exception in
stdout" before it ever reaches the reference comparison. That masked a second
failure: both new fixtures are absent from the reference, so the file also failed
"result differs with reference".

Excluding both matches how every other fixture that exists for another test is
handled here, including the four 04065 wrapper fixtures and the 04099 dictionary
memory fixture, neither of which throws. The alternative, regenerating the
reference, cannot work for the VARIANT file: the exception check runs first, and
no currently swept file emits an exception.

Verified with a driver that replays 00900's loop and applies both clickhouse-test
predicates. Before: FAIL on the exception check at the VARIANT file, plus +9
reference lines. After: both checks pass, output byte-identical to the reference.
Excluding only one file leaves the other failing on its own reason, so both
entries are load-bearing. 00900 is tagged no-debug and cannot run through the
runner in a debug build, which is why CI only reports it on arm_binary.
Comment thread src/Formats/EscapingRuleUtils.cpp
groeneai and others added 2 commits August 6, 2026 08:38
…ferent_types

The JSON escaping-rule branch of getAdditionalFormatInfoByEscapingRule hashes nine
settings.json fields but not infer_array_of_dynamic_from_array_of_different_values,
which transformInferredJSONTypesIfNeeded and transformFinalInferredJSONTypeIfNeededImpl
consult when choosing between Array(Dynamic) and an unnamed Tuple for a heterogeneous
JSON array. Both values therefore shared one cache entry, so the first query for a
source decided the array type every later query saw.

Measured on a mixed-type array, cache off: the setting flips the type between
Array(Dynamic) and Tuple(Nullable(Int64), Nullable(String), Array(Nullable(Int64))).
With the cache on it was order-dependent in both directions, 5 of 5 runs per order,
with the recorded key byte-identical either way. After the change each query gets the
type it asked for and the source keys two entries instead of one.

This closes the shared getter: across all five escaping-rule branches, every
settings.json, settings.csv and settings.tsv field the inference implementation reads
is now hashed, so the read-but-unkeyed set is empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…amic arms

The two order arms re-infer at each setting value either way, so they hold even if
cached schemas were never read back and the key row only proves two entries exist.
Repeating one query at unchanged settings pins the read: SchemaInferenceCacheSchemaHits
is 1 with the cache live and the event is never incremented when reads are bypassed.

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

groeneai commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

CI finish ledger — fe3502f

Every failure below has an owner: a fixing PR (mine or external), or a full-effort fix task
whose fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

175 check-runs, all completed: 158 success / 17 skipped / 0 failure. Config Workflow and
Finish Workflow both succeeded. The owner table is empty.

Check / test Reason Owner / fixing PR
(no failures)

Session id: cron:our-pr-ci-monitor:20260806-133000

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

Labels

can be tested Allows running workflows for external contributors pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants