This is an automated email from the ASF dual-hosted git repository.

bowenli86 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/master by this push:
     new 6de8055d00c [FLINK-40135][python] Prevent Beam state access after 
runner shutdown (#28716)
6de8055d00c is described below

commit 6de8055d00c5c7afcc7ec68b8edd5e2c944fd2d2
Author: bowenli86 <[email protected]>
AuthorDate: Fri Oct 23 20:56:34 2026 -0700

    [FLINK-40135][python] Prevent Beam state access after runner shutdown 
(#28716)
---
 .../apache/flink/python/PythonFunctionRunner.java  |  10 +
 .../AbstractExternalPythonFunctionOperator.java    |  34 +-
 .../python/beam/BeamPythonFunctionRunner.java      | 432 +++++++++-
 .../runners/python/beam/PythonSharedResources.java |  29 +-
 .../python/beam/state/BeamStateRequestHandler.java |  69 +-
 ...AbstractExternalPythonFunctionOperatorTest.java | 176 ++++
 .../python/beam/BeamPythonFunctionRunnerTest.java  | 954 +++++++++++++++++++++
 .../beam/state/BeamStateRequestHandlerTest.java    | 176 ++++
 8 files changed, 1811 insertions(+), 69 deletions(-)

diff --git 
a/flink-python/src/main/java/org/apache/flink/python/PythonFunctionRunner.java 
b/flink-python/src/main/java/org/apache/flink/python/PythonFunctionRunner.java
index c637c0e659c..708e8c70e8a 100644
--- 
a/flink-python/src/main/java/org/apache/flink/python/PythonFunctionRunner.java
+++ 
b/flink-python/src/main/java/org/apache/flink/python/PythonFunctionRunner.java
@@ -34,6 +34,16 @@ public interface PythonFunctionRunner extends AutoCloseable {
     /** Tear-down the Python function runner. */
     void close() throws Exception;
 
+    /**
+     * Cancels the Python function runner.
+     *
+     * <p>The default implementation keeps compatibility with runners that do 
not need a separate
+     * cancellation path. Runners with a blocking graceful close should 
override this method.
+     */
+    default void cancel() throws Exception {
+        close();
+    }
+
     /**
      * Executes the Python function with the input byte array.
      *
diff --git 
a/flink-python/src/main/java/org/apache/flink/streaming/api/operators/python/process/AbstractExternalPythonFunctionOperator.java
 
b/flink-python/src/main/java/org/apache/flink/streaming/api/operators/python/process/AbstractExternalPythonFunctionOperator.java
index da50b5335f2..f0367fb6b95 100644
--- 
a/flink-python/src/main/java/org/apache/flink/streaming/api/operators/python/process/AbstractExternalPythonFunctionOperator.java
+++ 
b/flink-python/src/main/java/org/apache/flink/streaming/api/operators/python/process/AbstractExternalPythonFunctionOperator.java
@@ -27,6 +27,7 @@ import 
org.apache.flink.python.env.process.ProcessPythonEnvironmentManager;
 import 
org.apache.flink.streaming.api.operators.python.AbstractPythonFunctionOperator;
 import 
org.apache.flink.streaming.api.runners.python.beam.BeamPythonFunctionRunner;
 import org.apache.flink.table.functions.python.PythonEnv;
+import org.apache.flink.util.concurrent.ExecutorThreadFactory;
 
 import java.util.HashMap;
 import java.util.concurrent.ExecutorService;
@@ -55,19 +56,35 @@ public abstract class 
AbstractExternalPythonFunctionOperator<OUT>
         super.open();
         this.pythonFunctionRunner = createPythonFunctionRunner();
         this.pythonFunctionRunner.open(config);
-        this.flushThreadPool = Executors.newSingleThreadExecutor();
+        this.flushThreadPool =
+                Executors.newSingleThreadExecutor(
+                        new ExecutorThreadFactory("beam-python-bundle-flush"));
     }
 
     @Override
     public void close() throws Exception {
+        final PythonFunctionRunner functionRunner = pythonFunctionRunner;
+        final ExecutorService currentFlushThreadPool = flushThreadPool;
+        final boolean canceling =
+                getContainingTask().isCanceled() || 
getContainingTask().isFailing();
         try {
-            if (pythonFunctionRunner != null) {
-                pythonFunctionRunner.close();
+            if (canceling && currentFlushThreadPool != null) {
+                currentFlushThreadPool.shutdownNow();
+            }
+
+            if (functionRunner != null) {
+                if (canceling) {
+                    functionRunner.cancel();
+                } else {
+                    functionRunner.close();
+                }
                 pythonFunctionRunner = null;
             }
 
-            if (flushThreadPool != null) {
-                flushThreadPool.shutdown();
+            if (currentFlushThreadPool != null) {
+                if (!canceling) {
+                    currentFlushThreadPool.shutdown();
+                }
                 flushThreadPool = null;
             }
         } finally {
@@ -78,24 +95,25 @@ public abstract class 
AbstractExternalPythonFunctionOperator<OUT>
     @Override
     protected void invokeFinishBundle() throws Exception {
         if (elementCount > 0) {
+            final PythonFunctionRunner functionRunner = pythonFunctionRunner;
             AtomicBoolean flushThreadFinish = new AtomicBoolean(false);
             AtomicReference<Throwable> exceptionReference = new 
AtomicReference<>();
             flushThreadPool.submit(
                     () -> {
                         try {
-                            pythonFunctionRunner.flush();
+                            functionRunner.flush();
                         } catch (Throwable e) {
                             exceptionReference.set(e);
                         } finally {
                             flushThreadFinish.set(true);
                             // interrupt the progress of takeResult to avoid 
the main thread is
                             // blocked forever.
-                            ((BeamPythonFunctionRunner) 
pythonFunctionRunner).notifyNoMoreResults();
+                            ((BeamPythonFunctionRunner) 
functionRunner).notifyNoMoreResults();
                         }
                     });
             Tuple3<String, byte[], Integer> resultTuple;
             while (!flushThreadFinish.get()) {
-                resultTuple = pythonFunctionRunner.takeResult();
+                resultTuple = functionRunner.takeResult();
                 if (resultTuple.f2 != 0) {
                     emitResult(resultTuple);
                     emitResults();
diff --git 
a/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/BeamPythonFunctionRunner.java
 
b/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/BeamPythonFunctionRunner.java
index 7d89c93cc36..46d0ee1a6f4 100644
--- 
a/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/BeamPythonFunctionRunner.java
+++ 
b/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/BeamPythonFunctionRunner.java
@@ -39,9 +39,11 @@ import org.apache.flink.runtime.state.OperatorStateBackend;
 import 
org.apache.flink.streaming.api.operators.python.process.timer.TimerRegistration;
 import 
org.apache.flink.streaming.api.operators.python.process.timer.TimerRegistrationAction;
 import 
org.apache.flink.streaming.api.runners.python.beam.state.BeamStateRequestHandler;
+import org.apache.flink.util.ExceptionUtils;
 import org.apache.flink.util.Preconditions;
 import org.apache.flink.util.ShutdownHookUtil;
 import org.apache.flink.util.TemporaryClassLoaderContext;
+import org.apache.flink.util.concurrent.ExecutorThreadFactory;
 import org.apache.flink.util.function.LongFunctionWithException;
 
 import org.apache.beam.model.fnexecution.v1.BeamFnApi;
@@ -55,7 +57,6 @@ import 
org.apache.beam.runners.fnexecution.control.RemoteBundle;
 import org.apache.beam.runners.fnexecution.control.StageBundleFactory;
 import org.apache.beam.runners.fnexecution.control.TimerReceiverFactory;
 import org.apache.beam.runners.fnexecution.provisioning.JobInfo;
-import org.apache.beam.runners.fnexecution.state.StateRequestHandler;
 import org.apache.beam.sdk.coders.ByteArrayCoder;
 import org.apache.beam.sdk.coders.Coder;
 import org.apache.beam.sdk.fn.data.FnDataReceiver;
@@ -93,6 +94,11 @@ import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.function.BiConsumer;
 import java.util.stream.Collectors;
@@ -165,7 +171,7 @@ public abstract class BeamPythonFunctionRunner implements 
PythonFunctionRunner {
     private transient StageBundleFactory stageBundleFactory;
 
     /** Handler for state requests. */
-    private transient StateRequestHandler stateRequestHandler;
+    private transient BeamStateRequestHandler stateRequestHandler;
 
     /** Handler for bundle progress messages, both during bundle execution and 
on its completion. */
     private transient BundleProgressHandler progressHandler;
@@ -195,6 +201,32 @@ public abstract class BeamPythonFunctionRunner implements 
PythonFunctionRunner {
     /** The shared resource among Python operators of the same slot. */
     private transient OpaqueMemoryResource<PythonSharedResources> 
sharedResources;
 
+    /** Prevents duplicate teardown from explicit close and the JVM shutdown 
hook. */
+    private transient boolean closed;
+
+    /** Coordinates duplicate close calls until teardown has finished. */
+    @Nullable private transient CompletableFuture<Void> closeCompletion;
+
+    /** Whether cancellation should skip waiting for an in-flight bundle 
close. */
+    private transient boolean cancelRequested;
+
+    /** Coordinates resource teardown until lease disposal has finished. */
+    @Nullable private transient CompletableFuture<Void> 
resourceCloseCompletion;
+
+    /** Coordinates duplicate close and cancel calls while the state handler 
drains requests. */
+    @Nullable private transient CompletableFuture<Void> 
stateHandlerCloseCompletion;
+
+    /** Owns a detached bundle close after cancellation returns to the task 
cleanup path. */
+    @Nullable private transient ExecutorService 
cancellationBundleCloseExecutor;
+
+    /**
+     * The bundle close currently owned by a flush thread.
+     *
+     * <p>The operation remains reachable while it is running so a concurrent 
graceful close can
+     * wait for the same bundle close without attempting a second one.
+     */
+    @Nullable private transient BundleCloseOperation bundleCloseOperation;
+
     private transient Thread shutdownHook;
 
     private transient Environment environment;
@@ -328,45 +360,74 @@ public abstract class BeamPythonFunctionRunner implements 
PythonFunctionRunner {
 
         shutdownHook =
                 ShutdownHookUtil.addShutdownHook(
-                        this, BeamPythonFunctionRunner.class.getSimpleName(), 
LOG);
+                        this::cancel, 
BeamPythonFunctionRunner.class.getSimpleName(), LOG);
 
         unregisteredTimers = Collections.synchronizedList(new LinkedList<>());
     }
 
     @Override
     public void close() throws Exception {
+        if (!startClose()) {
+            awaitCloseCompletion();
+            return;
+        }
+
         try {
-            if (jobBundleFactory != null) {
-                jobBundleFactory.close();
+            try {
+                // Normal shutdown finishes this runner's bundle before 
tearing down its factory or
+                // shared lease. Cancellation uses the separate non-blocking 
path below.
+                flush();
+            } finally {
+                try {
+                    closeResources();
+                } finally {
+                    try {
+                        closeStateRequestHandler();
+                    } finally {
+                        removeShutdownHook();
+                    }
+                }
             }
         } finally {
-            jobBundleFactory = null;
+            completeClose();
         }
+    }
+
+    @Override
+    public void cancel() throws Exception {
+        final BundleCloseClaim bundleCloseClaim = requestCancel();
 
         try {
-            if (sharedResources != null) {
-                sharedResources.close();
-            } else {
-                // if sharedResources is not null, the close of 
environmentManager will be managed
-                // in sharedResources,
-                // otherwise, we need to close the environmentManager 
explicitly
-                environmentManager.close();
+            if (bundleCloseClaim != null && bundleCloseClaim.ownsBundleClose) {
+                closeBundleAfterCancellation(bundleCloseClaim.closeOperation);
             }
+            // Release only this runner's resource lease. If it is the final 
lease, the shared
+            // resource disposer owns factory teardown; otherwise co-located 
runners keep using the
+            // shared worker.
+            closeResources();
         } finally {
-            sharedResources = null;
-        }
-
-        if (shutdownHook != null) {
-            ShutdownHookUtil.removeShutdownHook(
-                    shutdownHook, 
BeamPythonFunctionRunner.class.getSimpleName(), LOG);
-            shutdownHook = null;
+            try {
+                // A bundle close already owned by another thread may still 
issue callbacks while
+                // it unwinds. Gate those callbacks before state backends are 
disposed.
+                closeStateRequestHandler();
+            } finally {
+                try {
+                    removeShutdownHook();
+                } finally {
+                    completeClose();
+                }
+            }
         }
     }
 
     @Override
     public void process(byte[] data) throws Exception {
-        checkInvokeStartBundle();
-        mainInputReceiver.accept(WindowedValues.valueInGlobalWindow(data));
+        final FnDataReceiver<WindowedValue<byte[]>> inputReceiver;
+        synchronized (this) {
+            checkInvokeStartBundle();
+            inputReceiver = mainInputReceiver;
+        }
+        inputReceiver.accept(WindowedValues.valueInGlobalWindow(data));
     }
 
     @Override
@@ -381,23 +442,34 @@ public abstract class BeamPythonFunctionRunner implements 
PythonFunctionRunner {
 
     @Override
     public void processTimer(byte[] timerData) throws Exception {
-        if (timerInputReceiver == null) {
-            checkInvokeStartBundle();
-            timerInputReceiver =
-                    Preconditions.checkNotNull(
-                            
Iterables.getOnlyElement(remoteBundle.getTimerReceivers().values()),
-                            "Failed to retrieve main input receiver.");
+        final FnDataReceiver<Timer> inputReceiver;
+        synchronized (this) {
+            if (timerInputReceiver == null) {
+                checkInvokeStartBundle();
+                timerInputReceiver =
+                        Preconditions.checkNotNull(
+                                
Iterables.getOnlyElement(remoteBundle.getTimerReceivers().values()),
+                                "Failed to retrieve main input receiver.");
+            }
+            inputReceiver = timerInputReceiver;
         }
 
         Timer<byte[]> timerValue = Timer.cleared(timerData, "", 
Collections.emptyList());
-        timerInputReceiver.accept(timerValue);
+        inputReceiver.accept(timerValue);
     }
 
     /** Checks whether to invoke startBundle. */
     private void checkInvokeStartBundle() {
+        if (closed) {
+            throw new IllegalStateException("Beam Python function runner is 
closed.");
+        }
         if (!bundleStarted) {
+            if (bundleCloseOperation != null && 
!bundleCloseOperation.isDone()) {
+                throw new IllegalStateException("The previous Beam bundle is 
still closing.");
+            }
             startBundle();
             bundleStarted = true;
+            bundleCloseOperation = null;
         }
     }
 
@@ -442,13 +514,34 @@ public abstract class BeamPythonFunctionRunner implements 
PythonFunctionRunner {
 
     @Override
     public void flush() throws Exception {
-        if (bundleStarted) {
-            try {
-                finishBundle();
-            } finally {
+        final BundleCloseOperation closeOperation;
+        final boolean ownsBundleClose;
+        synchronized (this) {
+            if (cancelRequested) {
+                return;
+            }
+
+            if (bundleStarted) {
+                closeOperation = new BundleCloseOperation(remoteBundle);
+                bundleCloseOperation = closeOperation;
                 bundleStarted = false;
+                clearBundleReferences();
+                ownsBundleClose = true;
+            } else {
+                closeOperation = bundleCloseOperation;
+                ownsBundleClose = false;
             }
         }
+
+        if (closeOperation == null) {
+            return;
+        }
+
+        if (ownsBundleClose) {
+            finishBundle(closeOperation);
+        } else {
+            closeOperation.await();
+        }
     }
 
     /** Interrupts the progress of takeResult. */
@@ -456,15 +549,274 @@ public abstract class BeamPythonFunctionRunner 
implements PythonFunctionRunner {
         resultBuffer.add(Tuple2.of(null, new byte[0]));
     }
 
-    private void finishBundle() {
+    private void finishBundle(BundleCloseOperation closeOperation) {
+        RuntimeException failure = null;
         try {
-            remoteBundle.close();
+            closeOperation.remoteBundle.close();
         } catch (Throwable t) {
-            throw new RuntimeException("Failed to close remote bundle", t);
+            failure = new RuntimeException("Failed to close remote bundle", t);
+            throw failure;
         } finally {
-            remoteBundle = null;
-            mainInputReceiver = null;
-            timerInputReceiver = null;
+            closeOperation.complete(failure);
+            if (isCancelRequested()) {
+                try {
+                    closeResources();
+                } catch (Exception e) {
+                    LOG.warn("Failed to clean up Python resources after 
cancellation.", e);
+                }
+            }
+        }
+    }
+
+    private synchronized boolean startClose() {
+        if (closed) {
+            return false;
+        }
+        closed = true;
+        closeCompletion = new CompletableFuture<>();
+        return true;
+    }
+
+    /**
+     * Starts cancellation without waiting for a concurrent bundle close.
+     *
+     * <p>If no flush thread owns the active bundle yet, cancellation detaches 
it and becomes
+     * responsible for finishing it asynchronously.
+     */
+    @Nullable
+    private synchronized BundleCloseClaim requestCancel() {
+        cancelRequested = true;
+        closed = true;
+        if (closeCompletion == null) {
+            closeCompletion = new CompletableFuture<>();
+        }
+
+        if (bundleStarted) {
+            final BundleCloseOperation closeOperation = new 
BundleCloseOperation(remoteBundle);
+            bundleCloseOperation = closeOperation;
+            bundleStarted = false;
+            clearBundleReferences();
+            return new BundleCloseClaim(closeOperation, true);
+        }
+        if (bundleCloseOperation != null && !bundleCloseOperation.isDone()) {
+            return new BundleCloseClaim(bundleCloseOperation, false);
+        }
+        clearBundleReferences();
+        return null;
+    }
+
+    private synchronized boolean isCancelRequested() {
+        return cancelRequested;
+    }
+
+    private void awaitCloseCompletion() throws Exception {
+        final CompletableFuture<Void> currentCloseCompletion;
+        synchronized (this) {
+            currentCloseCompletion = closeCompletion;
+        }
+        if (currentCloseCompletion != null) {
+            awaitCompletion(currentCloseCompletion);
+        }
+    }
+
+    private void completeClose() {
+        final CompletableFuture<Void> currentCloseCompletion;
+        synchronized (this) {
+            currentCloseCompletion = closeCompletion;
+        }
+        if (currentCloseCompletion != null) {
+            currentCloseCompletion.complete(null);
+        }
+    }
+
+    private void closeResources() throws Exception {
+        final JobBundleFactory ownedJobBundleFactory;
+        final OpaqueMemoryResource<PythonSharedResources> 
currentSharedResources;
+        final CompletableFuture<Void> closeCompletion;
+        final boolean ownsClose;
+        synchronized (this) {
+            if (resourceCloseCompletion == null) {
+                closeCompletion = new CompletableFuture<>();
+                resourceCloseCompletion = closeCompletion;
+                ownedJobBundleFactory = jobBundleFactory;
+                currentSharedResources = sharedResources;
+                jobBundleFactory = null;
+                sharedResources = null;
+                ownsClose = true;
+            } else {
+                closeCompletion = resourceCloseCompletion;
+                ownedJobBundleFactory = null;
+                currentSharedResources = null;
+                ownsClose = false;
+            }
+        }
+
+        if (!ownsClose) {
+            // Disposal holds the slot's SharedResources lock. Even an 
interrupted cancellation
+            // must wait for that lock to be released before task cleanup can 
finish.
+            try {
+                closeCompletion.join();
+            } catch (CompletionException e) {
+                ExceptionUtils.rethrowException(e.getCause());
+            }
+            return;
+        }
+
+        try {
+            if (currentSharedResources != null) {
+                try {
+                    currentSharedResources.close();
+                } finally {
+                    clearBundleReferences();
+                }
+            } else {
+                try {
+                    if (ownedJobBundleFactory != null) {
+                        ownedJobBundleFactory.close();
+                    }
+                } finally {
+                    try {
+                        environmentManager.close();
+                    } finally {
+                        clearBundleReferences();
+                    }
+                }
+            }
+            closeCompletion.complete(null);
+        } catch (Throwable t) {
+            closeCompletion.completeExceptionally(t);
+            throw t;
+        }
+    }
+
+    private void closeBundleAfterCancellation(BundleCloseOperation 
closeOperation) {
+        final ExecutorService executor;
+        synchronized (this) {
+            if (cancellationBundleCloseExecutor == null) {
+                cancellationBundleCloseExecutor =
+                        Executors.newSingleThreadExecutor(
+                                new 
ExecutorThreadFactory("beam-python-bundle-cancellation"));
+            }
+            executor = cancellationBundleCloseExecutor;
+        }
+        executor.execute(
+                () -> {
+                    try {
+                        finishBundle(closeOperation);
+                    } catch (Throwable t) {
+                        LOG.debug("Beam bundle close failed during 
cancellation.", t);
+                    } finally {
+                        executor.shutdown();
+                    }
+                });
+    }
+
+    private void closeStateRequestHandler() throws Exception {
+        final BeamStateRequestHandler currentStateRequestHandler;
+        final CompletableFuture<Void> closeCompletion;
+        final boolean ownsClose;
+        synchronized (this) {
+            if (stateHandlerCloseCompletion == null) {
+                closeCompletion = new CompletableFuture<>();
+                stateHandlerCloseCompletion = closeCompletion;
+                currentStateRequestHandler = stateRequestHandler;
+                stateRequestHandler = null;
+                ownsClose = true;
+            } else {
+                closeCompletion = stateHandlerCloseCompletion;
+                currentStateRequestHandler = null;
+                ownsClose = false;
+            }
+        }
+
+        if (ownsClose) {
+            RuntimeException failure = null;
+            try {
+                if (currentStateRequestHandler != null) {
+                    currentStateRequestHandler.close();
+                }
+            } catch (RuntimeException e) {
+                failure = e;
+                throw e;
+            } finally {
+                if (failure == null) {
+                    closeCompletion.complete(null);
+                } else {
+                    closeCompletion.completeExceptionally(failure);
+                }
+            }
+        } else {
+            awaitCompletion(closeCompletion);
+        }
+    }
+
+    private synchronized void clearBundleReferences() {
+        remoteBundle = null;
+        mainInputReceiver = null;
+        timerInputReceiver = null;
+    }
+
+    private void removeShutdownHook() {
+        final Thread currentShutdownHook;
+        synchronized (this) {
+            currentShutdownHook = shutdownHook;
+            shutdownHook = null;
+        }
+        if (currentShutdownHook != null) {
+            ShutdownHookUtil.removeShutdownHook(
+                    currentShutdownHook, 
BeamPythonFunctionRunner.class.getSimpleName(), LOG);
+        }
+    }
+
+    private static final class BundleCloseOperation {
+
+        private final RemoteBundle remoteBundle;
+        private final CompletableFuture<Void> completion = new 
CompletableFuture<>();
+
+        private BundleCloseOperation(RemoteBundle remoteBundle) {
+            this.remoteBundle = remoteBundle;
+        }
+
+        private boolean isDone() {
+            return completion.isDone();
+        }
+
+        private void complete(@Nullable RuntimeException failure) {
+            if (failure == null) {
+                completion.complete(null);
+            } else {
+                completion.completeExceptionally(failure);
+            }
+        }
+
+        private void await() throws Exception {
+            awaitCompletion(completion);
+        }
+    }
+
+    private static final class BundleCloseClaim {
+
+        private final BundleCloseOperation closeOperation;
+        private final boolean ownsBundleClose;
+
+        private BundleCloseClaim(BundleCloseOperation closeOperation, boolean 
ownsBundleClose) {
+            this.closeOperation = closeOperation;
+            this.ownsBundleClose = ownsBundleClose;
+        }
+    }
+
+    private static void awaitCompletion(CompletableFuture<Void> completion) 
throws Exception {
+        try {
+            completion.get();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw e;
+        } catch (ExecutionException e) {
+            final Throwable cause = e.getCause();
+            if (cause instanceof Exception) {
+                throw (Exception) cause;
+            }
+            throw new RuntimeException(cause);
         }
     }
 
@@ -735,7 +1087,7 @@ public abstract class BeamPythonFunctionRunner implements 
PythonFunctionRunner {
         return new TimerReceiverFactory(stageBundleFactory, timerDataConsumer, 
null);
     }
 
-    private static StateRequestHandler getStateRequestHandler(
+    private static BeamStateRequestHandler getStateRequestHandler(
             @Nullable KeyedStateBackend<?> keyedStateBackend,
             @Nullable OperatorStateBackend operatorStateBackend,
             @Nullable TypeSerializer<?> keySerializer,
diff --git 
a/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/PythonSharedResources.java
 
b/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/PythonSharedResources.java
index 293d5a5ad83..9eaf2fbb468 100644
--- 
a/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/PythonSharedResources.java
+++ 
b/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/PythonSharedResources.java
@@ -44,6 +44,8 @@ public final class PythonSharedResources implements 
AutoCloseable {
     /** Keep track of the PythonEnvironmentManagers of the Python operators in 
one slot. */
     private final List<PythonEnvironmentManager> environmentManagers;
 
+    private boolean closed;
+
     PythonSharedResources(JobBundleFactory jobBundleFactory, Environment 
environment) {
         this.jobBundleFactory = jobBundleFactory;
         this.environment = environment;
@@ -63,10 +65,31 @@ public final class PythonSharedResources implements 
AutoCloseable {
     }
 
     @Override
-    public void close() throws Exception {
-        jobBundleFactory.close();
+    public synchronized void close() throws Exception {
+        if (closed) {
+            return;
+        }
+
+        Exception exception = null;
+        try {
+            jobBundleFactory.close();
+        } catch (Exception e) {
+            exception = e;
+        }
         for (PythonEnvironmentManager environmentManager : 
environmentManagers) {
-            environmentManager.close();
+            try {
+                environmentManager.close();
+            } catch (Exception e) {
+                if (exception == null) {
+                    exception = e;
+                } else {
+                    exception.addSuppressed(e);
+                }
+            }
+        }
+        closed = true;
+        if (exception != null) {
+            throw exception;
         }
     }
 }
diff --git 
a/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/state/BeamStateRequestHandler.java
 
b/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/state/BeamStateRequestHandler.java
index fb6765dff20..34295b5a519 100644
--- 
a/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/state/BeamStateRequestHandler.java
+++ 
b/flink-python/src/main/java/org/apache/flink/streaming/api/runners/python/beam/state/BeamStateRequestHandler.java
@@ -37,12 +37,14 @@ import java.util.Collections;
 import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionStage;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
 
 /**
  * The handler for Beam state requests sent from Python side, which does 
actual operations on Flink
  * state.
  */
-public class BeamStateRequestHandler implements StateRequestHandler {
+public class BeamStateRequestHandler implements StateRequestHandler, 
AutoCloseable {
 
     private final BeamStateStore keyedStateStore;
 
@@ -54,6 +56,10 @@ public class BeamStateRequestHandler implements 
StateRequestHandler {
 
     private final BeamFnApi.ProcessBundleRequest.CacheToken cacheToken;
 
+    private final ReentrantReadWriteLock lifecycleLock = new 
ReentrantReadWriteLock(true);
+
+    private boolean closed;
+
     public BeamStateRequestHandler(
             BeamStateStore keyedStateStore,
             BeamStateStore operatorStateStore,
@@ -69,23 +75,50 @@ public class BeamStateRequestHandler implements 
StateRequestHandler {
     @Override
     public CompletionStage<BeamFnApi.StateResponse.Builder> 
handle(BeamFnApi.StateRequest request)
             throws Exception {
-        BeamFnApi.StateKey.TypeCase typeCase = 
request.getStateKey().getTypeCase();
-        ListState<byte[]> listState;
-        MapState<ByteArrayWrapper, byte[]> mapState;
-
-        switch (typeCase) {
-            case BAG_USER_STATE:
-                listState = keyedStateStore.getListState(request);
-                return CompletableFuture.completedFuture(
-                        bagStateHandler.handle(request, listState));
-            case MULTIMAP_SIDE_INPUT:
-                mapState = keyedStateStore.getMapState(request);
-                return 
CompletableFuture.completedFuture(mapStateHandler.handle(request, mapState));
-            case MULTIMAP_KEYS_SIDE_INPUT:
-                mapState = operatorStateStore.getMapState(request);
-                return 
CompletableFuture.completedFuture(mapStateHandler.handle(request, mapState));
-            default:
-                throw new RuntimeException("Unsupported state type: " + 
typeCase);
+        final Lock readLock = lifecycleLock.readLock();
+        readLock.lock();
+        try {
+            if (closed) {
+                throw new IllegalStateException("Beam state request handler is 
closed.");
+            }
+
+            BeamFnApi.StateKey.TypeCase typeCase = 
request.getStateKey().getTypeCase();
+            ListState<byte[]> listState;
+            MapState<ByteArrayWrapper, byte[]> mapState;
+
+            switch (typeCase) {
+                case BAG_USER_STATE:
+                    listState = keyedStateStore.getListState(request);
+                    return CompletableFuture.completedFuture(
+                            bagStateHandler.handle(request, listState));
+                case MULTIMAP_SIDE_INPUT:
+                    mapState = keyedStateStore.getMapState(request);
+                    return CompletableFuture.completedFuture(
+                            mapStateHandler.handle(request, mapState));
+                case MULTIMAP_KEYS_SIDE_INPUT:
+                    mapState = operatorStateStore.getMapState(request);
+                    return CompletableFuture.completedFuture(
+                            mapStateHandler.handle(request, mapState));
+                default:
+                    throw new RuntimeException("Unsupported state type: " + 
typeCase);
+            }
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    /**
+     * Stops accepting state requests and waits for requests that are already 
accessing Flink state
+     * to complete.
+     */
+    @Override
+    public void close() {
+        final Lock writeLock = lifecycleLock.writeLock();
+        writeLock.lock();
+        try {
+            closed = true;
+        } finally {
+            writeLock.unlock();
         }
     }
 
diff --git 
a/flink-python/src/test/java/org/apache/flink/streaming/api/operators/python/process/AbstractExternalPythonFunctionOperatorTest.java
 
b/flink-python/src/test/java/org/apache/flink/streaming/api/operators/python/process/AbstractExternalPythonFunctionOperatorTest.java
new file mode 100644
index 00000000000..fa0d307a562
--- /dev/null
+++ 
b/flink-python/src/test/java/org/apache/flink/streaming/api/operators/python/process/AbstractExternalPythonFunctionOperatorTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.flink.streaming.api.operators.python.process;
+
+import org.apache.flink.api.java.tuple.Tuple3;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.python.PythonFunctionRunner;
+import org.apache.flink.streaming.api.operators.AbstractStreamOperator;
+import org.apache.flink.streaming.runtime.tasks.StreamTask;
+import org.apache.flink.table.functions.python.PythonEnv;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AbstractExternalPythonFunctionOperatorTest {
+
+    @Test
+    void testCloseCancelsRunnerAfterStoppingFlushExecutor() throws Exception {
+        final StreamTask<?, ?> containingTask = 
org.mockito.Mockito.mock(StreamTask.class);
+        org.mockito.Mockito.when(containingTask.isCanceled()).thenReturn(true);
+        final ExecutorService flushThreadPool = 
Executors.newSingleThreadExecutor();
+        final TestingPythonFunctionRunner functionRunner =
+                new TestingPythonFunctionRunner(flushThreadPool);
+        final TestingExternalPythonFunctionOperator operator =
+                createOperator(containingTask, functionRunner, 
flushThreadPool);
+
+        try {
+            operator.close();
+
+            assertThat(functionRunner.cancelCalled).isTrue();
+            assertThat(functionRunner.closeCalled).isFalse();
+            
assertThat(functionRunner.flushExecutorStoppedBeforeCancel).isTrue();
+            assertThat(flushThreadPool.isShutdown()).isTrue();
+        } finally {
+            flushThreadPool.shutdownNow();
+        }
+    }
+
+    @Test
+    void testCloseClosesRunnerGracefullyForNormalCompletion() throws Exception 
{
+        final StreamTask<?, ?> containingTask = 
org.mockito.Mockito.mock(StreamTask.class);
+        final ExecutorService flushThreadPool = 
Executors.newSingleThreadExecutor();
+        final TestingPythonFunctionRunner functionRunner =
+                new TestingPythonFunctionRunner(flushThreadPool);
+        final TestingExternalPythonFunctionOperator operator =
+                createOperator(containingTask, functionRunner, 
flushThreadPool);
+
+        try {
+            operator.close();
+
+            assertThat(functionRunner.closeCalled).isTrue();
+            assertThat(functionRunner.cancelCalled).isFalse();
+            assertThat(flushThreadPool.isShutdown()).isTrue();
+        } finally {
+            flushThreadPool.shutdownNow();
+        }
+    }
+
+    private static TestingExternalPythonFunctionOperator createOperator(
+            StreamTask<?, ?> containingTask,
+            TestingPythonFunctionRunner functionRunner,
+            ExecutorService flushThreadPool)
+            throws ReflectiveOperationException {
+        final TestingExternalPythonFunctionOperator operator =
+                new TestingExternalPythonFunctionOperator(functionRunner);
+        setField(AbstractStreamOperator.class, operator, "container", 
containingTask);
+        setField(
+                AbstractExternalPythonFunctionOperator.class,
+                operator,
+                "flushThreadPool",
+                flushThreadPool);
+        return operator;
+    }
+
+    private static void setField(Class<?> owner, Object target, String 
fieldName, Object value)
+            throws ReflectiveOperationException {
+        final Field field = owner.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private static class TestingExternalPythonFunctionOperator
+            extends AbstractExternalPythonFunctionOperator<Object> {
+
+        private final PythonFunctionRunner functionRunner;
+
+        private TestingExternalPythonFunctionOperator(PythonFunctionRunner 
functionRunner) {
+            super(new Configuration());
+            this.functionRunner = functionRunner;
+            this.pythonFunctionRunner = functionRunner;
+        }
+
+        @Override
+        public PythonEnv getPythonEnv() {
+            return new PythonEnv(PythonEnv.ExecType.PROCESS);
+        }
+
+        @Override
+        public void emitResult(Tuple3<String, byte[], Integer> resultTuple) {}
+
+        @Override
+        public PythonFunctionRunner createPythonFunctionRunner() {
+            return functionRunner;
+        }
+    }
+
+    private static class TestingPythonFunctionRunner implements 
PythonFunctionRunner {
+
+        private final ExecutorService flushThreadPool;
+        private final AtomicBoolean closeCalled = new AtomicBoolean();
+        private final AtomicBoolean cancelCalled = new AtomicBoolean();
+        private final AtomicBoolean flushExecutorStoppedBeforeCancel = new 
AtomicBoolean();
+
+        private TestingPythonFunctionRunner(ExecutorService flushThreadPool) {
+            this.flushThreadPool = flushThreadPool;
+        }
+
+        @Override
+        public void open(org.apache.flink.configuration.ReadableConfig config) 
{}
+
+        @Override
+        public void close() {
+            closeCalled.set(true);
+        }
+
+        @Override
+        public void cancel() {
+            flushExecutorStoppedBeforeCancel.set(flushThreadPool.isShutdown());
+            cancelCalled.set(true);
+        }
+
+        @Override
+        public void process(byte[] data) {}
+
+        @Override
+        public void processTimer(byte[] timerData) {}
+
+        @Override
+        public void drainUnregisteredTimers() {}
+
+        @Override
+        public Tuple3<String, byte[], Integer> pollResult() {
+            return null;
+        }
+
+        @Override
+        public Tuple3<String, byte[], Integer> takeResult() {
+            return null;
+        }
+
+        @Override
+        public void flush() {}
+    }
+}
diff --git 
a/flink-python/src/test/java/org/apache/flink/streaming/api/runners/python/beam/BeamPythonFunctionRunnerTest.java
 
b/flink-python/src/test/java/org/apache/flink/streaming/api/runners/python/beam/BeamPythonFunctionRunnerTest.java
new file mode 100644
index 00000000000..44979995a82
--- /dev/null
+++ 
b/flink-python/src/test/java/org/apache/flink/streaming/api/runners/python/beam/BeamPythonFunctionRunnerTest.java
@@ -0,0 +1,954 @@
+/*
+ * 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.flink.streaming.api.runners.python.beam;
+
+import org.apache.flink.api.common.JobID;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.MapState;
+import org.apache.flink.fnexecution.v1.FlinkFnApi;
+import org.apache.flink.python.env.PythonDependencyInfo;
+import org.apache.flink.python.env.process.ProcessPythonEnvironmentManager;
+import org.apache.flink.runtime.memory.OpaqueMemoryResource;
+import org.apache.flink.runtime.memory.SharedResources;
+import 
org.apache.flink.streaming.api.runners.python.beam.state.BeamStateHandler;
+import 
org.apache.flink.streaming.api.runners.python.beam.state.BeamStateRequestHandler;
+import org.apache.flink.streaming.api.runners.python.beam.state.BeamStateStore;
+import org.apache.flink.streaming.api.utils.ByteArrayWrapper;
+
+import org.apache.beam.model.fnexecution.v1.BeamFnApi;
+import org.apache.beam.model.pipeline.v1.RunnerApi;
+import org.apache.beam.runners.fnexecution.control.JobBundleFactory;
+import org.apache.beam.runners.fnexecution.control.RemoteBundle;
+import org.apache.beam.runners.fnexecution.control.StageBundleFactory;
+import org.apache.beam.sdk.fn.data.FnDataReceiver;
+import org.apache.beam.sdk.util.construction.Timer;
+import org.apache.beam.sdk.util.construction.graph.ExecutableStage;
+import org.apache.beam.sdk.util.construction.graph.TimerReference;
+import org.apache.beam.sdk.values.KV;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.LockSupport;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class BeamPythonFunctionRunnerTest {
+
+    @Test
+    void testCloseDrainsStateHandlerAfterStoppingOwnedRequestProduction() 
throws Exception {
+        final AtomicBoolean stateAccessedDuringFactoryClose = new 
AtomicBoolean();
+        final AtomicBoolean remoteBundleClosed = new AtomicBoolean();
+        final BeamStateRequestHandler stateRequestHandler =
+                createStateRequestHandler(stateAccessedDuringFactoryClose);
+        final JobBundleFactory jobBundleFactory = new 
TestingJobBundleFactory(stateRequestHandler);
+        final TestingBeamPythonFunctionRunner runner =
+                new 
TestingBeamPythonFunctionRunner(createEnvironmentManager());
+        setField(runner, "jobBundleFactory", jobBundleFactory);
+        setField(runner, "stateRequestHandler", stateRequestHandler);
+        setField(
+                runner,
+                "remoteBundle",
+                new TestingRemoteBundle(stateRequestHandler, 
remoteBundleClosed));
+        setField(runner, "bundleStarted", true);
+
+        runner.close();
+
+        assertThat(stateAccessedDuringFactoryClose).isTrue();
+        assertThat(remoteBundleClosed).isTrue();
+        assertStateHandlerClosed(stateRequestHandler);
+    }
+
+    @Test
+    void testCloseDrainsStateHandlerForNonFinalManagedMemoryLease() throws 
Exception {
+        final AtomicBoolean stateAccessedDuringBundleClose = new 
AtomicBoolean();
+        final AtomicBoolean remoteBundleClosed = new AtomicBoolean();
+        final AtomicBoolean sharedFactoryClosed = new AtomicBoolean();
+        final BeamStateRequestHandler stateRequestHandler =
+                createStateRequestHandler(stateAccessedDuringBundleClose);
+        final TestingBeamPythonFunctionRunner runner =
+                new 
TestingBeamPythonFunctionRunner(createEnvironmentManager());
+        final PythonSharedResources pythonSharedResources =
+                new PythonSharedResources(
+                        new TrackingJobBundleFactory(sharedFactoryClosed),
+                        RunnerApi.Environment.getDefaultInstance());
+        final AtomicInteger sharedResourceLeases = new AtomicInteger(2);
+        final OpaqueMemoryResource<PythonSharedResources> sharedResources =
+                createSharedResourceLease(pythonSharedResources, 
sharedResourceLeases);
+        final OpaqueMemoryResource<PythonSharedResources> 
remainingSharedResourceLease =
+                createSharedResourceLease(pythonSharedResources, 
sharedResourceLeases);
+        setField(runner, "stateRequestHandler", stateRequestHandler);
+        setField(
+                runner,
+                "remoteBundle",
+                new TestingRemoteBundle(stateRequestHandler, 
remoteBundleClosed));
+        setField(runner, "bundleStarted", true);
+        setField(runner, "sharedResources", sharedResources);
+
+        runner.close();
+
+        assertThat(stateAccessedDuringBundleClose).isTrue();
+        assertThat(remoteBundleClosed).isTrue();
+        assertThat(sharedFactoryClosed).isFalse();
+        assertStateHandlerClosed(stateRequestHandler);
+
+        remainingSharedResourceLease.close();
+
+        assertThat(sharedFactoryClosed).isTrue();
+    }
+
+    @Test
+    void testCloseDrainsStateHandlerForFinalManagedMemoryLease() throws 
Exception {
+        final AtomicBoolean stateAccessedDuringFactoryClose = new 
AtomicBoolean();
+        final BeamStateRequestHandler stateRequestHandler =
+                createStateRequestHandler(stateAccessedDuringFactoryClose);
+        final PythonSharedResources pythonSharedResources =
+                new PythonSharedResources(
+                        new TestingJobBundleFactory(stateRequestHandler),
+                        RunnerApi.Environment.getDefaultInstance());
+        final OpaqueMemoryResource<PythonSharedResources> sharedResources =
+                new OpaqueMemoryResource<>(pythonSharedResources, 1L, 
pythonSharedResources::close);
+        final TestingBeamPythonFunctionRunner runner =
+                new 
TestingBeamPythonFunctionRunner(createEnvironmentManager());
+        setField(runner, "stateRequestHandler", stateRequestHandler);
+        setField(runner, "sharedResources", sharedResources);
+
+        runner.close();
+
+        assertThat(stateAccessedDuringFactoryClose).isTrue();
+        assertStateHandlerClosed(stateRequestHandler);
+    }
+
+    @Test
+    void testCloseWaitsForConcurrentOwnedBundleFlush() throws Exception {
+        final AtomicBoolean stateAccessedDuringBundleClose = new 
AtomicBoolean();
+        final AtomicBoolean ownedFactoryClosed = new AtomicBoolean();
+        final AtomicInteger remoteBundleCloseCalls = new AtomicInteger();
+        final AtomicReference<Thread> closeThread = new AtomicReference<>();
+        final CountDownLatch firstBundleCloseStarted = new CountDownLatch(1);
+        final CountDownLatch releaseFirstBundleClose = new CountDownLatch(1);
+        final CountDownLatch closeStarted = new CountDownLatch(1);
+        final BeamStateRequestHandler stateRequestHandler =
+                createStateRequestHandler(stateAccessedDuringBundleClose);
+        final TestingBeamPythonFunctionRunner runner =
+                new 
TestingBeamPythonFunctionRunner(createEnvironmentManager());
+        setField(runner, "jobBundleFactory", new 
TrackingJobBundleFactory(ownedFactoryClosed));
+        setField(runner, "stateRequestHandler", stateRequestHandler);
+        setField(
+                runner,
+                "remoteBundle",
+                new BlockingTestingRemoteBundle(
+                        stateRequestHandler,
+                        remoteBundleCloseCalls,
+                        firstBundleCloseStarted,
+                        releaseFirstBundleClose));
+        setField(runner, "bundleStarted", true);
+        final ExecutorService executor = Executors.newFixedThreadPool(2);
+
+        try {
+            final Future<?> flushFuture =
+                    executor.submit(
+                            () -> {
+                                runner.flush();
+                                return null;
+                            });
+            assertThat(firstBundleCloseStarted.await(10, 
TimeUnit.SECONDS)).isTrue();
+
+            final Future<?> closeFuture =
+                    executor.submit(
+                            () -> {
+                                closeThread.set(Thread.currentThread());
+                                closeStarted.countDown();
+                                runner.close();
+                                return null;
+                            });
+            assertThat(closeStarted.await(10, TimeUnit.SECONDS)).isTrue();
+            assertThreadIsWaitingForBundleClose(
+                    closeThread.get(), remoteBundleCloseCalls, closeFuture);
+            assertThat(closeFuture.isDone()).isFalse();
+            assertThat(remoteBundleCloseCalls).hasValue(1);
+            assertThat(ownedFactoryClosed).isFalse();
+
+            releaseFirstBundleClose.countDown();
+            flushFuture.get(10, TimeUnit.SECONDS);
+            closeFuture.get(10, TimeUnit.SECONDS);
+
+            assertThat(remoteBundleCloseCalls).hasValue(1);
+            assertThat(ownedFactoryClosed).isTrue();
+            assertThat(stateAccessedDuringBundleClose).isTrue();
+            assertStateHandlerClosed(stateRequestHandler);
+        } finally {
+            releaseFirstBundleClose.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testCloseWaitsForConcurrentManagedBundleFlush() throws Exception {
+        final AtomicBoolean stateAccessedDuringBundleClose = new 
AtomicBoolean();
+        final AtomicBoolean sharedFactoryClosed = new AtomicBoolean();
+        final AtomicInteger remoteBundleCloseCalls = new AtomicInteger();
+        final AtomicReference<Thread> closeThread = new AtomicReference<>();
+        final CountDownLatch firstBundleCloseStarted = new CountDownLatch(1);
+        final CountDownLatch releaseFirstBundleClose = new CountDownLatch(1);
+        final CountDownLatch closeStarted = new CountDownLatch(1);
+        final BeamStateRequestHandler stateRequestHandler =
+                createStateRequestHandler(stateAccessedDuringBundleClose);
+        final PythonSharedResources pythonSharedResources =
+                new PythonSharedResources(
+                        new TrackingJobBundleFactory(sharedFactoryClosed),
+                        RunnerApi.Environment.getDefaultInstance());
+        final OpaqueMemoryResource<PythonSharedResources> sharedResources =
+                new OpaqueMemoryResource<>(pythonSharedResources, 1L, 
pythonSharedResources::close);
+        final TestingBeamPythonFunctionRunner runner =
+                new 
TestingBeamPythonFunctionRunner(createEnvironmentManager());
+        setField(runner, "stateRequestHandler", stateRequestHandler);
+        setField(
+                runner,
+                "remoteBundle",
+                new BlockingTestingRemoteBundle(
+                        stateRequestHandler,
+                        remoteBundleCloseCalls,
+                        firstBundleCloseStarted,
+                        releaseFirstBundleClose));
+        setField(runner, "bundleStarted", true);
+        setField(runner, "sharedResources", sharedResources);
+        final ExecutorService executor = Executors.newFixedThreadPool(2);
+
+        try {
+            final Future<?> flushFuture =
+                    executor.submit(
+                            () -> {
+                                runner.flush();
+                                return null;
+                            });
+            assertThat(firstBundleCloseStarted.await(10, 
TimeUnit.SECONDS)).isTrue();
+
+            final Future<?> closeFuture =
+                    executor.submit(
+                            () -> {
+                                closeThread.set(Thread.currentThread());
+                                closeStarted.countDown();
+                                runner.close();
+                                return null;
+                            });
+            assertThat(closeStarted.await(10, TimeUnit.SECONDS)).isTrue();
+            assertThreadIsWaitingForBundleClose(
+                    closeThread.get(), remoteBundleCloseCalls, closeFuture);
+            assertThat(closeFuture.isDone()).isFalse();
+            assertThat(remoteBundleCloseCalls).hasValue(1);
+            assertThat(sharedFactoryClosed).isFalse();
+
+            releaseFirstBundleClose.countDown();
+            flushFuture.get(10, TimeUnit.SECONDS);
+            closeFuture.get(10, TimeUnit.SECONDS);
+
+            assertThat(remoteBundleCloseCalls).hasValue(1);
+            assertThat(sharedFactoryClosed).isTrue();
+            assertThat(stateAccessedDuringBundleClose).isTrue();
+            assertStateHandlerClosed(stateRequestHandler);
+        } finally {
+            releaseFirstBundleClose.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testCancelDoesNotWaitForConcurrentManagedBundleFlush() throws 
Exception {
+        final AtomicBoolean stateAccessedDuringBundleClose = new 
AtomicBoolean();
+        final AtomicBoolean sharedFactoryClosed = new AtomicBoolean();
+        final AtomicInteger remoteBundleCloseCalls = new AtomicInteger();
+        final CountDownLatch firstBundleCloseStarted = new CountDownLatch(1);
+        final CountDownLatch releaseFirstBundleClose = new CountDownLatch(1);
+        final BeamStateRequestHandler stateRequestHandler =
+                createStateRequestHandler(stateAccessedDuringBundleClose);
+        final PythonSharedResources pythonSharedResources =
+                new PythonSharedResources(
+                        new TrackingJobBundleFactory(sharedFactoryClosed),
+                        RunnerApi.Environment.getDefaultInstance());
+        final OpaqueMemoryResource<PythonSharedResources> sharedResources =
+                new OpaqueMemoryResource<>(pythonSharedResources, 1L, 
pythonSharedResources::close);
+        final TestingBeamPythonFunctionRunner runner =
+                new 
TestingBeamPythonFunctionRunner(createEnvironmentManager());
+        setField(runner, "stateRequestHandler", stateRequestHandler);
+        setField(
+                runner,
+                "remoteBundle",
+                new BlockingTestingRemoteBundle(
+                        stateRequestHandler,
+                        remoteBundleCloseCalls,
+                        firstBundleCloseStarted,
+                        releaseFirstBundleClose));
+        setField(runner, "bundleStarted", true);
+        setField(runner, "sharedResources", sharedResources);
+        final ExecutorService executor = Executors.newFixedThreadPool(2);
+
+        try {
+            final Future<?> flushFuture =
+                    executor.submit(
+                            () -> {
+                                runner.flush();
+                                return null;
+                            });
+            assertThat(firstBundleCloseStarted.await(10, 
TimeUnit.SECONDS)).isTrue();
+
+            final Future<?> cancelFuture =
+                    executor.submit(
+                            () -> {
+                                runner.cancel();
+                                return null;
+                            });
+            cancelFuture.get(10, TimeUnit.SECONDS);
+
+            assertThat(remoteBundleCloseCalls).hasValue(1);
+            assertThat(sharedFactoryClosed).isTrue();
+            assertThat(stateAccessedDuringBundleClose).isFalse();
+            assertStateHandlerClosed(stateRequestHandler);
+
+            releaseFirstBundleClose.countDown();
+            assertThatThrownBy(() -> flushFuture.get(10, TimeUnit.SECONDS))
+                    .hasRootCauseInstanceOf(IllegalStateException.class);
+            assertThat(sharedFactoryClosed).isTrue();
+            assertThat(remoteBundleCloseCalls).hasValue(1);
+            assertThat(stateAccessedDuringBundleClose).isFalse();
+        } finally {
+            releaseFirstBundleClose.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testCancelRetainsUnclaimedManagedBundleCleanupOwnership() throws 
Exception {
+        final AtomicBoolean stateAccessedDuringBundleClose = new 
AtomicBoolean();
+        final AtomicBoolean sharedFactoryClosed = new AtomicBoolean();
+        final AtomicInteger remoteBundleCloseCalls = new AtomicInteger();
+        final CountDownLatch bundleCloseStarted = new CountDownLatch(1);
+        final CountDownLatch releaseBundleClose = new CountDownLatch(1);
+        final CountDownLatch bundleCloseFinished = new CountDownLatch(1);
+        final BeamStateRequestHandler stateRequestHandler =
+                createStateRequestHandler(stateAccessedDuringBundleClose);
+        final PythonSharedResources pythonSharedResources =
+                new PythonSharedResources(
+                        new TrackingJobBundleFactory(sharedFactoryClosed),
+                        RunnerApi.Environment.getDefaultInstance());
+        final AtomicInteger sharedResourceLeases = new AtomicInteger(2);
+        final OpaqueMemoryResource<PythonSharedResources> sharedResources =
+                createSharedResourceLease(pythonSharedResources, 
sharedResourceLeases);
+        final OpaqueMemoryResource<PythonSharedResources> 
remainingSharedResourceLease =
+                createSharedResourceLease(pythonSharedResources, 
sharedResourceLeases);
+        final TestingBeamPythonFunctionRunner runner =
+                new 
TestingBeamPythonFunctionRunner(createEnvironmentManager());
+        setField(runner, "stateRequestHandler", stateRequestHandler);
+        setField(
+                runner,
+                "remoteBundle",
+                new BlockingTestingRemoteBundle(
+                        stateRequestHandler,
+                        remoteBundleCloseCalls,
+                        bundleCloseStarted,
+                        releaseBundleClose,
+                        bundleCloseFinished));
+        setField(runner, "bundleStarted", true);
+        setField(runner, "sharedResources", sharedResources);
+
+        try {
+            runner.cancel();
+            assertThat(bundleCloseStarted.await(10, 
TimeUnit.SECONDS)).isTrue();
+            runner.flush();
+
+            assertThat(remoteBundleCloseCalls).hasValue(1);
+            assertThat(sharedFactoryClosed).isFalse();
+            assertStateHandlerClosed(stateRequestHandler);
+
+            releaseBundleClose.countDown();
+            assertThat(bundleCloseFinished.await(10, 
TimeUnit.SECONDS)).isTrue();
+            assertRemainingSharedResourceLeases(sharedResourceLeases, 1);
+            assertThat(stateAccessedDuringBundleClose).isFalse();
+            assertThat(sharedFactoryClosed).isFalse();
+
+            remainingSharedResourceLease.close();
+
+            assertThat(sharedFactoryClosed).isTrue();
+        } finally {
+            releaseBundleClose.countDown();
+            remainingSharedResourceLease.close();
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({"false, false", "true, false", "false, true", "true, true"})
+    void testCancelWaitsForConcurrentFinalLeaseDisposal(
+            boolean failDisposal, boolean interruptCancel) throws Exception {
+        final AtomicInteger factoryCloseCalls = new AtomicInteger();
+        final AtomicBoolean cancelInterrupted = new AtomicBoolean();
+        final AtomicReference<Thread> cancelThread = new AtomicReference<>();
+        final CountDownLatch resourceCleanupStarted = new CountDownLatch(1);
+        final CountDownLatch releaseResourceCleanup = new CountDownLatch(1);
+        final RuntimeException disposalFailure = new 
RuntimeException("Resource disposal failed.");
+        final JobBundleFactory jobBundleFactory =
+                new TrackingJobBundleFactory(new AtomicBoolean()) {
+                    @Override
+                    public void close() {
+                        factoryCloseCalls.incrementAndGet();
+                        resourceCleanupStarted.countDown();
+                        try {
+                            assertThat(releaseResourceCleanup.await(10, 
TimeUnit.SECONDS)).isTrue();
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                            throw new RuntimeException(e);
+                        }
+                        if (failDisposal) {
+                            throw disposalFailure;
+                        }
+                    }
+                };
+        final PythonSharedResources pythonSharedResources =
+                new PythonSharedResources(
+                        jobBundleFactory, 
RunnerApi.Environment.getDefaultInstance());
+        final SharedResources registry = new SharedResources();
+        final Object leaseHolder = new Object();
+        final SharedResources.ResourceAndSize<PythonSharedResources> resource =
+                registry.getOrAllocateSharedResource(
+                        "python", leaseHolder, ignored -> 
pythonSharedResources, 1L);
+        final OpaqueMemoryResource<PythonSharedResources> sharedResources =
+                new OpaqueMemoryResource<>(
+                        resource.resourceHandle(),
+                        resource.size(),
+                        () -> registry.release("python", leaseHolder, ignored 
-> {}));
+        final BeamStateRequestHandler stateRequestHandler =
+                createStateRequestHandler(new AtomicBoolean());
+        final TestingBeamPythonFunctionRunner runner =
+                new 
TestingBeamPythonFunctionRunner(createEnvironmentManager());
+        final ExecutorService cancellationExecutor =
+                new CleanupAwaitingExecutor(resourceCleanupStarted, 
interruptCancel);
+        setField(runner, "stateRequestHandler", stateRequestHandler);
+        setField(
+                runner,
+                "remoteBundle",
+                new TestingRemoteBundle(stateRequestHandler, new 
AtomicBoolean()));
+        setField(runner, "bundleStarted", true);
+        setField(runner, "sharedResources", sharedResources);
+        setField(runner, "cancellationBundleCloseExecutor", 
cancellationExecutor);
+        final ExecutorService executor = Executors.newSingleThreadExecutor();
+
+        try {
+            final Future<?> cancelFuture =
+                    executor.submit(
+                            () -> {
+                                cancelThread.set(Thread.currentThread());
+                                try {
+                                    runner.cancel();
+                                } finally {
+                                    
cancelInterrupted.set(Thread.currentThread().isInterrupted());
+                                }
+                                return null;
+                            });
+            assertThat(resourceCleanupStarted.await(10, 
TimeUnit.SECONDS)).isTrue();
+            assertThreadIsWaitingForBundleClose(
+                    cancelThread.get(), factoryCloseCalls, cancelFuture);
+            assertThat(cancelFuture.isDone()).isFalse();
+            assertThat(factoryCloseCalls).hasValue(1);
+
+            releaseResourceCleanup.countDown();
+            if (failDisposal) {
+                assertThatThrownBy(() -> cancelFuture.get(10, 
TimeUnit.SECONDS))
+                        .hasCause(disposalFailure);
+            } else {
+                cancelFuture.get(10, TimeUnit.SECONDS);
+            }
+            assertStateHandlerClosed(stateRequestHandler);
+            assertThat(factoryCloseCalls).hasValue(1);
+            assertThat(cancelInterrupted.get()).isEqualTo(interruptCancel);
+
+            final AtomicBoolean replacementFactoryClosed = new AtomicBoolean();
+            executor.submit(
+                            () -> {
+                                final Object replacementLeaseHolder = new 
Object();
+                                registry.getOrAllocateSharedResource(
+                                        "python",
+                                        replacementLeaseHolder,
+                                        ignored ->
+                                                new PythonSharedResources(
+                                                        new 
TrackingJobBundleFactory(
+                                                                
replacementFactoryClosed),
+                                                        
RunnerApi.Environment.getDefaultInstance()),
+                                        1L);
+                                registry.release("python", 
replacementLeaseHolder, ignored -> {});
+                                return null;
+                            })
+                    .get(10, TimeUnit.SECONDS);
+            assertThat(replacementFactoryClosed).isTrue();
+            assertThat(factoryCloseCalls).hasValue(1);
+        } finally {
+            releaseResourceCleanup.countDown();
+            cancellationExecutor.shutdownNow();
+            executor.shutdownNow();
+            assertThat(cancellationExecutor.awaitTermination(10, 
TimeUnit.SECONDS)).isTrue();
+            assertThat(executor.awaitTermination(10, 
TimeUnit.SECONDS)).isTrue();
+        }
+    }
+
+    @Test
+    void testConcurrentCloseIsIdempotent() throws Exception {
+        final AtomicInteger stateHandlerCloseCalls = new AtomicInteger();
+        final AtomicReference<Thread> secondCloseThread = new 
AtomicReference<>();
+        final CountDownLatch firstStateHandlerCloseStarted = new 
CountDownLatch(1);
+        final CountDownLatch releaseFirstStateHandlerClose = new 
CountDownLatch(1);
+        final CountDownLatch secondCloseStarted = new CountDownLatch(1);
+        final ProcessPythonEnvironmentManager environmentManager = 
createEnvironmentManager();
+        environmentManager.open();
+        final Path environmentDirectory = 
Paths.get(environmentManager.getBaseDirectory());
+        final BlockingCloseBeamStateRequestHandler stateRequestHandler =
+                new BlockingCloseBeamStateRequestHandler(
+                        stateHandlerCloseCalls,
+                        firstStateHandlerCloseStarted,
+                        releaseFirstStateHandlerClose);
+        final PythonSharedResources pythonSharedResources =
+                new PythonSharedResources(
+                        new TrackingJobBundleFactory(new AtomicBoolean()),
+                        RunnerApi.Environment.getDefaultInstance());
+        pythonSharedResources.addPythonEnvironmentManager(environmentManager);
+        final AtomicInteger sharedResourceLeases = new AtomicInteger(2);
+        final OpaqueMemoryResource<PythonSharedResources> sharedResources =
+                createSharedResourceLease(pythonSharedResources, 
sharedResourceLeases);
+        final OpaqueMemoryResource<PythonSharedResources> 
remainingSharedResourceLease =
+                createSharedResourceLease(pythonSharedResources, 
sharedResourceLeases);
+        final TestingBeamPythonFunctionRunner runner =
+                new TestingBeamPythonFunctionRunner(environmentManager);
+        setField(runner, "stateRequestHandler", stateRequestHandler);
+        setField(runner, "sharedResources", sharedResources);
+        final ExecutorService executor = Executors.newFixedThreadPool(2);
+
+        try {
+            final Future<?> firstCloseFuture =
+                    executor.submit(
+                            () -> {
+                                runner.close();
+                                return null;
+                            });
+            assertThat(firstStateHandlerCloseStarted.await(10, 
TimeUnit.SECONDS)).isTrue();
+
+            final Future<?> secondCloseFuture =
+                    executor.submit(
+                            () -> {
+                                secondCloseThread.set(Thread.currentThread());
+                                secondCloseStarted.countDown();
+                                runner.close();
+                                return null;
+                            });
+            assertThat(secondCloseStarted.await(10, 
TimeUnit.SECONDS)).isTrue();
+            assertThreadIsWaitingForBundleClose(
+                    secondCloseThread.get(), stateHandlerCloseCalls, 
secondCloseFuture);
+            assertThat(secondCloseFuture.isDone()).isFalse();
+            assertThat(stateHandlerCloseCalls).hasValue(1);
+            assertThat(Files.exists(environmentDirectory)).isTrue();
+
+            releaseFirstStateHandlerClose.countDown();
+            firstCloseFuture.get(10, TimeUnit.SECONDS);
+            secondCloseFuture.get(10, TimeUnit.SECONDS);
+
+            assertThat(stateHandlerCloseCalls).hasValue(1);
+            assertThat(Files.exists(environmentDirectory)).isTrue();
+        } finally {
+            releaseFirstStateHandlerClose.countDown();
+            executor.shutdownNow();
+            remainingSharedResourceLease.close();
+            environmentManager.close();
+        }
+    }
+
+    private static void assertThreadIsWaitingForBundleClose(
+            Thread closeThread, AtomicInteger concurrentCloseCalls, Future<?> 
closeFuture) {
+        final long timeoutNanos = TimeUnit.SECONDS.toNanos(10);
+        final long deadlineNanos = System.nanoTime() + timeoutNanos;
+        while (System.nanoTime() < deadlineNanos) {
+            if (closeFuture.isDone()) {
+                assertThat(closeFuture.isDone()).isFalse();
+            }
+            if (concurrentCloseCalls.get() > 1) {
+                assertThat(concurrentCloseCalls).hasValue(1);
+            }
+            if (closeThread.getState() == Thread.State.WAITING) {
+                return;
+            }
+            LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
+        }
+        assertThat(closeThread.getState()).isEqualTo(Thread.State.WAITING);
+    }
+
+    private static void assertRemainingSharedResourceLeases(
+            AtomicInteger remainingLeases, int expectedLeases) {
+        final long deadlineNanos = System.nanoTime() + 
TimeUnit.SECONDS.toNanos(10);
+        while (System.nanoTime() < deadlineNanos) {
+            if (remainingLeases.get() == expectedLeases) {
+                return;
+            }
+            LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
+        }
+        assertThat(remainingLeases).hasValue(expectedLeases);
+    }
+
+    private static OpaqueMemoryResource<PythonSharedResources> 
createSharedResourceLease(
+            PythonSharedResources pythonSharedResources, AtomicInteger 
remainingLeases) {
+        return new OpaqueMemoryResource<>(
+                pythonSharedResources,
+                1L,
+                () -> {
+                    if (remainingLeases.decrementAndGet() == 0) {
+                        pythonSharedResources.close();
+                    }
+                });
+    }
+
+    private static BeamStateRequestHandler 
createStateRequestHandler(AtomicBoolean stateAccessed) {
+        final BeamStateStore keyedStateStore =
+                new BeamStateStore() {
+                    @Override
+                    public ListState<byte[]> 
getListState(BeamFnApi.StateRequest request) {
+                        stateAccessed.set(true);
+                        return null;
+                    }
+
+                    @Override
+                    public MapState<ByteArrayWrapper, byte[]> getMapState(
+                            BeamFnApi.StateRequest request) {
+                        throw new UnsupportedOperationException();
+                    }
+                };
+        return new BeamStateRequestHandler(
+                keyedStateStore,
+                BeamStateStore.unsupported(),
+                new NoOpBeamStateHandler<>(),
+                new NoOpBeamStateHandler<>());
+    }
+
+    private static void assertStateHandlerClosed(BeamStateRequestHandler 
stateRequestHandler) {
+        assertThatThrownBy(() -> 
stateRequestHandler.handle(createBagUserStateRequest()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("Beam state request handler is closed.");
+    }
+
+    private static ProcessPythonEnvironmentManager createEnvironmentManager() {
+        return new ProcessPythonEnvironmentManager(
+                new PythonDependencyInfo(
+                        Collections.emptyMap(), null, null, 
Collections.emptyMap(), "python"),
+                new String[] {System.getProperty("java.io.tmpdir")},
+                Collections.emptyMap(),
+                new JobID());
+    }
+
+    private static void setField(Object target, String fieldName, Object value)
+            throws ReflectiveOperationException {
+        final Field field = 
BeamPythonFunctionRunner.class.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private static BeamFnApi.StateRequest createBagUserStateRequest() {
+        return BeamFnApi.StateRequest.newBuilder()
+                .setStateKey(
+                        BeamFnApi.StateKey.newBuilder()
+                                .setBagUserState(
+                                        
BeamFnApi.StateKey.BagUserState.getDefaultInstance()))
+                .setGet(BeamFnApi.StateGetRequest.getDefaultInstance())
+                .build();
+    }
+
+    private static class TestingJobBundleFactory implements JobBundleFactory {
+
+        private final BeamStateRequestHandler stateRequestHandler;
+
+        private TestingJobBundleFactory(BeamStateRequestHandler 
stateRequestHandler) {
+            this.stateRequestHandler = stateRequestHandler;
+        }
+
+        @Override
+        public StageBundleFactory forStage(ExecutableStage executableStage) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void close() throws Exception {
+            stateRequestHandler.handle(createBagUserStateRequest());
+        }
+    }
+
+    private static class TestingRemoteBundle implements RemoteBundle {
+
+        private final BeamStateRequestHandler stateRequestHandler;
+        private final AtomicBoolean closed;
+
+        private TestingRemoteBundle(
+                BeamStateRequestHandler stateRequestHandler, AtomicBoolean 
closed) {
+            this.stateRequestHandler = stateRequestHandler;
+            this.closed = closed;
+        }
+
+        @Override
+        public String getId() {
+            return "test-bundle";
+        }
+
+        @Override
+        public Map<String, FnDataReceiver> getInputReceivers() {
+            return Collections.emptyMap();
+        }
+
+        @Override
+        public Map<KV<String, String>, FnDataReceiver<Timer>> 
getTimerReceivers() {
+            return Collections.emptyMap();
+        }
+
+        @Override
+        public void requestProgress() {}
+
+        @Override
+        public void split(double fractionOfRemainder) {}
+
+        @Override
+        public void close() throws Exception {
+            closed.set(true);
+            stateRequestHandler.handle(createBagUserStateRequest());
+        }
+    }
+
+    private static class BlockingTestingRemoteBundle extends 
TestingRemoteBundle {
+
+        private final BeamStateRequestHandler stateRequestHandler;
+        private final AtomicInteger closeCalls;
+        private final CountDownLatch firstCloseStarted;
+        private final CountDownLatch releaseFirstClose;
+        private final CountDownLatch closeFinished;
+
+        private BlockingTestingRemoteBundle(
+                BeamStateRequestHandler stateRequestHandler,
+                AtomicInteger closeCalls,
+                CountDownLatch firstCloseStarted,
+                CountDownLatch releaseFirstClose) {
+            this(
+                    stateRequestHandler,
+                    closeCalls,
+                    firstCloseStarted,
+                    releaseFirstClose,
+                    new CountDownLatch(0));
+        }
+
+        private BlockingTestingRemoteBundle(
+                BeamStateRequestHandler stateRequestHandler,
+                AtomicInteger closeCalls,
+                CountDownLatch firstCloseStarted,
+                CountDownLatch releaseFirstClose,
+                CountDownLatch closeFinished) {
+            super(stateRequestHandler, new AtomicBoolean());
+            this.stateRequestHandler = stateRequestHandler;
+            this.closeCalls = closeCalls;
+            this.firstCloseStarted = firstCloseStarted;
+            this.releaseFirstClose = releaseFirstClose;
+            this.closeFinished = closeFinished;
+        }
+
+        @Override
+        public void close() throws Exception {
+            try {
+                if (closeCalls.incrementAndGet() == 1) {
+                    firstCloseStarted.countDown();
+                    awaitUninterruptibly(releaseFirstClose);
+                }
+                stateRequestHandler.handle(createBagUserStateRequest());
+            } finally {
+                closeFinished.countDown();
+            }
+        }
+
+        private static void awaitUninterruptibly(CountDownLatch latch) {
+            boolean interrupted = false;
+            while (true) {
+                try {
+                    latch.await();
+                    break;
+                } catch (InterruptedException e) {
+                    interrupted = true;
+                }
+            }
+            if (interrupted) {
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
+
+    private static class BlockingCloseBeamStateRequestHandler extends 
BeamStateRequestHandler {
+
+        private final AtomicInteger closeCalls;
+        private final CountDownLatch firstCloseStarted;
+        private final CountDownLatch releaseFirstClose;
+
+        private BlockingCloseBeamStateRequestHandler(
+                AtomicInteger closeCalls,
+                CountDownLatch firstCloseStarted,
+                CountDownLatch releaseFirstClose) {
+            super(
+                    BeamStateStore.unsupported(),
+                    BeamStateStore.unsupported(),
+                    new NoOpBeamStateHandler<>(),
+                    new NoOpBeamStateHandler<>());
+            this.closeCalls = closeCalls;
+            this.firstCloseStarted = firstCloseStarted;
+            this.releaseFirstClose = releaseFirstClose;
+        }
+
+        @Override
+        public void close() {
+            if (closeCalls.incrementAndGet() == 1) {
+                firstCloseStarted.countDown();
+                try {
+                    releaseFirstClose.await();
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new RuntimeException("Interrupted while closing 
state handler.", e);
+                }
+            }
+            super.close();
+        }
+    }
+
+    private static class TrackingJobBundleFactory implements JobBundleFactory {
+
+        private final AtomicBoolean closed;
+
+        private TrackingJobBundleFactory(AtomicBoolean closed) {
+            this.closed = closed;
+        }
+
+        @Override
+        public StageBundleFactory forStage(ExecutableStage executableStage) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void close() {
+            closed.set(true);
+        }
+    }
+
+    private static class CleanupAwaitingExecutor extends ThreadPoolExecutor {
+
+        private final CountDownLatch resourceCleanupStarted;
+        private final boolean interruptCancel;
+
+        private CleanupAwaitingExecutor(
+                CountDownLatch resourceCleanupStarted, boolean 
interruptCancel) {
+            super(1, 1, 0L, TimeUnit.MILLISECONDS, new 
LinkedBlockingQueue<>());
+            this.resourceCleanupStarted = resourceCleanupStarted;
+            this.interruptCancel = interruptCancel;
+        }
+
+        @Override
+        public void execute(Runnable command) {
+            super.execute(command);
+            try {
+                assertThat(resourceCleanupStarted.await(10, 
TimeUnit.SECONDS)).isTrue();
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(e);
+            }
+            if (interruptCancel) {
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
+
+    private static class NoOpBeamStateHandler<S> implements 
BeamStateHandler<S> {
+
+        @Override
+        public BeamFnApi.StateResponse.Builder handle(BeamFnApi.StateRequest 
request, S state) {
+            return BeamFnApi.StateResponse.newBuilder();
+        }
+
+        @Override
+        public BeamFnApi.StateResponse.Builder 
handleGet(BeamFnApi.StateRequest request, S state) {
+            return BeamFnApi.StateResponse.newBuilder();
+        }
+
+        @Override
+        public BeamFnApi.StateResponse.Builder handleAppend(
+                BeamFnApi.StateRequest request, S state) {
+            return BeamFnApi.StateResponse.newBuilder();
+        }
+
+        @Override
+        public BeamFnApi.StateResponse.Builder handleClear(
+                BeamFnApi.StateRequest request, S state) {
+            return BeamFnApi.StateResponse.newBuilder();
+        }
+    }
+
+    private static class TestingBeamPythonFunctionRunner extends 
BeamPythonFunctionRunner {
+
+        private TestingBeamPythonFunctionRunner(
+                ProcessPythonEnvironmentManager environmentManager) {
+            super(
+                    null,
+                    "test-task",
+                    environmentManager,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    null,
+                    0.0,
+                    FlinkFnApi.CoderInfoDescriptor.getDefaultInstance(),
+                    FlinkFnApi.CoderInfoDescriptor.getDefaultInstance(),
+                    Collections.emptyMap());
+        }
+
+        @Override
+        protected void buildTransforms(RunnerApi.Components.Builder 
componentsBuilder) {}
+
+        @Override
+        protected List<TimerReference> getTimers(RunnerApi.Components 
components) {
+            return Collections.emptyList();
+        }
+
+        @Override
+        protected Optional<RunnerApi.Coder> getOptionalTimerCoderProto() {
+            return Optional.empty();
+        }
+    }
+}
diff --git 
a/flink-python/src/test/java/org/apache/flink/streaming/api/runners/python/beam/state/BeamStateRequestHandlerTest.java
 
b/flink-python/src/test/java/org/apache/flink/streaming/api/runners/python/beam/state/BeamStateRequestHandlerTest.java
new file mode 100644
index 00000000000..bf99c827dd5
--- /dev/null
+++ 
b/flink-python/src/test/java/org/apache/flink/streaming/api/runners/python/beam/state/BeamStateRequestHandlerTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.flink.streaming.api.runners.python.beam.state;
+
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.MapState;
+import org.apache.flink.streaming.api.utils.ByteArrayWrapper;
+
+import org.apache.beam.model.fnexecution.v1.BeamFnApi;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.LockSupport;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class BeamStateRequestHandlerTest {
+
+    @Test
+    void testRejectsRequestsAfterClose() {
+        final AtomicBoolean stateAccessed = new AtomicBoolean();
+        final BeamStateStore keyedStateStore =
+                new BeamStateStore() {
+                    @Override
+                    public ListState<byte[]> 
getListState(BeamFnApi.StateRequest request) {
+                        stateAccessed.set(true);
+                        return null;
+                    }
+
+                    @Override
+                    public MapState<ByteArrayWrapper, byte[]> getMapState(
+                            BeamFnApi.StateRequest request) {
+                        throw new UnsupportedOperationException();
+                    }
+                };
+        final BeamStateRequestHandler handler = createHandler(keyedStateStore);
+
+        handler.close();
+
+        assertThatThrownBy(() -> handler.handle(createBagUserStateRequest()))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessage("Beam state request handler is closed.");
+        assertThat(stateAccessed).isFalse();
+    }
+
+    @Test
+    void testCloseWaitsForInFlightRequest() throws Exception {
+        final CountDownLatch stateAccessStarted = new CountDownLatch(1);
+        final CountDownLatch releaseStateAccess = new CountDownLatch(1);
+        final BeamStateStore keyedStateStore =
+                new BeamStateStore() {
+                    @Override
+                    public ListState<byte[]> 
getListState(BeamFnApi.StateRequest request)
+                            throws InterruptedException {
+                        stateAccessStarted.countDown();
+                        releaseStateAccess.await();
+                        return null;
+                    }
+
+                    @Override
+                    public MapState<ByteArrayWrapper, byte[]> getMapState(
+                            BeamFnApi.StateRequest request) {
+                        throw new UnsupportedOperationException();
+                    }
+                };
+        final BeamStateRequestHandler handler = createHandler(keyedStateStore);
+        final ExecutorService executor = Executors.newFixedThreadPool(2);
+
+        try {
+            final Future<?> requestFuture =
+                    executor.submit(() -> 
handler.handle(createBagUserStateRequest()));
+            assertThat(stateAccessStarted.await(10, 
TimeUnit.SECONDS)).isTrue();
+
+            final CountDownLatch closeStarted = new CountDownLatch(1);
+            final AtomicReference<Thread> closeThread = new 
AtomicReference<>();
+            final Future<?> closeFuture =
+                    executor.submit(
+                            () -> {
+                                closeThread.set(Thread.currentThread());
+                                closeStarted.countDown();
+                                handler.close();
+                            });
+            assertThat(closeStarted.await(10, TimeUnit.SECONDS)).isTrue();
+            assertCloseWaitsForInFlightRequest(closeThread.get(), closeFuture);
+
+            releaseStateAccess.countDown();
+            requestFuture.get(10, TimeUnit.SECONDS);
+            closeFuture.get(10, TimeUnit.SECONDS);
+        } finally {
+            releaseStateAccess.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    private static void assertCloseWaitsForInFlightRequest(
+            Thread closeThread, Future<?> closeFuture) {
+        final long timeoutNanos = TimeUnit.SECONDS.toNanos(10);
+        final long deadlineNanos = System.nanoTime() + timeoutNanos;
+        while (System.nanoTime() < deadlineNanos) {
+            if (closeFuture.isDone()) {
+                assertThat(closeFuture.isDone()).isFalse();
+            }
+            if (closeThread.getState() == Thread.State.WAITING) {
+                return;
+            }
+            LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
+        }
+        assertThat(closeThread.getState()).isEqualTo(Thread.State.WAITING);
+    }
+
+    private static BeamStateRequestHandler createHandler(BeamStateStore 
keyedStateStore) {
+        return new BeamStateRequestHandler(
+                keyedStateStore,
+                BeamStateStore.unsupported(),
+                new NoOpBeamStateHandler<>(),
+                new NoOpBeamStateHandler<>());
+    }
+
+    private static BeamFnApi.StateRequest createBagUserStateRequest() {
+        return BeamFnApi.StateRequest.newBuilder()
+                .setStateKey(
+                        BeamFnApi.StateKey.newBuilder()
+                                .setBagUserState(
+                                        
BeamFnApi.StateKey.BagUserState.getDefaultInstance()))
+                .setGet(BeamFnApi.StateGetRequest.getDefaultInstance())
+                .build();
+    }
+
+    private static class NoOpBeamStateHandler<S> implements 
BeamStateHandler<S> {
+
+        @Override
+        public BeamFnApi.StateResponse.Builder handle(BeamFnApi.StateRequest 
request, S state) {
+            return BeamFnApi.StateResponse.newBuilder();
+        }
+
+        @Override
+        public BeamFnApi.StateResponse.Builder 
handleGet(BeamFnApi.StateRequest request, S state) {
+            return BeamFnApi.StateResponse.newBuilder();
+        }
+
+        @Override
+        public BeamFnApi.StateResponse.Builder handleAppend(
+                BeamFnApi.StateRequest request, S state) {
+            return BeamFnApi.StateResponse.newBuilder();
+        }
+
+        @Override
+        public BeamFnApi.StateResponse.Builder handleClear(
+                BeamFnApi.StateRequest request, S state) {
+            return BeamFnApi.StateResponse.newBuilder();
+        }
+    }
+}

Reply via email to