Skip to content

Commit 17df8a3

Browse files
committed
[Spark] Support SDF self-checkpointing and bundle finalization on the portable runner
The portable Spark runner never registered a bundle checkpoint handler, so a splittable DoFn that self-checkpoints failed on its first bundle and unbounded reads could not run at all. In batch, the executable stage function now collects residual roots and re-feeds them in fresh bundles until the SDK returns none, resuming each residual at its requested time. Unbounded residuals are rejected, since draining them would never terminate. The stage also registers an in-memory bundle finalizer. In streaming, residuals leave the stage on a reserved union tag and are relayed on the driver: they are held until their resume time, fed back as stage input in a later micro-batch, and their output watermarks advance the stage's watermark so downstream event-time windows fire. The relay bounds its watermark by the stage's upstream sources, and is keyed per job so concurrent jobs stay isolated. Unskips the splittable DoFn and bundle finalization tests for the Spark runner.
1 parent de21b1c commit 17df8a3

13 files changed

Lines changed: 616 additions & 54 deletions
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"comment": "Modify this file in a trivial way to cause this test suite to run",
3-
"modification": 6
3+
"modification": 7
44
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"comment": "Modify this file in a trivial way to cause this test suite to run",
3-
"modification": 6
3+
"modification": 7
44
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"comment": "Modify this file in a trivial way to cause this test suite to run",
3-
"modification": 1
3+
"modification": 2
44
}

.github/trigger_files/beam_PostCommit_Python_ValidatesRunner_Spark.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"https://github.com/apache/beam/pull/34830": "testing",
33
"https://github.com/apache/beam/issues/35429": "testing",
44
"trigger-2026-04-04": "portable_runner expand_sdf opt-in",
5-
"https://github.com/apache/beam/pull/38892": "UnboundedSource portable VR test"
5+
"https://github.com/apache/beam/pull/38892": "UnboundedSource portable VR test",
6+
"https://github.com/apache/beam/issues/19468": "SDF self-checkpointing and bundle finalization"
67
}

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@
6969
## New Features / Improvements
7070

