Skip to content

[CALCITE-7807] Reuse unchanged SQL types during least-restrictive inference - #5281

Open
FrankChen021 wants to merge 1 commit into
apache:mainfrom
FrankChen021:codex/reuse-unchanged-sql-types
Open

FrankChen021 wants to merge 1 commit into
apache:mainfrom
FrankChen021:codex/reuse-unchanged-sql-types

Conversation

@FrankChen021

@FrankChen021 FrankChen021 commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Fixes CALCITE-7807.

Why

During least-restrictive character-type inference, Calcite can apply a charset and collation that a BasicSqlType already has. createWithCharsetAndCollation nevertheless creates an equivalent BasicSqlType and SerializableCharset, after which type-factory canonicalization returns the original instance.

In Druid's string-IN planning benchmark, avoiding this redundant construction reduced allocation by 9.05% at 100,000 literals and 8.72% at 1,000,000 literals. Timing confidence intervals overlapped, so no latency improvement is claimed.

What

Before: already-correct BasicSqlType -> allocate equivalent type -> canonize -> original type
After:  already-correct BasicSqlType -> return this -> canonize -> original type

Return the current immutable type when the requested charset is equal and the collation is the same instance. Collation identity is intentional because SqlCollation.equals does not distinguish coercibility. Requests that change either attribute retain the existing construction and canonicalization path.

This is an unchanged-attribute fast path, not a cache; it neither depends on a previous invocation nor retains derived types.

Verification

  • SqlTypeFactoryTest covers unchanged attributes, a different charset, and a collation that differs only in coercibility.
  • ./gradlew :core:test --tests org.apache.calcite.sql.type.SqlTypeFactoryTest :core:autostyleJavaCheck :core:checkstyleMain :core:checkstyleTest (27 completed, 0 failed)
  • Druid InPlanningBenchmark.queryStringInSqlPlanOnly with -prof gc
Druid benchmark results
String literals Allocation before Allocation after Reduction
100,000 1,450.76 MiB/op 1,319.51 MiB/op 131.24 MiB/op (9.05%)
1,000,000 14,703.47 MiB/op 13,421.72 MiB/op 1,281.76 MiB/op (8.72%)

Configuration: inSubQueryThreshold=2147483647, rowsPerSegment=500000, 2 forks, 2 one-second warmup iterations, and 5 one-second measurement iterations. Allocation is cumulative bytes per operation, not retained or peak heap.

The performance measurement currently comes from Druid; a Calcite-local ubenchmark is not yet included.

Scope

BasicSqlType is public and non-final, so an unchanged request now preserves a subclass instance instead of returning a plain canonical BasicSqlType. Calcite has no such subclasses, and createWithNullability already follows the same pattern. The pre-existing omission of collation coercibility from type canonicalization is outside this change.

Copilot AI lite review requested due to automatic review settings September 22, 2026 10:21

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@FrankChen021 FrankChen021 changed the title [CALCITE-7805] Reuse unchanged SQL types during least-restrictive inference [CALCITE-7807] Reuse unchanged SQL types during least-restrictive inference Sep 22, 2026

@julianhyde julianhyde left a comment

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.

-1 slop

@vlsi vlsi left a comment

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.

The change is correct as far as I can tell. createWithCharsetAndCollation has one production caller, SqlTypeFactoryImpl.createTypeWithCharsetAndCollation, and that caller passes the result to canonize. So canonize(this) returns the same interned instance that canonize(new ...) returned before, and only the allocation changes. I confirmed that the new test fails on 38413ec without the fix, at the first assertSame.

I'd like a few changes before merging; the code-level ones are inline.

Commit message. The commit subject says [CALCITE-7805], but this PR and the summary match CALCITE-7807. CALCITE-7805 is the umbrella issue for allocations in large string ARRAYs. Please rename the commit to [CALCITE-7807] Reuse unchanged SQL types during least-restrictive inference.

Subclasses. BasicSqlType is public and not final. Before this change, createWithCharsetAndCollation always returned a plain BasicSqlType. Now, when the attributes are unchanged, it returns a subclass instance as it is. Calcite has no subclasses, and createWithNullability already behaves this way, so I'm fine with it. Still, please mention it in the description.

Description. It still contains notes from earlier iterations: "The removed no-op SqlTypeFactoryImpl refactoring was excluded" and "based directly on main at 38413ece6". Please drop them.

A related issue that exists before this PR. The digest of a character type includes neither coercibility nor the IMPLICIT and COERCIBLE collations, so canonize returns whichever variant was interned first. Through the factory, createTypeWithCharsetAndCollation(VARCHAR NOT NULL /* IMPLICIT */, ISO-8859-1, SqlCollation.COERCIBLE) returns the same IMPLICIT instance. This PR does not make that worse. It does mean that "Execution Path 1" in the description allocates the derived type and then discards it, and that in path 2 the common type may already be COERCIBLE, in which case the fast path does not apply. I think this deserves its own JIRA issue, since it is about correctness rather than allocation.

