This is an automated email from the ASF dual-hosted git repository.
gaborgsomogyi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 23cec76515a [FLINK-39637][state] Add filter pushdown support for
savepoint connector
23cec76515a is described below
commit 23cec76515ad958502d5f5053fff07de3407bfd8
Author: Ilya Soin <[email protected]>
AuthorDate: Tue Jun 23 18:54:34 2026 +0300
[FLINK-39637][state] Add filter pushdown support for savepoint connector
---
.../apache/flink/state/api/SavepointReader.java | 32 +-
.../apache/flink/state/api/filter/BoundInfo.java | 45 ++
.../flink/state/api/filter/EmptyKeyFilter.java | 61 ++
.../flink/state/api/filter/ExactKeyFilter.java | 63 ++
.../flink/state/api/filter/RangeKeyFilter.java | 124 ++++
.../flink/state/api/filter/SavepointKeyFilter.java | 121 ++++
.../api/input/FilteringCloseableIterator.java | 71 ++
.../state/api/input/KeyedStateInputFormat.java | 77 +-
.../table/SavepointDataStreamScanProvider.java | 24 +-
.../state/table/SavepointDynamicTableSource.java | 40 +-
.../state/table/SavepointFilterTranslator.java | 371 ++++++++++
.../state/api/SavepointReaderKeyedStateITCase.java | 214 +++++-
.../api/input/FilteringCloseableIteratorTest.java | 121 ++++
.../state/api/input/KeyedStateInputFormatTest.java | 81 +++
.../table/SavepointDynamicTableSourceTest.java | 273 ++++++-
.../state/table/SavepointFilterTranslatorTest.java | 805 +++++++++++++++++++++
16 files changed, 2472 insertions(+), 51 deletions(-)
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/SavepointReader.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/SavepointReader.java
index 7c6bdf527d0..b556ee0739f 100644
---
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/SavepointReader.java
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/SavepointReader.java
@@ -18,6 +18,7 @@
package org.apache.flink.state.api;
+import org.apache.flink.annotation.Experimental;
import org.apache.flink.annotation.PublicEvolving;
import org.apache.flink.api.common.InvalidProgramException;
import org.apache.flink.api.common.functions.InvalidTypesException;
@@ -32,6 +33,7 @@ import org.apache.flink.runtime.checkpoint.OperatorState;
import org.apache.flink.runtime.checkpoint.metadata.CheckpointMetadata;
import org.apache.flink.runtime.state.StateBackend;
import org.apache.flink.runtime.state.VoidNamespace;
+import org.apache.flink.state.api.filter.SavepointKeyFilter;
import org.apache.flink.state.api.functions.KeyedStateReaderFunction;
import org.apache.flink.state.api.input.BroadcastStateInputFormat;
import org.apache.flink.state.api.input.KeyedStateInputFormat;
@@ -401,6 +403,33 @@ public class SavepointReader {
TypeInformation<K> keyTypeInfo,
TypeInformation<OUT> outTypeInfo)
throws IOException {
+ return readKeyedState(identifier, function, keyTypeInfo, outTypeInfo,
null);
+ }
+
+ /**
+ * Read keyed state from an operator in a {@code Savepoint}, optionally
pruning input splits and
+ * keys with a {@link SavepointKeyFilter}.
+ *
+ * @param identifier The identifier of the operator.
+ * @param function The {@link KeyedStateReaderFunction} that is called for
each key in state.
+ * @param keyTypeInfo The type information of the key in state.
+ * @param outTypeInfo The type information of the output of the transform
reader function.
+ * @param keyFilter Optional filter on the state key. When present, input
splits whose key
+ * groups cannot contain any matching key are skipped, and within each
split only matching
+ * keys are iterated.
+ * @param <K> The type of the key in state.
+ * @param <OUT> The output type of the transform function.
+ * @return A {@code DataStream} of objects read from keyed state.
+ * @throws IOException If the savepoint does not contain operator state
with the given uid.
+ */
+ @Experimental
+ public <K, OUT> DataStream<OUT> readKeyedState(
+ OperatorIdentifier identifier,
+ KeyedStateReaderFunction<K, OUT> function,
+ TypeInformation<K> keyTypeInfo,
+ TypeInformation<OUT> outTypeInfo,
+ @Nullable SavepointKeyFilter keyFilter)
+ throws IOException {
OperatorState operatorState = metadata.getOperatorState(identifier);
KeyedStateInputFormat<K, VoidNamespace, OUT> inputFormat =
@@ -409,7 +438,8 @@ public class SavepointReader {
stateBackend,
MutableConfig.of(env.getConfiguration()),
new KeyedStateReaderOperator<>(function, keyTypeInfo),
- env.getConfig());
+ env.getConfig(),
+ keyFilter);
return SourceBuilder.fromFormat(env, inputFormat, outTypeInfo);
}
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/BoundInfo.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/BoundInfo.java
new file mode 100644
index 00000000000..ddf3a24ef39
--- /dev/null
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/BoundInfo.java
@@ -0,0 +1,45 @@
+/*
+ * 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.filter;
+
+import org.apache.flink.annotation.Experimental;
+
+import java.io.Serializable;
+
+/** Information about a bound in a range filter. */
+@Experimental
+public final class BoundInfo implements Serializable {
+ private static final long serialVersionUID = 4L;
+
+ private final Comparable<?> value;
+ private final boolean inclusive;
+
+ public BoundInfo(Comparable<?> value, boolean inclusive) {
+ this.value = value;
+ this.inclusive = inclusive;
+ }
+
+ public Comparable<?> getValue() {
+ return value;
+ }
+
+ public boolean isInclusive() {
+ return inclusive;
+ }
+}
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/EmptyKeyFilter.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/EmptyKeyFilter.java
new file mode 100644
index 00000000000..cca74d2dac2
--- /dev/null
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/EmptyKeyFilter.java
@@ -0,0 +1,61 @@
+/*
+ * 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.filter;
+
+import java.util.Collections;
+import java.util.Set;
+
+/** A filter that rejects every key. */
+final class EmptyKeyFilter implements SavepointKeyFilter {
+
+ private static final long serialVersionUID = 1L;
+
+ static final EmptyKeyFilter INSTANCE = new EmptyKeyFilter();
+
+ private EmptyKeyFilter() {}
+
+ @Override
+ public boolean test(Object key) {
+ return false;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return true;
+ }
+
+ @Override
+ public Set<Object> getExactKeys() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public SavepointKeyFilter intersect(SavepointKeyFilter other) {
+ return this;
+ }
+
+ private Object readResolve() {
+ return INSTANCE;
+ }
+
+ @Override
+ public String toString() {
+ return "EmptyKeyFilter";
+ }
+}
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/ExactKeyFilter.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/ExactKeyFilter.java
new file mode 100644
index 00000000000..49b966c3f81
--- /dev/null
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/ExactKeyFilter.java
@@ -0,0 +1,63 @@
+/*
+ * 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.filter;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/** A filter that accepts a finite set of keys. */
+final class ExactKeyFilter implements SavepointKeyFilter {
+
+ private static final long serialVersionUID = 2L;
+
+ private final Set<Object> keys;
+
+ ExactKeyFilter(Set<Object> keys) {
+ this.keys = Set.copyOf(keys);
+ }
+
+ @Override
+ public boolean test(Object key) {
+ return keys.contains(key);
+ }
+
+ @Override
+ public Set<Object> getExactKeys() {
+ return keys;
+ }
+
+ @Override
+ public SavepointKeyFilter intersect(SavepointKeyFilter other) {
+ if (other.isEmpty()) {
+ return other;
+ }
+ final Set<Object> otherKeys = other.getExactKeys();
+ if (otherKeys != null) {
+ final Set<Object> intersection = new HashSet<>(keys);
+ intersection.retainAll(otherKeys);
+ return SavepointKeyFilter.exact(intersection);
+ }
+ return SavepointKeyFilter.filterKeys(keys, other);
+ }
+
+ @Override
+ public String toString() {
+ return "ExactKeyFilter" + keys;
+ }
+}
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/RangeKeyFilter.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/RangeKeyFilter.java
new file mode 100644
index 00000000000..3a2ee86af09
--- /dev/null
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/RangeKeyFilter.java
@@ -0,0 +1,124 @@
+/*
+ * 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.filter;
+
+import javax.annotation.Nullable;
+
+import java.util.Set;
+
+/** A filter based on a comparable range. */
+final class RangeKeyFilter implements SavepointKeyFilter {
+
+ private static final long serialVersionUID = 3L;
+
+ @Nullable private final BoundInfo lower;
+ @Nullable private final BoundInfo upper;
+
+ RangeKeyFilter(@Nullable BoundInfo lower, @Nullable BoundInfo upper) {
+ this.lower = lower;
+ this.upper = upper;
+ }
+
+ @Override
+ public boolean test(Object key) {
+ if (lower != null) {
+ int cmp = compare(lower.getValue(), key);
+ if (cmp > 0 || (cmp == 0 && !lower.isInclusive())) {
+ return false;
+ }
+ }
+ if (upper != null) {
+ int cmp = compare(upper.getValue(), key);
+ if (cmp < 0 || (cmp == 0 && !upper.isInclusive())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static int compare(Comparable<?> a, Object b) {
+ return ((Comparable<Object>) a).compareTo(b);
+ }
+
+ @Override
+ public BoundInfo getLowerBound() {
+ return lower;
+ }
+
+ @Override
+ public BoundInfo getUpperBound() {
+ return upper;
+ }
+
+ @Override
+ public SavepointKeyFilter intersect(SavepointKeyFilter other) {
+ if (other.isEmpty()) {
+ return other;
+ }
+ final Set<Object> otherExactKeys = other.getExactKeys();
+ if (otherExactKeys != null) {
+ return SavepointKeyFilter.filterKeys(otherExactKeys, this);
+ }
+ return intersectRange(other.getLowerBound(), other.getUpperBound());
+ }
+
+ private SavepointKeyFilter intersectRange(
+ @Nullable BoundInfo otherLower, @Nullable BoundInfo otherUpper) {
+ BoundInfo newLower = tighter(lower, otherLower, true);
+ BoundInfo newUpper = tighter(upper, otherUpper, false);
+
+ if (newLower != null && newUpper != null) {
+ int cmp = compare(newLower.getValue(), newUpper.getValue());
+ if (cmp > 0) {
+ return SavepointKeyFilter.empty();
+ }
+ if (cmp == 0 && (!newLower.isInclusive() ||
!newUpper.isInclusive())) {
+ return SavepointKeyFilter.empty();
+ }
+ }
+ return new RangeKeyFilter(newLower, newUpper);
+ }
+
+ @Nullable
+ private static BoundInfo tighter(
+ @Nullable BoundInfo a, @Nullable BoundInfo b, boolean
preferHigher) {
+ if (a == null) {
+ return b;
+ }
+ if (b == null) {
+ return a;
+ }
+ int c = compare(a.getValue(), b.getValue());
+ if (c == 0) {
+ return new BoundInfo(a.getValue(), a.isInclusive() &&
b.isInclusive());
+ }
+ boolean aWins = preferHigher ? c > 0 : c < 0;
+ return aWins ? a : b;
+ }
+
+ @Override
+ public String toString() {
+ String lowerStr =
+ lower == null ? "(-∞" : (lower.isInclusive() ? "[" : "(") +
lower.getValue();
+ String upperStr =
+ upper == null ? "+∞)" : upper.getValue() +
(upper.isInclusive() ? "]" : ")");
+ return "RangeKeyFilter" + lowerStr + ", " + upperStr;
+ }
+}
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/SavepointKeyFilter.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/SavepointKeyFilter.java
new file mode 100644
index 00000000000..b5a29db3043
--- /dev/null
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/filter/SavepointKeyFilter.java
@@ -0,0 +1,121 @@
+/*
+ * 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.filter;
+
+import org.apache.flink.annotation.Experimental;
+
+import javax.annotation.Nullable;
+
+import java.io.Serializable;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Represents a key filter that can be pushed down into a savepoint scan. */
+@Experimental
+public interface SavepointKeyFilter extends Serializable {
+
+ /** Returns {@code true} if the given key passes this filter. */
+ boolean test(Object key);
+
+ /**
+ * Returns {@code true} if this filter rejects every key.
+ *
+ * <p>Used only while combining filters during push-down translation, not
during the scan.
+ */
+ default boolean isEmpty() {
+ return false;
+ }
+
+ /**
+ * Returns the finite set of keys this filter matches, or {@code null} if
the filter does not
+ * resolve to a finite key set.
+ */
+ @Nullable
+ default Set<Object> getExactKeys() {
+ return null;
+ }
+
+ /**
+ * Returns the lower bound of this filter's range, or {@code null} if the
filter does not define
+ * a lower bound.
+ *
+ * <p>Used only while combining filters during push-down translation, not
during the scan.
+ */
+ @Nullable
+ default BoundInfo getLowerBound() {
+ return null;
+ }
+
+ /**
+ * Returns the upper bound of this filter's range, or {@code null} if the
filter does not define
+ * an upper bound.
+ *
+ * <p>Used only while combining filters during push-down translation, not
during the scan.
+ */
+ @Nullable
+ default BoundInfo getUpperBound() {
+ return null;
+ }
+
+ /**
+ * Returns a filter that accepts a key if and only if both {@code this}
and {@code other} accept
+ * it.
+ *
+ * <p>Used only while combining filters during push-down translation, not
during the scan.
+ */
+ default SavepointKeyFilter intersect(SavepointKeyFilter other) {
+ throw new UnsupportedOperationException(
+ getClass().getSimpleName() + " does not support intersect()");
+ }
+
+ static SavepointKeyFilter filterKeys(Set<Object> keys, SavepointKeyFilter
predicate) {
+ final Set<Object> retained = new HashSet<>();
+ for (Object key : keys) {
+ if (predicate.test(key)) {
+ retained.add(key);
+ }
+ }
+ return exact(retained);
+ }
+
+ static SavepointKeyFilter exact(Set<Object> keys) {
+ if (keys.isEmpty()) {
+ return EmptyKeyFilter.INSTANCE;
+ }
+ return new ExactKeyFilter(keys);
+ }
+
+ static SavepointKeyFilter exact(Object value) {
+ return new ExactKeyFilter(Set.of(value));
+ }
+
+ static SavepointKeyFilter range(
+ @Nullable Comparable<?> lower,
+ boolean lowerInclusive,
+ @Nullable Comparable<?> upper,
+ boolean upperInclusive) {
+ BoundInfo lowerBoundInfo = lower != null ? new BoundInfo(lower,
lowerInclusive) : null;
+ BoundInfo upperBoundInfo = upper != null ? new BoundInfo(upper,
upperInclusive) : null;
+ return new RangeKeyFilter(lowerBoundInfo, upperBoundInfo);
+ }
+
+ static SavepointKeyFilter empty() {
+ return EmptyKeyFilter.INSTANCE;
+ }
+}
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/FilteringCloseableIterator.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/FilteringCloseableIterator.java
new file mode 100644
index 00000000000..25963e2e389
--- /dev/null
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/FilteringCloseableIterator.java
@@ -0,0 +1,71 @@
+/*
+ * 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;
+
+import org.apache.flink.util.CloseableIterator;
+
+import javax.annotation.Nullable;
+
+import java.util.NoSuchElementException;
+import java.util.function.Predicate;
+
+/** A {@link CloseableIterator} that only exposes elements matching a
predicate. */
+final class FilteringCloseableIterator<T> implements CloseableIterator<T> {
+
+ private final CloseableIterator<T> source;
+ private final Predicate<T> predicate;
+
+ @Nullable private T pending;
+ private boolean hasPending;
+
+ FilteringCloseableIterator(CloseableIterator<T> source, Predicate<T>
predicate) {
+ this.source = source;
+ this.predicate = predicate;
+ }
+
+ @Override
+ public boolean hasNext() {
+ while (!hasPending && source.hasNext()) {
+ final T candidate = source.next();
+ if (predicate.test(candidate)) {
+ pending = candidate;
+ hasPending = true;
+ }
+ }
+
+ return hasPending;
+ }
+
+ @Override
+ public T next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+
+ final T result = pending;
+ pending = null;
+ hasPending = false;
+ return result;
+ }
+
+ @Override
+ public void close() throws Exception {
+ source.close();
+ }
+}
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/KeyedStateInputFormat.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/KeyedStateInputFormat.java
index ae95f79048c..aba34a8c65d 100644
---
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/KeyedStateInputFormat.java
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/api/input/KeyedStateInputFormat.java
@@ -33,8 +33,10 @@ import
org.apache.flink.runtime.checkpoint.StateAssignmentOperation;
import org.apache.flink.runtime.state.AbstractKeyedStateBackend;
import org.apache.flink.runtime.state.DefaultKeyedStateStore;
import org.apache.flink.runtime.state.KeyGroupRange;
+import org.apache.flink.runtime.state.KeyGroupRangeAssignment;
import org.apache.flink.runtime.state.KeyedStateHandle;
import org.apache.flink.runtime.state.StateBackend;
+import org.apache.flink.state.api.filter.SavepointKeyFilter;
import org.apache.flink.state.api.functions.KeyedStateReaderFunction;
import org.apache.flink.state.api.input.operator.StateReaderOperator;
import org.apache.flink.state.api.input.splits.KeyGroupRangeInputSplit;
@@ -54,8 +56,11 @@ import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.Comparator;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
/**
* Input format for reading partitioned state.
@@ -81,6 +86,8 @@ public class KeyedStateInputFormat<K, N, OUT>
private final SerializedValue<ExecutionConfig> serializedExecutionConfig;
+ @Nullable private final SavepointKeyFilter keyFilter;
+
private transient CloseableRegistry registry;
private transient BufferingCollector<OUT> out;
@@ -101,6 +108,27 @@ public class KeyedStateInputFormat<K, N, OUT>
StateReaderOperator<?, K, N, OUT> operator,
ExecutionConfig executionConfig)
throws IOException {
+ this(operatorState, stateBackend, configuration, operator,
executionConfig, null);
+ }
+
+ /**
+ * Creates an input format for reading partitioned state from an operator
in a savepoint.
+ *
+ * @param operatorState The state to be queried.
+ * @param stateBackend The state backed used to snapshot the operator.
+ * @param configuration The underlying Flink configuration used to
configure the state backend.
+ * @param keyFilter Optional filter on the state key. When present, splits
whose key groups
+ * cannot contain any matching key are skipped, and within each split
only matching keys are
+ * iterated.
+ */
+ public KeyedStateInputFormat(
+ OperatorState operatorState,
+ @Nullable StateBackend stateBackend,
+ Configuration configuration,
+ StateReaderOperator<?, K, N, OUT> operator,
+ ExecutionConfig executionConfig,
+ @Nullable SavepointKeyFilter keyFilter)
+ throws IOException {
Preconditions.checkNotNull(operatorState, "The operator state cannot
be null");
Preconditions.checkNotNull(configuration, "The configuration cannot be
null");
Preconditions.checkNotNull(operator, "The operator cannot be null");
@@ -114,6 +142,7 @@ public class KeyedStateInputFormat<K, N, OUT>
this.configuration = new Configuration(configuration);
this.operator = operator;
this.serializedExecutionConfig = new
SerializedValue<>(executionConfig);
+ this.keyFilter = keyFilter;
}
@Override
@@ -132,9 +161,15 @@ public class KeyedStateInputFormat<K, N, OUT>
@Override
public KeyGroupRangeInputSplit[] createInputSplits(int minNumSplits)
throws IOException {
final int maxParallelism = operatorState.getMaxParallelism();
-
final List<KeyGroupRange> keyGroups =
sortedKeyGroupRanges(minNumSplits, maxParallelism);
+ if (keyFilter != null) {
+ Set<Object> exactKeys = keyFilter.getExactKeys();
+ if (exactKeys != null) {
+ return pruneByExactKeys(keyGroups, exactKeys, maxParallelism);
+ }
+ }
+
return CollectionUtil.mapWithIndex(
keyGroups,
(keyGroupRange, index) ->
@@ -143,6 +178,28 @@ public class KeyedStateInputFormat<K, N, OUT>
.toArray(KeyGroupRangeInputSplit[]::new);
}
+ private KeyGroupRangeInputSplit[] pruneByExactKeys(
+ List<KeyGroupRange> keyGroups, Set<Object> exactKeys, int
maxParallelism) {
+ if (exactKeys.isEmpty()) {
+ return new KeyGroupRangeInputSplit[0];
+ }
+
+ final Set<Integer> targetKeyGroups = new HashSet<>();
+ for (Object key : exactKeys) {
+ targetKeyGroups.add(KeyGroupRangeAssignment.assignToKeyGroup(key,
maxParallelism));
+ }
+
+ final List<KeyGroupRangeInputSplit> prunedSplits = new ArrayList<>();
+ for (int i = 0; i < keyGroups.size(); i++) {
+ KeyGroupRange range = keyGroups.get(i);
+ if (rangeContainsAny(range, targetKeyGroups)) {
+ prunedSplits.add(
+ createKeyGroupRangeInputSplit(operatorState,
maxParallelism, range, i));
+ }
+ }
+ return prunedSplits.toArray(new KeyGroupRangeInputSplit[0]);
+ }
+
@Override
public void openInputFormat() {
out = new BufferingCollector<>();
@@ -188,7 +245,14 @@ public class KeyedStateInputFormat<K, N, OUT>
operator.setup(
runtimeContext::createSerializer, keyedStateBackend,
timeServiceManager, ctx);
operator.open();
- keysAndNamespaces = operator.getKeysAndNamespaces(ctx);
+ if (keyFilter != null) {
+ keysAndNamespaces =
+ new FilteringCloseableIterator<>(
+ operator.getKeysAndNamespaces(ctx),
+ keyAndNamespace ->
keyFilter.test(keyAndNamespace.f0));
+ } else {
+ keysAndNamespaces = operator.getKeysAndNamespaces(ctx);
+ }
} catch (Exception e) {
throw new IOException("Failed to restore timer state", e);
}
@@ -229,6 +293,15 @@ public class KeyedStateInputFormat<K, N, OUT>
return out.next();
}
+ private static boolean rangeContainsAny(KeyGroupRange range, Set<Integer>
keyGroups) {
+ for (int kg : keyGroups) {
+ if (range.contains(kg)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private static KeyGroupRangeInputSplit createKeyGroupRangeInputSplit(
OperatorState operatorState,
int maxParallelism,
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointDataStreamScanProvider.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointDataStreamScanProvider.java
index fd24b0f2448..35a9e04a7af 100644
---
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointDataStreamScanProvider.java
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointDataStreamScanProvider.java
@@ -30,6 +30,7 @@ import org.apache.flink.runtime.state.StateBackend;
import org.apache.flink.runtime.state.StateBackendLoader;
import org.apache.flink.state.api.OperatorIdentifier;
import org.apache.flink.state.api.SavepointReader;
+import org.apache.flink.state.api.filter.SavepointKeyFilter;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.connector.ProviderContext;
@@ -53,6 +54,7 @@ public class SavepointDataStreamScanProvider implements
DataStreamScanProvider {
private final TypeInformation keyTypeInfo;
private final Tuple2<Integer, List<StateValueColumnConfiguration>>
keyValueProjections;
private final RowType rowType;
+ @Nullable private final SavepointKeyFilter keyFilter;
public SavepointDataStreamScanProvider(
@Nullable final String stateBackendType,
@@ -61,12 +63,31 @@ public class SavepointDataStreamScanProvider implements
DataStreamScanProvider {
final TypeInformation keyTypeInfo,
final Tuple2<Integer, List<StateValueColumnConfiguration>>
keyValueProjections,
RowType rowType) {
+ this(
+ stateBackendType,
+ statePath,
+ operatorIdentifier,
+ keyTypeInfo,
+ keyValueProjections,
+ rowType,
+ null);
+ }
+
+ public SavepointDataStreamScanProvider(
+ @Nullable final String stateBackendType,
+ final String statePath,
+ final OperatorIdentifier operatorIdentifier,
+ final TypeInformation keyTypeInfo,
+ final Tuple2<Integer, List<StateValueColumnConfiguration>>
keyValueProjections,
+ RowType rowType,
+ @Nullable SavepointKeyFilter keyFilter) {
this.stateBackendType = stateBackendType;
this.statePath = statePath;
this.operatorIdentifier = operatorIdentifier;
this.keyTypeInfo = keyTypeInfo;
this.keyValueProjections = keyValueProjections;
this.rowType = rowType;
+ this.keyFilter = keyFilter;
}
@Override
@@ -133,7 +154,8 @@ public class SavepointDataStreamScanProvider implements
DataStreamScanProvider {
operatorIdentifier,
new KeyedStateReader(rowType, keyValueProjections),
keyTypeInfo,
- outTypeInfo);
+ outTypeInfo,
+ keyFilter);
} catch (Exception e) {
throw new RuntimeException(e);
}
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointDynamicTableSource.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointDynamicTableSource.java
index 0bb3bb36f9c..bc8e30c243e 100644
---
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointDynamicTableSource.java
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointDynamicTableSource.java
@@ -21,10 +21,14 @@ package org.apache.flink.state.table;
import org.apache.flink.api.common.typeinfo.TypeInformation;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.state.api.OperatorIdentifier;
+import org.apache.flink.state.api.filter.SavepointKeyFilter;
import org.apache.flink.table.connector.ChangelogMode;
import org.apache.flink.table.connector.source.DynamicTableSource;
import org.apache.flink.table.connector.source.ScanTableSource;
+import
org.apache.flink.table.connector.source.abilities.SupportsFilterPushDown;
+import org.apache.flink.table.expressions.ResolvedExpression;
import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.table.types.utils.TypeConversions;
import javax.annotation.Nullable;
@@ -32,13 +36,14 @@ import java.util.List;
/** Savepoint dynamic source. */
@SuppressWarnings("rawtypes")
-public class SavepointDynamicTableSource implements ScanTableSource {
+public class SavepointDynamicTableSource implements ScanTableSource,
SupportsFilterPushDown {
@Nullable private final String stateBackendType;
private final String statePath;
private final OperatorIdentifier operatorIdentifier;
private final TypeInformation keyTypeInfo;
private final Tuple2<Integer, List<StateValueColumnConfiguration>>
keyValueProjections;
private final RowType rowType;
+ @Nullable private SavepointKeyFilter keyFilter;
public SavepointDynamicTableSource(
@Nullable final String stateBackendType,
@@ -57,13 +62,29 @@ public class SavepointDynamicTableSource implements
ScanTableSource {
@Override
public DynamicTableSource copy() {
- return new SavepointDynamicTableSource(
- stateBackendType,
- statePath,
- operatorIdentifier,
- keyTypeInfo,
- keyValueProjections,
- rowType);
+ SavepointDynamicTableSource copy =
+ new SavepointDynamicTableSource(
+ stateBackendType,
+ statePath,
+ operatorIdentifier,
+ keyTypeInfo,
+ keyValueProjections,
+ rowType);
+ copy.keyFilter = this.keyFilter;
+ return copy;
+ }
+
+ @Override
+ public Result applyFilters(List<ResolvedExpression> filters) {
+ final int keyColumnIndex = keyValueProjections.f0;
+ final SavepointFilterTranslator.Result result =
+ new SavepointFilterTranslator(
+ keyColumnIndex,
+ TypeConversions.fromLogicalToDataType(
+ rowType.getTypeAt(keyColumnIndex)))
+ .apply(filters);
+ keyFilter = result.keyFilter();
+ return Result.of(result.accepted(), result.remaining());
}
@Override
@@ -84,6 +105,7 @@ public class SavepointDynamicTableSource implements
ScanTableSource {
operatorIdentifier,
keyTypeInfo,
keyValueProjections,
- rowType);
+ rowType,
+ keyFilter);
}
}
diff --git
a/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointFilterTranslator.java
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointFilterTranslator.java
new file mode 100644
index 00000000000..001973ed4ed
--- /dev/null
+++
b/flink-libraries/flink-state-processing-api/src/main/java/org/apache/flink/state/table/SavepointFilterTranslator.java
@@ -0,0 +1,371 @@
+/*
+ * 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.table;
+
+import org.apache.flink.state.api.filter.SavepointKeyFilter;
+import org.apache.flink.table.expressions.CallExpression;
+import org.apache.flink.table.expressions.FieldReferenceExpression;
+import org.apache.flink.table.expressions.ResolvedExpression;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
+import org.apache.flink.table.functions.FunctionDefinition;
+import org.apache.flink.table.types.DataType;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiFunction;
+
+/**
+ * Converts {@link ResolvedExpression} key filter predicates into {@link
SavepointKeyFilter}
+ * instances that can be used to prune key groups and key iterations during
savepoint reads.
+ */
+class SavepointFilterTranslator {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(SavepointFilterTranslator.class);
+
+ private static final Map<
+ FunctionDefinition,
+ BiFunction<SavepointFilterTranslator, CallExpression,
SavepointKeyFilter>>
+ FILTERS =
+ Map.of(
+ BuiltInFunctionDefinitions.EQUALS,
+ SavepointFilterTranslator::fromEquals,
+ BuiltInFunctionDefinitions.OR,
+ SavepointFilterTranslator::fromOr,
+ BuiltInFunctionDefinitions.AND,
+ SavepointFilterTranslator::fromAnd,
+ BuiltInFunctionDefinitions.BETWEEN,
+ SavepointFilterTranslator::fromBetween,
+ BuiltInFunctionDefinitions.GREATER_THAN,
+ SavepointFilterTranslator::fromGreaterThan,
+ BuiltInFunctionDefinitions.GREATER_THAN_OR_EQUAL,
+ SavepointFilterTranslator::fromGreaterThanOrEqual,
+ BuiltInFunctionDefinitions.LESS_THAN,
+ SavepointFilterTranslator::fromLessThan,
+ BuiltInFunctionDefinitions.LESS_THAN_OR_EQUAL,
+ SavepointFilterTranslator::fromLessThanOrEqual);
+
+ private final int keyColumnIndex;
+ private final DataType keyColumnType;
+
+ SavepointFilterTranslator(int keyColumnIndex, DataType keyColumnType) {
+ this.keyColumnIndex = keyColumnIndex;
+ this.keyColumnType = keyColumnType;
+ }
+
+ Result apply(List<ResolvedExpression> filters) {
+ final List<ResolvedExpression> accepted = new ArrayList<>();
+ final List<ResolvedExpression> remaining = new ArrayList<>();
+
+ SavepointKeyFilter keyFilter = null;
+ for (ResolvedExpression filter : filters) {
+ SavepointKeyFilter extracted = extractFilter(filter);
+ if (extracted == null) {
+ remaining.add(filter);
+ continue;
+ }
+
+ keyFilter = keyFilter == null ? extracted :
keyFilter.intersect(extracted);
+ accepted.add(filter);
+ }
+
+ return new Result(accepted, remaining, keyFilter);
+ }
+
+ @Nullable
+ private SavepointKeyFilter extractFilter(ResolvedExpression expr) {
+ final BiFunction<SavepointFilterTranslator, CallExpression,
SavepointKeyFilter> extractor =
+ expr instanceof CallExpression
+ ? FILTERS.get(((CallExpression)
expr).getFunctionDefinition())
+ : null;
+ if (extractor == null) {
+ LOG.debug(
+ "Unsupported predicate [{}] cannot be pushed into
savepoint key filter.", expr);
+ return null;
+ }
+ return extractor.apply(this, (CallExpression) expr);
+ }
+
+ //
-------------------------------------------------------------------------
+ // Equality
+ //
-------------------------------------------------------------------------
+
+ @Nullable
+ private SavepointKeyFilter fromEquals(CallExpression call) {
+ if (!isBinaryValid(call)) {
+ return null;
+ }
+ ResolvedExpression left = call.getResolvedChildren().get(0);
+ ResolvedExpression right = call.getResolvedChildren().get(1);
+
+ Object value = null;
+ if (isKeyField(left)) {
+ value = extractValue(right);
+ } else if (isKeyField(right)) {
+ value = extractValue(left);
+ }
+
+ if (value == null) {
+ return null;
+ }
+ return SavepointKeyFilter.exact(value);
+ }
+
+ @Nullable
+ private SavepointKeyFilter fromOr(CallExpression call) {
+ Set<Object> keys = new HashSet<>();
+ for (ResolvedExpression arg : call.getResolvedChildren()) {
+ SavepointKeyFilter sub = extractFilter(arg);
+ if (sub == null) {
+ return null;
+ }
+ Set<Object> subKeys = sub.getExactKeys();
+ // OR can only absorb finite key sets; a range branch cannot be
merged via union.
+ if (subKeys == null) {
+ return null;
+ }
+ keys.addAll(subKeys);
+ }
+ return SavepointKeyFilter.exact(keys);
+ }
+
+ //
-------------------------------------------------------------------------
+ // Range
+ //
-------------------------------------------------------------------------
+
+ @Nullable
+ private SavepointKeyFilter fromAnd(CallExpression call) {
+ SavepointKeyFilter merged = null;
+ for (ResolvedExpression arg : call.getResolvedChildren()) {
+ SavepointKeyFilter sub = extractFilter(arg);
+ // AND only absorbs range filters; exact (or null) children break
pushdown.
+ if (sub == null || sub.getExactKeys() != null) {
+ return null;
+ }
+ merged = (merged == null) ? sub : merged.intersect(sub);
+ if (merged.isEmpty()) {
+ return merged;
+ }
+ }
+ return merged;
+ }
+
+ @Nullable
+ private SavepointKeyFilter fromBetween(CallExpression call) {
+ List<ResolvedExpression> args = call.getResolvedChildren();
+ if (args.size() != 3) {
+ return null;
+ }
+ ResolvedExpression valueExpr = args.get(0);
+ ResolvedExpression lowerExpr = args.get(1);
+ ResolvedExpression upperExpr = args.get(2);
+
+ if (!isKeyField(valueExpr)) {
+ return null;
+ }
+
+ Object lower = extractValue(lowerExpr);
+ Object upper = extractValue(upperExpr);
+ if (lower == null || upper == null) {
+ return null;
+ }
+ if (!(lower instanceof Comparable) || !(upper instanceof Comparable)) {
+ LOG.debug(
+ "BETWEEN predicate on non-comparable key type {} cannot be
pushed into savepoint key filter.",
+ lower.getClass().getName());
+ return null;
+ }
+ return SavepointKeyFilter.range(
+ (Comparable<?>) lower, true,
+ (Comparable<?>) upper, true);
+ }
+
+ @Nullable
+ private SavepointKeyFilter fromGreaterThan(CallExpression call) {
+ return fromComparison(call, Comparison.GT);
+ }
+
+ @Nullable
+ private SavepointKeyFilter fromGreaterThanOrEqual(CallExpression call) {
+ return fromComparison(call, Comparison.GTE);
+ }
+
+ @Nullable
+ private SavepointKeyFilter fromLessThan(CallExpression call) {
+ return fromComparison(call, Comparison.LT);
+ }
+
+ @Nullable
+ private SavepointKeyFilter fromLessThanOrEqual(CallExpression call) {
+ return fromComparison(call, Comparison.LTE);
+ }
+
+ @Nullable
+ private SavepointKeyFilter fromComparison(CallExpression call, Comparison
cmp) {
+ if (!isBinaryValid(call)) {
+ return null;
+ }
+ ResolvedExpression left = call.getResolvedChildren().get(0);
+ ResolvedExpression right = call.getResolvedChildren().get(1);
+
+ final boolean keyOnLeft = isKeyField(left);
+ final boolean keyOnRight = isKeyField(right);
+ if (!keyOnLeft && !keyOnRight) {
+ return null;
+ }
+ Object bound = extractValue(keyOnLeft ? right : left);
+ if (bound == null) {
+ return null;
+ }
+ if (!(bound instanceof Comparable)) {
+ LOG.debug(
+ "Range predicate on non-comparable key type {} cannot be
pushed into savepoint key filter.",
+ bound.getClass().getName());
+ return null;
+ }
+ Comparable<?> b = (Comparable<?>) bound;
+ Comparison keyLeftCmp = keyOnLeft ? cmp : cmp.flip();
+ switch (keyLeftCmp) {
+ case GT:
+ return SavepointKeyFilter.range(b, false, null, true);
+ case GTE:
+ return SavepointKeyFilter.range(b, true, null, true);
+ case LT:
+ return SavepointKeyFilter.range(null, true, b, false);
+ case LTE:
+ return SavepointKeyFilter.range(null, true, b, true);
+ default:
+ throw new IllegalStateException("Unknown Comparison: " +
keyLeftCmp);
+ }
+ }
+
+ //
-------------------------------------------------------------------------
+ // Helpers
+ //
-------------------------------------------------------------------------
+
+ private static boolean isBinaryValid(CallExpression call) {
+ return call.getResolvedChildren().size() == 2;
+ }
+
+ private boolean isKeyField(ResolvedExpression expr) {
+ return expr instanceof FieldReferenceExpression
+ && ((FieldReferenceExpression) expr).getFieldIndex() ==
keyColumnIndex;
+ }
+
+ @Nullable
+ private Object extractValue(ResolvedExpression expr) {
+ if (!(expr instanceof ValueLiteralExpression)) {
+ LOG.debug("Refusing pushdown: predicate operand [{}] is not a
literal value.", expr);
+ return null;
+ }
+ ValueLiteralExpression literal = (ValueLiteralExpression) expr;
+ Class<?> literalClass =
literal.getOutputDataType().getConversionClass();
+ Object value = literal.getValueAs(literalClass).orElse(null);
+ if (value == null) {
+ LOG.debug(
+ "Refusing pushdown: literal {} of type {} cannot be read
as its conversion"
+ + " class {}.",
+ literal,
+ literal.getOutputDataType(),
+ literalClass.getName());
+ return null;
+ }
+ return widenToKeyType(value);
+ }
+
+ @Nullable
+ private Object widenToKeyType(Object value) {
+ Class<?> keyClass = keyColumnType.getConversionClass();
+ if (keyClass.isInstance(value)) {
+ return value;
+ }
+ if (value instanceof Number) {
+ if (keyClass == Long.class) {
+ return ((Number) value).longValue();
+ }
+ if (keyClass == Double.class) {
+ return ((Number) value).doubleValue();
+ }
+ }
+ LOG.debug(
+ "Refusing pushdown: literal value {} of type {} cannot be
widened to key type {}.",
+ value,
+ value.getClass().getName(),
+ keyColumnType);
+ return null;
+ }
+
+ static final class Result {
+ private final List<ResolvedExpression> accepted;
+ private final List<ResolvedExpression> remaining;
+ @Nullable private final SavepointKeyFilter keyFilter;
+
+ private Result(
+ List<ResolvedExpression> accepted,
+ List<ResolvedExpression> remaining,
+ @Nullable SavepointKeyFilter keyFilter) {
+ this.accepted = accepted;
+ this.remaining = remaining;
+ this.keyFilter = keyFilter;
+ }
+
+ List<ResolvedExpression> accepted() {
+ return accepted;
+ }
+
+ List<ResolvedExpression> remaining() {
+ return remaining;
+ }
+
+ @Nullable
+ SavepointKeyFilter keyFilter() {
+ return keyFilter;
+ }
+ }
+
+ private enum Comparison {
+ GT,
+ GTE,
+ LT,
+ LTE;
+
+ Comparison flip() {
+ switch (this) {
+ case GT:
+ return LT;
+ case GTE:
+ return LTE;
+ case LT:
+ return GT;
+ case LTE:
+ return GTE;
+ default:
+ throw new IllegalStateException("Unknown Comparison: " +
this);
+ }
+ }
+ }
+}
diff --git
a/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/SavepointReaderKeyedStateITCase.java
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/SavepointReaderKeyedStateITCase.java
index de3bdcd5f3a..5df10085c8a 100644
---
a/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/SavepointReaderKeyedStateITCase.java
+++
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/SavepointReaderKeyedStateITCase.java
@@ -18,6 +18,8 @@
package org.apache.flink.state.api;
+import org.apache.flink.api.common.JobExecutionResult;
+import org.apache.flink.api.common.accumulators.IntCounter;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
@@ -25,23 +27,29 @@ import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.runtime.state.StateBackend;
+import org.apache.flink.state.api.filter.SavepointKeyFilter;
import org.apache.flink.state.api.functions.KeyedStateReaderFunction;
import org.apache.flink.state.api.utils.JobResultRetriever;
import org.apache.flink.state.api.utils.SavepointTestBase;
+import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.streaming.api.functions.sink.v2.DiscardingSink;
+import org.apache.flink.streaming.util.testing.CollectingSink;
import org.apache.flink.util.Collector;
import org.junit.Assert;
import org.junit.Test;
-import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
/** IT case for reading state. */
public abstract class SavepointReaderKeyedStateITCase<B extends StateBackend>
@@ -51,8 +59,10 @@ public abstract class SavepointReaderKeyedStateITCase<B
extends StateBackend>
private static ValueStateDescriptor<Integer> valueState =
new ValueStateDescriptor<>("value", Types.INT);
+ private static final String STATE_READS_ACCUMULATOR =
"stateReadsAccumulator";
+
private static final List<Pojo> elements =
- Arrays.asList(Pojo.of(1, 1), Pojo.of(2, 2), Pojo.of(3, 3));
+ IntStream.rangeClosed(1, 10).mapToObj(i -> Pojo.of(i,
i)).collect(Collectors.toList());
protected abstract Tuple2<Configuration, B> getStateBackendTuple();
@@ -63,13 +73,7 @@ public abstract class SavepointReaderKeyedStateITCase<B
extends StateBackend>
StreamExecutionEnvironment.getExecutionEnvironment(backendTuple.f0);
env.setParallelism(4);
- env.addSource(createSource(elements))
- .returns(Pojo.class)
- .rebalance()
- .keyBy(id -> id.key)
- .process(new KeyedStatefulOperator())
- .uid(uid)
- .sinkTo(new DiscardingSink<>());
+ applyStatefulPipeline(env);
String savepointPath = takeSavepoint(env);
@@ -85,6 +89,170 @@ public abstract class SavepointReaderKeyedStateITCase<B
extends StateBackend>
"Unexpected results from keyed state", expected, new
HashSet<>(results));
}
+ @Test
+ public void testReadKeyedStateWithExactFilter() throws Exception {
+ Tuple2<Configuration, B> backendTuple = getStateBackendTuple();
+ StreamExecutionEnvironment env =
+
StreamExecutionEnvironment.getExecutionEnvironment(backendTuple.f0);
+ env.setParallelism(4);
+
+ applyStatefulPipeline(env);
+
+ String savepointPath = takeSavepoint(env);
+
+ SavepointReader savepoint = SavepointReader.read(env, savepointPath,
backendTuple.f1);
+ CountingReadResult result =
+ readKeyedStateWithCountingReader(savepoint,
SavepointKeyFilter.exact(5));
+ // Only key=5 reaches the reader and reads state.
+ assertThat(result.values).containsExactly(5);
+ assertThat(result.counter).isEqualTo(1);
+ }
+
+ @Test
+ public void testReadKeyedStateWithMultiKeyExactFilter() throws Exception {
+ Tuple2<Configuration, B> backendTuple = getStateBackendTuple();
+ StreamExecutionEnvironment env =
+
StreamExecutionEnvironment.getExecutionEnvironment(backendTuple.f0);
+ env.setParallelism(4);
+
+ applyStatefulPipeline(env);
+
+ String savepointPath = takeSavepoint(env);
+
+ SavepointReader savepoint = SavepointReader.read(env, savepointPath,
backendTuple.f1);
+ CountingReadResult result =
+ readKeyedStateWithCountingReader(
+ savepoint, SavepointKeyFilter.exact(Set.of(3, 5, 7)));
+ assertThat(result.values).containsExactlyInAnyOrder(3, 5, 7);
+ assertThat(result.counter).isEqualTo(3);
+ }
+
+ @Test
+ public void testReadKeyedStateWithInclusiveRangeFilter() throws Exception {
+ Tuple2<Configuration, B> backendTuple = getStateBackendTuple();
+ StreamExecutionEnvironment env =
+
StreamExecutionEnvironment.getExecutionEnvironment(backendTuple.f0);
+ env.setParallelism(4);
+
+ applyStatefulPipeline(env);
+
+ String savepointPath = takeSavepoint(env);
+
+ SavepointReader savepoint = SavepointReader.read(env, savepointPath,
backendTuple.f1);
+ CountingReadResult result =
+ readKeyedStateWithCountingReader(
+ savepoint, SavepointKeyFilter.range(3, true, 6, true));
+ // [3, 6]: only the in-range keys reach the reader and read state.
+ assertThat(result.values).containsExactlyInAnyOrder(3, 4, 5, 6);
+ assertThat(result.counter).isEqualTo(4);
+ }
+
+ @Test
+ public void
testReadKeyedStateWithInclusiveLowerExclusiveUpperRangeFilter() throws
Exception {
+ Tuple2<Configuration, B> backendTuple = getStateBackendTuple();
+ StreamExecutionEnvironment env =
+
StreamExecutionEnvironment.getExecutionEnvironment(backendTuple.f0);
+ env.setParallelism(4);
+
+ applyStatefulPipeline(env);
+
+ String savepointPath = takeSavepoint(env);
+
+ SavepointReader savepoint = SavepointReader.read(env, savepointPath,
backendTuple.f1);
+ CountingReadResult result =
+ readKeyedStateWithCountingReader(
+ savepoint, SavepointKeyFilter.range(3, true, 6,
false));
+ // [3, 6): the exclusive upper bound drops key 6.
+ assertThat(result.values).containsExactlyInAnyOrder(3, 4, 5);
+ assertThat(result.counter).isEqualTo(3);
+ }
+
+ @Test
+ public void
testReadKeyedStateWithExclusiveLowerInclusiveUpperRangeFilter() throws
Exception {
+ Tuple2<Configuration, B> backendTuple = getStateBackendTuple();
+ StreamExecutionEnvironment env =
+
StreamExecutionEnvironment.getExecutionEnvironment(backendTuple.f0);
+ env.setParallelism(4);
+
+ applyStatefulPipeline(env);
+
+ String savepointPath = takeSavepoint(env);
+
+ SavepointReader savepoint = SavepointReader.read(env, savepointPath,
backendTuple.f1);
+ CountingReadResult result =
+ readKeyedStateWithCountingReader(
+ savepoint, SavepointKeyFilter.range(3, false, 6,
true));
+ // (3, 6]: the exclusive lower bound drops key 3.
+ assertThat(result.values).containsExactlyInAnyOrder(4, 5, 6);
+ assertThat(result.counter).isEqualTo(3);
+ }
+
+ @Test
+ public void testReadKeyedStateWithExclusiveRangeFilter() throws Exception {
+ Tuple2<Configuration, B> backendTuple = getStateBackendTuple();
+ StreamExecutionEnvironment env =
+
StreamExecutionEnvironment.getExecutionEnvironment(backendTuple.f0);
+ env.setParallelism(4);
+
+ applyStatefulPipeline(env);
+
+ String savepointPath = takeSavepoint(env);
+
+ SavepointReader savepoint = SavepointReader.read(env, savepointPath,
backendTuple.f1);
+ CountingReadResult result =
+ readKeyedStateWithCountingReader(
+ savepoint, SavepointKeyFilter.range(3, false, 6,
false));
+ // (3, 6): both bounds exclusive, dropping keys 3 and 6.
+ assertThat(result.values).containsExactlyInAnyOrder(4, 5);
+ assertThat(result.counter).isEqualTo(2);
+ }
+
+ @Test
+ public void testReadKeyedStateWithEmptyFilter() throws Exception {
+ Tuple2<Configuration, B> backendTuple = getStateBackendTuple();
+ StreamExecutionEnvironment env =
+
StreamExecutionEnvironment.getExecutionEnvironment(backendTuple.f0);
+ env.setParallelism(4);
+
+ applyStatefulPipeline(env);
+
+ String savepointPath = takeSavepoint(env);
+
+ SavepointReader savepoint = SavepointReader.read(env, savepointPath,
backendTuple.f1);
+ CountingReadResult result =
+ readKeyedStateWithCountingReader(savepoint,
SavepointKeyFilter.empty());
+ // No key reaches the reader, so no state is read.
+ assertThat(result.values).isEmpty();
+ assertThat(result.counter).isZero();
+ }
+
+ private void applyStatefulPipeline(StreamExecutionEnvironment env) {
+ env.addSource(createSource(elements))
+ .returns(Pojo.class)
+ .rebalance()
+ .keyBy(id -> id.key)
+ .process(new KeyedStatefulOperator())
+ .uid(uid)
+ .sinkTo(new DiscardingSink<>());
+ }
+
+ private CountingReadResult readKeyedStateWithCountingReader(
+ SavepointReader savepoint, SavepointKeyFilter keyFilter) throws
Exception {
+ DataStream<Integer> stateValues =
+ savepoint.readKeyedState(
+ OperatorIdentifier.forUid(uid),
+ new CountingReader(),
+ Types.INT,
+ Types.INT,
+ keyFilter);
+ CollectingSink<Integer> collectingSink = new CollectingSink<>();
+ stateValues.sinkTo(collectingSink);
+ JobExecutionResult jobResult =
stateValues.getExecutionEnvironment().execute();
+ Integer counter =
jobResult.getAccumulatorResult(STATE_READS_ACCUMULATOR);
+ return new CountingReadResult(
+ collectingSink.getRemainingOutput(), counter == null ? 0 :
counter);
+ }
+
private static class KeyedStatefulOperator extends
KeyedProcessFunction<Integer, Pojo, Void> {
private transient ValueState<Integer> state;
@@ -124,6 +292,34 @@ public abstract class SavepointReaderKeyedStateITCase<B
extends StateBackend>
}
}
+ private static class CountingReader extends
KeyedStateReaderFunction<Integer, Integer> {
+
+ private transient ValueState<Integer> state;
+ private transient IntCounter counter;
+
+ @Override
+ public void open(OpenContext openContext) {
+ state = getRuntimeContext().getState(valueState);
+ counter =
getRuntimeContext().getIntCounter(STATE_READS_ACCUMULATOR);
+ }
+
+ @Override
+ public void readKey(Integer key, Context ctx, Collector<Integer> out)
throws Exception {
+ counter.add(1);
+ out.collect(state.value());
+ }
+ }
+
+ private static class CountingReadResult {
+ private final List<Integer> values;
+ private final int counter;
+
+ private CountingReadResult(List<Integer> values, int counter) {
+ this.values = values;
+ this.counter = counter;
+ }
+ }
+
/** A simple pojo type. */
public static class Pojo {
public static Pojo of(Integer key, Integer state) {
diff --git
a/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/input/FilteringCloseableIteratorTest.java
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/input/FilteringCloseableIteratorTest.java
new file mode 100644
index 00000000000..600ff77839f
--- /dev/null
+++
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/input/FilteringCloseableIteratorTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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;
+
+import org.apache.flink.util.CloseableIterator;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class FilteringCloseableIteratorTest {
+
+ @Test
+ void predicateMatchReturnsMatchingElements() throws Exception {
+ try (FilteringCloseableIterator<Integer> iterator =
+ new FilteringCloseableIterator<>(
+ CloseableIterator.fromList(Arrays.asList(1, 2, 3, 4),
value -> {}),
+ value -> value % 2 == 0)) {
+ assertThat(iterator.hasNext()).isTrue();
+ assertThat(iterator.next()).isEqualTo(2);
+ assertThat(iterator.hasNext()).isTrue();
+ assertThat(iterator.next()).isEqualTo(4);
+ assertThat(iterator.hasNext()).isFalse();
+ }
+ }
+
+ @Test
+ void predicateSkipReturnsNoElements() throws Exception {
+ try (FilteringCloseableIterator<Integer> iterator =
+ new FilteringCloseableIterator<>(
+ CloseableIterator.fromList(Arrays.asList(1, 2, 3),
value -> {}),
+ value -> value > 10)) {
+ assertThat(iterator.hasNext()).isFalse();
+
assertThatThrownBy(iterator::next).isInstanceOf(NoSuchElementException.class);
+ }
+ }
+
+ @Test
+ void emptySourceReturnsNoElements() throws Exception {
+ try (FilteringCloseableIterator<Integer> iterator =
+ new FilteringCloseableIterator<>(
+
CloseableIterator.fromList(Collections.<Integer>emptyList(), value -> {}),
+ value -> true)) {
+ assertThat(iterator.hasNext()).isFalse();
+
assertThatThrownBy(iterator::next).isInstanceOf(NoSuchElementException.class);
+ }
+ }
+
+ @Test
+ void closeDelegatesToSource() throws Exception {
+ final TestCloseableIterator source = new
TestCloseableIterator(Arrays.asList(1, 2, 3));
+ final FilteringCloseableIterator<Integer> iterator =
+ new FilteringCloseableIterator<>(source, value -> true);
+
+ iterator.close();
+
+ assertThat(source.closed).isTrue();
+ }
+
+ @Test
+ void hasNextMultipleCallsDoesNotAdvanceTwice() throws Exception {
+ try (FilteringCloseableIterator<Integer> iterator =
+ new FilteringCloseableIterator<>(
+ CloseableIterator.fromList(Arrays.asList(1, 2, 3),
value -> {}),
+ value -> value >= 2)) {
+ assertThat(iterator.hasNext()).isTrue();
+ assertThat(iterator.hasNext()).isTrue();
+ assertThat(iterator.next()).isEqualTo(2);
+ assertThat(iterator.hasNext()).isTrue();
+ assertThat(iterator.next()).isEqualTo(3);
+ assertThat(iterator.hasNext()).isFalse();
+ }
+ }
+
+ private static final class TestCloseableIterator implements
CloseableIterator<Integer> {
+ private final Iterator<Integer> iterator;
+ private boolean closed;
+
+ private TestCloseableIterator(List<Integer> values) {
+ this.iterator = values.iterator();
+ }
+
+ @Override
+ public boolean hasNext() {
+ return iterator.hasNext();
+ }
+
+ @Override
+ public Integer next() {
+ return iterator.next();
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+ }
+}
diff --git
a/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/input/KeyedStateInputFormatTest.java
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/input/KeyedStateInputFormatTest.java
index 75018679b75..292eb61d2c4 100644
---
a/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/input/KeyedStateInputFormatTest.java
+++
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/api/input/KeyedStateInputFormatTest.java
@@ -32,6 +32,7 @@ import
org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
import org.apache.flink.runtime.jobgraph.OperatorID;
import org.apache.flink.runtime.state.VoidNamespace;
import org.apache.flink.runtime.state.hashmap.HashMapStateBackend;
+import org.apache.flink.state.api.filter.SavepointKeyFilter;
import org.apache.flink.state.api.functions.KeyedStateReaderFunction;
import org.apache.flink.state.api.input.operator.KeyedStateReaderOperator;
import org.apache.flink.state.api.input.splits.KeyGroupRangeInputSplit;
@@ -62,6 +63,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Set;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Tests for keyed state input format. */
@@ -116,6 +118,85 @@ class KeyedStateInputFormatTest {
splits.length);
}
+ @ParameterizedTest(name = "Enable async state = {0}")
+ @ValueSource(booleans = {false, true})
+ void testExactKeyFilterPrunesInputSplits(boolean asyncState) throws
Exception {
+ OperatorID operatorID = OperatorIDGenerator.fromUid("uid");
+
+ OperatorSubtaskState state =
+ createOperatorSubtaskState(createFlatMap(asyncState),
asyncState);
+ OperatorState operatorState = new OperatorState(null, null,
operatorID, 1, 128);
+ operatorState.putState(0, state);
+
+ KeyedStateInputFormat<?, ?, ?> format =
+ new KeyedStateInputFormat<>(
+ operatorState,
+ new HashMapStateBackend(),
+ new Configuration(),
+ new KeyedStateReaderOperator<>(new ReaderFunction(),
Types.INT),
+ new ExecutionConfig(),
+ SavepointKeyFilter.exact(5));
+ KeyGroupRangeInputSplit[] splits = format.createInputSplits(10);
+
+ assertThat(splits)
+ .as("Single-key exact filter maps to exactly one key group
range")
+ .hasSize(1);
+ }
+
+ @ParameterizedTest(name = "Enable async state = {0}")
+ @ValueSource(booleans = {false, true})
+ void testEmptyFilterProducesNoInputSplits(boolean asyncState) throws
Exception {
+ OperatorID operatorID = OperatorIDGenerator.fromUid("uid");
+
+ OperatorSubtaskState state =
+ createOperatorSubtaskState(createFlatMap(asyncState),
asyncState);
+ OperatorState operatorState = new OperatorState(null, null,
operatorID, 1, 128);
+ operatorState.putState(0, state);
+
+ KeyedStateInputFormat<?, ?, ?> format =
+ new KeyedStateInputFormat<>(
+ operatorState,
+ new HashMapStateBackend(),
+ new Configuration(),
+ new KeyedStateReaderOperator<>(new ReaderFunction(),
Types.INT),
+ new ExecutionConfig(),
+ SavepointKeyFilter.empty());
+ KeyGroupRangeInputSplit[] splits = format.createInputSplits(10);
+
+ assertThat(splits).isEmpty();
+ }
+
+ @ParameterizedTest(name = "Enable async state = {0}")
+ @ValueSource(booleans = {false, true})
+ void testRangeFilterDoesNotPruneInputSplits(boolean asyncState) throws
Exception {
+ OperatorID operatorID = OperatorIDGenerator.fromUid("uid");
+
+ OperatorSubtaskState state =
+ createOperatorSubtaskState(createFlatMap(asyncState),
asyncState);
+ OperatorState operatorState = new OperatorState(null, null,
operatorID, 1, 128);
+ operatorState.putState(0, state);
+
+ KeyedStateInputFormat<?, ?, ?> formatRange =
+ new KeyedStateInputFormat<>(
+ operatorState,
+ new HashMapStateBackend(),
+ new Configuration(),
+ new KeyedStateReaderOperator<>(new ReaderFunction(),
Types.INT),
+ new ExecutionConfig(),
+ SavepointKeyFilter.range(3, true, 7, true));
+ KeyedStateInputFormat<?, ?, ?> formatNoFilter =
+ new KeyedStateInputFormat<>(
+ operatorState,
+ new HashMapStateBackend(),
+ new Configuration(),
+ new KeyedStateReaderOperator<>(new ReaderFunction(),
Types.INT),
+ new ExecutionConfig());
+
+ assertThat(formatRange.createInputSplits(10))
+ .as("Range filters cannot prune key-group splits")
+ .hasSize(formatNoFilter.createInputSplits(10).length);
+ }
+
@ParameterizedTest(name = "Enable async state = {0}")
@ValueSource(booleans = {false, true})
void testReadState(boolean asyncState) throws Exception {
diff --git
a/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/table/SavepointDynamicTableSourceTest.java
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/table/SavepointDynamicTableSourceTest.java
index 5ddcc9fab64..e2a8ca574d0 100644
---
a/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/table/SavepointDynamicTableSourceTest.java
+++
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/table/SavepointDynamicTableSourceTest.java
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
@@ -39,6 +40,22 @@ import static org.assertj.core.api.Assertions.assertThat;
/** Unit tests for the savepoint SQL reader. */
class SavepointDynamicTableSourceTest {
+
+ private static final String STATE_TABLE_DDL =
+ "CREATE TABLE state_table (\n"
+ + " k bigint,\n"
+ + " KeyedPrimitiveValue bigint,\n"
+ + " KeyedPojoValue ROW<privateLong bigint, publicLong
bigint>,\n"
+ + " KeyedPrimitiveValueList ARRAY<bigint>,\n"
+ + " KeyedPrimitiveValueMap MAP<string, bigint>,\n"
+ + " PRIMARY KEY (k) NOT ENFORCED\n"
+ + ")\n"
+ + "with (\n"
+ + " 'connector' = 'savepoint',\n"
+ + " 'state.path' = 'src/test/resources/table-state',\n"
+ + " 'operator.uid' = 'keyed-state-process-uid'\n"
+ + ")";
+
@Test
@SuppressWarnings("unchecked")
public void testReadKeyedState() throws Exception {
@@ -47,21 +64,7 @@ class SavepointDynamicTableSourceTest {
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment(config);
StreamTableEnvironment tEnv = StreamTableEnvironment.create(env);
- final String sql =
- "CREATE TABLE state_table (\n"
- + " k bigint,\n"
- + " KeyedPrimitiveValue bigint,\n"
- + " KeyedPojoValue ROW<privateLong bigint, publicLong
bigint>,\n"
- + " KeyedPrimitiveValueList ARRAY<bigint>,\n"
- + " KeyedPrimitiveValueMap MAP<string, bigint>,\n"
- + " PRIMARY KEY (k) NOT ENFORCED\n"
- + ")\n"
- + "with (\n"
- + " 'connector' = 'savepoint',\n"
- + " 'state.path' =
'src/test/resources/table-state',\n"
- + " 'operator.uid' = 'keyed-state-process-uid'\n"
- + ")";
- tEnv.executeSql(sql);
+ tEnv.executeSql(STATE_TABLE_DDL);
Table table = tEnv.sqlQuery("SELECT * FROM state_table");
List<Row> result = tEnv.toDataStream(table).executeAndCollect(100);
@@ -85,7 +88,7 @@ class SavepointDynamicTableSourceTest {
result.stream()
.map(r -> (Row) r.getField("KeyedPojoValue"))
.collect(Collectors.toSet());
- assertThat(pojoValues.size()).isEqualTo(1);
+ assertThat(pojoValues).hasSize(1);
Row pojoData = pojoValues.iterator().next();
assertThat(pojoData.getField("publicLong")).isEqualTo(1L);
assertThat(pojoData.getField("privateLong")).isEqualTo(1L);
@@ -100,10 +103,9 @@ class SavepointDynamicTableSourceTest {
(Long[])
r.getField("KeyedPrimitiveValueList")))
.flatMap(l -> Set.of(l).stream())
.collect(Collectors.toSet());
- assertThat(listValues.size()).isEqualTo(10);
- for (Tuple2<Long, Long[]> tuple2 : listValues) {
- assertThat(tuple2.f0).isEqualTo(tuple2.f1[0]);
- }
+ assertThat(listValues)
+ .hasSize(10)
+ .allSatisfy(tuple2 ->
assertThat(tuple2.f0).isEqualTo(tuple2.f1[0]));
// Check map state
Set<Tuple2<Long, Map<String, Long>>> mapValues =
@@ -116,12 +118,14 @@ class SavepointDynamicTableSourceTest {
r.getField("KeyedPrimitiveValueMap")))
.flatMap(l -> Set.of(l).stream())
.collect(Collectors.toSet());
- assertThat(mapValues.size()).isEqualTo(10);
- for (Tuple2<Long, Map<String, Long>> tuple2 : mapValues) {
- assertThat(tuple2.f1.size()).isEqualTo(1);
- String expectedKey = String.valueOf(tuple2.f0);
- assertThat(tuple2.f1.get(expectedKey)).isEqualTo(tuple2.f0);
- }
+ assertThat(mapValues)
+ .hasSize(10)
+ .allSatisfy(
+ tuple2 -> {
+ assertThat(tuple2.f1).hasSize(1);
+ String expectedKey = String.valueOf(tuple2.f0);
+
assertThat(tuple2.f1.get(expectedKey)).isEqualTo(tuple2.f0);
+ });
}
@Test
@@ -146,7 +150,7 @@ class SavepointDynamicTableSourceTest {
tEnv.executeSql(sql);
Table table = tEnv.sqlQuery("SELECT * FROM state_table");
List<Row> result = tEnv.toDataStream(table).executeAndCollect(100);
- assertThat(result.size()).isEqualTo(5);
+ assertThat(result).hasSize(5);
List<Long> keys =
result.stream().map(row -> (Long)
row.getField("k")).collect(Collectors.toList());
@@ -201,7 +205,7 @@ class SavepointDynamicTableSourceTest {
result.stream()
.map(r -> (Row) r.getField("KeyedSpecificAvroValue"))
.collect(Collectors.toSet());
- assertThat(specificAvroValues.size()).isEqualTo(1);
+ assertThat(specificAvroValues).hasSize(1);
Row avroData = specificAvroValues.iterator().next();
assertThat(avroData.getField("longData")).isEqualTo(1L);
@@ -209,8 +213,219 @@ class SavepointDynamicTableSourceTest {
result.stream()
.map(r -> (String) r.getField("KeyedGenericAvroValue"))
.collect(Collectors.toSet());
- assertThat(genericAvroValues.size()).isEqualTo(1);
+ assertThat(genericAvroValues).hasSize(1);
String avroGenericValue = genericAvroValues.iterator().next();
assertThat(avroGenericValue).isEqualTo("{\"longData\": 1}");
}
+
+ //
-------------------------------------------------------------------------
+ // Filter push-down tests
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void testFilterPushDownEqualityReturnsOnlyMatchingKey() throws Exception {
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql = "SELECT k FROM state_table WHERE k = 5";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isTrue();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getField("k")).isEqualTo(5L);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testFilterPushDownEqualityReturnsCorrectResult() throws Exception {
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql = "SELECT * FROM state_table WHERE k = 5";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isTrue();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ assertThat(result).hasSize(1);
+ Row row = result.get(0);
+ assertThat(row.getField("k")).isEqualTo(5L);
+ assertThat(row.getField("KeyedPrimitiveValue")).isEqualTo(1L);
+
+ Row pojo = (Row) row.getField("KeyedPojoValue");
+ assertThat(pojo.getField("privateLong")).isEqualTo(1L);
+ assertThat(pojo.getField("publicLong")).isEqualTo(1L);
+
+ Long[] list = (Long[]) row.getField("KeyedPrimitiveValueList");
+ assertThat(list).containsExactly(5L);
+
+ Map<String, Long> map = (Map<String, Long>)
row.getField("KeyedPrimitiveValueMap");
+ assertThat(map).containsExactlyEntriesOf(Map.of("5", 5L));
+ }
+
+ @Test
+ void testFilterPushDownRangeReturnsCorrectResult() throws Exception {
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql = "SELECT k FROM state_table WHERE k >= 7 ORDER BY k";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isTrue();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ assertThat(result).hasSize(3);
+ assertThat(result.get(0).getField("k")).isEqualTo(7L);
+ assertThat(result.get(1).getField("k")).isEqualTo(8L);
+ assertThat(result.get(2).getField("k")).isEqualTo(9L);
+ }
+
+ @Test
+ void testFilterPushDownNonexistentKeyReturnsEmpty() throws Exception {
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql = "SELECT k FROM state_table WHERE k = 999";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isTrue();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void testFilterPushDownInListReturnsOnlyMatchingKeys() throws Exception {
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql = "SELECT k FROM state_table WHERE k IN (3, 7) ORDER BY k";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isTrue();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).getField("k")).isEqualTo(3L);
+ assertThat(result.get(1).getField("k")).isEqualTo(7L);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testFilterPushDownPartialPushDown() throws Exception {
+ // When the WHERE clause contains both a key filter and a non-key
filter,
+ // both must be applied correctly regardless of which is pushed into
the source.
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql =
+ "SELECT k, KeyedPrimitiveValueMap FROM state_table"
+ + " WHERE k = 5 AND KeyedPrimitiveValueMap['5'] > 3";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isTrue();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getField("k")).isEqualTo(5L);
+ Map<String, Long> map =
+ (Map<String, Long>)
result.get(0).getField("KeyedPrimitiveValueMap");
+ assertThat(map).containsEntry("5", 5L);
+ }
+
+ @Test
+ void testFilterPushDownBetweenReturnsCorrectResult() throws Exception {
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql = "SELECT k FROM state_table WHERE k BETWEEN 3 AND 6 ORDER
BY k";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isTrue();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ assertThat(result).hasSize(4);
+ assertThat(result.get(0).getField("k")).isEqualTo(3L);
+ assertThat(result.get(1).getField("k")).isEqualTo(4L);
+ assertThat(result.get(2).getField("k")).isEqualTo(5L);
+ assertThat(result.get(3).getField("k")).isEqualTo(6L);
+ }
+
+ @Test
+ void testFilterPushDownLiteralOnLeftSide() throws Exception {
+ // verify that "5 = k" (literal on the left) works the same as "k = 5".
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql = "SELECT k FROM state_table WHERE 5 = k";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isTrue();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getField("k")).isEqualTo(5L);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void
testOrAcrossKeyAndNonKeyColumnIsNotPushedDownButReturnsCorrectResult() throws
Exception {
+ // OR involving a non-pushable column: correctness must be preserved
+ // regardless of whether the planner pushes the filter or not.
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql =
+ "SELECT k, KeyedPrimitiveValueMap FROM state_table"
+ + " WHERE k = 5 OR KeyedPrimitiveValueMap['0'] = 0"
+ + " ORDER BY k";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isFalse();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).getField("k")).isEqualTo(0L);
+ Map<String, Long> map0 =
+ (Map<String, Long>)
result.get(0).getField("KeyedPrimitiveValueMap");
+ assertThat(map0).containsEntry("0", 0L);
+ assertThat(result.get(1).getField("k")).isEqualTo(5L);
+ }
+
+ @Test
+ void testUnsupportedFilterIsNotPushedDownButReturnsCorrectResult() throws
Exception {
+ StreamTableEnvironment tEnv = createBatchTableEnv();
+ tEnv.executeSql(STATE_TABLE_DDL);
+
+ String sql = "SELECT k FROM state_table WHERE k % 2 = 0 ORDER BY k";
+
+ assertThat(hasPushedDownFilter(tEnv, sql)).isFalse();
+
+ List<Row> result =
tEnv.toDataStream(tEnv.sqlQuery(sql)).executeAndCollect(100);
+
+ List<Long> keys =
+ result.stream().map(r -> (Long)
r.getField("k")).collect(Collectors.toList());
+ assertThat(keys).containsExactly(0L, 2L, 4L, 6L, 8L);
+ }
+
+ //
-------------------------------------------------------------------------
+ // Helpers
+ //
-------------------------------------------------------------------------
+
+ private static StreamTableEnvironment createBatchTableEnv() {
+ Configuration config = new Configuration();
+ config.set(RUNTIME_MODE, RuntimeExecutionMode.BATCH);
+ StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment(config);
+ return StreamTableEnvironment.create(env);
+ }
+
+ private static final Pattern PUSHED_DOWN_FILTER =
+ Pattern.compile(
+ "TableSourceScan\\(table=\\[\\[default_catalog,
default_database, state_table, filter=\\[.+?]]]");
+
+ private static boolean hasPushedDownFilter(StreamTableEnvironment tEnv,
String sql) {
+ return PUSHED_DOWN_FILTER.matcher(tEnv.explainSql(sql)).find();
+ }
}
diff --git
a/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/table/SavepointFilterTranslatorTest.java
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/table/SavepointFilterTranslatorTest.java
new file mode 100644
index 00000000000..b2e4bc49cec
--- /dev/null
+++
b/flink-libraries/flink-state-processing-api/src/test/java/org/apache/flink/state/table/SavepointFilterTranslatorTest.java
@@ -0,0 +1,805 @@
+/*
+ * 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.table;
+
+import org.apache.flink.state.api.filter.SavepointKeyFilter;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.expressions.CallExpression;
+import org.apache.flink.table.expressions.FieldReferenceExpression;
+import org.apache.flink.table.expressions.ResolvedExpression;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
+import org.apache.flink.table.types.DataType;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/** Unit tests for {@link SavepointFilterTranslator}. */
+class SavepointFilterTranslatorTest {
+
+ private static final int KEY_COL = 0;
+ private static final DataType LONG_KEY_TYPE = DataTypes.BIGINT().notNull();
+
+ //
-------------------------------------------------------------------------
+ // Exact key filter — fromEquals
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void equalsKeyOnLeft() {
+ SavepointKeyFilter filter = keyFilterOf(eq(longKeyRef(),
longLit(42L)));
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).containsExactly(42L);
+ assertThat(filter.test(42L)).isTrue();
+ assertThat(filter.test(43L)).isFalse();
+ }
+
+ @Test
+ void equalsKeyOnRight() {
+ SavepointKeyFilter filter = keyFilterOf(eq(longLit(42L),
longKeyRef()));
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).containsExactly(42L);
+ assertThat(filter.test(42L)).isTrue();
+ assertThat(filter.test(0L)).isFalse();
+ }
+
+ @Test
+ void equalsNeitherSideIsKeyColumn_returnsNull() {
+ SavepointKeyFilter filter = keyFilterOf(eq(otherRef(), longLit(42L)));
+ assertThat(filter).isNull();
+ }
+
+ @Test
+ void equalsNeitherSideIsLiteral_returnsNull() {
+ SavepointKeyFilter filter = keyFilterOf(eq(longKeyRef(), otherRef()));
+ assertThat(filter).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Exact key filter — fromOr
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void orOfEqualsProducesMergedExactFilter() {
+ CallExpression expr =
+ or(
+ eq(longKeyRef(), longLit(1L)),
+ eq(longKeyRef(), longLit(2L)),
+ eq(longLit(3L), longKeyRef()));
+
+ SavepointKeyFilter filter = keyFilterOf(expr);
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).containsExactlyInAnyOrder(1L, 2L,
3L);
+ assertThat(filter.test(4L)).isFalse();
+ }
+
+ @Test
+ void orWithNonPushableChild_returnsNull() {
+ // One branch is a range, which OR cannot absorb
+ CallExpression expr = or(eq(longKeyRef(), longLit(1L)),
gt(longKeyRef(), longLit(5L)));
+ assertThat(keyFilterOf(expr)).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Range key filter — fromBetween
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void betweenProducesInclusiveRange() {
+ SavepointKeyFilter filter = keyFilterOf(between(longKeyRef(),
longLit(10L), longLit(20L)));
+
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(9L)).isFalse();
+ assertThat(filter.test(10L)).isTrue();
+ assertThat(filter.test(15L)).isTrue();
+ assertThat(filter.test(20L)).isTrue();
+ assertThat(filter.test(21L)).isFalse();
+ }
+
+ @Test
+ void betweenWithNonKeyField_returnsNull() {
+ SavepointKeyFilter filter = keyFilterOf(between(otherRef(),
longLit(1L), longLit(10L)));
+ assertThat(filter).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Range key filter — comparison operators
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void greaterThanProducesExclusiveLowerBound() {
+ SavepointKeyFilter filter = keyFilterOf(gt(longKeyRef(), longLit(5L)));
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(5L)).isFalse();
+ assertThat(filter.test(6L)).isTrue();
+ }
+
+ @Test
+ void greaterThanOrEqualProducesInclusiveLowerBound() {
+ SavepointKeyFilter filter = keyFilterOf(gte(longKeyRef(),
longLit(5L)));
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(4L)).isFalse();
+ assertThat(filter.test(5L)).isTrue();
+ assertThat(filter.test(6L)).isTrue();
+ }
+
+ @Test
+ void lessThanProducesExclusiveUpperBound() {
+ SavepointKeyFilter filter = keyFilterOf(lt(longKeyRef(),
longLit(10L)));
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(9L)).isTrue();
+ assertThat(filter.test(10L)).isFalse();
+ }
+
+ @Test
+ void lessThanOrEqualProducesInclusiveUpperBound() {
+ SavepointKeyFilter filter = keyFilterOf(lte(longKeyRef(),
longLit(10L)));
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(10L)).isTrue();
+ assertThat(filter.test(11L)).isFalse();
+ }
+
+ @Test
+ void comparisonWithLiteralOnLeft_flipsDirection() {
+ // literal > key → key < literal → upper bound (exclusive)
+ SavepointKeyFilter filter = keyFilterOf(gt(longLit(10L),
longKeyRef()));
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(9L)).isTrue();
+ assertThat(filter.test(10L)).isFalse();
+ }
+
+ @Test
+ void comparisonWithLiteralOnLeft_lte_flipsDirection() {
+ // literal <= key → key >= literal → lower bound (inclusive)
+ SavepointKeyFilter filter = keyFilterOf(lte(longLit(5L),
longKeyRef()));
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(4L)).isFalse();
+ assertThat(filter.test(5L)).isTrue();
+ }
+
+ //
-------------------------------------------------------------------------
+ // AND — range intersection
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void andOfTwoRangesProducesIntersection() {
+ // key >= 5 AND key <= 10
+ CallExpression expr = and(gte(longKeyRef(), longLit(5L)),
lte(longKeyRef(), longLit(10L)));
+ SavepointKeyFilter filter = keyFilterOf(expr);
+
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(4L)).isFalse();
+ assertThat(filter.test(5L)).isTrue();
+ assertThat(filter.test(10L)).isTrue();
+ assertThat(filter.test(11L)).isFalse();
+ }
+
+ @Test
+ void andWithProvablyEmptyIntersection_matchesNothing() {
+ // key > 10 AND key < 5 — disjoint
+ CallExpression expr = and(gt(longKeyRef(), longLit(10L)),
lt(longKeyRef(), longLit(5L)));
+ SavepointKeyFilter filter = keyFilterOf(expr);
+
+ assertNotNull(filter);
+ assertThat(filter.isEmpty()).isTrue();
+ }
+
+ @Test
+ void andWithExactKeyChildIsNotPushable() {
+ // AND requires all children to be range filters; exact filter breaks
pushdown
+ CallExpression expr = and(eq(longKeyRef(), longLit(5L)),
gt(longKeyRef(), longLit(3L)));
+ assertThat(keyFilterOf(expr)).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Unsupported predicates
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void nonCallExpressionReturnsNull() {
+ assertThat(keyFilterOf(longKeyRef())).isNull();
+ }
+
+ @Test
+ void unrecognizedFunctionReturnsNull() {
+ CallExpression isNull =
+ CallExpression.permanent(
+ BuiltInFunctionDefinitions.IS_NULL,
+ Collections.singletonList(longKeyRef()),
+ DataTypes.BOOLEAN());
+ assertThat(keyFilterOf(isNull)).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Range key filter behavior — string keys (natural comparison)
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void rangeFilterOnStringKey() {
+ SavepointKeyFilter filter =
+ keyFilterOf(between(stringKeyRef(), stringLit("beta"),
stringLit("delta")));
+
+ assertNotNull(filter);
+ assertThat(filter.test("alpha")).isFalse();
+ assertThat(filter.test("beta")).isTrue();
+ assertThat(filter.test("gamma")).isFalse(); // "gamma" > "delta"
lexicographically
+ assertThat(filter.test("delta")).isTrue();
+ assertThat(filter.test("epsilon")).isFalse();
+ }
+
+ @Test
+ void rangeFilterWithDoubleComparison() {
+ ValueLiteralExpression floatLower =
+ new ValueLiteralExpression(1.5f, DataTypes.FLOAT().notNull());
+ ValueLiteralExpression floatUpper =
+ new ValueLiteralExpression(3.5f, DataTypes.FLOAT().notNull());
+ FieldReferenceExpression floatKey =
+ new FieldReferenceExpression("key",
DataTypes.FLOAT().notNull(), 0, KEY_COL);
+
+ SavepointKeyFilter filter = keyFilterOf(between(floatKey, floatLower,
floatUpper));
+
+ assertNotNull(filter);
+ assertThat(filter.test(1.5f)).isTrue();
+ assertThat(filter.test(2.0f)).isTrue();
+ assertThat(filter.test(3.5f)).isTrue();
+ assertThat(filter.test(1.0f)).isFalse();
+ assertThat(filter.test(4.0f)).isFalse();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Range intersection
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void intersectNarrowsBounds() {
+ // [5, ∞) ∩ (-∞, 10] = [5, 10]
+ SavepointKeyFilter lower = SavepointKeyFilter.range(5L, true, null,
true);
+ SavepointKeyFilter upper = SavepointKeyFilter.range(null, true, 10L,
true);
+ SavepointKeyFilter result = lower.intersect(upper);
+
+ assertThat(result.isEmpty()).isFalse();
+ assertThat(result.getExactKeys()).isNull();
+ assertThat(result.test(4L)).isFalse();
+ assertThat(result.test(5L)).isTrue();
+ assertThat(result.test(10L)).isTrue();
+ assertThat(result.test(11L)).isFalse();
+ }
+
+ @Test
+ void intersectDisjointRangesReturnsEmpty() {
+ // [10, ∞) ∩ (-∞, 5] — disjoint
+ SavepointKeyFilter a = SavepointKeyFilter.range(10L, true, null, true);
+ SavepointKeyFilter b = SavepointKeyFilter.range(null, true, 5L, true);
+ assertThat(a.intersect(b).isEmpty()).isTrue();
+ }
+
+ @Test
+ void intersectEqualBoundsInclusiveIsNonEmpty() {
+ // [7, ∞) ∩ (-∞, 7] = [7, 7]
+ SavepointKeyFilter a = SavepointKeyFilter.range(7L, true, null, true);
+ SavepointKeyFilter b = SavepointKeyFilter.range(null, true, 7L, true);
+ SavepointKeyFilter result = a.intersect(b);
+ assertThat(result.isEmpty()).isFalse();
+ assertThat(result.test(7L)).isTrue();
+ assertThat(result.test(6L)).isFalse();
+ assertThat(result.test(8L)).isFalse();
+ }
+
+ @Test
+ void intersectEqualBoundsOneExclusiveIsEmpty() {
+ // (7, ∞) ∩ (-∞, 7] — empty because lower is exclusive
+ SavepointKeyFilter a = SavepointKeyFilter.range(7L, false, null, true);
+ SavepointKeyFilter b = SavepointKeyFilter.range(null, true, 7L, true);
+ assertThat(a.intersect(b).isEmpty()).isTrue();
+ }
+
+ //
-------------------------------------------------------------------------
+ // SavepointFilters.apply — intersection handling
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void applyAccumulatesRangeAndRange() {
+ SavepointFilterTranslator.Result applied =
+ apply(
+ List.of(gte(longKeyRef(), longLit(3L)),
lte(longKeyRef(), longLit(8L))),
+ LONG_KEY_TYPE);
+ SavepointKeyFilter result = applied.keyFilter();
+ assertNotNull(result);
+
+ assertThat(applied.accepted()).hasSize(2);
+ assertThat(applied.remaining()).isEmpty();
+ assertThat(result.test(2L)).isFalse();
+ assertThat(result.test(3L)).isTrue();
+ assertThat(result.test(8L)).isTrue();
+ assertThat(result.test(9L)).isFalse();
+ }
+
+ @Test
+ void applyAccumulatesExactAndExact() {
+ SavepointFilterTranslator.Result applied =
+ apply(
+ List.of(
+ or(
+ eq(longKeyRef(), longLit(1L)),
+ eq(longKeyRef(), longLit(2L)),
+ eq(longKeyRef(), longLit(3L))),
+ or(
+ eq(longKeyRef(), longLit(2L)),
+ eq(longKeyRef(), longLit(3L)),
+ eq(longKeyRef(), longLit(4L)))),
+ LONG_KEY_TYPE);
+ SavepointKeyFilter result = applied.keyFilter();
+
+ assertNotNull(result);
+ assertThat(applied.accepted()).hasSize(2);
+ assertThat(applied.remaining()).isEmpty();
+ assertThat(result.getExactKeys()).containsExactlyInAnyOrder(2L, 3L);
+ }
+
+ @Test
+ void applyAccumulatesExactAndExactEmptyResult_matchesNothing() {
+ SavepointFilterTranslator.Result applied =
+ apply(
+ List.of(eq(longKeyRef(), longLit(1L)),
eq(longKeyRef(), longLit(2L))),
+ LONG_KEY_TYPE);
+ SavepointKeyFilter result = applied.keyFilter();
+
+ assertNotNull(result);
+ assertThat(applied.accepted()).hasSize(2);
+ assertThat(applied.remaining()).isEmpty();
+ assertThat(result.isEmpty()).isTrue();
+ }
+
+ @Test
+ void applyAccumulatesExactAndRange_keepsOnlyKeysInRange() {
+ SavepointFilterTranslator.Result applied =
+ apply(
+ List.of(
+ or(
+ eq(longKeyRef(), longLit(1L)),
+ eq(longKeyRef(), longLit(5L)),
+ eq(longKeyRef(), longLit(10L)),
+ eq(longKeyRef(), longLit(15L))),
+ between(longKeyRef(), longLit(4L),
longLit(12L))),
+ LONG_KEY_TYPE);
+ SavepointKeyFilter result = applied.keyFilter();
+
+ assertNotNull(result);
+ assertThat(applied.accepted()).hasSize(2);
+ assertThat(applied.remaining()).isEmpty();
+ assertThat(result.getExactKeys()).containsExactlyInAnyOrder(5L, 10L);
+ }
+
+ @Test
+ void applyAccumulatesRangeAndExact_keepsOnlyKeysInRange() {
+ SavepointFilterTranslator.Result applied =
+ apply(
+ List.of(
+ between(longKeyRef(), longLit(4L),
longLit(12L)),
+ or(
+ eq(longKeyRef(), longLit(1L)),
+ eq(longKeyRef(), longLit(5L)),
+ eq(longKeyRef(), longLit(10L)),
+ eq(longKeyRef(), longLit(15L)))),
+ LONG_KEY_TYPE);
+ SavepointKeyFilter result = applied.keyFilter();
+
+ assertNotNull(result);
+ assertThat(applied.accepted()).hasSize(2);
+ assertThat(applied.remaining()).isEmpty();
+ assertThat(result.getExactKeys()).containsExactlyInAnyOrder(5L, 10L);
+ }
+
+ //
-------------------------------------------------------------------------
+ // Empty key filter
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void emptyKeyFilter_rejectsEverything() {
+ SavepointKeyFilter empty = SavepointKeyFilter.empty();
+ assertThat(empty.isEmpty()).isTrue();
+ assertThat(empty.getExactKeys()).isEmpty();
+ assertThat(empty.test(42L)).isFalse();
+ assertThat(empty.test("hello")).isFalse();
+ }
+
+ @Test
+ void exactWithEmptySetReturnsEmptyKeyFilter() {
+ SavepointKeyFilter filter =
SavepointKeyFilter.exact(Collections.emptySet());
+ assertThat(filter.isEmpty()).isTrue();
+ }
+
+ @Test
+ void emptyKeyFilterSingletonPreservedAcrossSerialization() throws
Exception {
+ SavepointKeyFilter original = SavepointKeyFilter.empty();
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+ oos.writeObject(original);
+ }
+ Object deserialized;
+ try (ObjectInputStream ois =
+ new ObjectInputStream(new
ByteArrayInputStream(baos.toByteArray()))) {
+ deserialized = ois.readObject();
+ }
+ assertThat(deserialized).isSameAs(SavepointKeyFilter.empty());
+ }
+
+ //
-------------------------------------------------------------------------
+ // Exact key filter — single-value factory
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void exactSingleValueFactory() {
+ SavepointKeyFilter filter = SavepointKeyFilter.exact(42L);
+ assertThat(filter.getExactKeys()).containsExactly(42L);
+ assertThat(filter.test(42L)).isTrue();
+ assertThat(filter.test(43L)).isFalse();
+ }
+
+ //
-------------------------------------------------------------------------
+ // AND with 3+ children
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void andOfThreeRangesProducesIntersection() {
+ // key >= 3 AND key <= 20 AND key < 10
+ CallExpression expr =
+ and(
+ gte(longKeyRef(), longLit(3L)),
+ lte(longKeyRef(), longLit(20L)),
+ lt(longKeyRef(), longLit(10L)));
+ SavepointKeyFilter filter = keyFilterOf(expr);
+
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(2L)).isFalse();
+ assertThat(filter.test(3L)).isTrue();
+ assertThat(filter.test(9L)).isTrue();
+ assertThat(filter.test(10L)).isFalse();
+ assertThat(filter.test(20L)).isFalse();
+ }
+
+ //
-------------------------------------------------------------------------
+ // OR edge cases
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void orWithSingleChild_returnsExactFilter() {
+ CallExpression expr = or(eq(longKeyRef(), longLit(7L)));
+ SavepointKeyFilter filter = keyFilterOf(expr);
+
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).containsExactly(7L);
+ }
+
+ //
-------------------------------------------------------------------------
+ // Comparison with neither side being the key column
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void comparisonWithNeitherSideBeingKeyColumn_returnsNull() {
+ assertThat(keyFilterOf(gt(otherRef(), longLit(5L)))).isNull();
+ assertThat(keyFilterOf(lt(longLit(5L), otherRef()))).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // SavepointFilters.apply — empty filter handling
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void applyWithEmptyThenRange_returnsEmpty() {
+ SavepointFilterTranslator.Result applied =
+ apply(
+ List.of(
+ and(gt(longKeyRef(), longLit(10L)),
lt(longKeyRef(), longLit(5L))),
+ lte(longKeyRef(), longLit(10L))),
+ LONG_KEY_TYPE);
+ SavepointKeyFilter result = applied.keyFilter();
+
+ assertNotNull(result);
+ assertThat(applied.accepted()).hasSize(2);
+ assertThat(applied.remaining()).isEmpty();
+ assertThat(result.isEmpty()).isTrue();
+ }
+
+ @Test
+ void applyWithRangeThenEmpty_returnsEmpty() {
+ SavepointFilterTranslator.Result applied =
+ apply(
+ List.of(
+ lte(longKeyRef(), longLit(10L)),
+ and(gt(longKeyRef(), longLit(10L)),
lt(longKeyRef(), longLit(5L)))),
+ LONG_KEY_TYPE);
+ SavepointKeyFilter result = applied.keyFilter();
+
+ assertNotNull(result);
+ assertThat(applied.accepted()).hasSize(2);
+ assertThat(applied.remaining()).isEmpty();
+ assertThat(result.isEmpty()).isTrue();
+ }
+
+ @Test
+ void applyWithEmptyThenExact_returnsEmpty() {
+ SavepointFilterTranslator.Result applied =
+ apply(
+ List.of(
+ and(gt(longKeyRef(), longLit(10L)),
lt(longKeyRef(), longLit(5L))),
+ eq(longKeyRef(), longLit(1L))),
+ LONG_KEY_TYPE);
+ SavepointKeyFilter result = applied.keyFilter();
+
+ assertNotNull(result);
+ assertThat(applied.accepted()).hasSize(2);
+ assertThat(applied.remaining()).isEmpty();
+ assertThat(result.isEmpty()).isTrue();
+ }
+
+ @Test
+ void applyWithConflictingExactPredicates_returnsEmptyFilter() {
+ SavepointFilterTranslator.Result applied =
+ apply(
+ List.of(eq(longKeyRef(), longLit(1L)),
eq(longKeyRef(), longLit(2L))),
+ LONG_KEY_TYPE);
+ SavepointKeyFilter result = applied.keyFilter();
+
+ assertNotNull(result);
+ assertThat(applied.accepted()).hasSize(2);
+ assertThat(applied.remaining()).isEmpty();
+ assertThat(result.isEmpty()).isTrue();
+ }
+
+ //
-------------------------------------------------------------------------
+ // BETWEEN — wrong arg count
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void betweenWithWrongArgCount_returnsNull() {
+ CallExpression malformed =
+ CallExpression.permanent(
+ BuiltInFunctionDefinitions.BETWEEN,
+ Arrays.asList(longKeyRef(), longLit(1L)),
+ DataTypes.BOOLEAN());
+ assertThat(keyFilterOf(malformed)).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // BETWEEN — non-literal bounds
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void betweenWithNonLiteralBound_returnsNull() {
+ CallExpression expr =
+ CallExpression.permanent(
+ BuiltInFunctionDefinitions.BETWEEN,
+ Arrays.asList(longKeyRef(), otherRef(), longLit(10L)),
+ DataTypes.BOOLEAN());
+ assertThat(keyFilterOf(expr)).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Comparison with non-literal value side
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void comparisonWithNonLiteralValue_returnsNull() {
+ assertThat(keyFilterOf(gt(longKeyRef(), otherRef()))).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // EQUALS — malformed (wrong arg count)
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void equalsWithWrongArgCount_returnsNull() {
+ CallExpression malformed =
+ CallExpression.permanent(
+ BuiltInFunctionDefinitions.EQUALS,
+ Collections.singletonList(longKeyRef()),
+ DataTypes.BOOLEAN());
+ assertThat(keyFilterOf(malformed)).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Literal type widening to key type
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void equalsWithIntLiteralIsWidenedToBigintKeyAndPushed() {
+ ValueLiteralExpression intLit = new ValueLiteralExpression(5,
DataTypes.INT().notNull());
+ SavepointKeyFilter filter = keyFilterOf(eq(longKeyRef(), intLit));
+
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).containsExactly(5L);
+ assertThat(filter.test(5L)).isTrue();
+ assertThat(filter.test(6L)).isFalse();
+ }
+
+ @Test
+ void betweenWithIntLiteralBoundsIsWidenedToBigintKeyAndPushed() {
+ ValueLiteralExpression lower = new ValueLiteralExpression(1,
DataTypes.INT().notNull());
+ ValueLiteralExpression upper = new ValueLiteralExpression(10,
DataTypes.INT().notNull());
+ SavepointKeyFilter filter = keyFilterOf(between(longKeyRef(), lower,
upper));
+
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).isNull();
+ assertThat(filter.test(0L)).isFalse();
+ assertThat(filter.test(1L)).isTrue();
+ assertThat(filter.test(10L)).isTrue();
+ assertThat(filter.test(11L)).isFalse();
+ }
+
+ @Test
+ void equalsWithIntLiteralIsWidenedToDoubleKeyAndPushed() {
+ FieldReferenceExpression doubleKey =
+ new FieldReferenceExpression("key",
DataTypes.DOUBLE().notNull(), 0, KEY_COL);
+ ValueLiteralExpression intLit = new ValueLiteralExpression(5,
DataTypes.INT().notNull());
+
+ SavepointKeyFilter filter = keyFilterOf(eq(doubleKey, intLit));
+
+ assertNotNull(filter);
+ assertThat(filter.getExactKeys()).containsExactly(5.0d);
+ assertThat(filter.test(5.0d)).isTrue();
+ assertThat(filter.test(6.0d)).isFalse();
+ }
+
+ @Test
+ void nonNumericLiteralAgainstNumericKeyIsNotWidenedAndNotPushed() {
+ assertThat(keyFilterOf(eq(longKeyRef(), stringLit("5")))).isNull();
+ }
+
+ @Test
+ void numericLiteralWithNonWidenableKeyTypeIsNotPushed() {
+ FieldReferenceExpression intKey =
+ new FieldReferenceExpression("key", DataTypes.INT().notNull(),
0, KEY_COL);
+ assertThat(keyFilterOf(eq(intKey, longLit(5L)))).isNull();
+ }
+
+ @Test
+ void decimalKeyEqualityIsPushedDownPreservingLiteralScale() {
+ FieldReferenceExpression decKey =
+ new FieldReferenceExpression("key", DataTypes.DECIMAL(10,
2).notNull(), 0, KEY_COL);
+ ValueLiteralExpression lit =
+ new ValueLiteralExpression(
+ new BigDecimal("5.00"), DataTypes.DECIMAL(10,
2).notNull());
+
+ SavepointKeyFilter filter = keyFilterOf(eq(decKey, lit));
+
+ assertNotNull(filter);
+ // Literal scale is preserved, so exact matching is scale sensitive:
5.00 matches, 5.0 not.
+ assertThat(filter.getExactKeys()).containsExactly(new
BigDecimal("5.00"));
+ assertThat(filter.test(new BigDecimal("5.00"))).isTrue();
+ assertThat(filter.test(new BigDecimal("5.0"))).isFalse();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Expression helpers
+ //
-------------------------------------------------------------------------
+
+ private static SavepointKeyFilter keyFilterOf(ResolvedExpression expr) {
+ DataType keyType = findKeyType(expr);
+ return apply(Collections.singletonList(expr), keyType).keyFilter();
+ }
+
+ private static SavepointFilterTranslator.Result apply(
+ List<ResolvedExpression> filters, DataType keyType) {
+ return new SavepointFilterTranslator(KEY_COL, keyType).apply(filters);
+ }
+
+ private static DataType findKeyType(ResolvedExpression expr) {
+ if (expr instanceof FieldReferenceExpression
+ && ((FieldReferenceExpression) expr).getFieldIndex() ==
KEY_COL) {
+ return expr.getOutputDataType();
+ }
+ for (ResolvedExpression child : expr.getResolvedChildren()) {
+ DataType found = findKeyType(child);
+ if (found != null) {
+ return found;
+ }
+ }
+ return LONG_KEY_TYPE;
+ }
+
+ private static FieldReferenceExpression longKeyRef() {
+ return new FieldReferenceExpression("key",
DataTypes.BIGINT().notNull(), 0, KEY_COL);
+ }
+
+ private static FieldReferenceExpression stringKeyRef() {
+ return new FieldReferenceExpression("key",
DataTypes.STRING().notNull(), 0, KEY_COL);
+ }
+
+ private static FieldReferenceExpression otherRef() {
+ return new FieldReferenceExpression("val",
DataTypes.BIGINT().notNull(), 0, 1);
+ }
+
+ private static ValueLiteralExpression longLit(long value) {
+ return new ValueLiteralExpression(value, DataTypes.BIGINT().notNull());
+ }
+
+ private static ValueLiteralExpression stringLit(String value) {
+ return new ValueLiteralExpression(value, DataTypes.STRING().notNull());
+ }
+
+ private static CallExpression eq(ResolvedExpression left,
ResolvedExpression right) {
+ return CallExpression.permanent(
+ BuiltInFunctionDefinitions.EQUALS, Arrays.asList(left, right),
DataTypes.BOOLEAN());
+ }
+
+ private static CallExpression or(ResolvedExpression... args) {
+ return CallExpression.permanent(
+ BuiltInFunctionDefinitions.OR, Arrays.asList(args),
DataTypes.BOOLEAN());
+ }
+
+ private static CallExpression and(ResolvedExpression... args) {
+ return CallExpression.permanent(
+ BuiltInFunctionDefinitions.AND, Arrays.asList(args),
DataTypes.BOOLEAN());
+ }
+
+ private static CallExpression between(
+ ResolvedExpression value, ResolvedExpression lower,
ResolvedExpression upper) {
+ return CallExpression.permanent(
+ BuiltInFunctionDefinitions.BETWEEN,
+ Arrays.asList(value, lower, upper),
+ DataTypes.BOOLEAN());
+ }
+
+ private static CallExpression gt(ResolvedExpression left,
ResolvedExpression right) {
+ return CallExpression.permanent(
+ BuiltInFunctionDefinitions.GREATER_THAN,
+ Arrays.asList(left, right),
+ DataTypes.BOOLEAN());
+ }
+
+ private static CallExpression gte(ResolvedExpression left,
ResolvedExpression right) {
+ return CallExpression.permanent(
+ BuiltInFunctionDefinitions.GREATER_THAN_OR_EQUAL,
+ Arrays.asList(left, right),
+ DataTypes.BOOLEAN());
+ }
+
+ private static CallExpression lt(ResolvedExpression left,
ResolvedExpression right) {
+ return CallExpression.permanent(
+ BuiltInFunctionDefinitions.LESS_THAN,
+ Arrays.asList(left, right),
+ DataTypes.BOOLEAN());
+ }
+
+ private static CallExpression lte(ResolvedExpression left,
ResolvedExpression right) {
+ return CallExpression.permanent(
+ BuiltInFunctionDefinitions.LESS_THAN_OR_EQUAL,
+ Arrays.asList(left, right),
+ DataTypes.BOOLEAN());
+ }
+}