7171
* (Python) Removed the `envoy-data-plane` (and transitive `betterproto`) dependency; `EnvoyRateLimiter` now uses a small vendored protobuf definition instead, resolving dependency conflicts for downstream projects ([#37854](https://github.com/apache/beam/issues/37854)).
72+
* Splittable DoFn self-checkpointing is now supported on the portable Spark runner, including unbounded reads in streaming mode ([#19468](https://github.com/apache/beam/issues/19468)).
73+
* Bundle finalization is now supported on the portable Spark runner ([#19517](https://github.com/apache/beam/issues/19517)).
7274
* X feature added (Java/Python) ([#X](https://github.com/apache/beam/issues/X)).
7375

7476
## Breaking Changes

runners/spark/job-server/spark_job_server.gradle

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,9 @@ def portableValidatesRunnerTask(String name, boolean streaming, boolean docker,
199199
excludeCategories 'org.apache.beam.sdk.testing.UsesKeyInParDo'
200200
excludeCategories 'org.apache.beam.sdk.testing.UsesOnWindowExpiration'
201201
excludeCategories 'org.apache.beam.sdk.testing.UsesTestStream'
202-
// TODO (https://github.com/apache/beam/issues/19468) SplittableDoFnTests
203-
excludeCategories 'org.apache.beam.sdk.testing.UsesBoundedSplittableParDo'
202+
// Unbounded SDF requires the streaming residual relay (#19468).
204203
excludeCategories 'org.apache.beam.sdk.testing.UsesUnboundedSplittableParDo'
205204
excludeCategories 'org.apache.beam.sdk.testing.UsesStrictTimerOrdering'
206-
excludeCategories 'org.apache.beam.sdk.testing.UsesBundleFinalizer'
207205
}
208206
testFilter = {
209207
// TODO (https://github.com/apache/beam/issues/20189)

runners/spark/src/main/java/org/apache/beam/runners/spark/SparkPipelineRunner.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import org.apache.beam.runners.spark.translation.SparkStreamingPortablePipelineTranslator;
4040
import org.apache.beam.runners.spark.translation.SparkStreamingTranslationContext;
4141
import org.apache.beam.runners.spark.translation.SparkTranslationContext;
42+
import org.apache.beam.runners.spark.translation.streaming.SdfResidualRelay;
4243
import org.apache.beam.runners.spark.util.GlobalWatermarkHolder;
4344
import org.apache.beam.sdk.io.FileSystems;
4445
import org.apache.beam.sdk.metrics.MetricsEnvironment;
@@ -166,8 +167,10 @@ public PortablePipelineResult run(RunnerApi.Pipeline pipeline, JobInfo jobInfo)
166167
jssc.awaitTerminationOrTimeout(timeout);
167168
} catch (InterruptedException e) {
168169
LOG.warn("Streaming context interrupted, shutting down.", e);
170+
} finally {
171+
jssc.stop();
172+
SdfResidualRelay.unregisterJob(jobInfo.jobId());
169173
}
170-
jssc.stop();
171174
LOG.info("Job {} finished.", jobInfo.jobId());
172175
});
173176
result = new SparkPipelineResult.PortableStreamingMode(submissionFuture, jssc);

runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkBatchPortablePipelineTranslator.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,9 @@ private static <InputT, OutputT, SideInputT> void translateExecutableStage(
262262
SparkExecutableStageContextFactory.getInstance(),
263263
broadcastVariables,
264264
MetricsAccumulator.getInstance(),
265-
windowCoder);
265+
windowCoder,
266+
getWindowedValueCoder(inputPCollectionId, components),
267+
false);
266268
staged = groupedByKey.flatMap(function.forPair());
267269
} else {
268270
JavaRDD<WindowedValue<InputT>> inputRdd2 = ((BoundedDataset<InputT>) inputDataset).getRDD();
@@ -275,7 +277,9 @@ private static <InputT, OutputT, SideInputT> void translateExecutableStage(
275277
SparkExecutableStageContextFactory.getInstance(),
276278
broadcastVariables,
277279
MetricsAccumulator.getInstance(),
278-
windowCoder);
280+
windowCoder,
281+
getWindowedValueCoder(inputPCollectionId, components),
282+
false);
279283
staged = inputRdd2.mapPartitions(function2);
280284
}
281285

runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkExecutableStageFunction.java

Lines changed: 178 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import java.io.IOException;
2121
import java.io.Serializable;
22+
import java.util.ArrayList;
2223
import java.util.Collections;
2324
import java.util.EnumMap;
2425
import java.util.Iterator;
@@ -27,6 +28,7 @@
2728
import java.util.Map;
2829
import java.util.concurrent.ConcurrentLinkedQueue;
2930
import java.util.stream.Collectors;
31+
import org.apache.beam.model.fnexecution.v1.BeamFnApi.DelayedBundleApplication;
3032
import org.apache.beam.model.fnexecution.v1.BeamFnApi.ProcessBundleProgressResponse;
3133
import org.apache.beam.model.fnexecution.v1.BeamFnApi.ProcessBundleResponse;
3234
import org.apache.beam.model.fnexecution.v1.BeamFnApi.StateKey;
@@ -36,6 +38,9 @@
3638
import org.apache.beam.runners.core.TimerInternals;
3739
import org.apache.beam.runners.core.construction.SerializablePipelineOptions;
3840
import org.apache.beam.runners.core.metrics.MetricsContainerImpl;
41+
import org.apache.beam.runners.fnexecution.control.BundleCheckpointHandler;
42+
import org.apache.beam.runners.fnexecution.control.BundleFinalizationHandlers;
43+
import org.apache.beam.runners.fnexecution.control.BundleFinalizationHandlers.InMemoryFinalizer;
3944
import org.apache.beam.runners.fnexecution.control.BundleProgressHandler;
4045
import org.apache.beam.runners.fnexecution.control.ExecutableStageContext;
4146
import org.apache.beam.runners.fnexecution.control.JobBundleFactory;
@@ -59,6 +64,7 @@
5964
import org.apache.beam.sdk.io.FileSystems;
6065
import org.apache.beam.sdk.transforms.join.RawUnionValue;
6166
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
67+
import org.apache.beam.sdk.util.CoderUtils;
6268
import org.apache.beam.sdk.util.construction.Timer;
6369
import org.apache.beam.sdk.util.construction.graph.ExecutableStage;
6470
import org.apache.beam.sdk.values.WindowedValue;
@@ -85,6 +91,10 @@
8591
class SparkExecutableStageFunction<InputT, SideInputT>
8692
implements FlatMapFunction<Iterator<WindowedValue<InputT>>, RawUnionValue> {
8793

94+
// Union tag carrying serialized SDF residuals to the streaming residual relay. Regular output
95+
// tags start at 0.
96+
static final int SDF_RESIDUAL_TAG = -1;
97+
8898
// Pipeline options for initializing the FileSystems
8999
private final SerializablePipelineOptions pipelineOptions;
90100
private final RunnerApi.ExecutableStagePayload stagePayload;
@@ -95,6 +105,10 @@ class SparkExecutableStageFunction<InputT, SideInputT>
95105
sideInputs;
96106
private final MetricsContainerStepMapAccumulator metricsAccumulator;
97107
private final Coder windowCoder;
108+
// Coder for the stage input, used to re-feed SDF self-checkpoint residuals.
109+
private final Coder<WindowedValue<InputT>> inputCoder;
110+
// Streaming emits residuals to the cross-batch relay; batch drains them in place.
111+
private final boolean emitSdfResiduals;
98112
private final JobInfo jobInfo;
99113

100114
private transient InMemoryBagUserStateFactory bagUserStateHandlerFactory;
@@ -108,7 +122,9 @@ class SparkExecutableStageFunction<InputT, SideInputT>
108122
SparkExecutableStageContextFactory contextFactory,
109123
Map<String, Tuple2<Broadcast<List<byte[]>>, WindowedValueCoder<SideInputT>>> sideInputs,
110124
MetricsContainerStepMapAccumulator metricsAccumulator,
111-
Coder windowCoder) {
125+
Coder windowCoder,
126+
Coder<WindowedValue<InputT>> inputCoder,
127+
boolean emitSdfResiduals) {
112128
this.pipelineOptions = pipelineOptions;
113129
this.stagePayload = stagePayload;
114130
this.jobInfo = jobInfo;
@@ -117,6 +133,8 @@ class SparkExecutableStageFunction<InputT, SideInputT>
117133
this.sideInputs = sideInputs;
118134
this.metricsAccumulator = metricsAccumulator;
119135
this.windowCoder = windowCoder;
136+
this.inputCoder = inputCoder;
137+
this.emitSdfResiduals = emitSdfResiduals;
120138
}
121139

122140
/** Call the executable stage function on the values of a PairRDD, ignoring the key. */
@@ -146,7 +164,30 @@ public Iterator<RawUnionValue> call(Iterator<WindowedValue<InputT>> inputs) thro
146164
executableStage, stageBundleFactory.getProcessBundleDescriptor());
147165
if (executableStage.getTimers().size() == 0) {
148166
ReceiverFactory receiverFactory = new ReceiverFactory(collector, outputMap);
149-
processElements(stateRequestHandler, receiverFactory, null, stageBundleFactory, inputs);
167+
ResidualCollector residualCollector = new ResidualCollector();
168+
InMemoryFinalizer finalizer =
169+
BundleFinalizationHandlers.inMemoryFinalizer(
170+
stageBundleFactory.getInstructionRequestHandler());
171+
processElements(
172+
stateRequestHandler,
173+
receiverFactory,
174+
null,
175+
stageBundleFactory,
176+
inputs,
177+
residualCollector,
178+
finalizer);
179+
if (emitSdfResiduals) {
180+
for (DelayedBundleApplication residual : residualCollector.drain()) {
181+
collector.add(new RawUnionValue(SDF_RESIDUAL_TAG, residual.toByteArray()));
182+
}
183+
} else {
184+
processResiduals(
185+
stateRequestHandler,
186+
receiverFactory,
187+
stageBundleFactory,
188+
residualCollector,
189+
finalizer);
190+
}
150191
return collector.iterator();
151192
}
152193
// Used with Batch, we know that all the data is available for this key. We can't use the
@@ -172,8 +213,18 @@ public Iterator<RawUnionValue> call(Iterator<WindowedValue<InputT>> inputs) thro
172213
windowCoder);
173214

174215
// Process inputs.
216+
ResidualCollector residualCollector = new ResidualCollector();
217+
InMemoryFinalizer finalizer =
218+
BundleFinalizationHandlers.inMemoryFinalizer(
219+
stageBundleFactory.getInstructionRequestHandler());
175220
processElements(
176-
stateRequestHandler, receiverFactory, timerReceiverFactory, stageBundleFactory, inputs);
221+
stateRequestHandler,
222+
receiverFactory,
223+
timerReceiverFactory,
224+
stageBundleFactory,
225+
inputs,
226+
residualCollector,
227+
finalizer);
177228