BasicSqlType createWithCharsetAndCollation(Charset charset,
SqlCollation collation) {
checkArgument(SqlTypeUtil.inCharFamily(this));
if (collation == this.collation && Objects.equals(charset, getCharset())) {

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.

SqlCollation.equals compares only collationName and ignores coercibility, so SqlCollation.IMPLICIT.equals(SqlCollation.COERCIBLE) is true. If someone later replaces == with Objects.equals(...), a request for COERCIBLE returns the IMPLICIT type. I tried that change: the test catches it only through the COERCIBLE case, which does not say that this is what it guards. A one-line comment keeps the next person from making that change.

charset is never null here, so Objects.equals is not needed either, and the java.util.Objects import can go:

Suggested change
if (collation == this.collation && Objects.equals(charset, getCharset())) {
// SqlCollation.equals ignores coercibility (IMPLICIT.equals(COERCIBLE)),
// so compare collations by reference.
if (collation == this.collation && charset.equals(getCharset())) {

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.

Note: it might be we should add something like SqlCollation#isSameAs(SqlCollation) so we compare the collation instead of "identity only" or add coercibility to equals/hashCode. Both changes require analysis.

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.

for the Objects.equals comment here, that's true the charset is not null, however, getCharset is declared as Nullable, the CheckerFramework reports error for the change you propose. So Objects.equals would be the most likely concise statement here.

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.

You are right: with -PenableCheckerframework on JDK 11, charset.equals(getCharset()) fails with argument.type.incompatible. Please keep Objects.equals.

Comment thread core/src/test/java/org/apache/calcite/sql/type/SqlTypeFactoryTest.java Outdated
Comment thread core/src/test/java/org/apache/calcite/sql/type/SqlTypeFactoryTest.java Outdated
Comment thread core/src/test/java/org/apache/calcite/sql/type/SqlTypeFactoryTest.java Outdated
@FrankChen021

Copy link
Copy Markdown
Member Author

@vlsi Thanks for the review. Let me address these comments.

@FrankChen021

FrankChen021 commented Sep 27, 2026 •

Copy link
Copy Markdown
Member Author

@vlsi Thanks again for your detailed feedback.

I have addressed the review comments:

  • Rewrote the commit message with the correct CALCITE-7807 issue and rationale.
  • Documented why collation must be compared by identity.
  • Split the test into focused cases covering unchanged attributes, a changed charset, and changed coercibility.
  • Added assertions for the resulting charset and collation.
  • The canonicalization concern remains outside this PR.
  • For SqlCollation#isSameAs, if you agree that we add a method in the SqlCollation to replace current reference comparision, I can update to include it as:
public boolean isSameAs(@Nullable SqlCollation that) {
  return that != null
      && equals(that)
      && coercibility == that.coercibility;
}

Let me know if you have other comments.

@sonarqubecloud

Copy link
Copy Markdown

@FrankChen021
FrankChen021 requested a review from vlsi September 27, 2026 04:56
…erence

Return the current BasicSqlType when its charset and collation are unchanged, avoiding temporary type allocation during least-restrictive inference. Use Objects.equals because BasicSqlType.getCharset() is nullable. Compare collations by identity because equals ignores coercibility.
@FrankChen021
FrankChen021 force-pushed the codex/reuse-unchanged-sql-types branch from ed12584 to f4953f6 Compare September 27, 2026 07:14
/** {@link SqlCollation#equals} ignores coercibility, so COERCIBLE equals
* IMPLICIT; the type must still be rebuilt. */
@Test void testCollationDifferingOnlyInCoercibilityIsNotReused() {
final BasicSqlType type = (BasicSqlType) new SqlTypeFixture().sqlVarchar;

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.

sqlVarchar comes from the global DATATYPE_CACHE, and the digest omits IMPLICIT/COERCIBLE, so the fixture holds whichever variant was interned first in this JVM. If a COERCIBLE VARCHAR NOT NULL gets there first, the fast path returns this and the test fails although the code is right; the precondition "differs only in coercibility" is not asserted. Build the base type outside the cache so the IMPLICIT is visible in the test:

    final BasicSqlType type =
        new BasicSqlType(RelDataTypeSystem.DEFAULT, SqlTypeName.VARCHAR, 10)
            .createWithCharsetAndCollation(StandardCharsets.ISO_8859_1, SqlCollation.IMPLICIT);

final BasicSqlType utf16Type =
type.createWithCharsetAndCollation(StandardCharsets.UTF_16, SqlCollation.IMPLICIT);

assertNotSame(type, utf16Type, "a different charset must produce a new type");

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.

The message restates the test name, and the failure prints only <VARCHAR>. The assertions after it already fail if this is returned, since the type is immutable, and they print the charset and collation. Please drop the assertNotSame here and in testCollationDifferingOnlyInCoercibilityIsNotReused.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants