Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to run",
"modification": 8,
"modification": 9,
"https://github.com/apache/beam/pull/39990": "removing dead code from FnApiDoFnRunner"
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to run",
"modification": 3
"modification": 4
}
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
## Bugfixes

* (Java) Fixed the Spark runner firing processing-time timers in reverse timestamp order ([#39824](https://github.com/apache/beam/issues/39824)).
* (Java) Fixed the Spark runner dropping the stored watermark of a streaming source with no update in a batch ([#39822](https://github.com/apache/beam/issues/39822)).
* (Python) Fixed incorrect profiler options handling on portable runners ([#39613](https://github.com/apache/beam/issues/39613)).
* (Java) KafkaIO dynamic reads no longer require the obsolete `beam_fn_api` experiment ([#29998](https://github.com/apache/beam/issues/29998)).
* (Prism) Self-checkpointing splittable DoFns now resume after their requested delay instead of immediately, so polling SDFs no longer busy-spin ([#39848](https://github.com/apache/beam/issues/39848)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
*/
package org.apache.beam.runners.spark.stateful;

import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;

import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
Expand Down Expand Up @@ -83,14 +81,13 @@ public static SparkTimerInternals forStreamFromSources(
slowestHighWatermark.isBefore(sparkWatermarks.getHighWatermark())
? slowestHighWatermark
: sparkWatermarks.getHighWatermark();
if (synchronizedProcessingTime == null) {
// firstime set.
// an idle source keeps its last reported time, so sources can disagree here; the
// synchronized processing time is the one every source has reached.
if (synchronizedProcessingTime == null
|| sparkWatermarks
.getSynchronizedProcessingTime()
.isBefore(synchronizedProcessingTime)) {
synchronizedProcessingTime = sparkWatermarks.getSynchronizedProcessingTime();
} else {
// assert on following.
checkArgument(
sparkWatermarks.getSynchronizedProcessingTime().equals(synchronizedProcessingTime),
"Synchronized time is expected to keep synchronized across sources.");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,22 +175,36 @@ public static void advance() {
/**
* Computes the next watermark values per source id.
*
* @return The new watermarks values or null if no source has reported its progress.
* <p>Sources with no queued update keep their last known watermarks.
*
* @return The new watermarks values, or an empty map if no source has reported its progress.
*/
private static Map<Integer, SparkWatermarks> computeNewWatermarks(BlockManager blockManager) {

if (sourceTimes.isEmpty()) {
return new HashMap<>();
}

// update all sources' watermarks into the new broadcast.
final Map<Integer, SparkWatermarks> currentWatermarks = initWatermarks(blockManager);

// Update the sources that reported progress over a copy of the stored map, so a source
// with no update in this batch keeps its last known watermarks. A source whose high
// watermark already reached the end of time has finished, so it is dropped before its
// last low watermark pins the minimum.
final Map<Integer, SparkWatermarks> newValues = new HashMap<>();
for (Map.Entry<Integer, SparkWatermarks> current : currentWatermarks.entrySet()) {
if (current.getValue().getHighWatermark().isBefore(BoundedWindow.TIMESTAMP_MAX_VALUE)) {
newValues.put(current.getKey(), current.getValue());
}
}
boolean hasUpdates = false;

for (final Map.Entry<Integer, Queue<SparkWatermarks>> watermarkInfo : sourceTimes.entrySet()) {

if (watermarkInfo.getValue().isEmpty()) {
continue;
}
hasUpdates = true;

final Integer sourceId = watermarkInfo.getKey();

Expand All @@ -199,8 +213,6 @@ private static Map<Integer, SparkWatermarks> computeNewWatermarks(BlockManager b
Instant currentHighWatermark = BoundedWindow.TIMESTAMP_MIN_VALUE;
Instant currentSynchronizedProcessingTime = BoundedWindow.TIMESTAMP_MIN_VALUE;

final Map<Integer, SparkWatermarks> currentWatermarks = initWatermarks(blockManager);

if (currentWatermarks.containsKey(sourceId)) {
final SparkWatermarks currentTimes = currentWatermarks.get(sourceId);
currentLowWatermark = currentTimes.getLowWatermark();
Expand Down Expand Up @@ -240,7 +252,7 @@ private static Map<Integer, SparkWatermarks> computeNewWatermarks(BlockManager b
new SparkWatermarks(nextLowWatermark, nextHighWatermark, nextSynchronizedProcessingTime));
}

return newValues;
return hasUpdates ? newValues : new HashMap<>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this used/why do we only return if there are updates? Does it actually save us anything?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasUpdates restores a signal the pre-change code got for free.

The caller is advance(batchId), which skips both publication calls when the returned map is empty:

final Map<Integer, SparkWatermarks> newWatermarks = computeNewWatermarks(blockManager);
if (!newWatermarks.isEmpty()) {
  writeRemoteWatermarkBlock(newWatermarks, blockManager);
  writeLocalWatermarkCopy(newWatermarks);
} else {
  LOG.info("No new watermarks could be computed upon completion of batch: {}", batchId);
}

Before this change newValues started empty and only a source with a queued update got an entry, so a batch where every queue was empty produced an empty map and advance published nothing. Seeding newValues from the stored map means a quiet batch now yields a non-empty map whenever a stored source still has high < TIMESTAMP_MAX_VALUE. hasUpdates is what keeps that batch from publishing.

What it saves on such a batch is the removeBlock plus putSingle pair and the local copy assignment. The comments on writeRemoteWatermarkBlock, and #18426 which they link, describe the window where an executor fetching WATERMARKS_BLOCK_ID finds nothing. The saving is narrow: the block fetch, the seeding and the queue scan all still happen.

One consequence is worth stating plainly. On a batch where every queue is empty, the stored block keeps a source whose high watermark already reached TIMESTAMP_MAX_VALUE, which seeding would have pruned. Such a source can still hold the effective low watermark back, because SparkTimerInternals.forStreamFromSources takes the slowest low watermark and a completed high watermark does not imply a completed low watermark. testCompletedSourceAgesOut uses exactly that shape, low at +5ms with high at TIMESTAMP_MAX_VALUE. The pre-change code also published nothing on an all-quiet batch, so that persistence predates this PR. What the guard defers is the pruning seeding introduced. A completed source drops out on the next published batch where its own queue is empty and another source supplies an update, and the update loop puts it back on any batch where it does report.

Dropping the flag is not the same as publishing every batch. On a quiet batch it would publish the seeded map, which prunes completed sources as long as one unfinished source remains. Once every stored source has completed, seeding yields an empty map and advance publishes nothing regardless, so clearing that block would need advance to tell "nothing to publish" apart from "publish this empty map".

The tests cover retention and pruning on a batch where another source updates. They do not cover the all-queues-empty path. I can add that case if you want the behavior pinned down.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this helps

}

private static void writeRemoteWatermarkBlock(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
*/
package org.apache.beam.runners.spark;

import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.core.IsEqual.equalTo;

import org.apache.beam.runners.spark.util.GlobalWatermarkHolder;
import org.apache.beam.runners.spark.util.GlobalWatermarkHolder.SparkWatermarks;
import org.apache.beam.sdk.testing.RegexMatcher;
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
import org.joda.time.Duration;
import org.joda.time.Instant;
import org.junit.ClassRule;
Expand Down Expand Up @@ -134,4 +136,74 @@ public void testMultiSource() {
assertThat(watermarksForSource2.getLowWatermark(), equalTo(instant.plus(Duration.millis(3))));
assertThat(watermarksForSource2.getHighWatermark(), equalTo(instant.plus(Duration.millis(6))));
}

@Test
public void testWatermarkRetainedForSourceWithoutUpdate() {
Instant instant = new Instant(0);

GlobalWatermarkHolder.add(
1,
new SparkWatermarks(
instant.plus(Duration.millis(5)), instant.plus(Duration.millis(10)), instant));
GlobalWatermarkHolder.add(
2,
new SparkWatermarks(
instant.plus(Duration.millis(3)), instant.plus(Duration.millis(6)), instant));

GlobalWatermarkHolder.advance();

// only source 1 reports progress in the next batch.
GlobalWatermarkHolder.add(
1,
new SparkWatermarks(
instant.plus(Duration.millis(8)),
instant.plus(Duration.millis(12)),
instant.plus(Duration.millis(100))));

GlobalWatermarkHolder.advance();

// source 1 advanced.
SparkWatermarks watermarksForSource1 = GlobalWatermarkHolder.get(0L).get(1);
assertThat(watermarksForSource1.getLowWatermark(), equalTo(instant.plus(Duration.millis(8))));
assertThat(watermarksForSource1.getHighWatermark(), equalTo(instant.plus(Duration.millis(12))));

// source 2 had no update; its last known watermarks must be retained.
SparkWatermarks watermarksForSource2 = GlobalWatermarkHolder.get(0L).get(2);
assertThat(watermarksForSource2, notNullValue());
assertThat(watermarksForSource2.getLowWatermark(), equalTo(instant.plus(Duration.millis(3))));
assertThat(watermarksForSource2.getHighWatermark(), equalTo(instant.plus(Duration.millis(6))));
}

@Test
public void testCompletedSourceAgesOut() {
Instant instant = new Instant(0);

// source 1 completes: its high watermark reaches the end of time.
GlobalWatermarkHolder.add(
1,
new SparkWatermarks(
instant.plus(Duration.millis(5)), BoundedWindow.TIMESTAMP_MAX_VALUE, instant));
GlobalWatermarkHolder.add(
2,
new SparkWatermarks(
instant.plus(Duration.millis(3)), instant.plus(Duration.millis(6)), instant));

GlobalWatermarkHolder.advance();

// the batch that completed the source still reports it.
assertThat(GlobalWatermarkHolder.get(0L).get(1), notNullValue());

GlobalWatermarkHolder.add(
2,
new SparkWatermarks(
instant.plus(Duration.millis(8)),
instant.plus(Duration.millis(12)),
instant.plus(Duration.millis(100))));

GlobalWatermarkHolder.advance();

// a completed source is dropped so its last low watermark stops pinning the minimum.
assertThat(GlobalWatermarkHolder.get(0L).containsKey(1), equalTo(false));
assertThat(GlobalWatermarkHolder.get(0L).get(2), notNullValue());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,9 @@ public void testFlattenedWithWatermarkHold() throws IOException {
TimestampedValue.of(1, instant),
TimestampedValue.of(2, instant),
TimestampedValue.of(3, instant))
.advanceWatermarkForNextBatch(instant.plus(Duration.standardMinutes(10)));
.advanceWatermarkForNextBatch(instant.plus(Duration.standardMinutes(10)))
.emptyBatch()
.advanceNextBatchWatermarkToInfinity();
CreateStream<Integer> source2 =
CreateStream.of(VarIntCoder.of(), batchDuration())
.emptyBatch()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,10 @@ public void shouldTriggerProcessingTimeTimerWithSparseKey() {
CreateStream.of(coder, batchDuration(p)).advanceWatermarkForNextBatch(instant);

sparseStream =
sparseStream.nextBatch(
TimestampedValue.of(KV.of(sparseKey, 0), instant.plus(Duration.millis(1000L))));
sparseStream
.nextBatch(
TimestampedValue.of(KV.of(sparseKey, 0), instant.plus(Duration.millis(1000L))))
.advanceNextBatchWatermarkToInfinity();

final PCollection<KV<Integer, Integer>> sparsePCollection =
p.apply("Create Sparse Key Stream", sparseStream);
Expand Down
Loading