Vladimir Sitnikov created CALCITE-7893:
------------------------------------------
Summary: Type mapping rules can be corrupted when identity hash
codes of SqlTypeName values collide
Key: CALCITE-7893
URL: https://issues.apache.org/jira/browse/CALCITE-7893
Project: Calcite
Issue Type: Bug
Components: core
Reporter: Vladimir Sitnikov
{{SqlTypeAssignmentRule}} and {{SqlTypeCoercionRule}} build their type tables
once per JVM. If two different sets of {{SqlTypeName}} passed to the builder
have the same hash code, the type added later receives the source set of the
type added earlier. {{SqlTypeName}} is an enum, so its hash code is the
identity hash code, and a set's hash code is the sum of its elements' hash
codes. Identity hash codes change from one JVM run to the next, so the
corruption is probabilistic: a query can pass in one run and fail in the next,
and the wrong table stays in effect until the JVM exits.
With a corrupted table, the validator accepts casts it should reject, rejects
valid calls, and resolves user-defined functions to the wrong overload. I have
not observed a collision with default JVM settings.
h3. How to reproduce
The JVM option {{-XX:+UnlockExperimentalVMOptions -XX:hashCode=2}} makes every
identity hash code equal to 1, so every collision that can happen does happen.
It is a way to make the defect deterministic, not a setting anyone runs in
production.
Gradle init script {{hash.gradle}}:
{code:groovy}
allprojects {
tasks.withType(Test).configureEach {
jvmArgs '-XX:+UnlockExperimentalVMOptions', '-XX:hashCode=2'
}
}
{code}
{code:bash}
./gradlew -I hash.gradle :core:test --tests
org.apache.calcite.test.SqlValidatorTest --tests org.apache.calcite.test.UdfTest
{code}
With the option, the validator accepts this query:
{code:sql}
values (cast(DATE '1243-12-01' as TIME))
{code}
Expected: validation fails with {{Cast function cannot convert value of type
DATE NOT NULL to type TIME}}, as {{SqlValidatorTest#testCastFails}} asserts.
Actual:
{noformat}
java.lang.AssertionError: Expected query to throw exception, but it did not;
query [values (cast(DATE '1243-12-01' as TIME))]; expected [(?s).*Cast function
cannot convert value of type DATE NOT NULL to type TIME.*]
at org.apache.calcite.sql.test.SqlTests.checkEx(SqlTests.java:251)
at
org.apache.calcite.test.SqlValidatorTest.testCastFails(SqlValidatorTest.java:1626)
{noformat}
The table itself is wrong:
{{SqlTypeMappingRules.instance(false).getTypeMapping().get(SqlTypeName.TIME)}}
returns {{[DATE]}}, while {{SqlTypeAssignmentRule}} declares TIME assignable
from {{[TIME]}} only. The same corruption breaks UDF lookup, for example in
{{UdfTest#testUserDefinedFunctionWithMethodName}}:
{noformat}
Suppressed: org.apache.calcite.util.TestUtil$ExtraInformation: With
materializationsEnabled=false, limit=-1, sql=values "adhoc"."fun1"('Foo Bar')
Caused by: java.lang.NumberFormatException: For input string: "Foo Bar"
at
org.apache.calcite.linq4j.tree.Primitive.checkNumericString(Primitive.java:463)
at org.apache.calcite.runtime.SqlFunctions.toInt(SqlFunctions.java:5519)
{noformat}
and {{UdfTest#testDate}} fails with {{No match found for function signature
timeFun(<TIME>)}}.
Locally on JDK 21.0.9 this fails 11 tests: {{SqlValidatorTest#testCastFails}}
and 10 tests in {{UdfTest}}. In CI ([run
40321987597|https://github.com/vlsi/calcite/actions/runs/40321987597], JDK 11,
21, and 24 on Linux, macOS, and Windows) the four jobs with the option that
reached {{:core:test}} each failed the same 41 tests there, among them
{{JdbcTest}} table macro and table function tests, {{SqlAdvisorJdbcTest}},
{{testCastDateTime}} in the operator tests, {{InterpreterTest}},
{{TableFunctionTest}}, and {{sql/spatial.iq}}. Jobs without the option passed.
h3. Cause
{{SqlTypeMappingRules.Builder#add}}
([SqlTypeMappingRules.java:77|https://github.com/apache/calcite/blob/2df193e496/core/src/main/java/org/apache/calcite/sql/type/SqlTypeMappingRules.java#L77])
uses the caller's set as the key of a Guava {{LoadingCache}}.
{{SqlTypeAssignmentRule}} and {{SqlTypeCoercionRule}} pass one {{HashSet}}
named {{rule}}, which they clear and refill between calls, so every cache entry
is keyed by the same mutable object. The cache matches an entry by its stored
hash and then by {{equals}}, and the key always equals itself. Any lookup whose
set has the hash code of an earlier set therefore returns that earlier set.
{{Set#hashCode}} is the sum of the element hash codes, so under the option
every one-element set hashes to 1, and {{\{TIME\}}} receives the entry stored
for an earlier one-element set such as {{\{DATE\}}}.
h3. Proposed fix
Make {{SqlTypeMappingRules.Builder#add}} copy its argument into an immutable
set before it uses the set as a key:
{code:java}
void add(SqlTypeName toType, Set<SqlTypeName> fromTypes) {
map.put(toType, sets.computeIfAbsent(Sets.immutableEnumSet(fromTypes), k ->
k));
}
{code}
Here {{sets}} becomes a plain {{HashMap<ImmutableSet<SqlTypeName>,
ImmutableSet<SqlTypeName>>}}: {{add}} is package-private, and Calcite fills its
rule tables once, in static initializers, so the builder needs neither eviction
nor thread safety. Equal sets still share one instance, and the values stored
in the table are {{ImmutableEnumSet}}s as before. The callers can keep reusing
their {{rule}} set.
A regression test in {{SqlTypeUtilTest}} can force the collision without JVM
options: pass a {{HashSet}} subclass whose {{hashCode()}} returns a constant,
fill it with DATE, add it for DATE, refill it with TIME, add it for TIME, and
assert that TIME maps to {{[TIME]}}.
A local patch that passes {{ImmutableSet.copyOf(fromTypes)}} as the cache key
makes all 17088 tests of {{:core:test}} pass under the option (0 failures, 146
skipped).
--
This message was sent by Atlassian Jira
(v10.3.26#10030026)