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 3b476c42095 [Flink] Cache batch side input materialization in Flink 2 
(#39867)
3b476c42095 is described below

commit 3b476c42095684279c71518bade1dc0f1eccae08
Author: Paulius Kuzmickas <[email protected]>
AuthorDate: Tue Sep 1 16:09:01 2026 +0100

    [Flink] Cache batch side input materialization in Flink 2 (#39867)
    
    * Cache Flink 2 batch side input materialization
    
    * Scope side input cache option to Flink 2
    
    * Cache safe bounded Flink side inputs automatically
---
 CHANGES.md                                         |   1 +
 .../wrappers/streaming/CachedSideInputReader.java  |  93 +++++++
 .../wrappers/streaming/DoFnOperator.java           |  31 ++-
 .../wrappers/streaming/SideInputCache.java         | 117 +++++++++
 .../runners/flink/FlinkPipelineOptionsTest.java    |   1 -
 .../streaming/FlinkCachedSideInputReaderTest.java  | 283 +++++++++++++++++++++
 .../wrappers/streaming/DoFnOperator.java           |  31 ++-
 .../runners/flink/FlinkPipelineOptionsTest.java    |   1 -
 8 files changed, 554 insertions(+), 4 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 5b52e9319c8..b857e09ac29 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -123,6 +123,7 @@
 
 ## New Features / Improvements
 
+* Added automatic caching of bounded, single-pane side-input views for classic 
Java Flink DataStream execution 
([#39866](https://github.com/apache/beam/issues/39866)).
 * Added `GroupIntoBatches` transform and the standard
   `beam:coder:sharded_key:v1` coder to the Go SDK, along with
   `beam.Coder.IsDeterministic`, `beam.PCollection.WindowingStrategy`,
diff --git 
a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/CachedSideInputReader.java
 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/CachedSideInputReader.java
new file mode 100644
index 00000000000..ee0d9df53b5
--- /dev/null
+++ 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/CachedSideInputReader.java
@@ -0,0 +1,93 @@
+/*
+ * 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.flink.translation.wrappers.streaming;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import org.apache.beam.runners.core.SideInputReader;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.transforms.windowing.DefaultTrigger;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionView;
+import org.apache.beam.sdk.values.WindowingStrategy;
+import org.apache.flink.api.common.JobID;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.joda.time.Duration;
+
+/** {@link SideInputReader} that caches single-pane materialized views within 
a TaskManager JVM. */
+public final class CachedSideInputReader implements SideInputReader {
+
+  public static CachedSideInputReader of(
+      JobID jobId,
+      int attemptNumber,
+      SideInputReader delegate,
+      Collection<PCollectionView<?>> cacheableViews) {
+    return new CachedSideInputReader(jobId, attemptNumber, delegate, 
cacheableViews);
+  }
+
+  static Collection<PCollectionView<?>> 
cacheableViews(Collection<PCollectionView<?>> sideInputs) {
+    Collection<PCollectionView<?>> cacheableViews = new ArrayList<>();
+    for (PCollectionView<?> view : sideInputs) {
+      PCollection<?> pCollection = view.getPCollection();
+      WindowingStrategy<?, ?> strategy = view.getWindowingStrategyInternal();
+      if (pCollection != null
+          && pCollection.isBounded() == PCollection.IsBounded.BOUNDED
+          && strategy.getTrigger() instanceof DefaultTrigger
+          && Duration.ZERO.equals(strategy.getAllowedLateness())) {
+        cacheableViews.add(view);
+      }
+    }
+    return Collections.unmodifiableCollection(cacheableViews);
+  }
+
+  private final JobID jobId;
+  private final int attemptNumber;
+  private final SideInputReader delegate;
+  private final Collection<PCollectionView<?>> cacheableViews;
+
+  private CachedSideInputReader(
+      JobID jobId,
+      int attemptNumber,
+      SideInputReader delegate,
+      Collection<PCollectionView<?>> cacheableViews) {
+    this.jobId = jobId;
+    this.attemptNumber = attemptNumber;
+    this.delegate = delegate;
+    this.cacheableViews = cacheableViews;
+  }
+
+  @Override
+  public <T> @Nullable T get(PCollectionView<T> view, BoundedWindow window) {
+    if (!cacheableViews.contains(view)) {
+      return delegate.get(view, window);
+    }
+    return SideInputCache.getOrMaterialize(
+        jobId, attemptNumber, view, window, () -> delegate.get(view, window));
+  }
+
+  @Override
+  public <T> boolean contains(PCollectionView<T> view) {
+    return delegate.contains(view);
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return delegate.isEmpty();
+  }
+}
diff --git 
a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/DoFnOperator.java
 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/DoFnOperator.java
index 4b619f1975e..fa30374f116 100644
--- 
a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/DoFnOperator.java
+++ 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/DoFnOperator.java
@@ -96,6 +96,7 @@ import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Joiner;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
+import org.apache.flink.api.common.JobID;
 import 
org.apache.flink.api.common.operators.ProcessingTimeService.ProcessingTimeCallback;
 import org.apache.flink.api.common.state.ListState;
 import org.apache.flink.api.common.state.ListStateDescriptor;
@@ -162,6 +163,7 @@ public class DoFnOperator<PreInputT, InputT, OutputT>
   protected final List<TupleTag<?>> additionalOutputTags;
 
   protected final Collection<PCollectionView<?>> sideInputs;
+  private final Collection<PCollectionView<?>> cacheableSideInputs;
   protected final Map<Integer, PCollectionView<?>> sideInputTagMapping;
 
   protected final WindowingStrategy<?, ?> windowingStrategy;
@@ -304,6 +306,7 @@ public class DoFnOperator<PreInputT, InputT, OutputT>
     this.additionalOutputTags = additionalOutputTags;
     this.sideInputTagMapping = sideInputTagMapping;
     this.sideInputs = sideInputs;
+    this.cacheableSideInputs = 
CachedSideInputReader.cacheableViews(sideInputs);
     this.serializedOptions = new SerializablePipelineOptions(options);
     this.isStreaming = 
serializedOptions.get().as(FlinkPipelineOptions.class).isStreaming();
     this.windowingStrategy = windowingStrategy;
@@ -473,7 +476,12 @@ public class DoFnOperator<PreInputT, InputT, OutputT>
               serializedOptions);
 
       sideInputHandler = new SideInputHandler(sideInputs, 
sideInputStateInternals);
-      sideInputReader = sideInputHandler;
+      sideInputReader =
+          createSideInputReader(
+              cacheableSideInputs,
+              getContainingTask().getEnvironment().getJobID(),
+              
getContainingTask().getEnvironment().getTaskInfo().getAttemptNumber(),
+              sideInputHandler);
 
       Stream<WindowedValue<InputT>> pushedBack = 
pushedBackElementsHandler.getElements();
       long min =
@@ -797,6 +805,27 @@ public class DoFnOperator<PreInputT, InputT, OutputT>
 
     PCollectionView<?> sideInput = 
sideInputTagMapping.get(streamRecord.getValue().getUnionTag());
     sideInputHandler.addSideInputValue(sideInput, value);
+    // Invalidate only after the state write: a concurrent reader that 
re-caches between an
+    // earlier invalidation and the write would pin the previous value with no 
later invalidation.
+    for (BoundedWindow window : value.getWindows()) {
+      SideInputCache.invalidate(
+          getContainingTask().getEnvironment().getJobID(),
+          
getContainingTask().getEnvironment().getTaskInfo().getAttemptNumber(),
+          sideInput,
+          window);
+    }
+  }
+
+  @VisibleForTesting
+  static SideInputReader createSideInputReader(
+      Collection<PCollectionView<?>> cacheableViews,
+      JobID jobId,
+      int attemptNumber,
+      SideInputReader delegate) {
+    if (!cacheableViews.isEmpty()) {
+      return CachedSideInputReader.of(jobId, attemptNumber, delegate, 
cacheableViews);
+    }
+    return delegate;
   }
 
   @Override
diff --git 
a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/SideInputCache.java
 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/SideInputCache.java
new file mode 100644
index 00000000000..ce49d8a5de9
--- /dev/null
+++ 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/SideInputCache.java
@@ -0,0 +1,117 @@
+/*
+ * 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.flink.translation.wrappers.streaming;
+
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.values.PCollectionView;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Throwables;
+import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.cache.Cache;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.cache.CacheBuilder;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.util.concurrent.UncheckedExecutionException;
+import org.apache.flink.api.common.JobID;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Process-wide cache of materialized side-input views. */
+final class SideInputCache {
+
+  // Materialized view sizes are unknown to the runner, so the cache cannot be 
bounded by weight;
+  // soft values let the JVM reclaim entries under memory pressure instead of 
failing with OOM.
+  // Operator instances must not clear job entries when they close because 
peer subtasks and later
+  // operators can still use them. Expiration bounds entries after their last 
access.
+  // Attempt-specific keys prevent restored state from using a value cached by 
an earlier attempt.
+  private static final Cache<Key<?>, Value<?>> MATERIALIZED_SIDE_INPUTS =
+      CacheBuilder.newBuilder().expireAfterAccess(5, 
TimeUnit.MINUTES).softValues().build();
+
+  private SideInputCache() {}
+
+  static <T> @Nullable T getOrMaterialize(
+      JobID jobId,
+      int attemptNumber,
+      PCollectionView<T> view,
+      BoundedWindow window,
+      Supplier<@Nullable T> materializer) {
+    @SuppressWarnings("unchecked")
+    Cache<Key<T>, Value<T>> cache =
+        (Cache<Key<T>, Value<T>>) (Cache<?, ?>) MATERIALIZED_SIDE_INPUTS;
+    try {
+      return cache
+          .get(new Key<>(jobId, attemptNumber, view, window), () -> new 
Value<>(materializer.get()))
+          .getValue();
+    } catch (ExecutionException | UncheckedExecutionException e) {
+      Throwable cause = e.getCause() != null ? e.getCause() : e;
+      Throwables.throwIfUnchecked(cause);
+      throw new RuntimeException(cause);
+    }
+  }
+
+  static void invalidate(
+      JobID jobId, int attemptNumber, PCollectionView<?> view, BoundedWindow 
window) {
+    MATERIALIZED_SIDE_INPUTS.invalidate(new Key<>(jobId, attemptNumber, view, 
window));
+  }
+
+  private static final class Key<T> {
+    private final JobID jobId;
+    private final int attemptNumber;
+    private final PCollectionView<T> view;
+    private final BoundedWindow window;
+
+    private Key(JobID jobId, int attemptNumber, PCollectionView<T> view, 
BoundedWindow window) {
+      this.jobId = jobId;
+      this.attemptNumber = attemptNumber;
+      this.view = view;
+      this.window = window;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object object) {
+      if (this == object) {
+        return true;
+      }
+      if (!(object instanceof Key)) {
+        return false;
+      }
+      Key<?> other = (Key<?>) object;
+      return Objects.equals(jobId, other.jobId)
+          && attemptNumber == other.attemptNumber
+          && Objects.equals(view, other.view)
+          && Objects.equals(window, other.window);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(jobId, attemptNumber, view, window);
+    }
+  }
+
+  /** Guava caches reject null values, but null is valid for a side-input 
reader. */
+  private static final class Value<T> {
+    private final @Nullable T value;
+
+    private Value(@Nullable T value) {
+      this.value = value;
+    }
+
+    private @Nullable T getValue() {
+      return value;
+    }
+  }
+}
diff --git 
a/runners/flink/2.0/src/test/java/org/apache/beam/runners/flink/FlinkPipelineOptionsTest.java
 
b/runners/flink/2.0/src/test/java/org/apache/beam/runners/flink/FlinkPipelineOptionsTest.java
index f1e35fafe83..f3cc919a7d4 100644
--- 
a/runners/flink/2.0/src/test/java/org/apache/beam/runners/flink/FlinkPipelineOptionsTest.java
+++ 
b/runners/flink/2.0/src/test/java/org/apache/beam/runners/flink/FlinkPipelineOptionsTest.java
@@ -97,7 +97,6 @@ public class FlinkPipelineOptionsTest {
     assertThat(options.getAllowNonRestoredState(), is(false));
     assertThat(options.getDisableMetrics(), is(false));
     assertThat(options.getFasterCopy(), is(false));
-
     assertThat(options.isStreaming(), is(false));
     assertThat(options.getMaxBundleSize(), is(5000L));
     assertThat(options.getMaxBundleTimeMills(), is(10000L));
diff --git 
a/runners/flink/2.0/src/test/java/org/apache/beam/runners/flink/translation/wrappers/streaming/FlinkCachedSideInputReaderTest.java
 
b/runners/flink/2.0/src/test/java/org/apache/beam/runners/flink/translation/wrappers/streaming/FlinkCachedSideInputReaderTest.java
new file mode 100644
index 00000000000..c71dcd29544
--- /dev/null
+++ 
b/runners/flink/2.0/src/test/java/org/apache/beam/runners/flink/translation/wrappers/streaming/FlinkCachedSideInputReaderTest.java
@@ -0,0 +1,283 @@
+/*
+ * 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.flink.translation.wrappers.streaming;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.beam.runners.core.SideInputReader;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.transforms.windowing.DefaultTrigger;
+import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
+import org.apache.beam.sdk.transforms.windowing.IntervalWindow;
+import org.apache.beam.sdk.transforms.windowing.Trigger;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionView;
+import org.apache.beam.sdk.values.WindowingStrategy;
+import org.apache.flink.api.common.JobID;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.joda.time.Duration;
+import org.joda.time.Instant;
+import org.junit.Test;
+
+/** Tests for cached materialization of Flink side-input views. */
+public class FlinkCachedSideInputReaderTest {
+
+  private static final int INITIAL_ATTEMPT = 0;
+  private static final int RETRY_ATTEMPT = 1;
+
+  @Test
+  public void repeatedGetMaterializesOnce() {
+    JobID jobId = new JobID();
+    PCollectionView<String> view = view();
+    CountingSideInputReader delegate = new CountingSideInputReader("value");
+    SideInputReader reader =
+        CachedSideInputReader.of(jobId, INITIAL_ATTEMPT, delegate, 
Collections.singleton(view));
+
+    assertThat(reader.get(view, GlobalWindow.INSTANCE), is("value"));
+    assertThat(reader.get(view, GlobalWindow.INSTANCE), is("value"));
+    assertThat(delegate.getCount(), is(1));
+  }
+
+  @Test
+  public void readerInstancesForSameJobShareMaterialization() {
+    JobID jobId = new JobID();
+    PCollectionView<String> view = view();
+    CountingSideInputReader delegate = new CountingSideInputReader("value");
+    Collection<PCollectionView<?>> views = Collections.singleton(view);
+
+    CachedSideInputReader.of(jobId, INITIAL_ATTEMPT, delegate, views)
+        .get(view, GlobalWindow.INSTANCE);
+    CachedSideInputReader.of(jobId, INITIAL_ATTEMPT, delegate, views)
+        .get(view, GlobalWindow.INSTANCE);
+
+    assertThat(delegate.getCount(), is(1));
+  }
+
+  @Test
+  public void retryAttemptRematerializesValue() {
+    JobID jobId = new JobID();
+    PCollectionView<String> view = view();
+    CountingSideInputReader delegate = new CountingSideInputReader("value");
+    Collection<PCollectionView<?>> views = Collections.singleton(view);
+
+    CachedSideInputReader.of(jobId, INITIAL_ATTEMPT, delegate, views)
+        .get(view, GlobalWindow.INSTANCE);
+    CachedSideInputReader.of(jobId, RETRY_ATTEMPT, delegate, views)
+        .get(view, GlobalWindow.INSTANCE);
+
+    assertThat(delegate.getCount(), is(2));
+  }
+
+  @Test
+  public void keyIncludesViewWindowAndJob() {
+    PCollectionView<String> firstView = view();
+    PCollectionView<String> secondView = view();
+    IntervalWindow firstWindow = new IntervalWindow(Instant.EPOCH, 
Instant.ofEpochMilli(10));
+    IntervalWindow secondWindow =
+        new IntervalWindow(Instant.ofEpochMilli(10), Instant.ofEpochMilli(20));
+    CountingSideInputReader delegate = new CountingSideInputReader("value");
+    JobID firstJob = new JobID();
+
+    Collection<PCollectionView<?>> views = Arrays.asList(firstView, 
secondView);
+    CachedSideInputReader.of(firstJob, INITIAL_ATTEMPT, delegate, views)
+        .get(firstView, firstWindow);
+    CachedSideInputReader.of(firstJob, INITIAL_ATTEMPT, delegate, views)
+        .get(secondView, firstWindow);
+    CachedSideInputReader.of(firstJob, INITIAL_ATTEMPT, delegate, views)
+        .get(firstView, secondWindow);
+    CachedSideInputReader.of(new JobID(), INITIAL_ATTEMPT, delegate, views)
+        .get(firstView, firstWindow);
+
+    assertThat(delegate.getCount(), is(4));
+  }
+
+  @Test
+  public void invalidateRematerializesValue() {
+    JobID jobId = new JobID();
+    PCollectionView<String> view = view();
+    CountingSideInputReader delegate = new CountingSideInputReader("value");
+    SideInputReader reader =
+        CachedSideInputReader.of(jobId, INITIAL_ATTEMPT, delegate, 
Collections.singleton(view));
+
+    reader.get(view, GlobalWindow.INSTANCE);
+    SideInputCache.invalidate(jobId, INITIAL_ATTEMPT, view, 
GlobalWindow.INSTANCE);
+    reader.get(view, GlobalWindow.INSTANCE);
+
+    assertThat(delegate.getCount(), is(2));
+  }
+
+  @Test
+  public void cachesNull() {
+    JobID jobId = new JobID();
+    PCollectionView<String> view = view();
+    CountingSideInputReader delegate = new CountingSideInputReader(null);
+    SideInputReader reader =
+        CachedSideInputReader.of(jobId, INITIAL_ATTEMPT, delegate, 
Collections.singleton(view));
+
+    assertThat(reader.get(view, GlobalWindow.INSTANCE), nullValue());
+    assertThat(reader.get(view, GlobalWindow.INSTANCE), nullValue());
+    assertThat(delegate.getCount(), is(1));
+  }
+
+  @Test
+  public void automaticallyWrapsReaderWithCacheableViews() {
+    JobID jobId = new JobID();
+    SideInputReader delegate = new CountingSideInputReader("value");
+    PCollectionView<String> view = cacheableView();
+    Collection<PCollectionView<?>> cacheableViews =
+        CachedSideInputReader.cacheableViews(Collections.singleton(view));
+
+    assertThat(
+        DoFnOperator.createSideInputReader(
+            Collections.emptyList(), jobId, INITIAL_ATTEMPT, delegate),
+        is(delegate));
+
+    assertThat(
+        DoFnOperator.createSideInputReader(cacheableViews, jobId, 
INITIAL_ATTEMPT, delegate),
+        instanceOf(CachedSideInputReader.class));
+  }
+
+  @Test
+  public void selectsOnlyBoundedDefaultTriggerViewsWithoutLateness() {
+    PCollectionView<String> cacheableView = cacheableView();
+    PCollectionView<String> unboundedView =
+        view(PCollection.IsBounded.UNBOUNDED, DefaultTrigger.of(), 
Duration.ZERO);
+    PCollectionView<String> customTriggerView =
+        view(PCollection.IsBounded.BOUNDED, mock(Trigger.class), 
Duration.ZERO);
+    PCollectionView<String> lateDataView =
+        view(PCollection.IsBounded.BOUNDED, DefaultTrigger.of(), 
Duration.standardMinutes(1));
+
+    Collection<PCollectionView<?>> cacheableViews =
+        CachedSideInputReader.cacheableViews(
+            Arrays.asList(cacheableView, unboundedView, customTriggerView, 
lateDataView));
+
+    assertThat(cacheableViews.size(), is(1));
+    assertThat(cacheableViews.contains(cacheableView), is(true));
+  }
+
+  @Test
+  public void nonCacheableViewAlwaysUsesDelegate() {
+    PCollectionView<String> cacheableView = view();
+    PCollectionView<String> nonCacheableView = view();
+    CountingSideInputReader delegate = new CountingSideInputReader("value");
+    SideInputReader reader =
+        CachedSideInputReader.of(
+            new JobID(), INITIAL_ATTEMPT, delegate, 
Collections.singleton(cacheableView));
+
+    reader.get(cacheableView, GlobalWindow.INSTANCE);
+    reader.get(cacheableView, GlobalWindow.INSTANCE);
+    reader.get(nonCacheableView, GlobalWindow.INSTANCE);
+    reader.get(nonCacheableView, GlobalWindow.INSTANCE);
+
+    assertThat(delegate.getCount(), is(3));
+  }
+
+  @Test
+  public void materializationExceptionPropagatesUnwrapped() {
+    PCollectionView<String> view = view();
+    SideInputReader reader =
+        CachedSideInputReader.of(
+            new JobID(),
+            INITIAL_ATTEMPT,
+            new SideInputReader() {
+              @Override
+              public <T> @Nullable T get(PCollectionView<T> view, 
BoundedWindow window) {
+                throw new IllegalStateException("materialization failed");
+              }
+
+              @Override
+              public <T> boolean contains(PCollectionView<T> view) {
+                return true;
+              }
+
+              @Override
+              public boolean isEmpty() {
+                return false;
+              }
+            },
+            Collections.singleton(view));
+
+    IllegalStateException exception =
+        assertThrows(IllegalStateException.class, () -> reader.get(view, 
GlobalWindow.INSTANCE));
+    assertThat(exception.getMessage(), is("materialization failed"));
+  }
+
+  private static <T> PCollectionView<T> cacheableView() {
+    return view(PCollection.IsBounded.BOUNDED, DefaultTrigger.of(), 
Duration.ZERO);
+  }
+
+  @SuppressWarnings("unchecked")
+  private static <T> PCollectionView<T> view(
+      PCollection.IsBounded bounded, Trigger trigger, Duration 
allowedLateness) {
+    PCollectionView<T> view = mock(PCollectionView.class);
+    PCollection<T> pCollection = mock(PCollection.class);
+    WindowingStrategy<?, ?> strategy = mock(WindowingStrategy.class);
+    doReturn(pCollection).when(view).getPCollection();
+    when(pCollection.isBounded()).thenReturn(bounded);
+    doReturn(strategy).when(view).getWindowingStrategyInternal();
+    when(strategy.getTrigger()).thenReturn(trigger);
+    when(strategy.getAllowedLateness()).thenReturn(allowedLateness);
+    return view;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static <T> PCollectionView<T> view() {
+    return mock(PCollectionView.class);
+  }
+
+  private static final class CountingSideInputReader implements 
SideInputReader {
+    private final AtomicInteger getCount = new AtomicInteger();
+    private final @Nullable Object value;
+
+    private CountingSideInputReader(@Nullable Object value) {
+      this.value = value;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <T> @Nullable T get(PCollectionView<T> view, BoundedWindow window) {
+      getCount.incrementAndGet();
+      return (T) value;
+    }
+
+    @Override
+    public <T> boolean contains(PCollectionView<T> view) {
+      return true;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return false;
+    }
+
+    private int getCount() {
+      return getCount.get();
+    }
+  }
+}
diff --git 
a/runners/flink/2.2/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/DoFnOperator.java
 
b/runners/flink/2.2/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/DoFnOperator.java
index 411fa71387e..74115c13c21 100644
--- 
a/runners/flink/2.2/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/DoFnOperator.java
+++ 
b/runners/flink/2.2/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/DoFnOperator.java
@@ -96,6 +96,7 @@ import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Joiner;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
+import org.apache.flink.api.common.JobID;
 import 
org.apache.flink.api.common.operators.ProcessingTimeService.ProcessingTimeCallback;
 import org.apache.flink.api.common.state.ListState;
 import org.apache.flink.api.common.state.ListStateDescriptor;
@@ -162,6 +163,7 @@ public class DoFnOperator<PreInputT, InputT, OutputT>
   protected final List<TupleTag<?>> additionalOutputTags;
 
   protected final Collection<PCollectionView<?>> sideInputs;
+  private final Collection<PCollectionView<?>> cacheableSideInputs;
   protected final Map<Integer, PCollectionView<?>> sideInputTagMapping;
 
   protected final WindowingStrategy<?, ?> windowingStrategy;
@@ -304,6 +306,7 @@ public class DoFnOperator<PreInputT, InputT, OutputT>
     this.additionalOutputTags = additionalOutputTags;
     this.sideInputTagMapping = sideInputTagMapping;
     this.sideInputs = sideInputs;
+    this.cacheableSideInputs = 
CachedSideInputReader.cacheableViews(sideInputs);
     this.serializedOptions = new SerializablePipelineOptions(options);
     this.isStreaming = 
serializedOptions.get().as(FlinkPipelineOptions.class).isStreaming();
     this.windowingStrategy = windowingStrategy;
@@ -473,7 +476,12 @@ public class DoFnOperator<PreInputT, InputT, OutputT>
               serializedOptions);
 
       sideInputHandler = new SideInputHandler(sideInputs, 
sideInputStateInternals);
-      sideInputReader = sideInputHandler;
+      sideInputReader =
+          createSideInputReader(
+              cacheableSideInputs,
+              getContainingTask().getEnvironment().getJobID(),
+              
getContainingTask().getEnvironment().getTaskInfo().getAttemptNumber(),
+              sideInputHandler);
 
       Stream<WindowedValue<InputT>> pushedBack = 
pushedBackElementsHandler.getElements();
       long min =
@@ -797,6 +805,27 @@ public class DoFnOperator<PreInputT, InputT, OutputT>
 
     PCollectionView<?> sideInput = 
sideInputTagMapping.get(streamRecord.getValue().getUnionTag());
     sideInputHandler.addSideInputValue(sideInput, value);
+    // Invalidate only after the state write: a concurrent reader that 
re-caches between an
+    // earlier invalidation and the write would pin the previous value with no 
later invalidation.
+    for (BoundedWindow window : value.getWindows()) {
+      SideInputCache.invalidate(
+          getContainingTask().getEnvironment().getJobID(),
+          
getContainingTask().getEnvironment().getTaskInfo().getAttemptNumber(),
+          sideInput,
+          window);
+    }
+  }
+
+  @VisibleForTesting
+  static SideInputReader createSideInputReader(
+      Collection<PCollectionView<?>> cacheableViews,
+      JobID jobId,
+      int attemptNumber,
+      SideInputReader delegate) {
+    if (!cacheableViews.isEmpty()) {
+      return CachedSideInputReader.of(jobId, attemptNumber, delegate, 
cacheableViews);
+    }
+    return delegate;
   }
 
   @Override
diff --git 
a/runners/flink/2.2/src/test/java/org/apache/beam/runners/flink/FlinkPipelineOptionsTest.java
 
b/runners/flink/2.2/src/test/java/org/apache/beam/runners/flink/FlinkPipelineOptionsTest.java
index 6cebadc49d5..06f3a39beab 100644
--- 
a/runners/flink/2.2/src/test/java/org/apache/beam/runners/flink/FlinkPipelineOptionsTest.java
+++ 
b/runners/flink/2.2/src/test/java/org/apache/beam/runners/flink/FlinkPipelineOptionsTest.java
@@ -111,7 +111,6 @@ public class FlinkPipelineOptionsTest {
     assertThat(options.getAllowNonRestoredState(), is(false));
     assertThat(options.getDisableMetrics(), is(false));
     assertThat(options.getFasterCopy(), is(false));
-
     assertThat(options.isStreaming(), is(false));
     assertThat(options.getMaxBundleSize(), is(5000L));
     assertThat(options.getMaxBundleTimeMills(), is(10000L));

Reply via email to