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 bd9620f61c0 [Spark] Fire processing-time timers in timestamp order
(#39825)
bd9620f61c0 is described below
commit bd9620f61c0aab27c3edc271b946578ed71e7fcf
Author: Elia Liu <[email protected]>
AuthorDate: Sun Aug 30 12:14:01 2026 +1000
[Spark] Fire processing-time timers in timestamp order (#39825)
---
.../beam_PostCommit_Java_PVR_Spark3_Streaming.json | 2 +-
...beam_PostCommit_Java_ValidatesRunner_Spark.json | 2 +-
CHANGES.md | 1 +
.../spark/stateful/SparkTimerInternals.java | 52 ++++++----
.../apache/beam/runners/spark/util/TimerUtils.java | 7 +-
.../spark/stateful/SparkTimerInternalsTest.java | 106 +++++++++++++++++++++
.../beam/runners/spark/util/TimerUtilsTest.java | 20 ++++
7 files changed, 165 insertions(+), 25 deletions(-)
diff --git
a/.github/trigger_files/beam_PostCommit_Java_PVR_Spark3_Streaming.json
b/.github/trigger_files/beam_PostCommit_Java_PVR_Spark3_Streaming.json
index d6a91b7e2e8..38ae1cf6822 100644
--- a/.github/trigger_files/beam_PostCommit_Java_PVR_Spark3_Streaming.json
+++ b/.github/trigger_files/beam_PostCommit_Java_PVR_Spark3_Streaming.json
@@ -1,4 +1,4 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run",
- "modification": 7
+ "modification": 8
}
diff --git
a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark.json
b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark.json
index 3f63c0c9975..bbdc3a3910e 100644
--- a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark.json
+++ b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark.json
@@ -1,4 +1,4 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run",
- "modification": 2
+ "modification": 3
}
diff --git a/CHANGES.md b/CHANGES.md
index db539669008..c3c4019c7da 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -87,6 +87,7 @@
## Bugfixes
+* (Java) Fixed the Spark runner firing processing-time timers in reverse
timestamp order ([#39824](https://github.com/apache/beam/issues/39824)).
* (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)).
diff --git
a/runners/spark/src/main/java/org/apache/beam/runners/spark/stateful/SparkTimerInternals.java
b/runners/spark/src/main/java/org/apache/beam/runners/spark/stateful/SparkTimerInternals.java
index 9ef75635c21..141c58f1a23 100644
---
a/runners/spark/src/main/java/org/apache/beam/runners/spark/stateful/SparkTimerInternals.java
+++
b/runners/spark/src/main/java/org/apache/beam/runners/spark/stateful/SparkTimerInternals.java
@@ -25,15 +25,15 @@ import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
-import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import org.apache.beam.runners.core.StateNamespace;
import org.apache.beam.runners.core.TimerInternals;
import org.apache.beam.runners.spark.coders.CoderHelpers;
import
org.apache.beam.runners.spark.util.GlobalWatermarkHolder.SparkWatermarks;
import org.apache.beam.sdk.state.TimeDomain;
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists;
-import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Sets;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.joda.time.Instant;
@@ -44,7 +44,8 @@ import org.joda.time.Instant;
public class SparkTimerInternals implements TimerInternals {
private final Instant highWatermark;
private final Instant synchronizedProcessingTime;
- private final Set<TimerData> timers = Sets.newConcurrentHashSet();
+ // Timers keyed by namespace, id and family, so a later setting replaces the
prior one.
+ private final Map<List<Object>, TimerData> timers = new
ConcurrentHashMap<>();
private Instant inputWatermark;
@@ -105,37 +106,46 @@ public class SparkTimerInternals implements
TimerInternals {
}
public Collection<TimerData> getTimers() {
- return timers;
+ return timers.values();
}
public void addTimers(Iterator<TimerData> timers) {
while (timers.hasNext()) {
TimerData timer = timers.next();
- this.timers.add(timer);
+ // State written before setTimer replaced prior settings can carry
several settings of
+ // one timer; collapse them to the setting with the latest target.
+ this.timers.merge(
+ logicalKey(timer),
+ timer,
+ (existing, restored) ->
+ restored.getTimestamp().isAfter(existing.getTimestamp()) ?
restored : existing);
}
}
@Override
public void setTimer(TimerData timer) {
- this.timers.add(timer);
+ // A later setting of the same timer clears the prior one, per the
TimerInternals contract.
+ this.timers.put(logicalKey(timer), timer);
}
@Override
public void deleteTimer(
StateNamespace namespace, String timerId, String timerFamilyId,
TimeDomain timeDomain) {
- this.timers.stream()
- .filter(
- timer ->
- namespace.equals(timer.getNamespace())
- && timerId.equals(timer.getTimerId())
- && timerFamilyId.equals(timer.getTimerFamilyId())
- && timeDomain.equals(timer.getDomain()))
- .forEach(this::deleteTimer);
+ List<Object> key = ImmutableList.of(namespace, timerId, timerFamilyId);
+ TimerData existing = this.timers.get(key);
+ if (existing != null && timeDomain.equals(existing.getDomain())) {
+ this.timers.remove(key, existing);
+ }
}
@Override
public void deleteTimer(TimerData timer) {
- this.timers.remove(timer);
+ // Deletes this setting only, so a setting made by the fired callback
survives.
+ this.timers.remove(logicalKey(timer), timer);
+ }
+
+ private static List<Object> logicalKey(TimerData timer) {
+ return ImmutableList.of(timer.getNamespace(), timer.getTimerId(),
timer.getTimerFamilyId());
}
@Override
@@ -199,7 +209,7 @@ public class SparkTimerInternals implements TimerInternals {
*/
public boolean hasNextProcessingTimer() {
final Instant currentProcessingTime = this.currentProcessingTime();
- return this.timers.stream()
+ return this.timers.values().stream()
.anyMatch(
(TimerData timerData) ->
timerData.getDomain().equals(TimeDomain.PROCESSING_TIME)
@@ -207,23 +217,23 @@ public class SparkTimerInternals implements
TimerInternals {
}
/**
- * Finds the latest timer in {@link TimeDomain#PROCESSING_TIME} domain that
has expired based on
+ * Finds the earliest timer in {@link TimeDomain#PROCESSING_TIME} domain
that has expired based on
* the current processing time.
*
* <p>A timer is considered expired when its timestamp is less than the
current processing time.
- * If multiple expired timers exist, the one with the latest timestamp will
be returned.
+ * Expired timers fire in timestamp order.
*
- * @return The expired processing timer with the latest timestamp if one
exists, or {@code null}
+ * @return The expired processing timer with the earliest timestamp if one
exists, or {@code null}
* if no processing timers are ready to fire.
*/
public @Nullable TimerData getNextProcessingTimer() {
final Instant currentProcessingTime = this.currentProcessingTime();
- return this.timers.stream()
+ return this.timers.values().stream()
.filter(
(TimerData timerData) ->
timerData.getDomain().equals(TimeDomain.PROCESSING_TIME)
&& currentProcessingTime.isAfter(timerData.getTimestamp()))
- .max(Comparator.comparing(TimerData::getTimestamp))
+ .min(Comparator.comparing(TimerData::getTimestamp))
.orElse(null);
}
diff --git
a/runners/spark/src/main/java/org/apache/beam/runners/spark/util/TimerUtils.java
b/runners/spark/src/main/java/org/apache/beam/runners/spark/util/TimerUtils.java
index 2111867d385..6014873d6bf 100644
---
a/runners/spark/src/main/java/org/apache/beam/runners/spark/util/TimerUtils.java
+++
b/runners/spark/src/main/java/org/apache/beam/runners/spark/util/TimerUtils.java
@@ -21,6 +21,7 @@ import io.opentelemetry.context.Context;
import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
+import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
@@ -202,10 +203,12 @@ public class TimerUtils {
SparkTimerInternals sparkTimerInternals,
WindowingStrategy<?, W> windowingStrategy,
AbstractInOutIterator<?, ?, ?> abstractInOutIterator) {
- final Collection<TimerInternals.TimerData> expiredTimers =
+ final List<TimerInternals.TimerData> expiredTimers =
getExpiredTimers(sparkTimerInternals, windowingStrategy);
if (!expiredTimers.isEmpty()) {
+ // Timers fire in timestamp order.
+
expiredTimers.sort(Comparator.comparing(TimerInternals.TimerData::getTimestamp));
expiredTimers.forEach(abstractInOutIterator::fireTimer);
}
}
@@ -221,7 +224,7 @@ public class TimerUtils {
}
}
- private static <W extends BoundedWindow>
Collection<TimerInternals.TimerData> getExpiredTimers(
+ private static <W extends BoundedWindow> List<TimerInternals.TimerData>
getExpiredTimers(
SparkTimerInternals sparkTimerInternals, WindowingStrategy<?, W>
windowingStrategy) {
return sparkTimerInternals.getTimers().stream()
.filter(
diff --git
a/runners/spark/src/test/java/org/apache/beam/runners/spark/stateful/SparkTimerInternalsTest.java
b/runners/spark/src/test/java/org/apache/beam/runners/spark/stateful/SparkTimerInternalsTest.java
new file mode 100644
index 00000000000..9b65b683ff2
--- /dev/null
+++
b/runners/spark/src/test/java/org/apache/beam/runners/spark/stateful/SparkTimerInternalsTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.stateful;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.beam.runners.core.StateNamespaces;
+import org.apache.beam.runners.core.TimerInternals.TimerData;
+import org.apache.beam.sdk.state.TimeDomain;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import org.joda.time.Instant;
+import org.junit.Test;
+
+/** Tests for {@link SparkTimerInternals}. */
+public class SparkTimerInternalsTest {
+
+ private static TimerData processingTimer(String timerId, Instant timestamp) {
+ return TimerData.of(
+ timerId, "", StateNamespaces.global(), timestamp, timestamp,
TimeDomain.PROCESSING_TIME);
+ }
+
+ @Test
+ public void testProcessingTimersFireInTimestampOrder() {
+ SparkTimerInternals timerInternals = SparkTimerInternals.global(null);
+
+ TimerData first = processingTimer("first", new Instant(1000));
+ TimerData second = processingTimer("second", new Instant(2000));
+ TimerData third = processingTimer("third", new Instant(3000));
+
+ // Set out of order; firing order must follow the timestamps.
+ timerInternals.setTimer(second);
+ timerInternals.setTimer(third);
+ timerInternals.setTimer(first);
+
+ // Drain the way ParDoStateUpdateFn.SparkTimerInternalsIterator does.
+ List<TimerData> fired = new ArrayList<>();
+ TimerData timer;
+ while ((timer = timerInternals.getNextProcessingTimer()) != null) {
+ fired.add(timer);
+ timerInternals.deleteTimer(timer);
+ }
+
+ assertEquals(ImmutableList.of(first, second, third), fired);
+ }
+
+ @Test
+ public void testSettingATimerAgainClearsThePriorSetting() {
+ SparkTimerInternals timerInternals = SparkTimerInternals.global(null);
+
+ timerInternals.setTimer(processingTimer("timer", new Instant(1000)));
+ TimerData latest = processingTimer("timer", new Instant(2000));
+ timerInternals.setTimer(latest);
+
+ assertEquals(ImmutableList.of(latest),
ImmutableList.copyOf(timerInternals.getTimers()));
+ assertEquals(latest, timerInternals.getNextProcessingTimer());
+ }
+
+ @Test
+ public void testAddTimersKeepsTheLatestSettingOfATimer() {
+ // State written before setTimer replaced prior settings can carry several
settings of one
+ // timer; the setting with the latest target wins regardless of restore
order.
+ TimerData earlier = processingTimer("timer", new Instant(1000));
+ TimerData latest = processingTimer("timer", new Instant(2000));
+
+ SparkTimerInternals timerInternals = SparkTimerInternals.global(null);
+ timerInternals.addTimers(ImmutableList.of(earlier, latest).iterator());
+ assertEquals(ImmutableList.of(latest),
ImmutableList.copyOf(timerInternals.getTimers()));
+
+ timerInternals = SparkTimerInternals.global(null);
+ timerInternals.addTimers(ImmutableList.of(latest, earlier).iterator());
+ assertEquals(ImmutableList.of(latest),
ImmutableList.copyOf(timerInternals.getTimers()));
+ }
+
+ @Test
+ public void testGetNextProcessingTimerIgnoresEventTimeTimers() {
+ SparkTimerInternals timerInternals = SparkTimerInternals.global(null);
+ timerInternals.setTimer(
+ TimerData.of(
+ "event",
+ "",
+ StateNamespaces.global(),
+ new Instant(0),
+ new Instant(0),
+ TimeDomain.EVENT_TIME));
+
+ assertNull(timerInternals.getNextProcessingTimer());
+ }
+}
diff --git
a/runners/spark/src/test/java/org/apache/beam/runners/spark/util/TimerUtilsTest.java
b/runners/spark/src/test/java/org/apache/beam/runners/spark/util/TimerUtilsTest.java
index a91b92aefad..24fbffd9e11 100644
---
a/runners/spark/src/test/java/org/apache/beam/runners/spark/util/TimerUtilsTest.java
+++
b/runners/spark/src/test/java/org/apache/beam/runners/spark/util/TimerUtilsTest.java
@@ -18,6 +18,8 @@
package org.apache.beam.runners.spark.util;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -36,6 +38,7 @@ import org.joda.time.Duration;
import org.joda.time.Instant;
import org.junit.Before;
import org.junit.Test;
+import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@@ -111,6 +114,23 @@ public class TimerUtilsTest {
verify(mockIterator, never()).fireTimer(any());
}
+ @Test
+ public void testTriggerExpiredTimersFiresInTimestampOrder() {
+ // An even older expired timer, listed after the newer one.
+ TimerInternals.TimerData olderExpiredTimer =
mock(TimerInternals.TimerData.class);
+ when(olderExpiredTimer.getTimestamp())
+
.thenReturn(NOW.minus(ALLOWED_LATENESS.plus(Duration.standardMinutes(2))));
+ when(olderExpiredTimer.getDomain()).thenReturn(TimeDomain.EVENT_TIME);
+
when(mockTimerInternals.getTimers()).thenReturn(Arrays.asList(expiredTimer,
olderExpiredTimer));
+
+ TimerUtils.triggerExpiredTimers(mockTimerInternals, mockWindowingStrategy,
mockIterator);
+
+ // Expired timers fire in timestamp order.
+ InOrder inOrder = inOrder(mockIterator);
+ inOrder.verify(mockIterator).fireTimer(olderExpiredTimer);
+ inOrder.verify(mockIterator).fireTimer(expiredTimer);
+ }
+
@Test
public void testTriggerExpiredTimersWithProcessingTimeDomain() {
// Set up a processing-time timer