178229
// Finish any pending windows by advancing the input watermark to infinity.
179230
timerInternals.advanceInputWatermark(BoundedWindow.TIMESTAMP_MAX_VALUE);
@@ -189,12 +240,17 @@ public Iterator<RawUnionValue> call(Iterator<WindowedValue<InputT>> inputs) thro
189240
receiverFactory,
190241
timerReceiverFactory,
191242
stateRequestHandler,
192-
getBundleProgressHandler())) {
243+
getBundleProgressHandler(),
244+
finalizer,
245+
residualCollector)) {
193246

194247
PipelineTranslatorUtils.fireEligibleTimers(
195248
timerInternals, bundle.getTimerReceivers(), currentTimerKey);
196249
}
250+
finalizer.finalizeAllOutstandingBundles();
197251
}
252+
processResiduals(
253+
stateRequestHandler, receiverFactory, stageBundleFactory, residualCollector, finalizer);
198254
return collector.iterator();
199255
}
200256
}
@@ -207,21 +263,116 @@ private void processElements(
207263
ReceiverFactory receiverFactory,
208264
TimerReceiverFactory timerReceiverFactory,
209265
StageBundleFactory stageBundleFactory,
210-
Iterator<WindowedValue<InputT>> inputs)
266+
Iterator<WindowedValue<InputT>> inputs,
267+
BundleCheckpointHandler checkpointHandler,
268+
InMemoryFinalizer finalizer)
211269
throws Exception {
212270
try (RemoteBundle bundle =
213271
stageBundleFactory.getBundle(
214272
receiverFactory,
215273
timerReceiverFactory,
216274
stateRequestHandler,
217-
getBundleProgressHandler())) {
275+
getBundleProgressHandler(),
276+
finalizer,
277+
checkpointHandler)) {
218278
FnDataReceiver<WindowedValue<?>> mainReceiver =
219279
Iterables.getOnlyElement(bundle.getInputReceivers().values());
220280
while (inputs.hasNext()) {
221281
WindowedValue<InputT> input = inputs.next();
222282
mainReceiver.accept(input);
223283
}
224284
}
285+
finalizer.finalizeAllOutstandingBundles();
286+
}
287+
288+
// Re-feeds SDF self-checkpoint residuals in fresh bundles until the SDK returns none. Each
289+
// residual resumes at its own requested time. Bounded restrictions always run out; unbounded ones
290+
// never would, so they are rejected rather than drained forever.
291+
private void processResiduals(
292+
StateRequestHandler stateRequestHandler,
293+
ReceiverFactory receiverFactory,
294+
StageBundleFactory stageBundleFactory,
295+
ResidualCollector residualCollector,
296+
InMemoryFinalizer finalizer)
297+
throws Exception {
298+
List<ScheduledResidual> scheduled = schedule(residualCollector.drain());
299+
while (!scheduled.isEmpty()) {
300+
List<ScheduledResidual> due = takeDue(scheduled);
301+
try (RemoteBundle bundle =
302+
stageBundleFactory.getBundle(
303+
receiverFactory,
304+
null,
305+
stateRequestHandler,
306+
getBundleProgressHandler(),
307+
finalizer,
308+
residualCollector)) {
309+
FnDataReceiver<WindowedValue<?>> mainReceiver =
310+
Iterables.getOnlyElement(bundle.getInputReceivers().values());
311+
for (ScheduledResidual residual : due) {
312+
mainReceiver.accept(CoderUtils.decodeFromByteArray(inputCoder, residual.elementBytes));
313+
}
314+
}
315+
finalizer.finalizeAllOutstandingBundles();
316+
scheduled.addAll(schedule(residualCollector.drain()));
317+
}
318+
}
319+
320+
private static List<ScheduledResidual> schedule(List<DelayedBundleApplication> residuals) {
321+
long now = System.currentTimeMillis();
322+
List<ScheduledResidual> scheduled = new ArrayList<>();
323+
for (DelayedBundleApplication residual : residuals) {
324+
if (residual.getApplication().getElement().isEmpty()) {
325+
continue;
326+
}
327+
if (residual.getApplication().getIsBounded() == RunnerApi.IsBounded.Enum.UNBOUNDED) {
328+
throw new UnsupportedOperationException(
329+
"Unbounded splittable DoFn is not supported in batch mode on the Spark runner. See "
330+
+ "https://github.com/apache/beam/issues/19468.");
331+
}
332+
long delayMillis =
333+
residual.hasRequestedTimeDelay()
334+
? residual.getRequestedTimeDelay().getSeconds() * 1000
335+
+ residual.getRequestedTimeDelay().getNanos() / 1_000_000
336+
: 0;
337+
scheduled.add(
338+
new ScheduledResidual(
339+
now + delayMillis, residual.getApplication().getElement().toByteArray()));
340+
}
341+
return scheduled;
342+
}
343+
344+
// Waits for the earliest resume time, then removes and returns everything due by then.
345+
private static List<ScheduledResidual> takeDue(List<ScheduledResidual> scheduled)
346+
throws InterruptedException {
347+
long earliest = Long.MAX_VALUE;
348+
for (ScheduledResidual residual : scheduled) {
349+
earliest = Math.min(earliest, residual.dueMillis);
350+
}
351+
long waitMillis = earliest - System.currentTimeMillis();
352+
if (waitMillis > 0) {
353+
Thread.sleep(waitMillis);
354+
}
355+
long now = System.currentTimeMillis();
356+
List<ScheduledResidual> due = new ArrayList<>();
357+
Iterator<ScheduledResidual> iterator = scheduled.iterator();
358+
while (iterator.hasNext()) {
359+
ScheduledResidual residual = iterator.next();
360+
if (residual.dueMillis <= now) {
361+
due.add(residual);
362+
iterator.remove();
363+
}
364+
}
365+
return due;
366+
}
367+
368+
private static class ScheduledResidual {
369+
private final long dueMillis;
370+
private final byte[] elementBytes;
371+
372+
ScheduledResidual(long dueMillis, byte[] elementBytes) {
373+
this.dueMillis = dueMillis;
374+
this.elementBytes = elementBytes;
375+
}
225376
}
226377

227378
private BundleProgressHandler getBundleProgressHandler() {
@@ -295,6 +446,27 @@ interface JobBundleFactoryCreator extends Serializable {
295446
JobBundleFactory create();
296447
}
297448

449+
/** Collects SDF self-checkpoint residuals returned by the SDK harness. */
450+
private static class ResidualCollector implements BundleCheckpointHandler {
451+
452+
private final ConcurrentLinkedQueue<DelayedBundleApplication> residuals =
453+
new ConcurrentLinkedQueue<>();
454+
455+
@Override
456+
public void onCheckpoint(ProcessBundleResponse response) {
457+
residuals.addAll(response.getResidualRootsList());
458+
}
459+
460+
private List<DelayedBundleApplication> drain() {
461+
List<DelayedBundleApplication> pending = new ArrayList<>();
462+
DelayedBundleApplication residual;
463+
while ((residual = residuals.poll()) != null) {
464+
pending.add(residual);
465+
}
466+
return pending;
467+
}
468+
}
469+
298470
/**
299471
* Receiver factory that wraps outgoing elements with the corresponding union tag for a
300472
* multiplexed PCollection.

0 commit comments

Comments
 (0)