fix!: replace assert-based validation with real exceptions - #1058
fix!: replace assert-based validation with real exceptions#1058nielspardon wants to merge 2 commits into
Conversation
Java assertions only run when the host JVM is started with -ea, which is true for Gradle's test JVMs and for almost nothing else. The assert-based invariant checks in :core and :isthmus were therefore enforced in CI and silently skipped in every real deployment. What that costs today: a non-literal where a literal is required is converted to an empty literal (a protobuf oneof getter returns the default instance when its case is not set), an empty NestedList throws IndexOutOfBoundsException from getType() instead of pointing at ExpressionCreator.emptyList(), and a missing Calcite catalog entry becomes an NPE inside Calcite rather than the "Table not found in Calcite catalog" message the same file already produces. Caller-facing invariants -- builder input, proto messages, Calcite RelNodes -- now throw IllegalArgumentException; internal and configuration invariants throw IllegalStateException. One check is dropped rather than converted: the null check on a value just assigned from a cast in SubstraitRelNodeConverter. A custom PMD rule (AvoidAssertStatement) keeps new asserts out of both main and test sources, which is why the one assert in isthmus test code is converted too. BREAKING CHANGE: validation that previously used `assert` now throws unconditionally. Callers catching AssertionError must catch IllegalArgumentException or IllegalStateException instead, and code running without -ea -- i.e. most deployments -- will now see these checks fire: VirtualTableScan and Expression.NestedList reject malformed input both from their builders and from ProtoRelConverter / ProtoExpressionConverter, and VariadicParameterConsistencyValidator throws IllegalArgumentException rather than AssertionError. Closes substrait-io#1047
ffc0d0e to
cb11d79
Compare
| * @return the Calcite catalog to resolve table names against | ||
| * @throws IllegalStateException if the RelBuilder was created without a RelOptSchema | ||
| */ | ||
| private RelOptSchema requireRelOptSchema() { |
There was a problem hiding this comment.
I don’t think this check is quite enough yet. It verifies that the RelBuilder has a RelOptSchema, but both callers ultimately need a Prepare.CatalogReader.
In the UPDATE path, the reader comes from table.getRelOptSchema(), which Calcite marks @Nullable. After dropping the old null assertion, we can still build a LogicalTableModify with a null catalog reader and get an NPE later from getExpectedInputRowType(). In the write path, a non-CatalogReader implementation will fail at the cast with ClassCastException, rather than the IllegalStateException this PR is aiming for.
Could we validate the actual value as a Prepare.CatalogReader before creating LogicalTableModify, and add a small test for the null and wrong-type cases?
There was a problem hiding this comment.
You're right on both counts, and the null one is worse than I assumed — thanks for catching it.
I dropped the catalogReader != null assert because the issue's table listed it as a dead check on "a value just assigned from a cast". That was wrong: the value comes from table.getRelOptSchema(), which Calcite declares @Nullable, and (Prepare.CatalogReader) null casts happily. It's also a different schema from the relBuilder.getRelOptSchema() that requireRelOptSchema() validates, so that helper never covered it. TableModify then stores the reader with no null check (the field is non-@Nullable, unlike the ones right below it) and only dereferences it in getExpectedInputRowType(), exactly as you describe.
Fixed in 45780f2 with a requireCatalogReader helper used at both sites. It narrows via instanceof, so a single check covers null and wrong-type, and the message names the class it actually got. The schema sources are unchanged — UPDATE still reads it from the resolved table, the write path from the RelBuilder — so this is validation only.
Tests are in CatalogReaderValidationTest: a RelOptSchema that resolves tables through a real catalog but deliberately isn't a CatalogReader, handing out tables whose getRelOptSchema() is either that schema or null. Three cases: write path wrong-type, update path null, update path wrong-type. I checked they fail without the fix, and the null case is the interesting one:
namedUpdateRejectsTableSchemaThatIsNotACatalogReader() FAILED
expected: <IllegalStateException> but was: <ClassCastException>
namedWriteRejectsSchemaThatIsNotACatalogReader() FAILED
expected: <IllegalStateException> but was: <ClassCastException>
namedUpdateRejectsTableWithoutASchema() FAILED
Expected java.lang.IllegalStateException to be thrown, but nothing was thrown.
That last line is the point: conversion completed and produced a LogicalTableModify carrying a null catalog reader, with the failure deferred to whoever touched it next.
There was a problem hiding this comment.
Thanks for the detailed follow-up! The fix and tests look good to me.
…ions `LogicalTableModify` needs a `Prepare.CatalogReader`, and neither site that builds one was checking that it had a usable value: - The UPDATE path casts `RelOptTable.getRelOptSchema()`, which Calcite declares `@Nullable`. `TableModify` stores the reader unchecked and only dereferences it later (`getExpectedInputRowType()` for UPDATE), so a null survives conversion and fails as an NPE somewhere else entirely. Dropping the `catalogReader != null` assert here was wrong: the cast succeeds for null, and this is a different schema from the one `requireRelOptSchema()` validates. - Both paths cast a `RelOptSchema` to `Prepare.CatalogReader` without checking the type, so a schema that is not a catalog reader fails as a `ClassCastException` rather than a reportable error. `requireCatalogReader` now narrows the schema at both sites. It uses `instanceof`, so one check covers the null and the wrong-type case, and the message names the actual class. The schema sources are unchanged: UPDATE still reads it from the resolved table, the write path from the RelBuilder.
assertcompiles behind$assertionsDisabled, so the invariant checks in:coreand:isthmusfired in Gradle's test JVMs (which enable-eaby default) and nowhere else. Theinvariants were documented by the code and enforced in CI, but not in the place where a
malformed plan actually causes damage. And when they do run,
AssertionErroris anError,not an
Exception, so a host that wraps plan conversion incatch (Exception e)to report abad plan does not catch it — it propagates as a hard failure.
Three concrete costs today, which is what a consumer sees:
ExpressionProtoConverter.toLiteral()— a protobuf oneof getter returns the defaultinstance when its case is not set, so a non-literal where a literal is required is silently
converted to an empty literal. We emit a wrong plan instead of failing.
Expression.NestedList—getType()readsvalues().get(0), so an empty nested list throwsIndexOutOfBoundsExceptionfrom an unrelated place instead of the assert's"use
ExpressionCreator.emptyList()" hint.SubstraitRelNodeConvertertargetTable— a missing catalog entry becomes an NPE insideCalcite rather than the clear
"Table not found in Calcite catalog"message the same filealready produces 150 lines earlier.
The rule applied
RelNode) →IllegalArgumentException→
IllegalStateExceptionassertleft, anywhere. An assert that can fail is worse than useless; an assertthat genuinely cannot fail costs nothing to write as an explicit throw.
:coreVirtualTableScan.check()— name count vs depth-first named-field count, no null names/rows, row shape, rows not nullable, row field types match schemaIllegalArgumentExceptionExpression.NestedList.check()— non-empty, all values same typeIllegalArgumentExceptionExpressionProtoConverter.toLiteral()IllegalArgumentExceptionVariadicParameterConsistencyValidator(already threwAssertionErrorunconditionally)IllegalArgumentExceptionVirtualTableScan.check()'s compound assert is split into one check per invariant so themessage names the mismatched counts, and the null-element checks now run before the
row.nullable()loop — a null row previously NPE'd there before reaching its own assert.:isthmusSubstraitRelVisitor×2 (INSERT/DELETE, UPDATE) —modify.getTable()IllegalArgumentExceptionSubstraitRelNodeConverter×2 —relBuilder.getRelOptSchema()IllegalStateExceptionSubstraitRelNodeConverter—catalogReader, asserted on a value just assigned from a castSubstraitRelNodeConverter—targetTableIllegalStateException, reusing the existing messageSqlMapValueConstructorCallConverter— operand count evenIllegalArgumentExceptionCallConverters.CASE— operand count oddIllegalArgumentExceptionFunctionConverter.matchKeys()— private, internalIllegalStateExceptionCreateTable.copy(),CreateView.copy()—inputs.size() == 1IllegalArgumentExceptionTwo small refactors fall out of this: a
requireTable(TableModify)helper inSubstraitRelVisitor(which also collapses the repeatedmodify.getTable()calls) and arequireRelOptSchema()helper inSubstraitRelNodeConvertershared by both schema checks. ThetargetTablenull check deliberately stays after theswitch— the CTAS branch returnsearlier and legitimately has no pre-existing table, so hoisting it to the lookup would break
handleCreateTableAs.The three stale
@throws AssertionErrorJavadoc tags are updated.Guarding against regressions
A custom PMD rule
AvoidAssertStatement(//AssertStatement) insubstrait-pmd.xmlfails thebuild on any new
assert, and its violation message states theIllegalArgumentException/IllegalStateExceptionrule above at the offending line. PMD scans test source sets too, so theone assert in isthmus test code (
RepeatRel.copy()) is converted as well.Two calls worth a second opinion
Plan.Root.check()is the precedent — hardIllegalArgumentExceptionforthe invariant,
LOGGER.warnonly for its one legacy allowance.Type.equals, so nullability must matchprecisely. That is the strictest reading of the spec and the most likely thing to reject another
producer's plan, but relaxing it would be a semantic change rather than part of this one.
:sparkneeded no changes: its Scala sources userequire(...), not the Javaassertkeyword.BREAKING CHANGE: validation that previously used
assertnow throws unconditionally. Callerscatching
AssertionErrormust catchIllegalArgumentExceptionorIllegalStateExceptioninstead, and code running without
-ea— i.e. most deployments — will now see these checksfire:
VirtualTableScanandExpression.NestedListreject malformed input both from theirbuilders and from
ProtoRelConverter/ProtoExpressionConverter, andVariadicParameterConsistencyValidatorthrowsIllegalArgumentExceptionrather thanAssertionError.Closes #1047
🤖 Generated with AI