Skip to content

[common] Keep the selected variant schema when its evidence is gone - #9532

Open
PDGGK wants to merge 3 commits into
apache:masterfrom
PDGGK:fix-variant-adaptive-no-counts
Open

[common] Keep the selected variant schema when its evidence is gone#9532
PDGGK wants to merge 3 commits into
apache:masterfrom
PDGGK:fix-variant-adaptive-no-counts

Conversation

@PDGGK

@PDGGK PDGGK commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Purpose

Adaptive variant shredding inference throws and fails the write when a node's evidence is gone but its selected schema is still a ROW.

finalizeAdaptiveSchema substitutes the previously selected schema when the current file has no evidence for a node:

if (combined == null || combined instanceof VariantType) {
    if (current != null && !(current instanceof VariantType)) {
        combined = current;
    } else if (previousSelected != null) {
        combined = previousSelected;          // InferVariantShreddingSchema:576-577
    } else {
        return DataTypes.VARIANT();
    }
}

Execution then falls into the RowType branch, which reads that substitute as if it were evidence:

double ratio = rootValueCount == 0 ? 0 : getFieldCount(field) / rootValueCount;   // :592

Selected schemas carry no per-field counts — finalizeSimpleSchema clears them, and the adaptive RowType branch rebuilds fields with the description-free three-argument DataField constructor — so getFieldCount throws:

java.lang.IllegalStateException: Field 'x' is missing count in description. This should not happen during schema inference.
  at InferVariantShreddingSchema.getFieldCount(InferVariantShreddingSchema.java:394)
  at InferVariantShreddingSchema.finalizeAdaptiveSchema(InferVariantShreddingSchema.java:592)
  at InferVariantShreddingSchema.finalizeAdaptiveSchema(InferVariantShreddingSchema.java:615)
  at InferVariantShreddingSchema.inferAdaptive(InferVariantShreddingSchema.java:134)
  at VariantShreddingInferenceSession.inferSchema(VariantShreddingInferenceSession.java:74)

Getting there needs three things in one rolling writer with variant.inferShreddingSchema=true and variant.shredding.inferenceMode=adaptive: a node drifts across a type family, which degrades its combined evidence to VARIANT while the schema selected for it is a ROW; and it is then absent from the next file, so there is no current evidence either. Two files cannot do it — when a node is absent, combineEvidence falls back to the previous file's evidence, which still carries counts — so it takes three.

The exception comes out of InferShreddingWritePlanWriter uncaught, so the file fails to write at all.

Three files of a ROW<v VARIANT> table, at the default options, are enough:

file rows result
1 {"k":1,"p":5} plans, p selected as BIGINT
2 {"k":1,"p":{"x":1}} plans, p selected as ROW<x BIGINT>, combined evidence for p degrades to VARIANT
3 {"k":1} throws

A root-level variant of the same shape — 42, then {"a":1}, then SQL NULL — fails the same way with Field 'a'.

Summary and Changelog

Return previousSelected instead of assigning it to combined. It is already a finalized selection: it has been through admission, retention and the field budget once, and with no evidence for this file there is nothing to re-threshold it against. Carrying it forward unchanged is also what the retention path does for a node whose evidence is still a RowType.

Tests

InferVariantShreddingSchemaTest#testAdaptiveInferenceKeepsSelectedRowWhenEvidenceDegradedAndNodeIsAbsent drives the three files above and asserts p keeps ROW<x BIGINT>. On master it errors with the IllegalStateException above, and the other 24 cases in the class are unaffected either way.

Four negative controls, run against a build of this ref, confirm the trigger is that specific combination rather than absence alone — no drift and p merely disappears; drift but p still present in file 3; object all the way, which is the shape every existing adaptive test uses; and root scalar to scalar to NULL. All four pass before and after.

The reason no existing test catches this: the four adaptive cases in InferVariantShreddingSchemaTest and InferVariantShreddingWriteTest all keep the root variant an object across every file, so combined evidence stays a RowType and line 577 is never reached with a RowType in previousSelected. testAdaptiveInferenceWidensScalarSelectedFromPriorEvidence comes closest — its second column has no current evidence in round 2 — but its combined evidence there is the previous file's evidence, which still carries counts.

mvn test -pl paimon-common -Dtest='org.apache.paimon.data.variant.**' — 52 tests, and -pl paimon-format -Dtest=InferVariantShreddingWriteTest — 18 tests, all passing. spotless:check and checkstyle:check clean.

Tests API and Compatibility

No API, format or configuration change. Only the path that currently throws behaves differently; every schema that infers successfully today infers the same way.

finalizeAdaptiveSchema falls back to the previously selected schema when a
node has no evidence in the current file, then runs it through the RowType
branch as if it were evidence. Selected schemas carry no per-field counts,
so getFieldCount throws IllegalStateException and the whole file fails to
write.

