gaborgsomogyi commented on code in PR #28837:
URL: https://github.com/apache/flink/pull/28837#discussion_r3767600900
##########
flink-core/src/main/java/org/apache/flink/api/java/typeutils/runtime/PojoSerializerSnapshotData.java:
##########
@@ -206,7 +212,17 @@ void writeSnapshotData(DataOutputView out) throws
IOException {
private static <T> PojoSerializerSnapshotData<T> readSnapshotData(
DataInputView in, ClassLoader userCodeClassLoader) throws
IOException {
- Class<T> pojoClass = InstantiationUtil.resolveClassByName(in,
userCodeClassLoader);
+ final String pojoClassName = in.readUTF();
+ Class<T> pojoClass = null;
Review Comment:
This is heavier than a missing-class fix: it silently downgrades the
guarantee for the whole compatibility check, not just this one case.
Before this PR, a missing class was the one kind of incompatibility caught
eagerly and unconditionally, at parse time, independent of whether the state
was ever accessed. Every *other* kind of incompatibility (field/type changes on
a class that does resolve) has always relied solely on
`resolveSchemaCompatibility()`, which only runs if `getState()` is called for
that state. For RocksDB, that's a pre-existing characteristic of the whole
compatibility mechanism, not something new here.
This PR removes missing-class's special eager check, so it now falls back to
that same access-gated mechanism too. Practically: on RocksDB, a state that a
job upgrade stops referencing is never checked again for *any* form of
incompatibility, missing class included, not because this PR weakens RocksDB's
compatibility check in general, but because it removes the one thing that used
to be exempt from that pre-existing weakness.
Operationally this matters because restoring a new job version against a
prod savepoint in staging/CI is only a reliable schema-compatibility gate if
it's unconditional. Whether it still catches a missing class now depends on
which states happen to get touched during that run. It can pass staging clean
and fail in production later with no trace back to the deploy that caused it.
Could this be gated on whether a `CustomRestoreSerializerFactory` is already
registered at parse time, falling back to the original unconditional throw
otherwise? That keeps missing-class detection's original exemption intact for
ordinary restores while still enabling this feature's classless read.
##########
flink-core/src/main/java/org/apache/flink/api/java/typeutils/runtime/PojoDeserializerCompatibilitySnapshot.java:
##########
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.api.java.typeutils.runtime;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.api.common.typeutils.TypeSerializerSchemaCompatibility;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
+import org.apache.flink.core.memory.DataInputView;
+import org.apache.flink.core.memory.DataOutputView;
+
+import javax.annotation.Nullable;
+
+/**
+ * A {@link TypeSerializerSnapshot} for deserializers that can read POJO
binary data without the
+ * user POJO class being on the classpath, such as the {@code
PojoToRowDataDeserializer} of the
+ * State Processing API. It declares itself {@link
+ * TypeSerializerSchemaCompatibility#compatibleAsIs() compatible as-is} with
any stored {@link
+ * PojoSerializerSnapshot}.
+ *
+ * <p>The snapshot only ever exists in memory, wrapping the live deserializer
it was created from:
+ * composite compatibility checks (e.g. {@code
+ * CompositeTypeSerializerSnapshot#resolveOuterSchemaCompatibility}) restore
the "new" side of a
+ * composite serializer even when nested-level compatibility already
short-circuited to {@code
+ * compatibleAsIs()}, so {@link #restoreSerializer()} hands back the wrapped
instance rather than
+ * reconstructing one from persisted bytes.
+ */
+@Internal
+public final class PojoDeserializerCompatibilitySnapshot<T> implements
TypeSerializerSnapshot<T> {
Review Comment:
Does this need to live in core? Only consumer named in its own javadoc is
PojoToRowDataDeserializer in the State Processing API, and it doesn't need
core-internal access, just a public instanceof check. Could this move next to
its consumer instead?
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/deserializer/PojoToRowDataDeserializer.java:
##########
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.state.api.input.deserializer;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
+import
org.apache.flink.api.java.typeutils.runtime.PojoDeserializerCompatibilitySnapshot;
+import org.apache.flink.api.java.typeutils.runtime.PojoSerializerSnapshot;
+import org.apache.flink.core.memory.DataInputView;
+import org.apache.flink.core.memory.DataOutputView;
+import
org.apache.flink.state.api.schema.SerializerSnapshotToLogicalTypeConverter;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.logical.LogicalType;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * A {@link TypeSerializer} that reads the POJO binary format written by {@link
+ * org.apache.flink.api.java.typeutils.runtime.PojoSerializer} and produces
{@link GenericRowData}.
+ *
+ * <p>This deserializer does <em>not</em> require the user POJO class to be on
the classpath. It
+ * mirrors the exact binary protocol of {@code PojoSerializer}:
+ *
+ * <pre>{@code
+ * 1 byte: flags (bitmask)
+ * 0x01 IS_NULL → value is null, return null
+ * 0x02 NO_SUBCLASS → exact POJO class: read numFields × (isNull
boolean + field bytes)
+ * 0x08 IS_TAGGED_SUBCLASS → 1 byte subclass tag; delegate to registered
subclass deserializer
+ * 0x04 IS_SUBCLASS → UTF class name (must be read); Kryo not
supported → throws IOException
+ * }</pre>
+ *
+ * <p>Use {@link #create(PojoSerializerSnapshot)} to build an instance from a
savepoint snapshot.
+ */
+@Internal
+public final class PojoToRowDataDeserializer extends TypeSerializer<RowData> {
+
+ private static final long serialVersionUID = 1L;
+
+ private static final Logger LOG =
LoggerFactory.getLogger(PojoToRowDataDeserializer.class);
+
+ // Mirrors constants in PojoSerializer
+ static final int IS_NULL = 0x01;
+ static final int NO_SUBCLASS = 0x02;
+ static final int IS_SUBCLASS = 0x04;
+ static final int IS_TAGGED_SUBCLASS = 0x08;
+
+ private final int numFields;
+ private final TypeSerializer<?>[] fieldDeserializers;
+ private final LogicalType[] fieldTypes;
+ private final String[] fieldNames;
+ private final List<PojoToRowDataDeserializer>
registeredSubclassDeserializers;
+
+ /**
+ * Builds a {@link PojoToRowDataDeserializer} from a {@link
PojoSerializerSnapshot}.
+ *
+ * <p>For each field:
+ *
+ * <ul>
+ * <li>If the field snapshot is itself a {@link PojoSerializerSnapshot},
this method recurses
+ * to build a nested {@link PojoToRowDataDeserializer}.
+ * <li>For all other field types, the field's original serializer is
restored via {@link
+ * TypeSerializerSnapshot#restoreSerializer()}.
+ * </ul>
+ *
+ * <p>Registered subclasses are handled by building a deserializer for
each registered subclass
+ * snapshot in order (matching the tag index used in the binary format).
+ *
+ * @throws IllegalStateException if a required field serializer snapshot
is absent
+ */
+ public static PojoToRowDataDeserializer create(PojoSerializerSnapshot<?>
snapshot) {
+ List<AbstractMap.SimpleEntry<String, TypeSerializerSnapshot<?>>>
fieldEntries =
+ snapshot.getFieldSnapshotEntries();
+
+ List<TypeSerializer<?>> fieldDeserializerList = new
ArrayList<>(fieldEntries.size());
+ List<LogicalType> fieldTypeList = new ArrayList<>(fieldEntries.size());
+ List<String> fieldNameList = new ArrayList<>(fieldEntries.size());
+
+ for (AbstractMap.SimpleEntry<String, TypeSerializerSnapshot<?>> entry
: fieldEntries) {
+ String fieldName = entry.getKey();
+ TypeSerializerSnapshot<?> fieldSnapshot = entry.getValue();
+
+ if (fieldSnapshot == null) {
+ throw new IllegalStateException(
+ "Cannot build deserializer for field '"
+ + fieldName
+ + "': its serializer snapshot was not readable
from the savepoint. "
+ + "This field cannot be deserialized without
the original snapshot.");
+ }
+
+ TypeSerializer<?> fieldDeserializer;
+ if (fieldSnapshot instanceof PojoSerializerSnapshot) {
+ fieldDeserializer = create((PojoSerializerSnapshot<?>)
fieldSnapshot);
+ } else {
+ fieldDeserializer = fieldSnapshot.restoreSerializer();
+ }
+
+ fieldDeserializerList.add(fieldDeserializer);
+
fieldTypeList.add(SerializerSnapshotToLogicalTypeConverter.convert(fieldSnapshot));
+ fieldNameList.add(fieldName);
+ }
+
+ List<TypeSerializerSnapshot<?>> subSnapshots =
+ snapshot.getRegisteredSubclassSnapshotsOrdered();
+ List<PojoToRowDataDeserializer> subDeserializers = new
ArrayList<>(subSnapshots.size());
+ for (TypeSerializerSnapshot<?> subSnap : subSnapshots) {
+ subDeserializers.add(
+ subSnap instanceof PojoSerializerSnapshot
+ ? create((PojoSerializerSnapshot<?>) subSnap)
+ : null);
+ }
+
+ return new PojoToRowDataDeserializer(
+ fieldDeserializerList.toArray(new TypeSerializer[0]),
+ fieldTypeList.toArray(new LogicalType[0]),
+ fieldNameList.toArray(new String[0]),
+ subDeserializers);
+ }
+
+ PojoToRowDataDeserializer(
+ TypeSerializer<?>[] fieldDeserializers,
+ LogicalType[] fieldTypes,
+ String[] fieldNames,
+ List<PojoToRowDataDeserializer> registeredSubclassDeserializers) {
+ this.numFields = fieldDeserializers.length;
+ this.fieldDeserializers = fieldDeserializers;
+ this.fieldTypes = fieldTypes;
+ this.fieldNames = fieldNames;
+ this.registeredSubclassDeserializers = registeredSubclassDeserializers;
+ }
+
+ @Override
+ public RowData deserialize(DataInputView source) throws IOException {
+ int flags = source.readByte() & 0xFF;
+
+ if ((flags & IS_NULL) != 0) {
+ return null;
+ }
+
+ if ((flags & NO_SUBCLASS) != 0) {
+ return readFields(source);
+ }
+
+ if ((flags & IS_TAGGED_SUBCLASS) != 0) {
+ int tag = source.readByte() & 0xFF;
+ if (tag < registeredSubclassDeserializers.size()) {
+ PojoToRowDataDeserializer subDeserializer =
+ registeredSubclassDeserializers.get(tag);
+ if (subDeserializer == null) {
+ // Either the subclass's own snapshot was unreadable, or
the subclass is not
+ // itself a POJO (e.g. it falls back to Kryo) — either way
we have no way to
+ // decode its bytes, and, like the IS_SUBCLASS/Kryo case
below, its length is
+ // unknown so the bytes cannot even be skipped.
+ throw new IOException(
+ "Cannot deserialize registered POJO subclass at
tag "
+ + tag
+ + ": its serializer snapshot is missing or
is not a POJO "
+ + "serializer (e.g. it uses Kryo), which
requires the class on "
+ + "the classpath.");
+ }
+ return subDeserializer.deserialize(source);
+ }
+ throw new IOException(
+ "Unknown registered subclass tag "
+ + tag
+ + " (have "
+ + registeredSubclassDeserializers.size()
+ + " registered). The savepoint may have been
written with more subclasses registered.");
+ }
+
+ if ((flags & IS_SUBCLASS) != 0) {
+ String className = source.readUTF();
+ throw new IOException(
+ "Cannot deserialize POJO subclass '"
+ + className
+ + "': the subclass uses Kryo serialization, which
requires the class on the"
+ + " classpath. Kryo-encoded bytes have unknown
length and cannot be skipped."
+ + " Register the subclass or add the JAR to the
classpath.");
+ }
+
+ throw new IOException("Unrecognised POJO flags byte: 0x" +
Integer.toHexString(flags));
+ }
+
+ @Override
+ public RowData deserialize(RowData reuse, DataInputView source) throws
IOException {
+ return deserialize(source);
+ }
+
+ private GenericRowData readFields(DataInputView source) throws IOException
{
+ GenericRowData row = new GenericRowData(numFields);
+ for (int i = 0; i < numFields; i++) {
+ boolean isNull = source.readBoolean();
+ if (isNull) {
+ row.setField(i, null);
+ continue;
+ }
+ Object raw;
+ try {
+ raw = fieldDeserializers[i].deserialize(source);
+ } catch (Exception e) {
Review Comment:
PojoSerializer.deserialize(), the reference implementation this mirrors,
does not catch per-field failures at all, it fails loudly. This
catch-and-null-with-warn pattern recurs in several other places across this PR
(StateTableUtils, SavepointTypeInfoResolver, KeyedStateInputFormat, etc). Why
is it needed here specifically? A real bug becomes indistinguishable from
expected messy data, silently, in query results.
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/deserializer/InternalTypeConverter.java:
##########
@@ -0,0 +1,288 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.state.api.input.deserializer;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.table.data.DecimalData;
+import org.apache.flink.table.data.GenericArrayData;
+import org.apache.flink.table.data.GenericMapData;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.types.logical.ArrayType;
+import org.apache.flink.table.types.logical.DecimalType;
+import org.apache.flink.table.types.logical.IntType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.MapType;
+import org.apache.flink.table.types.logical.MultisetType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.types.Row;
+
+import javax.annotation.Nullable;
+
+import java.math.BigDecimal;
+import java.nio.ByteBuffer;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.StreamSupport;
+
+/**
+ * Converts external Java objects (as produced by DataStream serializers) to
Flink table internal
+ * types as expected by {@link GenericRowData}.
+ *
+ * <p>Conversion rules:
+ *
+ * <ul>
+ * <li>{@link String} → {@link StringData}
+ * <li>{@link BigDecimal} → {@link DecimalData} (precision/scale from {@link
DecimalType})
+ * <li>{@link ByteBuffer} or {@code byte[]} → {@link DecimalData} (unscaled
bytes)
+ * <li>{@link ByteBuffer} → {@code byte[]} for BINARY/VARBINARY
+ * <li>{@link java.sql.Date}, {@link LocalDate} → {@code int} (days since
epoch)
+ * <li>{@link Timestamp}, {@link Instant}, {@link LocalDateTime} → {@link
TimestampData}
+ * <li>{@link List}, arrays, {@link Iterable} → {@link GenericArrayData}
(elements recursively
+ * converted)
+ * <li>{@link Map} or {@link Iterable} of {@link Map.Entry} → {@link
GenericMapData} (keys/values
+ * recursively converted)
+ * <li>{@link Row} → {@link GenericRowData} (fields recursively converted)
+ * <li>{@link RowData} subtypes → passed through unchanged
+ * <li>Primitives (boxed) → passed through unchanged
+ * </ul>
+ */
+@Internal
+public final class InternalTypeConverter {
+
+ private InternalTypeConverter() {}
+
+ /**
+ * Converts {@code value} to the Flink table internal representation
dictated by {@code type}.
+ *
+ * @param value the raw Java object; may be null
+ * @param type the target logical type; used to drive nested conversions
+ * @return the converted value, or null if value is null
+ */
+ @Nullable
+ public static Object toInternal(@Nullable Object value, LogicalType type) {
+ if (value == null) {
+ return null;
+ }
+
+ switch (type.getTypeRoot()) {
+ case CHAR:
+ case VARCHAR:
+ if (value instanceof StringData) {
+ return value;
+ }
+ return StringData.fromString(value.toString());
+
+ case BOOLEAN:
+ case TINYINT:
+ case SMALLINT:
+ case INTEGER:
+ case BIGINT:
+ case FLOAT:
+ case DOUBLE:
+ case TIME_WITHOUT_TIME_ZONE:
+ case INTERVAL_YEAR_MONTH:
+ case INTERVAL_DAY_TIME:
+ return value;
+
+ case DECIMAL:
+ if (value instanceof DecimalData) {
+ return value;
+ }
+ if (value instanceof BigDecimal) {
+ DecimalType dt = (DecimalType) type;
+ return DecimalData.fromBigDecimal(
+ (BigDecimal) value, dt.getPrecision(),
dt.getScale());
+ }
+ if (value instanceof ByteBuffer) {
+ DecimalType dt = (DecimalType) type;
+ return DecimalData.fromUnscaledBytes(
+ toByteArray((ByteBuffer) value),
dt.getPrecision(), dt.getScale());
+ }
+ if (value instanceof byte[]) {
+ DecimalType dt = (DecimalType) type;
+ return DecimalData.fromUnscaledBytes(
+ (byte[]) value, dt.getPrecision(), dt.getScale());
+ }
+ return value;
+
+ case DATE:
+ if (value instanceof Integer) {
+ return value;
+ }
+ if (value instanceof java.sql.Date) {
+ return (int) ((java.sql.Date)
value).toLocalDate().toEpochDay();
+ }
+ if (value instanceof LocalDate) {
+ return (int) ((LocalDate) value).toEpochDay();
+ }
+ return value;
+
+ case TIMESTAMP_WITHOUT_TIME_ZONE:
+ case TIMESTAMP_WITH_TIME_ZONE:
+ case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+ if (value instanceof TimestampData) {
+ return value;
+ }
+ if (value instanceof Timestamp) {
+ return TimestampData.fromTimestamp((Timestamp) value);
+ }
+ if (value instanceof Instant) {
+ return TimestampData.fromInstant((Instant) value);
+ }
+ if (value instanceof LocalDateTime) {
+ return TimestampData.fromLocalDateTime((LocalDateTime)
value);
+ }
+ return value;
+
+ case BINARY:
+ case VARBINARY:
+ if (value instanceof ByteBuffer) {
+ return toByteArray((ByteBuffer) value);
+ }
+ return value;
+
+ case NULL:
+ return null;
+
+ case ROW:
+ case STRUCTURED_TYPE:
+ if (value instanceof GenericRowData) {
+ return value;
+ }
+ if (value instanceof Row) {
+ return rowToGenericRowData((Row) value, (RowType) type);
+ }
+ return value;
+
+ case ARRAY:
+ if (value instanceof GenericArrayData) {
+ return value;
+ }
+ ArrayType at = (ArrayType) type;
+ if (value instanceof Object[]) {
+ return objectArrayToArrayData((Object[]) value,
at.getElementType());
+ }
+ if (value instanceof Iterable) {
+ return iterableToArrayData((Iterable<?>) value,
at.getElementType());
+ }
+ return value;
+
+ case MAP:
+ if (value instanceof GenericMapData) {
+ return value;
+ }
+ MapType mt = (MapType) type;
+ if (value instanceof Map) {
+ return mapToMapData((Map<?, ?>) value, mt.getKeyType(),
mt.getValueType());
+ }
+ if (value instanceof Iterable) {
+ return mapEntryIterableToMapData(
+ (Iterable<?>) value, mt.getKeyType(),
mt.getValueType());
+ }
+ return value;
+
+ case MULTISET:
+ // MultisetType is not a MapType: it has only an element type,
represented
+ // internally as Map<element, Integer> (element ->
multiplicity).
+ if (value instanceof GenericMapData) {
+ return value;
+ }
+ LogicalType elementType = ((MultisetType)
type).getElementType();
+ if (value instanceof Map) {
+ return mapToMapData((Map<?, ?>) value, elementType, new
IntType());
+ }
+ if (value instanceof Iterable) {
+ return mapEntryIterableToMapData(
+ (Iterable<?>) value, elementType, new IntType());
+ }
+ return value;
+
+ default:
+ return value;
Review Comment:
Minor: `default: return value;` here silently passes through unhandled
`LogicalTypeRoot`s, unlike every other enum dispatch in this PR, which throws
`UnsupportedOperationException` on unhandled cases. Is this different somehow?
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/StateTableUtils.java:
##########
@@ -0,0 +1,641 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.state.api;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.state.StateDescriptor;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
+import org.apache.flink.runtime.checkpoint.OperatorState;
+import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
+import org.apache.flink.runtime.checkpoint.metadata.CheckpointMetadata;
+import org.apache.flink.runtime.state.IncrementalKeyedStateHandle;
+import org.apache.flink.runtime.state.KeyGroupsSavepointStateHandle;
+import org.apache.flink.runtime.state.KeyGroupsStateHandle;
+import org.apache.flink.runtime.state.KeyedStateHandle;
+import org.apache.flink.runtime.state.StateBackendLoader;
+import org.apache.flink.runtime.state.VoidNamespaceSerializer;
+import org.apache.flink.runtime.state.changelog.ChangelogStateBackendHandle;
+import org.apache.flink.state.api.schema.KeyedStateSchemaInfo;
+import
org.apache.flink.state.api.schema.SerializerSnapshotToLogicalTypeConverter;
+import org.apache.flink.state.api.schema.StateSchemaExtractor;
+import org.apache.flink.state.api.schema.StateSchemaInfo;
+import org.apache.flink.state.table.SavepointConnectorOptions;
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.factories.FactoryUtil;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.ArrayType;
+import org.apache.flink.table.types.logical.BigIntType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.MapType;
+import org.apache.flink.table.types.logical.VarBinaryType;
+import org.apache.flink.table.types.utils.LogicalTypeDataTypeConverter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * High-level utility for inspecting and reading keyed state from a checkpoint
/ savepoint without
+ * requiring user POJO classes on the classpath.
+ */
+@Internal
+public final class StateTableUtils {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(StateTableUtils.class);
+
+ private StateTableUtils() {}
+
+ /**
+ * Returns the {@link OperatorIdentifier}s of all operators present in the
given checkpoint
+ * metadata that have at least one non-internal keyed state.
+ *
+ * @param metadata the checkpoint metadata to inspect
+ * @return list of operator identifiers; never null, may be empty
+ */
+ public static List<OperatorIdentifier>
getOperatorIdentifiers(CheckpointMetadata metadata) {
+ return metadata.getOperatorStates().stream()
+ .filter(StateTableUtils::hasNonInternalKeyedState)
+ .map(
+ op ->
+ op.getOperatorUid()
+ .map(OperatorIdentifier::forUid)
+ .orElseGet(
+ () ->
+
OperatorIdentifier.forUidHash(
+
op.getOperatorID().toHexString())))
+ .collect(Collectors.toList());
+ }
+
+ private static boolean hasNonInternalKeyedState(OperatorState op) {
+ try {
+ List<StateSchemaInfo> schemas =
StateSchemaExtractor.extractSchema(op);
+ ClassifiedStates classified =
classifyStates(op.getOperatorID().toHexString(), schemas);
+ return !classified.voidNamespaceStates.isEmpty()
+ || !classified.windowNamespaceStates.isEmpty();
+ } catch (Exception e) {
Review Comment:
extractSchema()'s own contract already returns an empty list for no keyed
state, no exception involved. So anything landing here is either the documented
IOException (corrupted header) or a bug, and both silently disappear this
operator from the catalog listing with only a WARN log. What is the
plan/justification for hiding these from SHOW TABLES instead of surfacing them?
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointDynamicTableSourceFactory.java:
##########
@@ -89,207 +55,152 @@ public DynamicTableSource
createDynamicTableSource(Context context) {
final String statePath = options.get(STATE_PATH);
final OperatorIdentifier operatorIdentifier =
getOperatorIdentifier(options);
- final Map<String, StateMetaInfoSnapshot> preloadedStateMetadata =
- preloadStateMetadata(statePath, operatorIdentifier);
-
- // Create resolver with preloaded metadata
- SavepointTypeInfoResolver typeResolver =
- new SavepointTypeInfoResolver(preloadedStateMetadata,
serializerConfig);
-
- final Tuple2<Integer, int[]> keyValueProjections =
- createKeyValueProjections(context.getCatalogTable());
+ SavepointConnectorOptions.StateReaderMode readerMode =
options.get(STATE_READER_MODE);
+ switch (readerMode) {
+ case KEYED:
+ return createKeyedDynamicTableSource(
+ context,
+ options,
+ serializerConfig,
+ stateBackendType,
+ statePath,
+ operatorIdentifier);
+ case KEYED_FLAT:
+ return createFlattenedDynamicTableSource(
+ context,
+ options,
+ serializerConfig,
+ stateBackendType,
+ statePath,
+ operatorIdentifier);
+ default:
+ throw new IllegalArgumentException("Unsupported state reader
mode: " + readerMode);
+ }
+ }
- LogicalType logicalType =
context.getPhysicalRowDataType().getLogicalType();
- Preconditions.checkArgument(logicalType.is(LogicalTypeRoot.ROW), "Row
data type expected.");
- RowType rowType = (RowType) logicalType;
+ /**
+ * Creates a {@link SavepointDynamicTableSource} for the general
keyed-state table (selected via
+ * {@link SavepointConnectorOptions#STATE_READER_MODE} being set to {@link
+ * SavepointConnectorOptions.StateReaderMode#KEYED}, the default).
+ */
+ private DynamicTableSource createKeyedDynamicTableSource(
+ Context context,
+ Configuration options,
+ SerializerConfig serializerConfig,
+ String stateBackendType,
+ String statePath,
+ OperatorIdentifier operatorIdentifier) {
Set<ConfigOption<?>> requiredOptions = new
HashSet<>(requiredOptions());
Set<ConfigOption<?>> optionalOptions = new
HashSet<>(optionalOptions());
- RowType.RowField keyRowField =
rowType.getFields().get(keyValueProjections.f0);
- ConfigOption<String> keyFormatOption =
- optionOf(keyRowField.getName(),
VALUE_CLASS).stringType().noDefaultValue();
- optionalOptions.add(keyFormatOption);
-
- ConfigOption<String> keyTypeInfoFactoryOption =
- optionOf(keyRowField.getName(),
VALUE_TYPE_FACTORY).stringType().noDefaultValue();
- optionalOptions.add(keyTypeInfoFactoryOption);
-
- TypeInformation<?> keyTypeInfo =
- typeResolver.resolveKeyType(
- options, keyFormatOption, keyTypeInfoFactoryOption,
keyRowField);
-
- final Tuple2<Integer, List<StateValueColumnConfiguration>>
keyValueConfigProjections =
- Tuple2.of(
- keyValueProjections.f0,
- Arrays.stream(keyValueProjections.f1)
- .mapToObj(
- columnIndex ->
- createStateColumnConfiguration(
- columnIndex,
- rowType,
- options,
- optionalOptions,
- typeResolver))
- .collect(Collectors.toList()));
- FactoryUtil.validateFactoryOptions(requiredOptions, optionalOptions,
options);
+ // Validate schema and register per-field options eagerly (no class
loading) so that
+ // option validation passes at planning time.
+ int keyColumnIndex =
+ StateTableMapping.validateAndExtractKeyColumn(
+ context.getCatalogTable(), optionalOptions);
- Set<String> consumedOptionKeys = new HashSet<>();
- consumedOptionKeys.add(CONNECTOR.key());
-
requiredOptions.stream().map(ConfigOption::key).forEach(consumedOptionKeys::add);
-
optionalOptions.stream().map(ConfigOption::key).forEach(consumedOptionKeys::add);
- FactoryUtil.validateUnconsumedKeys(
- factoryIdentifier(), options.keySet(), consumedOptionKeys);
+ validateOptions(options, requiredOptions, optionalOptions);
+
+ // Defer I/O and class loading to scan time by creating the
StateTableMapping lazily.
+ Supplier<StateTableMapping> mappingSupplier =
+ () ->
+ StateTableMapping.from(
+ context.getCatalogTable(),
+ options,
+ statePath,
+ operatorIdentifier,
+ serializerConfig);
- return new SavepointDynamicTableSource(
+ RowType rowType = (RowType)
context.getPhysicalRowDataType().getLogicalType();
+
+ return new SavepointDynamicTableSource<>(
stateBackendType,
statePath,
operatorIdentifier,
- keyTypeInfo,
- keyValueConfigProjections,
- rowType);
+ keyColumnIndex,
+ mappingSupplier,
+ rowType,
+ "Savepoint Table Source",
+ SavepointDataStreamScanProvider::new);
}
- private StateValueColumnConfiguration createStateColumnConfiguration(
- int columnIndex,
- RowType rowType,
+ /**
+ * Creates a {@link FlattenedSavepointDynamicTableSource} for a table
exposing a single
+ * flattened LIST/MAP state (selected via {@link
SavepointConnectorOptions#STATE_READER_MODE}
+ * being set to {@link
SavepointConnectorOptions.StateReaderMode#KEYED_FLAT}). The state name is
+ * resolved from {@link SavepointConnectorOptions#FLATTENED_STATE_NAME}.
+ */
+ private DynamicTableSource createFlattenedDynamicTableSource(
+ Context context,
Configuration options,
- Set<ConfigOption<?>> optionalOptions,
- SavepointTypeInfoResolver typeResolver) {
-
- RowType.RowField valueRowField = rowType.getFields().get(columnIndex);
-
- ConfigOption<String> stateNameOption =
- optionOf(valueRowField.getName(),
STATE_NAME).stringType().noDefaultValue();
- optionalOptions.add(stateNameOption);
-
- ConfigOption<SavepointConnectorOptions.StateType> stateTypeOption =
- optionOf(valueRowField.getName(), STATE_TYPE)
- .enumType(SavepointConnectorOptions.StateType.class)
- .noDefaultValue();
- optionalOptions.add(stateTypeOption);
-
- ConfigOption<String> mapKeyFormatOption =
- optionOf(valueRowField.getName(),
KEY_CLASS).stringType().noDefaultValue();
- optionalOptions.add(mapKeyFormatOption);
-
- ConfigOption<String> mapKeyTypeInfoFactoryOption =
- optionOf(valueRowField.getName(),
KEY_TYPE_FACTORY).stringType().noDefaultValue();
- optionalOptions.add(mapKeyTypeInfoFactoryOption);
-
- ConfigOption<String> valueFormatOption =
- optionOf(valueRowField.getName(),
VALUE_CLASS).stringType().noDefaultValue();
- optionalOptions.add(valueFormatOption);
-
- ConfigOption<String> valueTypeInfoFactoryOption =
- optionOf(valueRowField.getName(),
VALUE_TYPE_FACTORY).stringType().noDefaultValue();
- optionalOptions.add(valueTypeInfoFactoryOption);
-
- LogicalType valueLogicalType = valueRowField.getType();
+ SerializerConfig serializerConfig,
+ String stateBackendType,
+ String statePath,
+ OperatorIdentifier operatorIdentifier) {
SavepointConnectorOptions.StateType stateType =
- options.getOptional(stateTypeOption)
- .orElseGet(() -> inferStateType(valueLogicalType));
+
FlattenedStateTableMapping.validateFlattenedSchema(context.getCatalogTable());
- TypeSerializer<?> mapKeyTypeSerializer =
- typeResolver.resolveSerializer(
- options,
- mapKeyFormatOption,
- mapKeyTypeInfoFactoryOption,
- valueRowField,
-
stateType.equals(SavepointConnectorOptions.StateType.MAP),
- SavepointTypeInfoResolver.InferenceContext.MAP_KEY);
-
- TypeSerializer<?> valueTypeSerializer =
- typeResolver.resolveSerializer(
- options,
- valueFormatOption,
- valueTypeInfoFactoryOption,
- valueRowField,
- true,
- SavepointTypeInfoResolver.InferenceContext.VALUE);
-
- return new StateValueColumnConfiguration(
- columnIndex,
-
options.getOptional(stateNameOption).orElse(valueRowField.getName()),
- stateType,
- mapKeyTypeSerializer,
- valueTypeSerializer);
- }
-
- private static ConfigOptions.OptionBuilder optionOf(String rowField,
String optionName) {
- return ConfigOptions.key(String.format("%s.%s.%s", FIELDS, rowField,
optionName));
- }
-
- private Tuple2<Integer, int[]>
createKeyValueProjections(ResolvedCatalogTable catalogTable) {
- ResolvedSchema schema = catalogTable.getResolvedSchema();
- if (schema.getPrimaryKey().isEmpty()) {
- throw new ValidationException("Could not find the primary key in
the table schema.");
- }
-
- List<String> keyFields = schema.getPrimaryKey().get().getColumns();
- if (keyFields.size() != 1) {
- throw new ValidationException(
- "Only a single primary key must be defined in the table
schema.");
- }
+ RowType rowType = (RowType)
context.getPhysicalRowDataType().getLogicalType();
- DataType physicalDataType = schema.toPhysicalRowDataType();
- int keyProjection = createKeyFormatProjection(physicalDataType,
keyFields.get(0));
- int[] valueProjection = createValueFormatProjection(physicalDataType,
keyProjection);
+ String stateName = validateAndGetFlattenedStateName(options);
- return Tuple2.of(keyProjection, valueProjection);
- }
-
- private int createKeyFormatProjection(DataType physicalDataType, String
keyField) {
- final LogicalType physicalType = physicalDataType.getLogicalType();
- Preconditions.checkArgument(
- physicalType.is(LogicalTypeRoot.ROW), "Row data type
expected.");
- final List<String> physicalFields =
LogicalTypeChecks.getFieldNames(physicalType);
- return physicalFields.indexOf(keyField);
- }
-
- private int[] createValueFormatProjection(DataType physicalDataType, int
keyProjection) {
- final LogicalType physicalType = physicalDataType.getLogicalType();
- Preconditions.checkArgument(
- physicalType.is(LogicalTypeRoot.ROW), "Row data type
expected.");
- final int physicalFieldCount =
LogicalTypeChecks.getFieldCount(physicalType);
- final IntStream physicalFields = IntStream.range(0,
physicalFieldCount);
+ // Defer I/O to scan time by creating the mapping lazily.
+ Supplier<FlattenedStateTableMapping> mappingSupplier =
+ () ->
+ FlattenedStateTableMapping.from(
+ context.getCatalogTable(),
+ stateName,
+ statePath,
+ operatorIdentifier,
+ serializerConfig,
+ stateType);
- return physicalFields.filter(pos -> keyProjection != pos).toArray();
+ return new FlattenedSavepointDynamicTableSource<>(
+ stateBackendType,
+ statePath,
+ operatorIdentifier,
+ FlattenedStateTableMapping.STATE_KEY_COLUMN_INDEX,
+ mappingSupplier,
+ rowType,
+ "Flattened Savepoint Table Source",
+ FlattenedSavepointDataStreamScanProvider::new);
}
- private SavepointConnectorOptions.StateType inferStateType(LogicalType
logicalType) {
- switch (logicalType.getTypeRoot()) {
- case ARRAY:
- return SavepointConnectorOptions.StateType.LIST;
+ /**
+ * Validates {@code options} against the required/optional option sets
extended with {@link
+ * SavepointConnectorOptions#FLATTENED_STATE_NAME}, and returns the
resolved state name — shared
+ * by every table kind whose columns represent a single named state's
flattened value fields (or
+ * a single scalar value column) rather than encoding the state's name via
the column layout
+ * itself.
+ */
+ private String validateAndGetFlattenedStateName(Configuration options) {
Review Comment:
FLATTENED_STATE_NAME is added ad-hoc inside
validateAndGetFlattenedStateName(), never through
requiredOptions()/optionalOptions() like every other option here. Anything
introspecting this factory's declared options directly (docs tooling, generic
Table API code) would never learn it exists or is required for KEYED_FLAT.
Worth registering it declaratively too?
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/deserializer/PojoToRowDataDeserializer.java:
##########
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.state.api.input.deserializer;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
+import
org.apache.flink.api.java.typeutils.runtime.PojoDeserializerCompatibilitySnapshot;
+import org.apache.flink.api.java.typeutils.runtime.PojoSerializerSnapshot;
+import org.apache.flink.core.memory.DataInputView;
+import org.apache.flink.core.memory.DataOutputView;
+import
org.apache.flink.state.api.schema.SerializerSnapshotToLogicalTypeConverter;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.logical.LogicalType;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * A {@link TypeSerializer} that reads the POJO binary format written by {@link
+ * org.apache.flink.api.java.typeutils.runtime.PojoSerializer} and produces
{@link GenericRowData}.
+ *
+ * <p>This deserializer does <em>not</em> require the user POJO class to be on
the classpath. It
+ * mirrors the exact binary protocol of {@code PojoSerializer}:
+ *
+ * <pre>{@code
+ * 1 byte: flags (bitmask)
+ * 0x01 IS_NULL → value is null, return null
+ * 0x02 NO_SUBCLASS → exact POJO class: read numFields × (isNull
boolean + field bytes)
+ * 0x08 IS_TAGGED_SUBCLASS → 1 byte subclass tag; delegate to registered
subclass deserializer
+ * 0x04 IS_SUBCLASS → UTF class name (must be read); Kryo not
supported → throws IOException
+ * }</pre>
+ *
+ * <p>Use {@link #create(PojoSerializerSnapshot)} to build an instance from a
savepoint snapshot.
+ */
+@Internal
+public final class PojoToRowDataDeserializer extends TypeSerializer<RowData> {
+
+ private static final long serialVersionUID = 1L;
+
+ private static final Logger LOG =
LoggerFactory.getLogger(PojoToRowDataDeserializer.class);
+
+ // Mirrors constants in PojoSerializer
+ static final int IS_NULL = 0x01;
+ static final int NO_SUBCLASS = 0x02;
+ static final int IS_SUBCLASS = 0x04;
+ static final int IS_TAGGED_SUBCLASS = 0x08;
Review Comment:
Can we share somehow this internal logic (`isNulll(...)`, or something like
that) instead of duplicating it?
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/catalog/StateCatalog.java:
##########
@@ -0,0 +1,701 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.state.catalog;
+
+import org.apache.flink.annotation.PublicEvolving;
+import org.apache.flink.runtime.checkpoint.metadata.CheckpointMetadata;
+import org.apache.flink.state.api.OperatorIdentifier;
+import org.apache.flink.state.api.StateTableUtils;
+import org.apache.flink.state.api.runtime.SavepointLoader;
+import org.apache.flink.state.api.schema.KeyedStateSchemaInfo;
+import org.apache.flink.state.table.SavepointConnectorOptions.StateReaderMode;
+import org.apache.flink.state.table.SavepointConnectorOptions.StateType;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.catalog.AbstractCatalog;
+import org.apache.flink.table.catalog.CatalogBaseTable;
+import org.apache.flink.table.catalog.CatalogDatabase;
+import org.apache.flink.table.catalog.CatalogDatabaseImpl;
+import org.apache.flink.table.catalog.CatalogFunction;
+import org.apache.flink.table.catalog.CatalogPartition;
+import org.apache.flink.table.catalog.CatalogPartitionSpec;
+import org.apache.flink.table.catalog.CatalogView;
+import org.apache.flink.table.catalog.ObjectPath;
+import org.apache.flink.table.catalog.exceptions.CatalogException;
+import org.apache.flink.table.catalog.exceptions.DatabaseAlreadyExistException;
+import org.apache.flink.table.catalog.exceptions.DatabaseNotEmptyException;
+import org.apache.flink.table.catalog.exceptions.DatabaseNotExistException;
+import org.apache.flink.table.catalog.exceptions.FunctionAlreadyExistException;
+import org.apache.flink.table.catalog.exceptions.FunctionNotExistException;
+import
org.apache.flink.table.catalog.exceptions.PartitionAlreadyExistsException;
+import org.apache.flink.table.catalog.exceptions.PartitionNotExistException;
+import org.apache.flink.table.catalog.exceptions.PartitionSpecInvalidException;
+import org.apache.flink.table.catalog.exceptions.TableAlreadyExistException;
+import org.apache.flink.table.catalog.exceptions.TableNotExistException;
+import org.apache.flink.table.catalog.exceptions.TableNotPartitionedException;
+import org.apache.flink.table.catalog.stats.CatalogColumnStatistics;
+import org.apache.flink.table.catalog.stats.CatalogTableStatistics;
+import org.apache.flink.table.expressions.Expression;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * A read-only Flink SQL catalog that discovers checkpoints and savepoints
from a configured set of
+ * directories and exposes their metadata as queryable SQL databases and views.
+ *
+ * <p>The catalog maps Flink's three-level hierarchy as follows:
+ *
+ * <ul>
+ * <li>Catalog: the name given at registration time (e.g. {@code "state"})
+ * <li>Database: one entry per discovered snapshot (e.g. {@code
"app1_savepoint-acce1cedsad"})
+ * <li>Table: a single view named {@code "metadata"} per database, backed by
the {@code
+ * savepoint_metadata} function from {@code StateModule}
+ * </ul>
+ *
+ * <p>Database names preserve hyphens from the original directory names.
Backtick quoting is
+ * required in SQL for identifiers containing hyphens:
+ *
+ * <pre>{@code
+ * USE CATALOG state;
+ * USE `app1_savepoint-acce1cedsad`;
+ * SELECT * FROM metadata;
+ * }</pre>
+ *
+ * <p>{@code StateModule} must be loaded before querying any {@code metadata}
view:
+ *
+ * <pre>{@code
+ * tableEnv.loadModule("state", StateModule.INSTANCE);
+ * }</pre>
+ *
+ * <p>Each catalog operation fetches state on demand. {@link #listDatabases()}
performs a full
+ * directory scan; all other operations perform a single file check on the
specific snapshot path
+ * reconstructed from the database name. There is no background polling and no
shared cache.
+ *
+ * <p>All write operations throw {@link UnsupportedOperationException}.
+ */
+@PublicEvolving
+public class StateCatalog extends AbstractCatalog {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(StateCatalog.class);
+
+ public static final String METADATA_TABLE = "metadata";
+ public static final String OPERATOR_UID_PREFIX = "uid_";
+ public static final String OPERATOR_ID_PREFIX = "id_";
+ public static final String OPERATOR_TABLE_SUFFIX = "_keyed";
+ public static final String FLAT_STATE_TABLE_SUFFIX = "_keyed_flat";
+
+ private static final CatalogDatabase EMPTY_DATABASE =
+ new CatalogDatabaseImpl(Collections.emptyMap(), "");
+
+ private final SnapshotDiscovery discovery;
+
+ public StateCatalog(String name, Map<String, String> labelsToDirs) {
+ this(name, labelsToDirs,
StateCatalogOptions.LISTING_PARALLELISM.defaultValue());
+ }
+
+ public StateCatalog(String name, Map<String, String> labelsToDirs, int
listingParallelism) {
+ this(
+ name,
+ labelsToDirs,
+ listingParallelism,
+ StateCatalogOptions.DB_NAME_INCLUDE_TS.defaultValue());
+ }
+
+ public StateCatalog(
+ String name,
+ Map<String, String> labelsToDirs,
+ int listingParallelism,
+ boolean dbNameIncludeTs) {
+ super(name, "default");
+ this.discovery = new SnapshotDiscovery(labelsToDirs,
listingParallelism, dbNameIncludeTs);
+ }
+
+ @Override
+ @Nullable
+ public String getDefaultDatabase() {
+ return null;
+ }
+
+ //
-------------------------------------------------------------------------
+ // Lifecycle
+ //
-------------------------------------------------------------------------
+
+ @Override
+ public void open() throws CatalogException {
+ discovery.start();
+ listDatabases();
+ }
+
+ @Override
+ public void close() throws CatalogException {
+ discovery.stop();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Databases
+ //
-------------------------------------------------------------------------
+
+ @Override
+ public List<String> listDatabases() throws CatalogException {
+ try {
+ return discovery.list();
+ } catch (IOException e) {
+ LOG.warn("Failed to list databases in catalog '{}'", getName(), e);
+ return Collections.emptyList();
+ }
+ }
+
+ @Override
+ public CatalogDatabase getDatabase(String databaseName)
+ throws DatabaseNotExistException, CatalogException {
+ if (discovery.find(databaseName).isEmpty()) {
+ throw new DatabaseNotExistException(getName(), databaseName);
+ }
+ return EMPTY_DATABASE;
+ }
+
+ @Override
+ public boolean databaseExists(String databaseName) throws CatalogException
{
+ return discovery.find(databaseName).isPresent();
+ }
+
+ @Override
+ public void createDatabase(String name, CatalogDatabase database, boolean
ignoreIfExists)
+ throws DatabaseAlreadyExistException, CatalogException {
+ throw new UnsupportedOperationException("StateCatalog is read-only.");
+ }
+
+ @Override
+ public void dropDatabase(String name, boolean ignoreIfNotExists, boolean
cascade)
+ throws DatabaseNotExistException, DatabaseNotEmptyException,
CatalogException {
+ throw new UnsupportedOperationException("StateCatalog is read-only.");
+ }
+
+ @Override
+ public void alterDatabase(String name, CatalogDatabase newDatabase,
boolean ignoreIfNotExists)
+ throws DatabaseNotExistException, CatalogException {
+ throw new UnsupportedOperationException("StateCatalog is read-only.");
+ }
+
+ //
-------------------------------------------------------------------------
+ // Tables and views
+ //
-------------------------------------------------------------------------
+
+ @Override
+ public List<String> listTables(String databaseName)
Review Comment:
listTables() never includes METADATA_TABLE, only real state-derived tables.
Catalog.listTables()'\''s own javadoc says it should return all tables, views
and materialized tables, views included. So SHOW VIEWS finds metadata but SHOW
TABLES will not, even though getTable() handles it fine directly. Why is it
excluded from listTables()?
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/deserializer/PojoToRowDataDeserializer.java:
##########
@@ -0,0 +1,330 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.state.api.input.deserializer;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.typeutils.TypeSerializer;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
+import
org.apache.flink.api.java.typeutils.runtime.PojoDeserializerCompatibilitySnapshot;
+import org.apache.flink.api.java.typeutils.runtime.PojoSerializerSnapshot;
+import org.apache.flink.core.memory.DataInputView;
+import org.apache.flink.core.memory.DataOutputView;
+import
org.apache.flink.state.api.schema.SerializerSnapshotToLogicalTypeConverter;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.types.logical.LogicalType;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.AbstractMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * A {@link TypeSerializer} that reads the POJO binary format written by {@link
+ * org.apache.flink.api.java.typeutils.runtime.PojoSerializer} and produces
{@link GenericRowData}.
+ *
+ * <p>This deserializer does <em>not</em> require the user POJO class to be on
the classpath. It
+ * mirrors the exact binary protocol of {@code PojoSerializer}:
+ *
+ * <pre>{@code
+ * 1 byte: flags (bitmask)
+ * 0x01 IS_NULL → value is null, return null
+ * 0x02 NO_SUBCLASS → exact POJO class: read numFields × (isNull
boolean + field bytes)
+ * 0x08 IS_TAGGED_SUBCLASS → 1 byte subclass tag; delegate to registered
subclass deserializer
+ * 0x04 IS_SUBCLASS → UTF class name (must be read); Kryo not
supported → throws IOException
+ * }</pre>
+ *
+ * <p>Use {@link #create(PojoSerializerSnapshot)} to build an instance from a
savepoint snapshot.
+ */
+@Internal
+public final class PojoToRowDataDeserializer extends TypeSerializer<RowData> {
+
+ private static final long serialVersionUID = 1L;
+
+ private static final Logger LOG =
LoggerFactory.getLogger(PojoToRowDataDeserializer.class);
+
+ // Mirrors constants in PojoSerializer
+ static final int IS_NULL = 0x01;
+ static final int NO_SUBCLASS = 0x02;
+ static final int IS_SUBCLASS = 0x04;
+ static final int IS_TAGGED_SUBCLASS = 0x08;
+
+ private final int numFields;
+ private final TypeSerializer<?>[] fieldDeserializers;
+ private final LogicalType[] fieldTypes;
+ private final String[] fieldNames;
+ private final List<PojoToRowDataDeserializer>
registeredSubclassDeserializers;
+
+ /**
+ * Builds a {@link PojoToRowDataDeserializer} from a {@link
PojoSerializerSnapshot}.
+ *
+ * <p>For each field:
+ *
+ * <ul>
+ * <li>If the field snapshot is itself a {@link PojoSerializerSnapshot},
this method recurses
+ * to build a nested {@link PojoToRowDataDeserializer}.
+ * <li>For all other field types, the field's original serializer is
restored via {@link
+ * TypeSerializerSnapshot#restoreSerializer()}.
+ * </ul>
+ *
+ * <p>Registered subclasses are handled by building a deserializer for
each registered subclass
+ * snapshot in order (matching the tag index used in the binary format).
+ *
+ * @throws IllegalStateException if a required field serializer snapshot
is absent
+ */
+ public static PojoToRowDataDeserializer create(PojoSerializerSnapshot<?>
snapshot) {
+ List<AbstractMap.SimpleEntry<String, TypeSerializerSnapshot<?>>>
fieldEntries =
+ snapshot.getFieldSnapshotEntries();
+
+ List<TypeSerializer<?>> fieldDeserializerList = new
ArrayList<>(fieldEntries.size());
+ List<LogicalType> fieldTypeList = new ArrayList<>(fieldEntries.size());
+ List<String> fieldNameList = new ArrayList<>(fieldEntries.size());
+
+ for (AbstractMap.SimpleEntry<String, TypeSerializerSnapshot<?>> entry
: fieldEntries) {
+ String fieldName = entry.getKey();
+ TypeSerializerSnapshot<?> fieldSnapshot = entry.getValue();
+
+ if (fieldSnapshot == null) {
Review Comment:
EnumSerializerSnapshot was not patched with CustomRestoreSerializerFactory
support, its readSnapshot() still throws eagerly on a missing enum class.
Unlike Kryo, this does not seem to be a stated limitation, and it fails at
table-creation time (fieldSnapshot == null here), not per-row. Was enum
considered?
##########
flink-runtime/src/main/java/org/apache/flink/runtime/state/KeyedStateBackend.java:
##########
@@ -91,6 +91,21 @@ <N, S extends State, T> void applyToAllKeys(
*/
<N> Stream<K> getKeys(List<String> states, N namespace);
+ /**
+ * @return A stream of all keys for the multiple states and a given
namespace, paired with the
Review Comment:
This javadoc requires knowing about classless deserialization to parse,
shouldn't be necessary for a core, widely-implemented interface. I can be more
self-contained: `returns the key-group each key is actually stored under,
instead of recomputed via hashCode. Use when the key object's hash isn't
guaranteed to match what wrote the data.` The classless-deserialization detail
can be moved to the call site in `MultiStateKeyIterator` instead.
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/KeyedStateInputFormat.java:
##########
@@ -229,19 +226,37 @@ public void open(KeyGroupRangeInputSplit split) throws
IOException {
stateBackend,
executionConfig)
.withMaxParallelism(split.getNumKeyGroups())
- .withKey(operator,
runtimeContext.createSerializer(operator.getKeyType()))
- .build(LOG);
+ .withKey(operator,
runtimeContext.createSerializer(operator.getKeyType()));
+
+ // Deserialize any POJO/Avro state whose class is missing from the
classpath into
+ // RowData/GenericRecord instead of failing the restore.
PojoSerializerSnapshot and
+ // AvroSerializerSnapshot only consult this factory once they've
already determined that the
+ // class they need is genuinely missing, so registering it
unconditionally is safe and has
+ // no
+ // effect on states whose classes are present.
+ //
+ // The factory is never cleared: it is registered on the task thread
that also drives all
+ // subsequent state access, and a given type is either on the
classpath for the whole read
+ // or
+ // not at all. Clearing it after build() would break the RocksDB
backend, which defers
+ // schema
+ // compatibility checks to the first state access in nextRecord().
+
CustomRestoreSerializerFactory.set(MissingClassSerializerFactory::create);
Review Comment:
remove()'s own javadoc says to clear it after restore, to avoid leaking into
a reused thread. Somehow there is an inconsistency then, right? `The factory is
never cleared`
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/StateTableUtils.java:
##########
@@ -0,0 +1,641 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.state.api;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.state.StateDescriptor;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
+import org.apache.flink.runtime.checkpoint.OperatorState;
+import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
+import org.apache.flink.runtime.checkpoint.metadata.CheckpointMetadata;
+import org.apache.flink.runtime.state.IncrementalKeyedStateHandle;
+import org.apache.flink.runtime.state.KeyGroupsSavepointStateHandle;
+import org.apache.flink.runtime.state.KeyGroupsStateHandle;
+import org.apache.flink.runtime.state.KeyedStateHandle;
+import org.apache.flink.runtime.state.StateBackendLoader;
+import org.apache.flink.runtime.state.VoidNamespaceSerializer;
+import org.apache.flink.runtime.state.changelog.ChangelogStateBackendHandle;
+import org.apache.flink.state.api.schema.KeyedStateSchemaInfo;
+import
org.apache.flink.state.api.schema.SerializerSnapshotToLogicalTypeConverter;
+import org.apache.flink.state.api.schema.StateSchemaExtractor;
+import org.apache.flink.state.api.schema.StateSchemaInfo;
+import org.apache.flink.state.table.SavepointConnectorOptions;
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.factories.FactoryUtil;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.ArrayType;
+import org.apache.flink.table.types.logical.BigIntType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.MapType;
+import org.apache.flink.table.types.logical.VarBinaryType;
+import org.apache.flink.table.types.utils.LogicalTypeDataTypeConverter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * High-level utility for inspecting and reading keyed state from a checkpoint
/ savepoint without
+ * requiring user POJO classes on the classpath.
+ */
+@Internal
+public final class StateTableUtils {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(StateTableUtils.class);
+
+ private StateTableUtils() {}
+
+ /**
+ * Returns the {@link OperatorIdentifier}s of all operators present in the
given checkpoint
+ * metadata that have at least one non-internal keyed state.
+ *
+ * @param metadata the checkpoint metadata to inspect
+ * @return list of operator identifiers; never null, may be empty
+ */
+ public static List<OperatorIdentifier>
getOperatorIdentifiers(CheckpointMetadata metadata) {
+ return metadata.getOperatorStates().stream()
+ .filter(StateTableUtils::hasNonInternalKeyedState)
+ .map(
+ op ->
+ op.getOperatorUid()
+ .map(OperatorIdentifier::forUid)
+ .orElseGet(
+ () ->
+
OperatorIdentifier.forUidHash(
+
op.getOperatorID().toHexString())))
+ .collect(Collectors.toList());
+ }
+
+ private static boolean hasNonInternalKeyedState(OperatorState op) {
+ try {
+ List<StateSchemaInfo> schemas =
StateSchemaExtractor.extractSchema(op);
+ ClassifiedStates classified =
classifyStates(op.getOperatorID().toHexString(), schemas);
+ return !classified.voidNamespaceStates.isEmpty()
+ || !classified.windowNamespaceStates.isEmpty();
+ } catch (Exception e) {
+ LOG.warn(
+ "Could not extract state schema for operator '{}': {}.
Excluding from catalog.",
+ op.getOperatorID(),
+ e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Returns the names of all keyed states registered by the given operator.
+ *
+ * @param metadata the checkpoint metadata to inspect
+ * @param operatorId identifies the operator
+ * @param classLoader the class loader used when reading serializer
snapshots
+ * @return list of state names; never null, may be empty
+ * @throws IOException if the state header cannot be read
+ */
+ public static List<String> getKeyedStates(
+ CheckpointMetadata metadata, OperatorIdentifier operatorId) throws
IOException {
+ OperatorState opState = findOperatorState(metadata, operatorId);
+ List<StateSchemaInfo> schemaInfos =
StateSchemaExtractor.extractSchema(opState);
+ ClassifiedStates classified = classifyStates(operatorId.toString(),
schemaInfos);
+ return classified.voidNamespaceStates.stream()
+ .map(info -> info.stateName)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Returns the {@link KeyedStateSchemaInfo} for the plain per-key
(void-namespace) states of the
+ * given operator — the ones exposed by the {@code _keyed}/{@code
_keyed_flat} tables.
+ *
+ * <p>Schema extraction is lenient: POJO field names and types are derived
from the serializer
+ * snapshot and do not require the user POJO class to be on the classpath.
+ *
+ * @param metadata the checkpoint metadata to inspect
+ * @param operatorId identifies the operator
+ * @return schema information covering the key type and all registered
state entries
+ * @throws IOException if the state header cannot be read
+ */
+ public static KeyedStateSchemaInfo getKeyedStateSchema(
+ CheckpointMetadata metadata, OperatorIdentifier operatorId) throws
IOException {
+ OperatorState opState = findOperatorState(metadata, operatorId);
+ List<StateSchemaInfo> schemas =
StateSchemaExtractor.extractSchema(opState);
+ ClassifiedStates classified = classifyStates(operatorId.toString(),
schemas);
+ return buildKeyedStateSchemaInfo(schemas,
classified.voidNamespaceStates, null);
+ }
+
+ private static KeyedStateSchemaInfo buildKeyedStateSchemaInfo(
+ List<StateSchemaInfo> allSchemas,
+ List<StateSchemaInfo> statesToInclude,
+ @Nullable LogicalType windowLogicalType) {
+ LogicalType keyType =
+ allSchemas.isEmpty()
+ ? new VarBinaryType(true, VarBinaryType.MAX_LENGTH)
+ : SerializerSnapshotToLogicalTypeConverter.convert(
+ allSchemas.get(0).keySnapshot);
+
+ LinkedHashMap<String, KeyedStateSchemaInfo.StateEntryInfo>
stateSchemas =
+ new LinkedHashMap<>();
+ for (StateSchemaInfo info : statesToInclude) {
+ SavepointConnectorOptions.StateType stateType;
+ if (info.stateKind == StateDescriptor.Type.LIST) {
+ stateType = SavepointConnectorOptions.StateType.LIST;
+ } else if (info.stateKind == StateDescriptor.Type.MAP) {
+ stateType = SavepointConnectorOptions.StateType.MAP;
+ } else {
+ stateType = SavepointConnectorOptions.StateType.VALUE;
+ }
+
+ try {
+ LogicalType logicalType =
+
SerializerSnapshotToLogicalTypeConverter.convert(info.valueSnapshot);
+ stateSchemas.put(
+ info.stateName,
+ new KeyedStateSchemaInfo.StateEntryInfo(
+ stateType, logicalType, windowLogicalType));
+ } catch (Exception e) {
+ logSchemaExtractionFailure("", info.stateName,
info.valueSnapshot, e);
+ }
+ }
+
+ return new KeyedStateSchemaInfo(keyType, stateSchemas);
+ }
+
+ /**
+ * Logs that a single state's schema could not be extracted and will
therefore be excluded from
+ * the table schema, shared by {@link #buildKeyedStateSchemaInfo}.
+ *
+ * @param label a prefix inserted before "state" in the log message (e.g.
{@code "non-keyed "}
+ * or {@code ""}), distinguishing which caller excluded the state
+ */
+ private static void logSchemaExtractionFailure(
+ String label,
+ String stateName,
+ @Nullable TypeSerializerSnapshot<?> valueSnapshot,
+ Exception e) {
+ LOG.warn(
+ "Cannot extract schema for {}state '{}' (serializer type: {}):
{}. "
+ + "This state will be excluded from the table schema. "
+ + "Use explicit connector options to include it.",
+ label,
+ stateName,
+ valueSnapshot == null ? "null" :
valueSnapshot.getClass().getSimpleName(),
+ e.getMessage());
+ }
+
+ /**
+ * Builds a {@link CatalogTable} representing all keyed states of an
operator.
+ *
+ * <p>The resulting table has one column named {@code "state_key"} for the
key and one column
+ * per keyed state. The connector options are pre-populated so the table
can be registered
+ * directly in a {@link org.apache.flink.table.catalog.CatalogManager}.
+ *
+ * <p>When the state backend that produced the operator's keyed state can
be unambiguously
+ * determined from the checkpoint metadata, {@link
SavepointConnectorOptions#STATE_BACKEND_TYPE}
+ * is pre-populated as well, so callers don't need to specify it
themselves.
+ *
+ * @param metadata the checkpoint metadata the operator belongs to
+ * @param schemaInfo the schema information returned by {@link
#getKeyedStateSchema}
+ * @param statePath the path to the savepoint / checkpoint
+ * @param operatorIdentifier identifies the operator whose state to read
+ * @return a {@link CatalogTable} ready for registration
+ */
+ public static CatalogTable getStateCatalogTable(
+ CheckpointMetadata metadata,
+ KeyedStateSchemaInfo schemaInfo,
+ String statePath,
+ OperatorIdentifier operatorIdentifier) {
+ return buildKeyedCatalogTable(metadata, schemaInfo, statePath,
operatorIdentifier, null);
+ }
+
+ /**
+ * Builds a {@link CatalogTable} representing all keyed states of an
operator, or, when {@code
+ * windowType} is non-null, all namespaced (e.g. window-scoped) states of
an operator.
+ */
+ private static CatalogTable buildKeyedCatalogTable(
+ CheckpointMetadata metadata,
+ KeyedStateSchemaInfo schemaInfo,
+ String statePath,
+ OperatorIdentifier operatorIdentifier,
+ @Nullable LogicalType windowType) {
+
+ Schema.Builder schemaBuilder = Schema.newBuilder();
+ schemaBuilder.column(
+ "state_key",
LogicalTypeDataTypeConverter.toDataType(schemaInfo.keyType).notNull());
+ if (windowType != null) {
+ schemaBuilder.column(
+ "state_window",
LogicalTypeDataTypeConverter.toDataType(windowType).notNull());
+ }
+
+ for (Map.Entry<String, KeyedStateSchemaInfo.StateEntryInfo> entry :
+ schemaInfo.stateSchemas.entrySet()) {
+ schemaBuilder.column(entry.getKey(),
stateValueColumnDataType(entry.getValue()));
+ }
+ if (windowType == null) {
+ schemaBuilder.primaryKeyNamed("PK_state_key", "state_key");
+ }
+ Schema schema = schemaBuilder.build();
+
+ Map<String, String> options = buildBaseConnectorOptions(statePath,
operatorIdentifier);
+ options.put(
+ SavepointConnectorOptions.STATE_READER_MODE.key(),
+ (windowType == null
+ ?
SavepointConnectorOptions.StateReaderMode.KEYED
+ :
SavepointConnectorOptions.StateReaderMode.WINDOWED)
+ .toString());
+ withStateBackendType(options, metadata, operatorIdentifier);
+
+ return
CatalogTable.newBuilder().schema(schema).options(options).build();
+ }
+
+ /**
+ * Builds a {@link CatalogTable} exposing a single keyed LIST or MAP state
flattened into one
+ * row per list element / map entry, rather than one row per key.
+ *
+ * <p>The resulting table has 3 columns, with a composite primary key on
{@code state_key} and
+ * the sub-key column (the {@code state_key} value repeats across rows
belonging to the same
+ * key, but the pair uniquely identifies a row). The third column has a
fixed name — not the
+ * state's own name, to avoid collisions with other (reserved) column
names:
+ *
+ * <ul>
+ * <li>LIST: {@code (state_key, list_index, list_value)}, primary key
{@code (state_key,
+ * list_index)}
+ * <li>MAP: {@code (state_key, map_key, map_value)}, primary key {@code
(state_key, map_key)}
+ * </ul>
+ *
+ * @param metadata the checkpoint metadata the operator belongs to
+ * @param schemaInfo the schema information returned by {@link
#getKeyedStateSchema}
+ * @param stateName the name of the LIST or MAP state to flatten
+ * @param statePath the path to the savepoint / checkpoint
+ * @param operatorIdentifier identifies the operator whose state to read
+ * @return a {@link CatalogTable} ready for registration
+ */
+ public static CatalogTable getFlattenedStateCatalogTable(
+ CheckpointMetadata metadata,
+ KeyedStateSchemaInfo schemaInfo,
+ String stateName,
+ String statePath,
+ OperatorIdentifier operatorIdentifier) {
+ return buildFlattenedKeyedCatalogTable(
+ metadata, schemaInfo, stateName, statePath,
operatorIdentifier, false);
+ }
+
+ /**
+ * Builds a {@link CatalogTable} exposing a single LIST or MAP state
flattened into one row per
+ * list element / map entry, either plain-keyed ({@code windowed ==
false}, see {@link
+ * #getFlattenedStateCatalogTable}) or namespaced ({@code windowed ==
true}).
+ */
+ private static CatalogTable buildFlattenedKeyedCatalogTable(
+ CheckpointMetadata metadata,
+ KeyedStateSchemaInfo schemaInfo,
+ String stateName,
+ String statePath,
+ OperatorIdentifier operatorIdentifier,
+ boolean windowed) {
+
+ KeyedStateSchemaInfo.StateEntryInfo entryInfo =
schemaInfo.stateSchemas.get(stateName);
+ if (entryInfo == null) {
+ throw new IllegalArgumentException(
+ "State '"
+ + stateName
+ + "' not found for operator '"
+ + operatorIdentifier
+ + "'.");
+ }
+ if (entryInfo.stateType != SavepointConnectorOptions.StateType.LIST
+ && entryInfo.stateType !=
SavepointConnectorOptions.StateType.MAP) {
+ throw new IllegalArgumentException(
+ "Flattened state tables are only supported for LIST and
MAP states, but '"
+ + stateName
+ + "' is "
+ + entryInfo.stateType
+ + ".");
+ }
+ if (windowed && entryInfo.windowLogicalType == null) {
+ throw new IllegalArgumentException(
+ "State '"
+ + stateName
+ + "' is not a namespaced state for operator '"
+ + operatorIdentifier
+ + "'.");
+ }
+
+ Schema.Builder schemaBuilder = Schema.newBuilder();
+ schemaBuilder.column(
+ "state_key",
LogicalTypeDataTypeConverter.toDataType(schemaInfo.keyType).notNull());
+ if (windowed) {
+ schemaBuilder.column(
+ "state_window",
+
LogicalTypeDataTypeConverter.toDataType(entryInfo.windowLogicalType).notNull());
+ }
+
+ String subKeyColumnName = addFlattenedValueColumns(schemaBuilder,
entryInfo);
+ if (!windowed) {
+ schemaBuilder.primaryKeyNamed(
+ "PK_state_key_" + subKeyColumnName, "state_key",
subKeyColumnName);
+ }
+ Schema schema = schemaBuilder.build();
+
+ Map<String, String> options = buildBaseConnectorOptions(statePath,
operatorIdentifier);
+ options.put(
+ SavepointConnectorOptions.STATE_READER_MODE.key(),
+ (windowed
+ ?
SavepointConnectorOptions.StateReaderMode.WINDOWED_FLAT
+ :
SavepointConnectorOptions.StateReaderMode.KEYED_FLAT)
+ .toString());
+ options.put(SavepointConnectorOptions.FLATTENED_STATE_NAME.key(),
stateName);
+ withStateBackendType(options, metadata, operatorIdentifier);
+
+ return
CatalogTable.newBuilder().schema(schema).options(options).build();
+ }
+
+ /**
+ * Adds the LIST- or MAP-shaped sub-key and value columns (e.g. {@code
(list_index, list_value)}
+ * or {@code (map_key, map_value)}) for a flattened state table, and
returns the sub-key
+ * column's name.
+ */
+ private static String addFlattenedValueColumns(
+ Schema.Builder schemaBuilder, KeyedStateSchemaInfo.StateEntryInfo
entryInfo) {
+ LogicalType valueType;
+ String subKeyColumnName;
+ String valueColumnName;
+ if (entryInfo.stateType == SavepointConnectorOptions.StateType.LIST) {
+ valueType = ((ArrayType) entryInfo.logicalType).getElementType();
+ subKeyColumnName = "list_index";
+ valueColumnName = "list_value";
+ schemaBuilder.column(
+ subKeyColumnName,
+ LogicalTypeDataTypeConverter.toDataType(new
BigIntType(false)));
+ } else {
+ MapType mapType = (MapType) entryInfo.logicalType;
+ valueType = mapType.getValueType();
+ subKeyColumnName = "map_key";
+ valueColumnName = "map_value";
+ schemaBuilder.column(
+ subKeyColumnName,
+
LogicalTypeDataTypeConverter.toDataType(mapType.getKeyType()).notNull());
+ }
+ schemaBuilder.column(valueColumnName,
LogicalTypeDataTypeConverter.toDataType(valueType));
+ return subKeyColumnName;
+ }
+
+ //
-------------------------------------------------------------------------
+ // Private helpers
+ //
-------------------------------------------------------------------------
+
+ /**
+ * Resolves the SQL column {@link org.apache.flink.table.types.DataType}
for a single state's
+ * value column, forcing it nullable for VALUE-shaped state: unlike
LIST/MAP (which always have
+ * a value, possibly empty), a {@code ValueState}/{@code
ReducingState}/{@code AggregatingState}
+ * can legitimately hold no value (e.g. never written, or cleared by a
trigger such as {@code
+ * CountTrigger}), in which case a read returns {@code null}.
+ */
+ private static DataType stateValueColumnDataType(
+ KeyedStateSchemaInfo.StateEntryInfo entryInfo) {
+ DataType dataType =
LogicalTypeDataTypeConverter.toDataType(entryInfo.logicalType);
+ return entryInfo.stateType == SavepointConnectorOptions.StateType.VALUE
+ ? dataType.nullable()
+ : dataType;
+ }
+
+ private static OperatorState findOperatorState(
+ CheckpointMetadata metadata, OperatorIdentifier operatorId) {
+ for (OperatorState op : metadata.getOperatorStates()) {
+ if (op.getOperatorID().equals(operatorId.getOperatorId())) {
+ return op;
+ }
+ }
+ throw new IllegalArgumentException(
+ "Operator '" + operatorId + "' not found in checkpoint
metadata.");
+ }
+
+ /**
+ * Returns the base connector options ({@link FactoryUtil#CONNECTOR},
{@link
+ * SavepointConnectorOptions#STATE_PATH}, and the operator identifier
option) shared by every
+ * savepoint-backed {@link CatalogTable}.
+ */
+ private static Map<String, String> buildBaseConnectorOptions(
+ String statePath, OperatorIdentifier operatorIdentifier) {
+ Map<String, String> options = new HashMap<>();
+ options.put(FactoryUtil.CONNECTOR.key(), "savepoint");
+ options.put(SavepointConnectorOptions.STATE_PATH.key(), statePath);
+ operatorIdentifier
+ .getUid()
+ .ifPresentOrElse(
+ uid ->
options.put(SavepointConnectorOptions.OPERATOR_UID.key(), uid),
+ () ->
+ options.put(
+
SavepointConnectorOptions.OPERATOR_UID_HASH.key(),
+
operatorIdentifier.getOperatorId().toHexString()));
+ return options;
+ }
+
+ /**
+ * Adds {@link SavepointConnectorOptions#STATE_BACKEND_TYPE} to {@code
options} when it can be
+ * unambiguously determined from the checkpoint metadata. Only meaningful
for keyed state
+ * tables: non-keyed (list/union/broadcast) state isn't stored in a state
backend, so callers
+ * for those table kinds must not call this.
+ */
+ private static void withStateBackendType(
+ Map<String, String> options,
+ CheckpointMetadata metadata,
+ OperatorIdentifier operatorIdentifier) {
+ OperatorState opState = findOperatorState(metadata,
operatorIdentifier);
+ detectStateBackendType(opState)
+ .ifPresent(
+ type ->
+ options.put(
+
SavepointConnectorOptions.STATE_BACKEND_TYPE.key(), type));
+ }
+
+ /**
+ * Attempts to determine the state backend (shortcut name, see {@link
+ * StateBackendLoader#HASHMAP_STATE_BACKEND_NAME} / {@link
+ * StateBackendLoader#ROCKSDB_STATE_BACKEND_NAME}) that produced the
operator's keyed state, by
+ * inspecting the concrete {@link KeyedStateHandle} subtype found in the
checkpoint metadata:
+ * heap/HashMap backends produce {@link KeyGroupsStateHandle},
RocksDB/ForSt backends produce
+ * {@link IncrementalKeyedStateHandle}.
+ *
+ * <p>Canonical-format savepoints rewrite keyed state into the
backend-agnostic {@link
+ * KeyGroupsSavepointStateHandle}, in which case the originating backend
can no longer be
+ * determined from the handle alone; an empty result is returned rather
than guessing.
+ */
+ static Optional<String> detectStateBackendType(OperatorState opState) {
+ Set<String> detectedTypes = new HashSet<>();
+ for (OperatorSubtaskState subtaskState : opState.getStates()) {
+ collectStateBackendTypes(subtaskState.getManagedKeyedState(),
detectedTypes);
+ collectStateBackendTypes(subtaskState.getRawKeyedState(),
detectedTypes);
+ }
+ if (detectedTypes.size() != 1) {
+ if (detectedTypes.size() > 1) {
+ LOG.warn(
+ "Operator '{}' has keyed state handles from multiple
state backends {}; "
+ + "not setting '{}'.",
+ opState.getOperatorID(),
+ detectedTypes,
+ SavepointConnectorOptions.STATE_BACKEND_TYPE.key());
+ }
+ return Optional.empty();
+ }
+ return Optional.of(detectedTypes.iterator().next());
+ }
+
+ private static void collectStateBackendTypes(
+ Iterable<KeyedStateHandle> handles, Set<String> detectedTypes) {
+ for (KeyedStateHandle handle : handles) {
+ if (handle instanceof ChangelogStateBackendHandle) {
+ collectStateBackendTypes(
+ ((ChangelogStateBackendHandle)
handle).getMaterializedStateHandles(),
+ detectedTypes);
+ } else if (handle instanceof IncrementalKeyedStateHandle) {
+
detectedTypes.add(StateBackendLoader.ROCKSDB_STATE_BACKEND_NAME);
+ } else if (handle instanceof KeyGroupsSavepointStateHandle) {
+ // Canonical-format savepoints rewrite keyed state into a
backend-agnostic
+ // format; the originating backend can no longer be told apart
from the handle.
+ } else if (handle instanceof KeyGroupsStateHandle) {
+
detectedTypes.add(StateBackendLoader.HASHMAP_STATE_BACKEND_NAME);
+ } else {
+ LOG.warn("Unknown handle type '{}'.",
handle.getClass().getSimpleName());
+ }
+ }
+ }
+
+ /** Returns {@code true} for Flink-internal states that are not
user-registered states. */
+ private static boolean isInternalState(String stateName) {
Review Comment:
Both literals are unlinked duplicates: _timer_state mirrors a
package-private constant in InternalTimeServiceManagerImpl, merging-window-set
is not even named anywhere, it is inline in WindowOperator. Either changing
there silently breaks this, with nothing to catch it. Worth hardening this
coupling somehow?
##########
flink-core/src/main/java/org/apache/flink/api/java/typeutils/runtime/PojoSerializerSnapshotData.java:
##########
@@ -206,7 +212,17 @@ void writeSnapshotData(DataOutputView out) throws
IOException {
private static <T> PojoSerializerSnapshotData<T> readSnapshotData(
DataInputView in, ClassLoader userCodeClassLoader) throws
IOException {
- Class<T> pojoClass = InstantiationUtil.resolveClassByName(in,
userCodeClassLoader);
+ final String pojoClassName = in.readUTF();
+ Class<T> pojoClass = null;
Review Comment:
TLDR: If condition can be added to both places
1. `PojoSerializerSnapshotData.readSnapshotData()` (ClassNotFoundException
e) block:
```
if (CustomRestoreSerializerFactory.get() == null) {
throw new IOException("Could not find class '" + pojoClassName + "'
in classpath.", e);
}
```
2. `AvroSerializerSnapshot.tryFindClass()` (ClassNotFoundException e) block:
```
if (CustomRestoreSerializerFactory.get() == null) {
throw new IllegalStateException("Unable to find the class '" +
className + "' ... Was the class moved or renamed?", e);
}
```
##########
flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/StateTableUtils.java:
##########
@@ -0,0 +1,641 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.state.api;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.state.StateDescriptor;
+import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot;
+import org.apache.flink.runtime.checkpoint.OperatorState;
+import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
+import org.apache.flink.runtime.checkpoint.metadata.CheckpointMetadata;
+import org.apache.flink.runtime.state.IncrementalKeyedStateHandle;
+import org.apache.flink.runtime.state.KeyGroupsSavepointStateHandle;
+import org.apache.flink.runtime.state.KeyGroupsStateHandle;
+import org.apache.flink.runtime.state.KeyedStateHandle;
+import org.apache.flink.runtime.state.StateBackendLoader;
+import org.apache.flink.runtime.state.VoidNamespaceSerializer;
+import org.apache.flink.runtime.state.changelog.ChangelogStateBackendHandle;
+import org.apache.flink.state.api.schema.KeyedStateSchemaInfo;
+import
org.apache.flink.state.api.schema.SerializerSnapshotToLogicalTypeConverter;
+import org.apache.flink.state.api.schema.StateSchemaExtractor;
+import org.apache.flink.state.api.schema.StateSchemaInfo;
+import org.apache.flink.state.table.SavepointConnectorOptions;
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.factories.FactoryUtil;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.ArrayType;
+import org.apache.flink.table.types.logical.BigIntType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.MapType;
+import org.apache.flink.table.types.logical.VarBinaryType;
+import org.apache.flink.table.types.utils.LogicalTypeDataTypeConverter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * High-level utility for inspecting and reading keyed state from a checkpoint
/ savepoint without
+ * requiring user POJO classes on the classpath.
+ */
+@Internal
+public final class StateTableUtils {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(StateTableUtils.class);
+
+ private StateTableUtils() {}
+
+ /**
+ * Returns the {@link OperatorIdentifier}s of all operators present in the
given checkpoint
+ * metadata that have at least one non-internal keyed state.
+ *
+ * @param metadata the checkpoint metadata to inspect
+ * @return list of operator identifiers; never null, may be empty
+ */
+ public static List<OperatorIdentifier>
getOperatorIdentifiers(CheckpointMetadata metadata) {
+ return metadata.getOperatorStates().stream()
+ .filter(StateTableUtils::hasNonInternalKeyedState)
+ .map(
+ op ->
+ op.getOperatorUid()
+ .map(OperatorIdentifier::forUid)
+ .orElseGet(
+ () ->
+
OperatorIdentifier.forUidHash(
+
op.getOperatorID().toHexString())))
+ .collect(Collectors.toList());
+ }
+
+ private static boolean hasNonInternalKeyedState(OperatorState op) {
+ try {
+ List<StateSchemaInfo> schemas =
StateSchemaExtractor.extractSchema(op);
+ ClassifiedStates classified =
classifyStates(op.getOperatorID().toHexString(), schemas);
+ return !classified.voidNamespaceStates.isEmpty()
+ || !classified.windowNamespaceStates.isEmpty();
+ } catch (Exception e) {
+ LOG.warn(
+ "Could not extract state schema for operator '{}': {}.
Excluding from catalog.",
+ op.getOperatorID(),
+ e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Returns the names of all keyed states registered by the given operator.
+ *
+ * @param metadata the checkpoint metadata to inspect
+ * @param operatorId identifies the operator
+ * @param classLoader the class loader used when reading serializer
snapshots
+ * @return list of state names; never null, may be empty
+ * @throws IOException if the state header cannot be read
+ */
+ public static List<String> getKeyedStates(
+ CheckpointMetadata metadata, OperatorIdentifier operatorId) throws
IOException {
+ OperatorState opState = findOperatorState(metadata, operatorId);
+ List<StateSchemaInfo> schemaInfos =
StateSchemaExtractor.extractSchema(opState);
+ ClassifiedStates classified = classifyStates(operatorId.toString(),
schemaInfos);
+ return classified.voidNamespaceStates.stream()
+ .map(info -> info.stateName)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Returns the {@link KeyedStateSchemaInfo} for the plain per-key
(void-namespace) states of the
+ * given operator — the ones exposed by the {@code _keyed}/{@code
_keyed_flat} tables.
+ *
+ * <p>Schema extraction is lenient: POJO field names and types are derived
from the serializer
+ * snapshot and do not require the user POJO class to be on the classpath.
+ *
+ * @param metadata the checkpoint metadata to inspect
+ * @param operatorId identifies the operator
+ * @return schema information covering the key type and all registered
state entries
+ * @throws IOException if the state header cannot be read
+ */
+ public static KeyedStateSchemaInfo getKeyedStateSchema(
+ CheckpointMetadata metadata, OperatorIdentifier operatorId) throws
IOException {
+ OperatorState opState = findOperatorState(metadata, operatorId);
+ List<StateSchemaInfo> schemas =
StateSchemaExtractor.extractSchema(opState);
+ ClassifiedStates classified = classifyStates(operatorId.toString(),
schemas);
+ return buildKeyedStateSchemaInfo(schemas,
classified.voidNamespaceStates, null);
+ }
+
+ private static KeyedStateSchemaInfo buildKeyedStateSchemaInfo(
+ List<StateSchemaInfo> allSchemas,
+ List<StateSchemaInfo> statesToInclude,
+ @Nullable LogicalType windowLogicalType) {
+ LogicalType keyType =
+ allSchemas.isEmpty()
+ ? new VarBinaryType(true, VarBinaryType.MAX_LENGTH)
+ : SerializerSnapshotToLogicalTypeConverter.convert(
+ allSchemas.get(0).keySnapshot);
+
+ LinkedHashMap<String, KeyedStateSchemaInfo.StateEntryInfo>
stateSchemas =
+ new LinkedHashMap<>();
+ for (StateSchemaInfo info : statesToInclude) {
+ SavepointConnectorOptions.StateType stateType;
+ if (info.stateKind == StateDescriptor.Type.LIST) {
+ stateType = SavepointConnectorOptions.StateType.LIST;
+ } else if (info.stateKind == StateDescriptor.Type.MAP) {
+ stateType = SavepointConnectorOptions.StateType.MAP;
+ } else {
+ stateType = SavepointConnectorOptions.StateType.VALUE;
+ }
+
+ try {
+ LogicalType logicalType =
+
SerializerSnapshotToLogicalTypeConverter.convert(info.valueSnapshot);
+ stateSchemas.put(
+ info.stateName,
+ new KeyedStateSchemaInfo.StateEntryInfo(
+ stateType, logicalType, windowLogicalType));
+ } catch (Exception e) {
Review Comment:
convert()'s contract documents UnsupportedOperationException for unsupported
types, catching that specifically would still handle the legitimate case. Why
catch (Exception e) here? It also absorbs a genuine bug (NPE,
ClassCastException) into the same silent column missing outcome.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]