autophagy commented on code in PR #29345:
URL: https://github.com/apache/flink/pull/29345#discussion_r4246518565


##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/ProcessTableFunctionTestHarness.java:
##########
@@ -234,6 +239,58 @@ public static <OUT> Builder<OUT> ofClass(
         return new Builder<>(functionClass);
     }
 
+    // 
-------------------------------------------------------------------------
+    // Test State Snapshotting
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * Takes a snapshot of this harness: the configuration it was built with, 
the state of every
+     * partition, the pending and the fired timers, the watermark of every 
table argument, and the
+     * collected output.
+     *
+     * <p>The snapshot is isolated from this harness, so processing further 
elements, advancing a
+     * watermark or mutating state afterwards does not change what the 
snapshot holds. State is
+     * copied in its internal representation, which means no user-facing state 
object is touched.
+     * The one part that is not copied is the values collected by the PTF 
itself (see {@link
+     * #getFunctionOutput()}), since those are the instances the function 
emitted.

Review Comment:
   I wonder if we should also copy the values collected by the PTF itself, 
since I think this means that output from a pojo-typed ptf would leak across 
restore/snapshot. 



##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/ProcessTableFunctionTestHarness.java:
##########
@@ -234,6 +239,58 @@ public static <OUT> Builder<OUT> ofClass(
         return new Builder<>(functionClass);
     }
 
+    // 
-------------------------------------------------------------------------
+    // Test State Snapshotting
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * Takes a snapshot of this harness: the configuration it was built with, 
the state of every
+     * partition, the pending and the fired timers, the watermark of every 
table argument, and the
+     * collected output.
+     *
+     * <p>The snapshot is isolated from this harness, so processing further 
elements, advancing a
+     * watermark or mutating state afterwards does not change what the 
snapshot holds. State is
+     * copied in its internal representation, which means no user-facing state 
object is touched.
+     * The one part that is not copied is the values collected by the PTF 
itself (see {@link
+     * #getFunctionOutput()}), since those are the instances the function 
emitted.
+     *
+     * @see #restoreFromSnapshot(TestSnapshot)
+     */
+    public TestSnapshot<OUT> snapshot() {
+        return new TestSnapshot<>(
+                builder,
+                stateManager.snapshotState(),
+                timerManager.snapshot(),
+                new ArrayList<>(functionOutput),
+                copyRows(output));
+    }
+
+    /**
+     * Creates a new harness that continues from the given snapshot.
+     *
+     * <p>The returned harness runs a fresh instance of the PTF, opened as 
{@link Builder#build()}
+     * would, and starts out with the state, timers, watermarks and output 
that the snapshot was
+     * taken with. A snapshot can be restored more than once, and each 
restored harness is
+     * independent of the others.
+     *
+     * @see #snapshot()
+     */
+    public static <OUT> ProcessTableFunctionTestHarness<OUT> 
restoreFromSnapshot(
+            TestSnapshot<OUT> snapshot) throws Exception {
+        checkNotNull(snapshot, "snapshot must not be null");
+
+        final ProcessTableFunctionTestHarness<OUT> harness = 
snapshot.builder.build();
+        harness.stateManager.restoreState(snapshot.state);
+        harness.timerManager.restore(snapshot.timers);
+        harness.functionOutput.addAll(snapshot.functionOutput);
+        harness.output.addAll(copyRows(snapshot.output));
+        return harness;
+    }
+
+    private static List<Row> copyRows(List<Row> rows) {
+        return rows.stream().map(Row::copy).collect(Collectors.toList());

Review Comment:
   According to the doc for `Row.copy`, it doesn't perform a deep copy. In a 
situation with a PTF with a nested output type, like `"ROW<arr ARRAY<INT>>"`, 
this means you could modify an output row from the harness and it would modify 
the output row in the snapshot (or vice verse), like:
   
   ```
   final TestSnapshot<Row> snapshot = harness.snapshot();
   
   final Integer[] liveArray = (Integer[]) 
harness.getOutput().get(0).getField(0);
   liveArray[0] = -1;
   ```
   
   If you then did 
`ProcessTableFunctionTestHarness.restoreFromSnapshot(snapshot)`, the restored 
harness' `getOutput().get(0).getField(0)[0]` would contain `-1`, and not 
whatever the value was when the snapshot was taken.
   
   It's a bit contrived but worth keeping in mind, I'm not sure of the best way 
of doing a deep copy of a row is (other than roundtripping it through something 
like `ExternalSerializer`).
   



##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/ListViewStateConverter.java:
##########
@@ -76,4 +80,15 @@ public Object toExternal(Object internal) {
     public Object createNewInternalState() {
         return new GenericArrayData(new Object[0]);
     }
+
+    @Override
+    public Object copyInternal(Object internal) {
+        final ArrayData arrayData = (ArrayData) internal;

Review Comment:
   I think if you replaced the `elementSerializer` with a `ArrayDataSerializer` 
of `arrayType.getElementType()`, this function could just use its existing copy 
rather than re-implementing it:
   
   ```
   public Object copyInternal(Object internal) {
       return arrayDataSerializer.copy((ArrayData) internal);
   }
   ```



##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/ProcessTableFunctionTestHarness.java:
##########
@@ -234,6 +239,58 @@ public static <OUT> Builder<OUT> ofClass(
         return new Builder<>(functionClass);
     }
 
+    // 
-------------------------------------------------------------------------
+    // Test State Snapshotting
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * Takes a snapshot of this harness: the configuration it was built with, 
the state of every
+     * partition, the pending and the fired timers, the watermark of every 
table argument, and the
+     * collected output.
+     *
+     * <p>The snapshot is isolated from this harness, so processing further 
elements, advancing a
+     * watermark or mutating state afterwards does not change what the 
snapshot holds. State is
+     * copied in its internal representation, which means no user-facing state 
object is touched.

Review Comment:
   I'm not sure stating how the snapshotting is done is very relevant to the 
user, I think detail like this description of how state is snapshotted can be 
omitted. 



##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/ProcessTableFunctionTestHarness.java:
##########
@@ -1124,6 +1181,20 @@ private Builder(Class<? extends 
ProcessTableFunction<OUT>> functionClass) {
             this.functionClass = checkNotNull(functionClass, "functionClass 
must not be null");
         }
 
+        private Builder(Builder<OUT> other) {
+            this.functionClass = other.functionClass;
+            this.scalarArgs.putAll(other.scalarArgs);
+            this.tableArgs.putAll(other.tableArgs);
+            this.partitionConfigs.putAll(other.partitionConfigs);
+            other.stateArgs.forEach((name, config) -> this.stateArgs.put(name, 
config.copy()));
+            this.onTimeColumnName = other.onTimeColumnName;
+        }
+
+        /** Creates a copy of this builder, detached from any further 
configuration of it. */
+        private Builder<OUT> copy() {
+            return new Builder<>(this);
+        }
+

Review Comment:
   As stated above, if we instead have the builder construct and pass in a 
frozen immutable representation of its build state/initial conditions to the 
harness, we wouldn't need this constructor or the copy.



##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/Timer.java:
##########
@@ -81,6 +81,15 @@ void markFired() {
         this.fired = true;
     }
 
+    /** Creates an isolated copy of this timer, including whether it has 
already fired. */
+    Timer copy() {
+        final Timer copy = new Timer(timestamp, name, Row.copy(partitionKey));

Review Comment:
   Might be more simpler to have a `Timer` constructor that also takes in the 
fired state too, rather than marking it after construction.



##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/TestHarnessStateManager.java:
##########
@@ -198,6 +198,43 @@ <T> Map<Row, T> getStateForAllKeys(String stateName) {
         return result;
     }
 
+    /**
+     * Returns a deep copy of all state, keyed by partition key. Later 
mutations of the live state
+     * are not visible through the returned map.
+     */
+    Map<Row, Map<String, Object>> snapshotState() {
+        return copyStateByKey(stateByKey);
+    }
+
+    /** Replaces all state with a deep copy of the given snapshot. */
+    void restoreState(Map<Row, Map<String, Object>> snapshot) {
+        stateByKey.clear();
+        stateByKey.putAll(copyStateByKey(snapshot));
+    }

Review Comment:
   I think if we tackle the restoration of state/timers as commented in 
https://github.com/apache/flink/pull/29345/changes#r4246665648 then I think 
this pattern of clearing and then `putAll` would not be necessary, as we'd be 
able to initialise the state manager with the correct state.



##########
flink-table/flink-table-test-utils/src/test/java/org/apache/flink/table/runtime/functions/ProcessTableFunctionTestHarnessSnapshotTest.java:
##########
@@ -0,0 +1,586 @@
+/*
+ * 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.table.runtime.functions;
+
+import org.apache.flink.table.annotation.ArgumentHint;
+import org.apache.flink.table.annotation.ArgumentTrait;
+import org.apache.flink.table.annotation.DataTypeHint;
+import org.apache.flink.table.annotation.StateHint;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.dataview.ListView;
+import org.apache.flink.table.api.dataview.MapView;
+import org.apache.flink.table.api.dataview.ValueView;
+import org.apache.flink.table.functions.ProcessTableFunction;
+import 
org.apache.flink.table.runtime.functions.ProcessTableFunctionTestHarness.TableArgument;
+import 
org.apache.flink.table.runtime.functions.ProcessTableFunctionTestHarness.TestSnapshot;
+import org.apache.flink.types.Row;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** Tests for {@link ProcessTableFunctionTestHarness#snapshot()} and its 
restoration. */
+class ProcessTableFunctionTestHarnessSnapshotTest {
+
+    /** Emits the input row if its value reaches the given threshold. */
+    @DataTypeHint("ROW<value INT>")
+    public static class ThresholdFilterPTF extends ProcessTableFunction<Row> {
+        public void eval(
+                @ArgumentHint(ArgumentTrait.ROW_SEMANTIC_TABLE) Row input,
+                @ArgumentHint(ArgumentTrait.SCALAR) Integer threshold) {
+            int value = input.getFieldAs("value");
+            if (value >= threshold) {
+                collect(input);
+            }
+        }
+    }
+
+    /** Counts rows per partition in POJO state. */
+    @DataTypeHint("ROW<count BIGINT>")
+    public static class CountingPTF extends ProcessTableFunction<Row> {
+        /** Counter held as structured-type state. */
+        public static class CounterState {
+            public long counter = 0L;
+        }
+
+        public void eval(
+                @StateHint CounterState state,
+                @ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input) {
+            state.counter++;
+            collect(Row.of(state.counter));
+        }
+    }
+
+    /** Accumulates all seen values per partition in {@link ListView} state. */
+    @DataTypeHint("ROW<values ARRAY<INT>>")
+    public static class ListStatePTF extends ProcessTableFunction<Row> {
+        public void eval(
+                @StateHint(type = @DataTypeHint("ARRAY<INT>")) 
ListView<Integer> listState,
+                @ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input)
+                throws Exception {
+            listState.add(input.<Integer>getFieldAs("value"));
+
+            final List<Integer> values = new ArrayList<>();
+            for (Integer value : listState.get()) {
+                values.add(value);
+            }
+            collect(Row.of((Object) values.toArray(new Integer[0])));
+        }
+    }
+
+    /** Counts occurrences of every key per partition in {@link MapView} 
state. */
+    @DataTypeHint("ROW<key STRING, count INT>")
+    public static class MapStatePTF extends ProcessTableFunction<Row> {
+        public void eval(
+                @StateHint MapView<String, Integer> mapState,
+                @ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input)
+                throws Exception {
+            final String key = input.getFieldAs("key");
+            final Integer count = mapState.get(key);
+            mapState.put(key, count == null ? 1 : count + 1);
+            collect(Row.of(key, mapState.get(key)));
+        }
+    }
+
+    /** Counts rows per partition in {@link Row} state. */
+    @DataTypeHint("ROW<count BIGINT>")
+    public static class RowStatePTF extends ProcessTableFunction<Row> {
+        public void eval(
+                @StateHint(type = @DataTypeHint("ROW<count BIGINT>")) Row 
memory,
+                @ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input) {
+            long count = 1L;
+            if (memory.getField("count") != null) {
+                count += memory.<Long>getFieldAs("count");
+            }
+            memory.setField("count", count);
+            collect(Row.of(count));
+        }
+    }
+
+    /** Accumulates a running total per partition in {@link ValueView} state. 
*/
+    @DataTypeHint("ROW<total INT>")
+    public static class ValueViewStatePTF extends ProcessTableFunction<Row> {
+        public void eval(
+                @StateHint ValueView<Integer> total,
+                @ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input) {
+            Integer current = total.getValue();
+            if (current == null) {
+                current = 0;
+            }
+            current += input.<Integer>getFieldAs("value");
+            total.setValue(current);
+            collect(Row.of(current));
+        }
+    }
+
+    /** Registers a named timer five seconds after the row's event time. */
+    @DataTypeHint("ROW<message STRING>")
+    public static class NamedTimerPTF extends ProcessTableFunction<Row> {
+        public void eval(
+                Context ctx,
+                @ArgumentHint({ArgumentTrait.SET_SEMANTIC_TABLE, 
ArgumentTrait.REQUIRE_ON_TIME})
+                        Row input) {
+            final TimeContext<LocalDateTime> timeCtx = 
ctx.timeContext(LocalDateTime.class);
+            timeCtx.registerOnTime("myTimer", 
timeCtx.time().plus(Duration.ofSeconds(5)));
+        }
+
+        public void onTimer(OnTimerContext ctx) {
+            collect(Row.of("fired-" + ctx.currentTimer()));
+        }
+    }
+
+    /** Registers a timer one millisecond after the current watermark. */
+    @DataTypeHint("ROW<message STRING>")
+    public static class WatermarkTimerPTF extends ProcessTableFunction<Row> {
+        public void eval(Context ctx, 
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input) {
+            final TimeContext<Long> timeCtx = ctx.timeContext(Long.class);
+            final Long watermark = timeCtx.currentWatermark();
+            timeCtx.registerOnTime("t", watermark == null ? 1L : watermark + 
1);
+            collect(Row.of("registered"));
+        }
+
+        public void onTimer(OnTimerContext ctx) {
+            collect(Row.of("fired-" + ctx.currentTimer()));
+        }
+    }
+
+    /** Counts its invocations on the function instance instead of in state. */
+    @DataTypeHint("ROW<invocation INT>")
+    public static class InstanceCountingPTF extends ProcessTableFunction<Row> {
+        private int invocations = 0;
+
+        public void eval(@ArgumentHint(ArgumentTrait.ROW_SEMANTIC_TABLE) Row 
input) {
+            invocations++;
+            collect(Row.of(invocations));
+        }
+    }
+
+    // 
-------------------------------------------------------------------------
+    // Output
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void testSnapshotAndRestoreOutput() throws Exception {
+        final TestSnapshot<Row> snapshot;
+
+        try (ProcessTableFunctionTestHarness<Row> harness = filterHarness()) {
+            harness.processElement(Row.of(100));
+            harness.processElement(Row.of(30));
+            assertThat(harness.getOutput()).containsExactly(Row.of(100));
+
+            snapshot = harness.snapshot();
+
+            harness.processElement(Row.of(75));
+            assertThat(harness.getOutput()).containsExactly(Row.of(100), 
Row.of(75));
+        }
+
+        try (ProcessTableFunctionTestHarness<Row> restored =
+                ProcessTableFunctionTestHarness.restoreFromSnapshot(snapshot)) 
{
+            assertThat(restored.getOutput()).containsExactly(Row.of(100));
+            
assertThat(restored.getFunctionOutput()).containsExactly(Row.of(100));
+
+            restored.processElement(Row.of(75));
+            assertThat(restored.getOutput()).containsExactly(Row.of(100), 
Row.of(75));
+        }
+    }
+
+    @Test
+    void testSnapshotIsNotAffectedByLaterOutput() throws Exception {

Review Comment:
   I think this test could be folded into `testSnapshotAndRestoreOutput`



##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/ProcessTableFunctionTestHarness.java:
##########
@@ -234,6 +239,58 @@ public static <OUT> Builder<OUT> ofClass(
         return new Builder<>(functionClass);
     }
 
+    // 
-------------------------------------------------------------------------
+    // Test State Snapshotting
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * Takes a snapshot of this harness: the configuration it was built with, 
the state of every
+     * partition, the pending and the fired timers, the watermark of every 
table argument, and the
+     * collected output.
+     *
+     * <p>The snapshot is isolated from this harness, so processing further 
elements, advancing a
+     * watermark or mutating state afterwards does not change what the 
snapshot holds. State is
+     * copied in its internal representation, which means no user-facing state 
object is touched.
+     * The one part that is not copied is the values collected by the PTF 
itself (see {@link
+     * #getFunctionOutput()}), since those are the instances the function 
emitted.
+     *
+     * @see #restoreFromSnapshot(TestSnapshot)
+     */
+    public TestSnapshot<OUT> snapshot() {
+        return new TestSnapshot<>(
+                builder,
+                stateManager.snapshotState(),
+                timerManager.snapshot(),
+                new ArrayList<>(functionOutput),
+                copyRows(output));
+    }
+
+    /**
+     * Creates a new harness that continues from the given snapshot.
+     *
+     * <p>The returned harness runs a fresh instance of the PTF, opened as 
{@link Builder#build()}
+     * would, and starts out with the state, timers, watermarks and output 
that the snapshot was
+     * taken with. A snapshot can be restored more than once, and each 
restored harness is
+     * independent of the others.
+     *
+     * @see #snapshot()
+     */
+    public static <OUT> ProcessTableFunctionTestHarness<OUT> 
restoreFromSnapshot(
+            TestSnapshot<OUT> snapshot) throws Exception {
+        checkNotNull(snapshot, "snapshot must not be null");
+
+        final ProcessTableFunctionTestHarness<OUT> harness = 
snapshot.builder.build();
+        harness.stateManager.restoreState(snapshot.state);
+        harness.timerManager.restore(snapshot.timers);
+        harness.functionOutput.addAll(snapshot.functionOutput);
+        harness.output.addAll(copyRows(snapshot.output));

Review Comment:
   I guess here what we are doing is building the harness from its initial 
builder again (with initial conditions like initial state), creating the 
state/timer manager from scratch and the updating all of the harness properties 
with the restoration state. I suppose this is why we also test for 
`testRestoreDropsStateThatWasClearedBeforeTheSnapshot`.
   
   I wonder if it would be easier & clearer to have a private 
`buildFromSnapshot(snapshot)` method on the builder itself that handle this. By 
this i mean that instead of setting up the initial state and then overwriting 
it on the harness' internal state/timer managers, the builder itself could 
construct the right initial state from the snapshot and initialise the harness 
with it directly?
   
   It would also make it easier to handle `close()`ing the opened harness too 
on restoration failure (since in this current construction I dont think this 
happens if `restoreFromSnapshot` fails.



##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/ProcessTableFunctionTestHarness.java:
##########
@@ -155,7 +155,11 @@ private static class TableArgumentConverters {
 
     @Nullable private InvocationContext currentInvocation;
 
+    /** The configuration this harness was built with, kept for {@link 
#snapshot()}. */
+    private final Builder<OUT> builder;

Review Comment:
   I'm not sure passing in the builder to the harness itself (then having to 
copy it) and having a bit of a circular dependency is right to do here. Like 
your comment says we don't care about this as a `Builder` but as the initial 
configuration that the harness was built with. Typing it as a `Builder` means 
it still allows adding more initial args, state etc even though we know its 
meant to be a fixed representation.
   
   I think instead having some sort of representation of the builder's build 
state would make more sense, as an immutable value class that the builder 
constructs at `build()` time. This way it'd both be clear what this is from the 
type (not a mutable builder but as a frozen initial configuration) and would 
prevent mutating the initial state.



##########
flink-table/flink-table-test-utils/src/main/java/org/apache/flink/table/runtime/functions/TestHarnessStateManager.java:
##########
@@ -198,6 +198,43 @@ <T> Map<Row, T> getStateForAllKeys(String stateName) {
         return result;
     }
 
+    /**
+     * Returns a deep copy of all state, keyed by partition key. Later 
mutations of the live state
+     * are not visible through the returned map.
+     */
+    Map<Row, Map<String, Object>> snapshotState() {

Review Comment:
   Small nit, but I think just `snapshot` and `restore` would be fine to use 
here. its clear from them being in the state manager what we are snapshotting 
and restoring.



##########
docs/content/docs/dev/table/functions/ptfs.md:
##########
@@ -2892,6 +2892,65 @@ void testAtomicOutputFunctionOutput() throws Exception {
 
 {{< top >}}
 
+### Snapshotting and Restoring a Test
+
+`snapshot()` captures a test at a point in time: the state of every partition, 
the pending and the
+fired timers, the watermark of every table argument, and the collected output. 
Passing the snapshot
+to `ProcessTableFunctionTestHarness.restoreFromSnapshot()` creates a new 
harness that continues from
+there, which lets several test cases share one setup instead of repeating it.
+
+A snapshot is isolated from the harness it was taken from, so processing 
further elements, advancing
+a watermark or changing state afterwards does not change what it holds. It can 
also be restored more
+than once, and the restored harnesses are independent of each other. Every 
restore runs a fresh

Review Comment:
   I guess this is not _entirely_ independent since I think the 
`getFunctionOutput` might leak since we dont copy it.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to