Reaching it needs a node that drifted across a type family, which degrades
its combined evidence to VARIANT while its selected schema is a ROW, and
that is then absent from the next file. Return the selected schema as it
is instead: it is already a finalized selection and has nothing left to
threshold.
// not
// evidence - its fields carry no counts - so it cannot be run through admission and
// retention again; carry it forward unchanged.
return previousSelected;

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.

[P2] Count preserved descendants against the shared width budget

maxFields is shared across every Variant path in this inference, but this early return only consumes the single unit decremented at method entry. A preserved RowType/ArrayType can contain descendants that would normally consume additional units, so later Variant columns can now exceed variant.shredding.maxSchemaWidth.

I reproduced this with maxSchemaWidth = 8 and two Variant columns: the first evolves from p: scalar to p: {x: scalar} and then omits p, while the second adds a new r field in the third round. This branch carries ROW<x BIGINT> forward without accounting for x, then also selects q and r in the second column, using 9 budget units. A focused assertion that r remains untyped fails because it becomes BIGINT.

Please preserve the selected schema while also walking/debiting its nested width (and handling insufficient remaining budget consistently) before returning.

Returning the selected schema early only spent the single unit taken at
method entry, so its descendants occupied width that maxSchemaWidth never
counted and a later variant column could overspend the shared budget.

Walk the retained schema instead, charging one unit per node and dropping
what no longer fits, which is what the evidence-driven walk does.
List<DataField> fields = new ArrayList<>();
for (DataField field : ((RowType) selected).getFields()) {
maxFields.remaining--;
if (maxFields.remaining <= 0) {

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.

[P2] Preserve the retained field/container at the last budget unit

This does not match the evidence-driven walk described above. When one unit remains, that walk enters the child, decrements the unit to zero, returns VARIANT, and still adds the parent DataField; this helper decrements first and breaks before adding it. If it was the only field, line 678 then collapses the whole retained RowType to the root VARIANT. The Array branch similarly collapses the array instead of retaining ARRAY<VARIANT>.

I reproduced this with maxSchemaWidth = 7 and two Variant columns. Round 1 is (1, 5), round 2 is (1, {q:1}), and round 3 is ({x:1,y:1}, null). Column a consumes five units, leaving exactly the root plus one field unit for retained column b. The updated code returns an untyped root for b and loses q; a focused assertion that b still contains q fails.

Please mirror finalizeAdaptiveSchema at exhaustion: retain the row field/array container and downgrade the exhausted child to VARIANT, rather than breaking/returning before preserving the parent shape.

The helper decremented before its guard, so the field that consumed the
final unit was dropped rather than kept with a VARIANT child, and a
retained node holding only that field collapsed entirely. Enter a child
only while budget remains, spend the unit on entry, and downgrade an
exhausted child to VARIANT with its field or array container intact,
which is what the evidence-driven walk does.
@PDGGK

PDGGK commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Both P2s confirmed and fixed in 20d2ec77 — I reproduced each of them against an unmodified build first.

Width budget. With maxSchemaWidth = 8, r came out ROW<value BYTES, typed_value BIGINT>, so the second column did get to spend what the carried-forward ROW<x BIGINT> was holding. The branch now walks the retained schema instead of returning it.

Exhaustion semantics. Your second point was right about my first attempt: it decremented before its guard, so the field that consumed the final unit was dropped rather than kept with a VARIANT child. With maxSchemaWidth = 7 and your rounds, b came back as an untyped root and lost q. It now enters a child only while budget remains, spends the unit on entry, and downgrades an exhausted child to VARIANT with its field or array container intact — the same shape as the loop in finalizeAdaptiveSchema.

Three controls, so each test is pinned to the thing it covers rather than to the crash:

source what fails
master both retention tests error with the IllegalStateException
early return, no debiting only testRetainedSchemaStillConsumesTheSharedWidthBudget, as an assertion failure
decrement before the guard only testRetainedSchemaKeepsItsFieldsAtTheLastBudgetUnit

mvn test -pl paimon-common -Dtest='org.apache.paimon.data.variant.**' — 54 tests, and -pl paimon-format -Dtest=InferVariantShreddingWriteTest — 18 tests, all passing. spotless:check and checkstyle:check clean.

((ArrayType) selected).getElementType(), maxFields);
return new ArrayType(element);
}
maxFields.remaining--;

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.

[P2] Do not charge retained VARIANT leaves twice

The caller already spends the node entry unit before retainSelectedSchema is reached. For a VariantType, there is no typed-value child to spend another unit on; the normal finalizeAdaptiveSchema path likewise returns VARIANT after only the entry debit. This branch therefore overcharges retained VARIANT leaves.

A minimal boundary case reproduces the regression: use two top-level variant columns with maxSchemaWidth = 3, infer/commit [null, 5], then infer [null, 6]. The first retained VARIANT should cost one unit and leave two for the second BIGINT, but this extra decrement exhausts the budget and the second column becomes untyped VARIANT. I confirmed that assertion fails against this head while the existing 27 tests pass. Please special-case VariantType here and add the shared-budget regression.

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.

2 participants