This is an automated email from the ASF dual-hosted git repository.
Abacn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new 67018be2650 [runners-spark] Support stateful ParDo in the Structured
Streaming batch runner (#39793)
67018be2650 is described below
commit 67018be26507a4afdda3a8368c53db4a0ddf0366
Author: Fabian Loris <[email protected]>
AuthorDate: Thu Aug 20 22:41:44 2026 +0200
[runners-spark] Support stateful ParDo in the Structured Streaming batch
runner (#39793)
* [runners-spark] Support stateful ParDo in the Structured Streaming batch
runner
Stateful, timer-using and @RequiresTimeSortedInput DoFns are dispatched to a
new StatefulParDoTranslatorBatch, which groups by key and sorts each group
by
event time; StatefulDoFnGroupFunction then runs the DoFn per key with
in-memory
state and timers. Additional (tagged) outputs are encoded as one column per
tag
and split into per-tag datasets, mirroring the stateless multi-output
translation in ParDoTranslatorBatch, keeping the single-output fast path.
Requires Spark 3.4+ for KeyValueGroupedDataset#flatMapSortedGroups; earlier
versions are rejected at translation time rather than failing mid-job.
- Enable the UsesStatefulParDo, UsesKeyInParDo, UsesTimersInParDo,
UsesMapState, UsesMultimapState, UsesSetState and UsesTimerMap
ValidatesRunner categories for validatesStructuredStreamingRunnerBatch
- Exclude UsesOrderedListState: every such test also uses
@OnWindowExpiration,
which is not supported
- Exclude PerKeyOrderingTest#testMultipleStatefulOrdering* by name: they
build
on PeriodicImpulse and so are unbounded, but are not categorized as
UsesUnboundedPCollections, and adding that category upstream would also
stop
other runners running them
@OnWindowExpiration remains unsupported (#22524).
---
...eam_PostCommit_Java_ValidatesRunner_Spark4.json | 4 +
...a_ValidatesRunner_SparkStructuredStreaming.json | 3 +-
CHANGES.md | 1 +
runners/spark/spark_runner.gradle | 15 +-
.../translation/batch/DoFnRunnerFactory.java | 26 +-
.../translation/batch/ParDoTranslatorBatch.java | 50 +--
.../translation/batch/PipelineTranslatorBatch.java | 19 +
.../batch/StatefulDoFnGroupFunction.java | 391 +++++++++++++++++++++
.../batch/StatefulParDoTranslatorBatch.java | 282 +++++++++++++++
.../batch/StatefulParDoExecutionTest.java | 357 +++++++++++++++++++
.../batch/StatefulParDoTranslatorBatchTest.java | 261 ++++++++++++++
11 files changed, 1378 insertions(+), 31 deletions(-)
diff --git
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json
new file mode 100644
index 00000000000..e3d6056a5de
--- /dev/null
+++ b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json
@@ -0,0 +1,4 @@
+{
+ "comment": "Modify this file in a trivial way to cause this test suite to
run",
+ "modification": 1
+}
diff --git
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json
index 77f63217b86..cad8d98b8ea 100644
---
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json
+++
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json
@@ -7,5 +7,6 @@
"https://github.com/apache/beam/pull/34123": "noting that PR #34123 should
run this test",
"https://github.com/apache/beam/pull/34080": "noting that PR #34080 should
run this test",
"https://github.com/apache/beam/pull/34155": "noting that PR #34155 should
run this test",
- "https://github.com/apache/beam/pull/35159": "moving WindowedValue and
making an interface"
+ "https://github.com/apache/beam/pull/35159": "moving WindowedValue and
making an interface",
+ "https://github.com/apache/beam/pull/39793": "noting that PR #39793 should
run this test"
}
diff --git a/CHANGES.md b/CHANGES.md
index 73966a48313..56c062790e2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -69,6 +69,7 @@
## New Features / Improvements
* X feature added (Java/Python)
([#X](https://github.com/apache/beam/issues/X)).
+* (Java) Spark Structured Streaming runner: stateful ParDo with state, timers,
`@RequiresTimeSortedInput` and tagged outputs is now supported in batch mode
([#39779](https://github.com/apache/beam/issues/39779)).
## Breaking Changes
diff --git a/runners/spark/spark_runner.gradle
b/runners/spark/spark_runner.gradle
index 77da3d36db9..2a161db0a82 100644
--- a/runners/spark/spark_runner.gradle
+++ b/runners/spark/spark_runner.gradle
@@ -510,15 +510,9 @@ tasks.register("validatesStructuredStreamingRunnerBatch",
Test) {
excludeCategories 'org.apache.beam.sdk.testing.UsesUnboundedPCollections'
excludeCategories 'org.apache.beam.sdk.testing.UsesTestStream'
// State and Timers
- excludeCategories 'org.apache.beam.sdk.testing.UsesStatefulParDo'
- excludeCategories 'org.apache.beam.sdk.testing.UsesMapState'
- excludeCategories 'org.apache.beam.sdk.testing.UsesMultimapState'
- excludeCategories 'org.apache.beam.sdk.testing.UsesSetState'
- excludeCategories 'org.apache.beam.sdk.testing.UsesOrderedListState'
- excludeCategories 'org.apache.beam.sdk.testing.UsesTimersInParDo'
- excludeCategories 'org.apache.beam.sdk.testing.UsesTimerMap'
- excludeCategories 'org.apache.beam.sdk.testing.UsesKeyInParDo'
excludeCategories 'org.apache.beam.sdk.testing.UsesOnWindowExpiration'
+ // Every UsesOrderedListState test also uses @OnWindowExpiration, which is
unsupported
+ excludeCategories 'org.apache.beam.sdk.testing.UsesOrderedListState'
// Metrics
excludeCategories 'org.apache.beam.sdk.testing.UsesCommittedMetrics'
excludeCategories 'org.apache.beam.sdk.testing.UsesSystemMetrics'
@@ -532,6 +526,11 @@ tasks.register("validatesStructuredStreamingRunnerBatch",
Test) {
excludeCategories 'org.apache.beam.sdk.testing.UsesTriggeredSideInputs'
}
filter {
+ // These build on PeriodicImpulse, so the pipeline is unbounded and
rejected by this batch only
+ // runner, but they are not categorized as UsesUnboundedPCollections.
Excluded by name rather
+ // than adding that category upstream, which would also stop other runners
running them.
+ excludeTestsMatching
'org.apache.beam.sdk.transforms.PerKeyOrderingTest.testMultipleStatefulOrderingWithShuffle'
+ excludeTestsMatching
'org.apache.beam.sdk.transforms.PerKeyOrderingTest.testMultipleStatefulOrderingWithoutShuffle'
// Combine with context not implemented
excludeTestsMatching
'org.apache.beam.sdk.transforms.CombineFnsTest.testComposedCombineWithContext'
excludeTestsMatching
'org.apache.beam.sdk.transforms.CombineTest$CombineWithContextTests.testSimpleCombineWithContext'
diff --git
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/DoFnRunnerFactory.java
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/DoFnRunnerFactory.java
index 5e8703a05b0..ce4155ee8e1 100644
---
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/DoFnRunnerFactory.java
+++
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/DoFnRunnerFactory.java
@@ -25,6 +25,7 @@ import java.util.Map;
import org.apache.beam.runners.core.DoFnRunner;
import org.apache.beam.runners.core.DoFnRunners;
import org.apache.beam.runners.core.SideInputReader;
+import org.apache.beam.runners.core.StepContext;
import
org.apache.beam.runners.spark.structuredstreaming.metrics.MetricsAccumulator;
import
org.apache.beam.runners.spark.structuredstreaming.translation.batch.functions.CachedSideInputReader;
import
org.apache.beam.runners.spark.structuredstreaming.translation.batch.functions.NoOpStepContext;
@@ -71,6 +72,20 @@ abstract class DoFnRunnerFactory<InT, T> implements
Serializable {
abstract DoFnRunnerWithTeardown<InT, T> create(
PipelineOptions options, MetricsAccumulator metrics,
WindowedValueMultiReceiver output);
+ /**
+ * Creates a runner backed by {@code stepContext} so that state and timers
are available.
+ *
+ * <p>Only supported for a single, unfused {@link DoFn}: a fused runner
cannot drive timers.
+ */
+ DoFnRunnerWithTeardown<InT, T> create(
+ PipelineOptions options,
+ MetricsAccumulator metrics,
+ WindowedValueMultiReceiver output,
+ StepContext stepContext) {
+ throw new UnsupportedOperationException(
+ "Stateful execution is not supported by " +
getClass().getSimpleName());
+ }
+
/**
* Fuses the factory for the following {@link DoFnRunner} into a single
factory that processes
* both DoFns in a single step.
@@ -128,6 +143,15 @@ abstract class DoFnRunnerFactory<InT, T> implements
Serializable {
@Override
DoFnRunnerWithTeardown<InT, T> create(
PipelineOptions options, MetricsAccumulator metrics,
WindowedValueMultiReceiver output) {
+ return create(options, metrics, output, new NoOpStepContext());
+ }
+
+ @Override
+ DoFnRunnerWithTeardown<InT, T> create(
+ PipelineOptions options,
+ MetricsAccumulator metrics,
+ WindowedValueMultiReceiver output,
+ StepContext stepContext) {
DoFnRunner<InT, T> simpleRunner =
DoFnRunners.simpleRunner(
options,
@@ -136,7 +160,7 @@ abstract class DoFnRunnerFactory<InT, T> implements
Serializable {
filterMainOutput ? new FilteredOutput<>(output, mainOutput) :
output,
mainOutput,
additionalOutputs,
- new NoOpStepContext(),
+ stepContext,
coder,
outputCoders,
windowingStrategy,
diff --git
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/ParDoTranslatorBatch.java
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/ParDoTranslatorBatch.java
index 0f43f329b0d..14058a73733 100644
---
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/ParDoTranslatorBatch.java
+++
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/ParDoTranslatorBatch.java
@@ -24,7 +24,7 @@ import static
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Pr
import static org.apache.spark.sql.functions.col;
import java.io.IOException;
-import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@@ -35,6 +35,7 @@ import org.apache.beam.runners.core.DoFnRunners;
import org.apache.beam.runners.core.SideInputReader;
import org.apache.beam.runners.spark.SparkCommonPipelineOptions;
import
org.apache.beam.runners.spark.structuredstreaming.metrics.MetricsAccumulator;
+import
org.apache.beam.runners.spark.structuredstreaming.translation.PipelineTranslator.TranslationState;
import
org.apache.beam.runners.spark.structuredstreaming.translation.PipelineTranslator.UnresolvedTranslation;
import
org.apache.beam.runners.spark.structuredstreaming.translation.TransformTranslator;
import
org.apache.beam.runners.spark.structuredstreaming.translation.batch.functions.SideInputValues;
@@ -64,8 +65,11 @@ import scala.Tuple2;
*
* <p>Each tag is encoded as individual column with a respective schema &
encoder each.
*
+ * <p>Stateful {@link org.apache.beam.sdk.transforms.DoFn DoFns}, those using
timers, and those
+ * annotated with {@link DoFn.RequiresTimeSortedInput} are translated by {@link
+ * StatefulParDoTranslatorBatch} instead.
+ *
* <p>TODO:
- * <li>Add support for state and timers.
* <li>Add support for SplittableDoFn
*/
class ParDoTranslatorBatch<InputT, OutputT>
@@ -87,18 +91,18 @@ class ParDoTranslatorBatch<InputT, OutputT>
"Not expected to directly translate splittable DoFn, should have been
overridden: %s",
doFn);
- // TODO: add support of states and timers
+ // Stateful, timer using and time sorted DoFns are routed to
StatefulParDoTranslatorBatch by
+ // PipelineTranslatorBatch#getTransformTranslator. Reaching here with one
means dispatch is
+ // broken, not that the feature is unsupported.
checkState(
- !signature.usesState() && !signature.usesTimers(),
- "States and timers are not supported for the moment.");
+ !StatefulParDoTranslatorBatch.appliesTo(transform),
+ "Stateful / time sorted DoFn should have been translated by %s: %s",
+ StatefulParDoTranslatorBatch.class.getSimpleName(),
+ doFn);
checkState(
signature.onWindowExpiration() == null, "onWindowExpiration is not
supported: %s", doFn);
- checkState(
- !signature.processElement().requiresTimeSortedInput(),
- "@RequiresTimeSortedInput is not supported for the moment");
-
SparkSideInputReader.validateMaterializations(transform.getSideInputs().values());
return true;
}
@@ -211,11 +215,11 @@ class ParDoTranslatorBatch<InputT, OutputT>
* <p>This can help to avoid unnecessary caching in case of multiple outputs
if only {@code
* mainTag} is consumed.
*/
- private Map<TupleTag<?>, PCollection<?>> skipUnconsumedOutputs(
+ static Map<TupleTag<?>, PCollection<?>> skipUnconsumedOutputs(
Map<TupleTag<?>, PCollection<?>> outputs,
TupleTag<?> mainTag,
TupleTagList otherTags,
- Context cxt) {
+ TranslationState cxt) {
switch (outputs.size()) {
case 1:
return outputs; // always keep main output
@@ -235,7 +239,7 @@ class ParDoTranslatorBatch<InputT, OutputT>
}
}
- private Map<String, Integer> tagsColumnIndex(Collection<TupleTag<?>> tags) {
+ static Map<String, Integer> tagsColumnIndex(Collection<TupleTag<?>> tags) {
Map<String, Integer> index = Maps.newHashMapWithExpectedSize(tags.size());
for (TupleTag<?> tag : tags) {
index.put(tag.getId(), index.size());
@@ -243,20 +247,24 @@ class ParDoTranslatorBatch<InputT, OutputT>
return index;
}
- /** List of encoders matching the order of tagIds. */
- private List<Encoder<WindowedValue<Object>>> createEncoders(
- Map<TupleTag<?>, PCollection<?>> outputs, Map<String, Integer>
tagIdColIdx, Context ctx) {
- ArrayList<Encoder<WindowedValue<Object>>> encoders = new
ArrayList<>(outputs.size());
+ /** List of encoders indexed by column index, as assigned by {@code
tagIdColIdx}. */
+ @SuppressWarnings("rawtypes") // generic array creation
+ static List<Encoder<WindowedValue<Object>>> createEncoders(
+ Map<TupleTag<?>, PCollection<?>> outputs,
+ Map<String, Integer> tagIdColIdx,
+ TransformTranslator<?, ?, ?>.Context ctx) {
+ // Indexed rather than appended, so the iteration order of outputs need
not match the columns.
+ Encoder<WindowedValue<Object>>[] encoders = new Encoder[outputs.size()];
for (Entry<TupleTag<?>, PCollection<?>> e : outputs.entrySet()) {
- Encoder<WindowedValue<Object>> enc = ctx.windowedEncoder((Coder)
e.getValue().getCoder());
int colIdx = checkStateNotNull(tagIdColIdx.get(e.getKey().getId()));
- encoders.add(colIdx, enc);
+ encoders[colIdx] = ctx.windowedEncoder((Coder) e.getValue().getCoder());
}
- return encoders;
+ return Arrays.asList(encoders);
}
- private <T> SideInputReader createSideInputReader(
- Collection<PCollectionView<?>> views, Context cxt) {
+ /** Broadcasts {@code views}, if any, and exposes them as a {@link
SideInputReader}. */
+ static <T> SideInputReader createSideInputReader(
+ Collection<PCollectionView<?>> views, TranslationState cxt) {
if (views.isEmpty()) {
return SparkSideInputReader.empty();
}
diff --git
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/PipelineTranslatorBatch.java
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/PipelineTranslatorBatch.java
index c4a18801ccb..ba7cbb0fa03 100644
---
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/PipelineTranslatorBatch.java
+++
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/PipelineTranslatorBatch.java
@@ -82,11 +82,30 @@ public class PipelineTranslatorBatch extends
PipelineTranslator {
SplittableParDo.PrimitiveBoundedRead.class, new
ReadSourceTranslatorBatch<>());
}
+ /**
+ * Translators that shadow the {@link #TRANSFORM_TRANSLATORS} entry for
their transform class when
+ * a predicate matches, so that a single transform class can be translated
in more than one way
+ * depending on the transform instance.
+ *
+ * <p>Currently only {@link ParDo.MultiOutput} needs this, to route stateful
and time sorted
+ * {@link org.apache.beam.sdk.transforms.DoFn DoFns} away from {@link
ParDoTranslatorBatch}.
+ */
+ @SuppressWarnings("rawtypes")
+ private static final TransformTranslator STATEFUL_PARDO_TRANSLATOR =
+ new StatefulParDoTranslatorBatch<>();
+
/** Returns a {@link TransformTranslator} for the given {@link PTransform}
if known. */
@Override
@Nullable
protected <InT extends PInput, OutT extends POutput, TransformT extends
PTransform<InT, OutT>>
TransformTranslator<InT, OutT, TransformT>
getTransformTranslator(TransformT transform) {
+ // Resolved ahead of the class keyed registry: ParDo.MultiOutput maps to a
different translator
+ // depending on the DoFn signature, which a lookup by transform class
alone cannot express. This
+ // is the predicated dispatch of the TODO above, limited to the single
transform needing it.
+ if (transform instanceof ParDo.MultiOutput
+ && StatefulParDoTranslatorBatch.appliesTo((ParDo.MultiOutput<?, ?>)
transform)) {
+ return STATEFUL_PARDO_TRANSLATOR;
+ }
return TRANSFORM_TRANSLATORS.get(transform.getClass());
}
}
diff --git
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulDoFnGroupFunction.java
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulDoFnGroupFunction.java
new file mode 100644
index 00000000000..b5c2d602407
--- /dev/null
+++
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulDoFnGroupFunction.java
@@ -0,0 +1,391 @@
+/*
+ * 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.beam.runners.spark.structuredstreaming.translation.batch;
+
+import static
org.apache.beam.runners.spark.structuredstreaming.translation.utils.ScalaInterop.tuple;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.function.Supplier;
+import javax.annotation.CheckForNull;
+import org.apache.beam.runners.core.InMemoryStateInternals;
+import org.apache.beam.runners.core.InMemoryTimerInternals;
+import org.apache.beam.runners.core.StateInternals;
+import org.apache.beam.runners.core.StateNamespaces;
+import org.apache.beam.runners.core.StepContext;
+import org.apache.beam.runners.core.TimerInternals;
+import org.apache.beam.runners.core.TimerInternals.TimerData;
+import
org.apache.beam.runners.spark.structuredstreaming.metrics.MetricsAccumulator;
+import
org.apache.beam.runners.spark.structuredstreaming.translation.batch.DoFnRunnerFactory.DoFnRunnerWithTeardown;
+import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.util.WindowedValueMultiReceiver;
+import org.apache.beam.sdk.values.CausedByDrain;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.sdk.values.WindowedValue;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.AbstractIterator;
+import org.apache.spark.TaskContext;
+import org.apache.spark.api.java.function.FlatMapGroupsFunction;
+import org.apache.spark.util.TaskCompletionListener;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import scala.Tuple2;
+
+/**
+ * Runs a stateful {@link DoFn} over the key groups of a {@code
flatMapSortedGroups}, where the
+ * elements of each group are already ordered by event time.
+ *
+ * <p>State is a plain heap object scoped to the key being processed and is
dropped when that key is
+ * done. This is what makes batch state cheap: there is no state store to
bridge onto, so every Beam
+ * state type is already implemented by {@link InMemoryStateInternals}. Memory
is bounded by the
+ * state a single key holds, not by the number of elements that key received.
+ *
+ * <p>The {@link DoFn} is set up <b>once per task</b> and torn down from a
task completion listener,
+ * not once per key. Spark calls {@link #call} per key group, but {@code
@Setup}/{@code @Teardown}
+ * bracket the lifetime of the {@link DoFn} instance, and there is a single
instance per
+ * deserialized closure, so tearing it down between keys would violate the
contract that no method
+ * runs after {@code @Teardown} (and would re-run expensive setup for every
key). Each key gets its
+ * own <em>bundle</em>, which is the level the model does allow to vary, and
its own state and
+ * timers via {@link MutableStepContext}.
+ *
+ * <p>Outputs are pulled lazily: the {@link DoFn} pushes into a buffer and the
returned iterator
+ * drains it, advancing the input only when the buffer runs dry, so neither a
key with many elements
+ * nor one with many timers is ever materialized.
+ */
+abstract class StatefulDoFnGroupFunction<K, InT extends KV<K, ?>, OutT>
+ implements FlatMapGroupsFunction<K, WindowedValue<InT>, OutT> {
+
+ private final Supplier<PipelineOptions> options;
+ private final MetricsAccumulator metrics;
+ private final DoFnRunnerFactory<InT, ?> factory;
+
+ private transient @Nullable Deque<OutT> buffer;
+ private transient @Nullable MutableStepContext stepContext;
+ private transient @Nullable DoFnRunnerWithTeardown<InT, ?> doFnRunner;
+ private transient boolean needsBundleStart;
+ private transient boolean isTornDown;
+
+ private StatefulDoFnGroupFunction(
+ Supplier<PipelineOptions> options,
+ MetricsAccumulator metrics,
+ DoFnRunnerFactory<InT, ?> factory) {
+ this.options = options;
+ this.metrics = metrics;
+ this.factory = factory;
+ }
+
+ /**
+ * {@link StatefulDoFnGroupFunction} emitting a single output of type {@link
WindowedValue} of
+ * {@link FnOutT}.
+ */
+ static <K, InT extends KV<K, ?>, FnOutT>
+ StatefulDoFnGroupFunction<K, InT, WindowedValue<FnOutT>> singleOutput(
+ Supplier<PipelineOptions> options,
+ MetricsAccumulator metrics,
+ DoFnRunnerFactory<InT, FnOutT> factory) {
+ return new SingleOut<>(options, metrics, factory);
+ }
+
+ /**
+ * {@link StatefulDoFnGroupFunction} emitting multiple outputs encoded as
tuple of column index
+ * and {@link WindowedValue} of {@link OutT}, where column index corresponds
to the index of a
+ * {@link TupleTag#getId()} in {@code tagColIdx}.
+ */
+ static <K, InT extends KV<K, ?>, FnOutT, OutT>
+ StatefulDoFnGroupFunction<K, InT, Tuple2<Integer, WindowedValue<OutT>>>
multiOutput(
+ Supplier<PipelineOptions> options,
+ MetricsAccumulator metrics,
+ DoFnRunnerFactory<InT, FnOutT> factory,
+ Map<String, Integer> tagColIdx) {
+ return new MultiOut<>(options, metrics, factory, tagColIdx);
+ }
+
+ @Override
+ public Iterator<OutT> call(K key, Iterator<WindowedValue<InT>> values) {
+ DoFnRunnerWithTeardown<InT, ?> runner = runner();
+ // Fresh state and timers for this key; the DoFn instance itself is
untouched.
+ stepContext().reset(key);
+ if (needsBundleStart) {
+ needsBundleStart = false;
+ runner.startBundle();
+ }
+ return new StatefulGroupIt(key, values, runner);
+ }
+
+ /**
+ * The runner for this task, created on first use. {@code factory.create}
invokes {@code @Setup}
+ * and opens the first bundle, so this happens exactly once per task rather
than once per key.
+ */
+ private DoFnRunnerWithTeardown<InT, ?> runner() {
+ DoFnRunnerWithTeardown<InT, ?> runner = doFnRunner;
+ if (runner == null) {
+ MutableStepContext ctx = new MutableStepContext();
+ Deque<OutT> buf = new ArrayDeque<>();
+ buffer = buf;
+ stepContext = ctx;
+ runner = factory.create(options.get(), metrics, outputManager(buf), ctx);
+ doFnRunner = runner;
+ // Spark is free to abandon an iterator part way through (a downstream
limit, a task kill, an
+ // exception elsewhere in the stage). Tearing down from the task
completion listener is the
+ // only way to guarantee @Teardown runs and DoFn resources are released.
+ TaskContext taskContext = TaskContext.get();
+ if (taskContext != null) {
+ // An explicit listener rather than a lambda: TaskContext overloads
this for both the Scala
+ // function and the Java interface, so a lambda is ambiguous.
+ taskContext.addTaskCompletionListener(
+ new TaskCompletionListener() {
+ @Override
+ public void onTaskCompletion(TaskContext context) {
+ teardownOnce();
+ }
+ });
+ }
+ }
+ return runner;
+ }
+
+ private MutableStepContext stepContext() {
+ MutableStepContext ctx = stepContext;
+ if (ctx == null) {
+ throw new IllegalStateException("StepContext requested before the runner
was created");
+ }
+ return ctx;
+ }
+
+ private Deque<OutT> buffer() {
+ Deque<OutT> buf = buffer;
+ if (buf == null) {
+ throw new IllegalStateException("Buffer requested before the runner was
created");
+ }
+ return buf;
+ }
+
+ private void teardownOnce() {
+ DoFnRunnerWithTeardown<InT, ?> runner = doFnRunner;
+ if (runner != null && !isTornDown) {
+ isTornDown = true;
+ runner.teardown();
+ }
+ }
+
+ /** Output manager emitting outputs of type {@link OutT} to the buffer. */
+ abstract WindowedValueMultiReceiver outputManager(Deque<OutT> buffer);
+
+ /**
+ * {@link StatefulDoFnGroupFunction} emitting a single output of type {@link
WindowedValue} of
+ * {@link FnOutT}.
+ */
+ private static class SingleOut<K, InT extends KV<K, ?>, FnOutT>
+ extends StatefulDoFnGroupFunction<K, InT, WindowedValue<FnOutT>> {
+ private SingleOut(
+ Supplier<PipelineOptions> options,
+ MetricsAccumulator metrics,
+ DoFnRunnerFactory<InT, FnOutT> factory) {
+ super(options, metrics, factory);
+ }
+
+ @Override
+ WindowedValueMultiReceiver outputManager(Deque<WindowedValue<FnOutT>>
buffer) {
+ return new WindowedValueMultiReceiver() {
+ @Override
+ public <T> void output(TupleTag<T> tag, WindowedValue<T> output) {
+ buffer.add((WindowedValue<FnOutT>) output);
+ }
+ };
+ }
+ }
+
+ /**
+ * {@link StatefulDoFnGroupFunction} emitting multiple outputs encoded as
tuple of column index
+ * and {@link WindowedValue} of {@link OutT}, where column index corresponds
to the index of a
+ * {@link TupleTag#getId()} in {@link #tagColIdx}.
+ */
+ private static class MultiOut<K, InT extends KV<K, ?>, FnOutT, OutT>
+ extends StatefulDoFnGroupFunction<K, InT, Tuple2<Integer,
WindowedValue<OutT>>> {
+ private final Map<String, Integer> tagColIdx;
+
+ private MultiOut(
+ Supplier<PipelineOptions> options,
+ MetricsAccumulator metrics,
+ DoFnRunnerFactory<InT, FnOutT> factory,
+ Map<String, Integer> tagColIdx) {
+ super(options, metrics, factory);
+ this.tagColIdx = tagColIdx;
+ }
+
+ @Override
+ WindowedValueMultiReceiver outputManager(Deque<Tuple2<Integer,
WindowedValue<OutT>>> buffer) {
+ return new WindowedValueMultiReceiver() {
+ @Override
+ public <T> void output(TupleTag<T> tag, WindowedValue<T> output) {
+ // Additional unused outputs can be skipped here. In that case
columnIdx is null.
+ Integer columnIdx = tagColIdx.get(tag.getId());
+ if (columnIdx != null) {
+ buffer.add(tuple(columnIdx, (WindowedValue<OutT>) output));
+ }
+ }
+ };
+ }
+ }
+
+ /**
+ * A {@link StepContext} whose state and timers are swapped per key, so that
one {@link DoFn} and
+ * one {@link org.apache.beam.runners.core.DoFnRunner DoFnRunner} can serve
every key of a task.
+ *
+ * <p>{@code SimpleDoFnRunner} re-reads {@code stateInternals()} on each
access rather than
+ * caching it, which is what makes rebinding safe.
+ */
+ private static class MutableStepContext implements StepContext {
+ private @Nullable StateInternals stateInternals;
+ private @Nullable InMemoryTimerInternals timerInternals;
+
+ void reset(@Nullable Object key) {
+ stateInternals = InMemoryStateInternals.forKey(key);
+ timerInternals = new InMemoryTimerInternals();
+ }
+
+ InMemoryTimerInternals timers() {
+ InMemoryTimerInternals timers = timerInternals;
+ if (timers == null) {
+ throw new IllegalStateException("StepContext used before reset");
+ }
+ return timers;
+ }
+
+ @Override
+ public StateInternals stateInternals() {
+ StateInternals state = stateInternals;
+ if (state == null) {
+ throw new IllegalStateException("StepContext used before reset");
+ }
+ return state;
+ }
+
+ @Override
+ public TimerInternals timerInternals() {
+ return timers();
+ }
+ }
+
+ private class StatefulGroupIt extends AbstractIterator<OutT> {
+ private final Iterator<WindowedValue<InT>> groupIt;
+ private final K key;
+ private final DoFnRunnerWithTeardown<InT, ?> runner;
+ private final InMemoryTimerInternals timerInternals;
+
+ private boolean areTimersDrained;
+ private boolean clocksAdvanced;
+ private boolean isBundleFinished;
+
+ private StatefulGroupIt(
+ K key, Iterator<WindowedValue<InT>> groupIt,
DoFnRunnerWithTeardown<InT, ?> runner) {
+ this.key = key;
+ this.groupIt = groupIt;
+ this.runner = runner;
+ this.timerInternals = stepContext().timers();
+ }
+
+ @Override
+ protected @CheckForNull OutT computeNext() {
+ Deque<OutT> buffer = buffer();
+ try {
+ while (true) {
+ if (!buffer.isEmpty()) {
+ return buffer.remove();
+ }
+ if (groupIt.hasNext()) {
+ runner.processElement(groupIt.next());
+ } else if (!areTimersDrained) {
+ // Timers fire while the bundle is still open (the model processes
a key's timers
+ // before finishBundle) and one at a time, so their output is
pulled lazily too.
+ areTimersDrained = !fireNextTimer();
+ } else if (!isBundleFinished) {
+ isBundleFinished = true;
+ needsBundleStart = true; // the next key opens a fresh bundle
+ runner.finishBundle(); // may produce more output
+ } else {
+ return endOfData(); // teardown is task scoped, not per key
+ }
+ }
+ } catch (RuntimeException re) {
+ teardownOnce();
+ throw re;
+ } catch (Exception e) {
+ teardownOnce();
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Fires at most one pending timer, returning whether one fired.
+ *
+ * <p>Polled once per {@code computeNext} rather than drained in a loop
for two reasons. An
+ * {@code OnTimer} method may set further event time timers, and those
must fire too: draining a
+ * snapshot silently truncates timer chains, the failure mode recorded for
the RDD based runner
+ * in <a
href="https://issues.apache.org/jira/browse/BEAM-12712">BEAM-12712</a>. And
firing one
+ * at a time keeps a timer heavy key from having to buffer all of its
output at once.
+ *
+ * <p>The clocks only need advancing once: {@code removeNext*} reads them
live. As both clocks
+ * are pinned at {@code TIMESTAMP_MAX_VALUE}, a processing time timer
re-armed from {@code
+ * OnTimer} targets a time past the pinned clock and never becomes
eligible; batch mode makes no
+ * processing time guarantees (the RDD based runner drains the same way).
+ */
+ private boolean fireNextTimer() throws Exception {
+ if (!clocksAdvanced) {
+ clocksAdvanced = true;
+
timerInternals.advanceInputWatermark(BoundedWindow.TIMESTAMP_MAX_VALUE);
+
timerInternals.advanceProcessingTime(BoundedWindow.TIMESTAMP_MAX_VALUE);
+
timerInternals.advanceSynchronizedProcessingTime(BoundedWindow.TIMESTAMP_MAX_VALUE);
+ }
+ TimerData timer = nextTimer();
+ if (timer == null) {
+ return false;
+ }
+ fire(timer);
+ return true;
+ }
+
+ private @Nullable TimerData nextTimer() {
+ TimerData timer = timerInternals.removeNextEventTimer();
+ if (timer == null) {
+ timer = timerInternals.removeNextProcessingTimer();
+ }
+ if (timer == null) {
+ timer = timerInternals.removeNextSynchronizedProcessingTimer();
+ }
+ return timer;
+ }
+
+ private void fire(TimerData timer) {
+ BoundedWindow window =
+ ((StateNamespaces.WindowNamespace<?>)
timer.getNamespace()).getWindow();
+ runner.onTimer(
+ timer.getTimerId(),
+ timer.getTimerFamilyId(),
+ key,
+ window,
+ timer.getTimestamp(),
+ timer.getOutputTimestamp(),
+ timer.getDomain(),
+ CausedByDrain.NORMAL);
+ }
+ }
+}
diff --git
a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulParDoTranslatorBatch.java
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulParDoTranslatorBatch.java
new file mode 100644
index 00000000000..75d84b630bb
--- /dev/null
+++
b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulParDoTranslatorBatch.java
@@ -0,0 +1,282 @@
+/*
+ * 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.beam.runners.spark.structuredstreaming.translation.batch;
+
+import static
org.apache.beam.runners.spark.structuredstreaming.translation.helpers.EncoderHelpers.oneOfEncoder;
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
+import static
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState;
+import static org.apache.spark.sql.functions.col;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.apache.beam.runners.core.SideInputReader;
+import org.apache.beam.runners.spark.SparkCommonPipelineOptions;
+import
org.apache.beam.runners.spark.structuredstreaming.metrics.MetricsAccumulator;
+import
org.apache.beam.runners.spark.structuredstreaming.translation.TransformTranslator;
+import
org.apache.beam.runners.spark.structuredstreaming.translation.batch.functions.SparkSideInputReader;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.reflect.DoFnSignature;
+import org.apache.beam.sdk.transforms.reflect.DoFnSignatures;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionTuple;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.sdk.values.WindowedValue;
+import org.apache.beam.sdk.values.WindowingStrategy;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
+import org.apache.spark.api.java.function.FlatMapGroupsFunction;
+import org.apache.spark.sql.Column;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Encoder;
+import org.apache.spark.sql.KeyValueGroupedDataset;
+import org.apache.spark.sql.TypedColumn;
+import org.apache.spark.storage.StorageLevel;
+import scala.Tuple2;
+
+/**
+ * Translator for a stateful {@link ParDo.MultiOutput}, or one requiring time
sorted input.
+ *
+ * <p>Selected by {@link PipelineTranslatorBatch} in place of {@link
ParDoTranslatorBatch} when the
+ * {@link DoFn} uses state, uses timers, or is annotated with {@link
DoFn.RequiresTimeSortedInput};
+ * see {@link #appliesTo}.
+ *
+ * <p>Unlike {@link ParDoTranslatorBatch} this translator never produces an
{@code
+ * UnresolvedTranslation}: a stateful {@link DoFn} must not be fused with
neighbouring {@link ParDo
+ * ParDos}, because the fused runner cannot drive timers. Resolving the input
dataset via {@code
+ * Context#getDataset} breaks any pending fusion chain.
+ *
+ * <p>Additional (tagged) outputs are encoded as one column per tag, as in
{@link
+ * ParDoTranslatorBatch}.
+ */
+class StatefulParDoTranslatorBatch<K, V, OutputT>
+ extends TransformTranslator<
+ PCollection<? extends KV<K, V>>, PCollectionTuple,
ParDo.MultiOutput<KV<K, V>, OutputT>> {
+
+ StatefulParDoTranslatorBatch() {
+ // A stateful ParDo introduces a shuffle to co-locate and order each key,
so it contributes to
+ // plan complexity much like GroupByKey rather than like a plain ParDo.
+ super(0.2f);
+ }
+
+ /**
+ * Whether {@code transform} must be translated by this translator rather
than {@link
+ * ParDoTranslatorBatch}.
+ *
+ * <p>Note {@link DoFn.RequiresTimeSortedInput} is tested independently of
state: the SDK only
+ * treats state and timers as making a {@link DoFn} stateful, so a {@code
DoFn} carrying only that
+ * annotation reaches the runner with neither signature flag set.
+ */
+ static boolean appliesTo(ParDo.MultiOutput<?, ?> transform) {
+ DoFnSignature signature =
DoFnSignatures.signatureForDoFn(transform.getFn());
+ return signature.usesState()
+ || signature.usesTimers()
+ || signature.processElement().requiresTimeSortedInput();
+ }
+
+ @Override
+ protected boolean canTranslate(ParDo.MultiOutput<KV<K, V>, OutputT>
transform) {
+ DoFn<KV<K, V>, OutputT> doFn = transform.getFn();
+ DoFnSignature signature = DoFnSignatures.signatureForDoFn(doFn);
+
+ checkState(
+ appliesTo(transform),
+ "Not a stateful or time sorted DoFn, should have been translated by
%s: %s",
+ ParDoTranslatorBatch.class.getSimpleName(),
+ doFn);
+
+ checkState(
+ isSupported(),
+ "Stateful and time sorted ParDo require Spark 3.4+ "
+ + "(KeyValueGroupedDataset#flatMapSortedGroups): %s",
+ doFn);
+
+ checkState(
+ !signature.processElement().isSplittable(),
+ "Not expected to directly translate splittable DoFn, should have been
overridden: %s",
+ doFn);
+
+ // Not implemented: firing @OnWindowExpiration requires tracking the
windows observed per key
+ // and a dedicated firing pass at the end of each key, see
+ // https://github.com/apache/beam/issues/22524
+ checkState(
+ signature.onWindowExpiration() == null, "onWindowExpiration is not
supported: %s", doFn);
+
+
SparkSideInputReader.validateMaterializations(transform.getSideInputs().values());
+ return true;
+ }
+
+ @Override
+ protected void translate(ParDo.MultiOutput<KV<K, V>, OutputT> transform,
Context cxt)
+ throws IOException {
+ PCollection<KV<K, V>> input = (PCollection<KV<K, V>>) cxt.getInput();
+
+ validateKeyCoder(input.getCoder(), transform.getFn());
+ validateWindowingStrategy(input.getWindowingStrategy(), transform.getFn());
+
+ TupleTag<OutputT> mainOut = transform.getMainOutputTag();
+ // Filter out obsolete PCollections to only cache when absolutely necessary
+ Map<TupleTag<?>, PCollection<?>> outputs =
+ ParDoTranslatorBatch.skipUnconsumedOutputs(
+ cxt.getOutputs(), mainOut, transform.getAdditionalOutputTags(),
cxt);
+
+ KvCoder<K, V> inputCoder = (KvCoder<K, V>) input.getCoder();
+ Encoder<K> keyEnc = cxt.keyEncoderOf(inputCoder);
+ MetricsAccumulator metrics =
MetricsAccumulator.getInstance(cxt.getSparkSession());
+ SideInputReader sideInputReader =
+
ParDoTranslatorBatch.createSideInputReader(transform.getSideInputs().values(),
cxt);
+
+ // Group by key, then order each group by event time before handing it to
the DoFn. The
+ // timestamp is a top level LongType column of the WindowedValue encoder
(epoch millis), so
+ // ordering is plain signed numeric ordering; no composite sort key is
needed. Nulls sort
+ // last: a null timestamp encodes END_OF_WINDOW (see
GroupByKeyTranslatorBatch), which no
+ // concrete timestamp of the same window can exceed. Only null and
concrete timestamps of
+ // different windows mixed into one key group may still order imprecisely;
deriving the
+ // timestamp from the window column is not portable across Spark versions.
+ Column[] sortCols = new Column[] {col(TIMESTAMP_COLUMN).asc_nulls_last()};
+
+ if (outputs.size() > 1) {
+ // In case of multiple outputs / tags, map each tag to a column by index.
+ // At the end split the result into multiple datasets selecting one
column each.
+ Map<String, Integer> tagColIdx =
ParDoTranslatorBatch.tagsColumnIndex(outputs.keySet());
+ List<Encoder<WindowedValue<Object>>> encoders =
+ ParDoTranslatorBatch.createEncoders(outputs, tagColIdx, cxt);
+
+ DoFnRunnerFactory<KV<K, V>, OutputT> runnerFactory =
+ DoFnRunnerFactory.simple(cxt.getCurrentTransform(), input,
sideInputReader, false);
+ StatefulDoFnGroupFunction<K, KV<K, V>, Tuple2<Integer,
WindowedValue<Object>>> groupFn =
+ StatefulDoFnGroupFunction.multiOutput(
+ cxt.getOptionsSupplier(), metrics, runnerFactory, tagColIdx);
+
+ SparkCommonPipelineOptions opts =
cxt.getOptions().as(SparkCommonPipelineOptions.class);
+ StorageLevel storageLevel =
StorageLevel.fromString(opts.getStorageLevel());
+
+ // Persist as wide rows with one column per TupleTag to support
different schemas
+ Dataset<Tuple2<Integer, WindowedValue<Object>>> allTagsDS =
+ cxt.getDataset(input)
+ .groupByKey(GroupByKeyHelpers.valueKey(), keyEnc)
+ .flatMapSortedGroups(sortCols, groupFn, oneOfEncoder(encoders));
+ allTagsDS.persist(storageLevel);
+
+ // divide into separate output datasets per tag
+ for (TupleTag<?> tag : outputs.keySet()) {
+ int colIdx = checkStateNotNull(tagColIdx.get(tag.getId()), "Unknown
tag");
+ // Resolve specific column matching the tuple tag (by id)
+ TypedColumn<Tuple2<Integer, WindowedValue<Object>>,
WindowedValue<Object>> col =
+ (TypedColumn)
col(Integer.toString(colIdx)).as(encoders.get(colIdx));
+
+ // Caching of the returned outputs is disabled to avoid caching the
same data twice.
+ cxt.putDataset(
+ cxt.getOutput((TupleTag) tag),
allTagsDS.filter(col.isNotNull()).select(col), false);
+ }
+ } else {
+ PCollection<OutputT> output = cxt.getOutput(mainOut);
+ // Obsolete outputs might have to be filtered out
+ boolean filterMainOutput = cxt.getOutputs().size() > 1;
+ DoFnRunnerFactory<KV<K, V>, OutputT> runnerFactory =
+ DoFnRunnerFactory.simple(
+ cxt.getCurrentTransform(), input, sideInputReader,
filterMainOutput);
+ StatefulDoFnGroupFunction<K, KV<K, V>, WindowedValue<OutputT>> groupFn =
+ StatefulDoFnGroupFunction.singleOutput(cxt.getOptionsSupplier(),
metrics, runnerFactory);
+
+ Dataset<WindowedValue<OutputT>> result =
+ cxt.getDataset(input)
+ .groupByKey(GroupByKeyHelpers.valueKey(), keyEnc)
+ .flatMapSortedGroups(sortCols, groupFn,
cxt.windowedEncoder(output.getCoder()));
+
+ cxt.putDataset(output, result);
+ }
+ }
+
+ /** Field of the {@code WindowedValue} encoder holding the event time, as
epoch millis. */
+ private static final String TIMESTAMP_COLUMN = "timestamp";
+
+ private static final boolean SORTED_GROUPS_API_AVAILABLE =
sortedGroupsApiAvailable();
+
+ /**
+ * Whether this Spark version supports stateful / time sorted ParDo: the
required {@code
+ * flatMapSortedGroups(Column[], FlatMapGroupsFunction, Encoder)} only
exists since Spark 3.4.
+ */
+ static boolean isSupported() {
+ return SORTED_GROUPS_API_AVAILABLE;
+ }
+
+ private static boolean sortedGroupsApiAvailable() {
+ try {
+ KeyValueGroupedDataset.class.getMethod(
+ "flatMapSortedGroups", Column[].class, FlatMapGroupsFunction.class,
Encoder.class);
+ return true;
+ } catch (NoSuchMethodException e) {
+ return false;
+ }
+ }
+
+ /**
+ * A stateful {@link DoFn} is keyed, and this translator co-locates and
orders elements by the
+ * encoded key, so the key coder must be deterministic.
+ *
+ * <p>{@code ParDo} already enforces both of these for {@code DoFns} using
state or timers (see
+ * {@code ParDo.validateStateApplicableForInput}), but that validation is
skipped for a {@link
+ * DoFn} carrying only {@link DoFn.RequiresTimeSortedInput}, which still
reaches this translator.
+ * So it is checked here rather than assumed.
+ */
+ @VisibleForTesting
+ static void validateKeyCoder(Coder<?> coder, DoFn<?, ?> doFn) {
+ checkState(
+ coder instanceof KvCoder,
+ "Input to a stateful or time sorted ParDo requires a %s, but the coder
was %s: %s",
+ KvCoder.class.getSimpleName(),
+ coder,
+ doFn);
+
+ Coder<?> keyCoder = ((KvCoder<?, ?>) coder).getKeyCoder();
+ try {
+ keyCoder.verifyDeterministic();
+ } catch (Coder.NonDeterministicException e) {
+ throw new IllegalStateException(
+ String.format(
+ "Input to a stateful or time sorted ParDo requires a
deterministic key coder, "
+ + "but %s is not deterministic: %s",
+ keyCoder, doFn),
+ e);
+ }
+ }
+
+ /**
+ * State is scoped per key and window, which is only well defined if windows
are not still subject
+ * to merging.
+ *
+ * <p>This deliberately mirrors Dataflow's {@code
verifyStateSupportForWindowingStrategy} and
+ * tests {@link WindowingStrategy#needsMerge()} rather than {@code
WindowFn#isNonMerging()}: after
+ * a {@link org.apache.beam.sdk.transforms.GroupByKey GroupByKey} the
strategy keeps its merging
+ * {@code WindowFn} but is flagged as already merged, and such pipelines are
legal.
+ */
+ @VisibleForTesting
+ static void validateWindowingStrategy(
+ WindowingStrategy<?, ?> windowingStrategy, DoFn<?, ?> doFn) {
+ checkState(
+ !windowingStrategy.needsMerge(),
+ "Stateful and time sorted ParDo are not supported for merging windows,
"
+ + "state cannot be scoped to a window that may still merge.
WindowFn: %s, DoFn: %s",
+ windowingStrategy.getWindowFn(),
+ doFn);
+ }
+}
diff --git
a/runners/spark/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulParDoExecutionTest.java
b/runners/spark/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulParDoExecutionTest.java
new file mode 100644
index 00000000000..952f9c99a6b
--- /dev/null
+++
b/runners/spark/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulParDoExecutionTest.java
@@ -0,0 +1,357 @@
+/*
+ * 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.beam.runners.spark.structuredstreaming.translation.batch;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.beam.runners.spark.structuredstreaming.SparkSessionRule;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
+import org.apache.beam.sdk.coders.VarIntCoder;
+import org.apache.beam.sdk.state.StateSpec;
+import org.apache.beam.sdk.state.StateSpecs;
+import org.apache.beam.sdk.state.TimeDomain;
+import org.apache.beam.sdk.state.Timer;
+import org.apache.beam.sdk.state.TimerSpec;
+import org.apache.beam.sdk.state.TimerSpecs;
+import org.apache.beam.sdk.state.ValueState;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionTuple;
+import org.apache.beam.sdk.values.TimestampedValue;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.sdk.values.TupleTagList;
+import org.joda.time.Duration;
+import org.joda.time.Instant;
+import org.junit.Assume;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Execution tests for {@link StatefulParDoTranslatorBatch} / {@link
StatefulDoFnGroupFunction}:
+ * these run full pipelines, unlike {@link StatefulParDoTranslatorBatchTest}
which only covers
+ * dispatch and translation preconditions.
+ */
+@RunWith(JUnit4.class)
+public class StatefulParDoExecutionTest implements Serializable {
+ @ClassRule public static final SparkSessionRule SESSION = new
SparkSessionRule();
+
+ @BeforeClass
+ public static void requireSortedGroupsApi() {
+ Assume.assumeTrue(
+ "Stateful ParDo requires Spark 3.4+",
StatefulParDoTranslatorBatch.isSupported());
+ }
+
+ @Rule
+ public transient TestPipeline pipeline =
+ TestPipeline.fromOptions(SESSION.createPipelineOptions());
+
+ /** {@link ValueState} accumulates per key: totals must be scoped to the
key, not shared. */
+ @Test
+ public void testStatefulAccumulationPerKey() {
+ PCollection<KV<String, Integer>> result =
+ pipeline
+ .apply(
+ Create.timestamped(
+ TimestampedValue.of(KV.of("a", 1), sec(1)),
+ TimestampedValue.of(KV.of("a", 2), sec(2)),
+ TimestampedValue.of(KV.of("a", 3), sec(3)),
+ TimestampedValue.of(KV.of("b", 10), sec(1)),
+ TimestampedValue.of(KV.of("b", 20), sec(2))))
+ .apply(ParDo.of(new RunningSumDoFn()));
+
+ PAssert.that(result)
+ .containsInAnyOrder(
+ KV.of("a", 1), KV.of("a", 3), KV.of("a", 6), KV.of("b", 10),
KV.of("b", 30));
+ pipeline.run();
+ }
+
+ /**
+ * Many keys share the two shuffle partitions of the {@code local[2]}
session, so several keys are
+ * served in sequence by the same DoFn instance and {@code
MutableStepContext}. Each key's sums
+ * must be independent; any state bleeding between keys corrupts them.
+ */
+ @Test
+ public void testStateIsolationAcrossManyKeysInOnePartition() {
+ List<TimestampedValue<KV<String, Integer>>> input = new ArrayList<>();
+ List<KV<String, Integer>> expected = new ArrayList<>();
+ for (int i = 0; i < 60; i++) {
+ String key = "key-" + i;
+ input.add(TimestampedValue.of(KV.of(key, i), sec(1)));
+ input.add(TimestampedValue.of(KV.of(key, 1000 + i), sec(2)));
+ expected.add(KV.of(key, i));
+ expected.add(KV.of(key, 1000 + 2 * i));
+ }
+
+ PCollection<KV<String, Integer>> result =
+ pipeline.apply(Create.timestamped(input)).apply(ParDo.of(new
RunningSumDoFn()));
+
+ PAssert.that(result).containsInAnyOrder(expected);
+ pipeline.run();
+ }
+
+ /**
+ * Elements are created out of order but must reach a {@link
DoFn.RequiresTimeSortedInput} DoFn in
+ * ascending timestamp order. The DoFn appends each value to state and emits
the sequence so far,
+ * so the multiset of outputs pins the exact observation order.
+ */
+ @Test
+ public void testRequiresTimeSortedInput() {
+ PCollection<String> result =
+ pipeline
+ .apply(
+ Create.timestamped(
+ TimestampedValue.of(KV.of("k", 4), sec(4)),
+ TimestampedValue.of(KV.of("k", 1), sec(1)),
+ TimestampedValue.of(KV.of("k", 6), sec(6)),
+ TimestampedValue.of(KV.of("k", 3), sec(3)),
+ TimestampedValue.of(KV.of("k", 2), sec(2)),
+ TimestampedValue.of(KV.of("k", 5), sec(5))))
+ .apply(ParDo.of(new TimeSortedSequenceDoFn()));
+
+ PAssert.that(result)
+ .containsInAnyOrder("1", "1,2", "1,2,3", "1,2,3,4", "1,2,3,4,5",
"1,2,3,4,5,6");
+ pipeline.run();
+ }
+
+ /** An event time timer set in {@code @ProcessElement} must fire its {@code
@OnTimer}. */
+ @Test
+ public void testEventTimeTimerFires() {
+ PCollection<String> result =
+ pipeline
+ .apply(
+ Create.timestamped(
+ TimestampedValue.of(KV.of("k", 1), sec(1)),
+ TimestampedValue.of(KV.of("k", 2), sec(2))))
+ .apply(ParDo.of(new EventTimeTimerDoFn()));
+
+ PAssert.that(result).containsInAnyOrder("elem-1", "elem-2", "timer-fired");
+ pipeline.run();
+ }
+
+ /**
+ * An {@code @OnTimer} that re-sets its own timer must see every iteration
fire: draining a
+ * snapshot of pending timers silently truncates such chains (the failure
mode recorded for the
+ * RDD based runner in https://issues.apache.org/jira/browse/BEAM-12712).
+ */
+ @Test
+ public void testLoopingTimerFiresAllIterations() {
+ PCollection<String> result =
+ pipeline
+ .apply(Create.timestamped(TimestampedValue.of(KV.of("k", 1),
sec(1))))
+ .apply(ParDo.of(new LoopingTimerDoFn()));
+
+ PAssert.that(result).containsInAnyOrder("fire-1", "fire-2", "fire-3",
"fire-4", "fire-5");
+ pipeline.run();
+ }
+
+ /**
+ * Timers fire while the bundle is still open: a buffer flushed by {@code
@FinishBundle} must
+ * contain the {@code @OnTimer} contribution. Running {@code finishBundle}
before the timers
+ * silently drops the timer's data.
+ */
+ @Test
+ public void testFinishBundleFlushesTimerOutput() {
+ PCollection<String> result =
+ pipeline
+ .apply(
+ Create.timestamped(
+ TimestampedValue.of(KV.of("k", 1), sec(1)),
+ TimestampedValue.of(KV.of("k", 2), sec(2))))
+ .apply(ParDo.of(new BufferUntilFinishBundleDoFn()));
+
+ PAssert.that(result).containsInAnyOrder("elem-1", "elem-2", "timer");
+ pipeline.run();
+ }
+
+ /**
+ * A stateful {@link DoFn} with additional (tagged) outputs: per element the
running sum goes to
+ * the main output while even values are also emitted to the additional
output.
+ */
+ @Test
+ public void testTaggedAdditionalOutput() {
+ TupleTag<KV<String, Integer>> sums = new TupleTag<KV<String, Integer>>()
{};
+ TupleTag<Integer> evens = new TupleTag<Integer>() {};
+
+ PCollectionTuple result =
+ pipeline
+ .apply(
+ Create.timestamped(
+ TimestampedValue.of(KV.of("a", 1), sec(1)),
+ TimestampedValue.of(KV.of("a", 2), sec(2)),
+ TimestampedValue.of(KV.of("b", 4), sec(1))))
+ .apply(
+ ParDo.of(new RunningSumWithEvensDoFn(evens))
+ .withOutputTags(sums, TupleTagList.of(evens)));
+
+ PAssert.that(result.get(sums)).containsInAnyOrder(KV.of("a", 1),
KV.of("a", 3), KV.of("b", 4));
+ PAssert.that(result.get(evens)).containsInAnyOrder(2, 4);
+ pipeline.run();
+ }
+
+ private static Instant sec(long seconds) {
+ return new Instant(seconds * 1000);
+ }
+
+ /** Emits the per key running sum for every element. */
+ private static class RunningSumDoFn extends DoFn<KV<String, Integer>,
KV<String, Integer>> {
+ @StateId("sum")
+ private final StateSpec<ValueState<Integer>> sumSpec =
StateSpecs.value(VarIntCoder.of());
+
+ @ProcessElement
+ public void processElement(ProcessContext c, @StateId("sum")
ValueState<Integer> sum) {
+ Integer current = sum.read();
+ int newSum = (current == null ? 0 : current) + c.element().getValue();
+ sum.write(newSum);
+ c.output(KV.of(c.element().getKey(), newSum));
+ }
+ }
+
+ /** Emits the per key running sum to the main output and even values to
{@code evens}. */
+ private static class RunningSumWithEvensDoFn
+ extends DoFn<KV<String, Integer>, KV<String, Integer>> {
+ private final TupleTag<Integer> evens;
+
+ @StateId("sum")
+ private final StateSpec<ValueState<Integer>> sumSpec =
StateSpecs.value(VarIntCoder.of());
+
+ RunningSumWithEvensDoFn(TupleTag<Integer> evens) {
+ this.evens = evens;
+ }
+
+ @ProcessElement
+ public void processElement(ProcessContext c, @StateId("sum")
ValueState<Integer> sum) {
+ Integer current = sum.read();
+ int value = c.element().getValue();
+ int newSum = (current == null ? 0 : current) + value;
+ sum.write(newSum);
+ c.output(KV.of(c.element().getKey(), newSum));
+ if (value % 2 == 0) {
+ c.output(evens, value);
+ }
+ }
+ }
+
+ /** Appends each value to state and emits the sequence observed so far. */
+ private static class TimeSortedSequenceDoFn extends DoFn<KV<String,
Integer>, String> {
+ @StateId("seen")
+ private final StateSpec<ValueState<String>> seenSpec =
StateSpecs.value(StringUtf8Coder.of());
+
+ @RequiresTimeSortedInput
+ @ProcessElement
+ public void processElement(ProcessContext c, @StateId("seen")
ValueState<String> seen) {
+ String previous = seen.read();
+ String sequence =
+ previous == null
+ ? c.element().getValue().toString()
+ : previous + "," + c.element().getValue();
+ seen.write(sequence);
+ c.output(sequence);
+ }
+ }
+
+ /** Sets one event time timer (re-set by each element, so it fires once). */
+ private static class EventTimeTimerDoFn extends DoFn<KV<String, Integer>,
String> {
+ @TimerId("timer")
+ private final TimerSpec timerSpec =
TimerSpecs.timer(TimeDomain.EVENT_TIME);
+
+ @ProcessElement
+ public void processElement(ProcessContext c, @TimerId("timer") Timer
timer) {
+ c.output("elem-" + c.element().getValue());
+ timer.set(c.timestamp().plus(Duration.standardSeconds(10)));
+ }
+
+ @OnTimer("timer")
+ public void onTimer(OnTimerContext c) {
+ c.output("timer-fired");
+ }
+ }
+
+ /** A bounded looping timer: each firing re-sets the timer until five have
fired. */
+ private static class LoopingTimerDoFn extends DoFn<KV<String, Integer>,
String> {
+ @TimerId("loop")
+ private final TimerSpec loopSpec = TimerSpecs.timer(TimeDomain.EVENT_TIME);
+
+ @StateId("fires")
+ private final StateSpec<ValueState<Integer>> firesSpec =
StateSpecs.value(VarIntCoder.of());
+
+ @ProcessElement
+ public void processElement(ProcessContext c, @TimerId("loop") Timer loop) {
+ loop.set(c.timestamp().plus(Duration.standardSeconds(1)));
+ }
+
+ @OnTimer("loop")
+ public void onTimer(
+ OnTimerContext c,
+ @TimerId("loop") Timer loop,
+ @StateId("fires") ValueState<Integer> fires) {
+ Integer current = fires.read();
+ int fired = (current == null ? 0 : current) + 1;
+ fires.write(fired);
+ c.output("fire-" + fired);
+ if (fired < 5) {
+ loop.set(c.fireTimestamp().plus(Duration.standardSeconds(1)));
+ }
+ }
+ }
+
+ /**
+ * Buffers in the instance across {@code @ProcessElement} and {@code
@OnTimer} and only outputs
+ * from {@code @FinishBundle}.
+ */
+ private static class BufferUntilFinishBundleDoFn extends DoFn<KV<String,
Integer>, String> {
+ @TimerId("flush")
+ private final TimerSpec flushSpec =
TimerSpecs.timer(TimeDomain.EVENT_TIME);
+
+ private transient List<String> buffer;
+
+ @StartBundle
+ public void startBundle() {
+ buffer = new ArrayList<>();
+ }
+
+ @ProcessElement
+ public void processElement(ProcessContext c, @TimerId("flush") Timer
flush) {
+ buffer.add("elem-" + c.element().getValue());
+ flush.set(c.timestamp().plus(Duration.standardSeconds(10)));
+ }
+
+ @OnTimer("flush")
+ public void onTimer() {
+ buffer.add("timer");
+ }
+
+ @FinishBundle
+ public void finishBundle(FinishBundleContext c) {
+ for (String value : buffer) {
+ c.output(value, GlobalWindow.INSTANCE.maxTimestamp(),
GlobalWindow.INSTANCE);
+ }
+ buffer = new ArrayList<>();
+ }
+ }
+}
diff --git
a/runners/spark/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulParDoTranslatorBatchTest.java
b/runners/spark/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulParDoTranslatorBatchTest.java
new file mode 100644
index 00000000000..59f32b2a90f
--- /dev/null
+++
b/runners/spark/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/StatefulParDoTranslatorBatchTest.java
@@ -0,0 +1,261 @@
+/*
+ * 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.beam.runners.spark.structuredstreaming.translation.batch;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import
org.apache.beam.runners.spark.structuredstreaming.translation.TransformTranslator;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.CustomCoder;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
+import org.apache.beam.sdk.coders.VarIntCoder;
+import org.apache.beam.sdk.state.StateSpec;
+import org.apache.beam.sdk.state.StateSpecs;
+import org.apache.beam.sdk.state.TimeDomain;
+import org.apache.beam.sdk.state.Timer;
+import org.apache.beam.sdk.state.TimerSpec;
+import org.apache.beam.sdk.state.TimerSpecs;
+import org.apache.beam.sdk.state.ValueState;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.windowing.FixedWindows;
+import org.apache.beam.sdk.transforms.windowing.Sessions;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.sdk.values.TupleTagList;
+import org.apache.beam.sdk.values.WindowingStrategy;
+import org.joda.time.Duration;
+import org.junit.Assume;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests dispatch to {@link StatefulParDoTranslatorBatch} and its translation
preconditions.
+ *
+ * <p>These deliberately avoid {@code TestPipeline}: the behaviour under test
is translator
+ * selection and validation, both of which are decided before any Spark
session exists.
+ */
+@RunWith(JUnit4.class)
+public class StatefulParDoTranslatorBatchTest {
+
+ private static final KvCoder<String, Integer> KV_CODER =
+ KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of());
+
+ @BeforeClass
+ public static void requireSortedGroupsApi() {
+ Assume.assumeTrue(
+ "Stateful ParDo requires Spark 3.4+",
StatefulParDoTranslatorBatch.isSupported());
+ }
+
+ //
--------------------------------------------------------------------------------------------
+ // Dispatch
+ //
--------------------------------------------------------------------------------------------
+
+ @Test
+ public void appliesToStatefulDoFn() {
+ assertTrue(StatefulParDoTranslatorBatch.appliesTo(multiOutput(new
StatefulDoFn())));
+ }
+
+ @Test
+ public void appliesToTimerDoFn() {
+ assertTrue(StatefulParDoTranslatorBatch.appliesTo(multiOutput(new
TimerDoFn())));
+ }
+
+ /**
+ * A {@link DoFn} carrying only {@link DoFn.RequiresTimeSortedInput} is not
considered stateful by
+ * the SDK, so dispatch must test the annotation separately from {@code
usesState}/{@code
+ * usesTimers}.
+ */
+ @Test
+ public void appliesToTimeSortedOnlyDoFn() {
+ assertTrue(StatefulParDoTranslatorBatch.appliesTo(multiOutput(new
TimeSortedOnlyDoFn())));
+ }
+
+ @Test
+ public void doesNotApplyToPlainDoFn() {
+ assertFalse(StatefulParDoTranslatorBatch.appliesTo(multiOutput(new
PlainDoFn())));
+ }
+
+ @Test
+ public void registryRoutesStatefulDoFnToStatefulTranslator() {
+ TransformTranslator<?, ?, ?> translator =
+ new PipelineTranslatorBatch().getTransformTranslator(multiOutput(new
StatefulDoFn()));
+ assertTrue(
+ "Expected StatefulParDoTranslatorBatch but got " + translator,
+ translator instanceof StatefulParDoTranslatorBatch);
+ }
+
+ @Test
+ public void registryRoutesPlainDoFnToParDoTranslator() {
+ TransformTranslator<?, ?, ?> translator =
+ new PipelineTranslatorBatch().getTransformTranslator(multiOutput(new
PlainDoFn()));
+ assertTrue(
+ "Expected ParDoTranslatorBatch but got " + translator,
+ translator instanceof ParDoTranslatorBatch);
+ }
+
+ //
--------------------------------------------------------------------------------------------
+ // Windowing precondition
+ //
--------------------------------------------------------------------------------------------
+
+ @Test
+ public void rejectsMergingWindows() {
+ WindowingStrategy<?, ?> merging =
+
WindowingStrategy.of(Sessions.withGapDuration(Duration.standardMinutes(1)));
+
+ IllegalStateException thrown =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ StatefulParDoTranslatorBatch.validateWindowingStrategy(
+ merging, new StatefulDoFn()));
+ assertTrue(thrown.getMessage(), thrown.getMessage().contains("merging
windows"));
+ }
+
+ /**
+ * After a {@code GroupByKey} the strategy keeps its merging {@link
Sessions} {@code WindowFn} but
+ * is flagged as already merged. Such pipelines are legal, so the
precondition must test {@code
+ * needsMerge()} rather than {@code WindowFn#isNonMerging()}.
+ */
+ @Test
+ public void acceptsWindowsAlreadyMerged() {
+ WindowingStrategy<?, ?> alreadyMerged =
+
WindowingStrategy.of(Sessions.withGapDuration(Duration.standardMinutes(1)))
+ .withAlreadyMerged(true);
+
+ assertFalse("precondition of this test",
alreadyMerged.getWindowFn().isNonMerging());
+ StatefulParDoTranslatorBatch.validateWindowingStrategy(alreadyMerged, new
StatefulDoFn());
+ }
+
+ @Test
+ public void acceptsNonMergingWindows() {
+ StatefulParDoTranslatorBatch.validateWindowingStrategy(
+ WindowingStrategy.of(FixedWindows.of(Duration.standardMinutes(1))),
new StatefulDoFn());
+ }
+
+ //
--------------------------------------------------------------------------------------------
+ // Key coder precondition
+ //
--------------------------------------------------------------------------------------------
+
+ @Test
+ public void acceptsDeterministicKeyCoder() {
+ StatefulParDoTranslatorBatch.validateKeyCoder(KV_CODER, new
StatefulDoFn());
+ }
+
+ @Test
+ public void rejectsNonKvCoder() {
+ IllegalStateException thrown =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ StatefulParDoTranslatorBatch.validateKeyCoder(
+ StringUtf8Coder.of(), new TimeSortedOnlyDoFn()));
+ assertTrue(thrown.getMessage(), thrown.getMessage().contains("KvCoder"));
+ }
+
+ /**
+ * {@code ParDo} only validates the key coder for {@code DoFns} using state
or timers, so a time
+ * sorted only {@code DoFn} can reach the translator with a
non-deterministic key coder.
+ */
+ @Test
+ public void rejectsNonDeterministicKeyCoder() {
+ Coder<KV<String, Integer>> nonDeterministic =
+ KvCoder.of(new NonDeterministicStringCoder(), VarIntCoder.of());
+
+ IllegalStateException thrown =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ StatefulParDoTranslatorBatch.validateKeyCoder(
+ nonDeterministic, new TimeSortedOnlyDoFn()));
+ assertTrue(thrown.getMessage(),
thrown.getMessage().contains("deterministic"));
+ }
+
+ //
--------------------------------------------------------------------------------------------
+ // Fixtures
+ //
--------------------------------------------------------------------------------------------
+
+ private static ParDo.MultiOutput<KV<String, Integer>, Integer> multiOutput(
+ DoFn<KV<String, Integer>, Integer> doFn) {
+ return ParDo.of(doFn).withOutputTags(new TupleTag<Integer>() {},
TupleTagList.empty());
+ }
+
+ private static class PlainDoFn extends DoFn<KV<String, Integer>, Integer> {
+ @ProcessElement
+ public void processElement(ProcessContext ctx) {
+ ctx.output(ctx.element().getValue());
+ }
+ }
+
+ private static class StatefulDoFn extends DoFn<KV<String, Integer>, Integer>
{
+ @StateId("value")
+ private final StateSpec<ValueState<Integer>> state =
StateSpecs.value(VarIntCoder.of());
+
+ @ProcessElement
+ public void processElement(ProcessContext ctx, @StateId("value")
ValueState<Integer> state) {
+ ctx.output(ctx.element().getValue());
+ }
+ }
+
+ private static class TimerDoFn extends DoFn<KV<String, Integer>, Integer> {
+ @TimerId("timer")
+ private final TimerSpec timer = TimerSpecs.timer(TimeDomain.EVENT_TIME);
+
+ @ProcessElement
+ public void processElement(ProcessContext ctx, @TimerId("timer") Timer
timer) {
+ ctx.output(ctx.element().getValue());
+ }
+
+ @OnTimer("timer")
+ public void onTimer() {}
+ }
+
+ private static class TimeSortedOnlyDoFn extends DoFn<KV<String, Integer>,
Integer> {
+ @RequiresTimeSortedInput
+ @ProcessElement
+ public void processElement(ProcessContext ctx) {
+ ctx.output(ctx.element().getValue());
+ }
+ }
+
+ /** A String coder that refuses to declare itself deterministic. */
+ private static class NonDeterministicStringCoder extends CustomCoder<String>
{
+ @Override
+ public void encode(String value, OutputStream outStream) throws
IOException {
+ StringUtf8Coder.of().encode(value, outStream);
+ }
+
+ @Override
+ public String decode(InputStream inStream) throws IOException {
+ return StringUtf8Coder.of().decode(inStream);
+ }
+
+ @Override
+ public void verifyDeterministic() throws NonDeterministicException {
+ throw new NonDeterministicException(this, "not deterministic, by design,
for this test");
+ }
+ }
+}