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

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


The following commit(s) were added to refs/heads/master by this push:
     new 0b407cd78ce Fix lost state updates in Set state (#39175)
0b407cd78ce is described below

commit 0b407cd78ce967f2345c88d6b51233a9eeabcc2c
Author: Shunping Huang <[email protected]>
AuthorDate: Mon Jul 6 10:25:29 2026 -0400

    Fix lost state updates in Set state (#39175)
    
    * Add a test to reproduce the problem.
    
    * Fix race condition in SetState compaction by awaiting outstanding state 
requests
    
    * Reformat
    
    * Simply logic
    
    * Fix lints
    
    * Do not force to use prism for the new test.
---
 .../apache_beam/runners/worker/bundle_processor.py | 37 ++++++++----
 .../apache_beam/transforms/userstate_test.py       | 69 ++++++++++++++++++++++
 2 files changed, 95 insertions(+), 11 deletions(-)

diff --git a/sdks/python/apache_beam/runners/worker/bundle_processor.py 
b/sdks/python/apache_beam/runners/worker/bundle_processor.py
index 1a852aa19d9..851efc81221 100644
--- a/sdks/python/apache_beam/runners/worker/bundle_processor.py
+++ b/sdks/python/apache_beam/runners/worker/bundle_processor.py
@@ -641,6 +641,8 @@ class SynchronousSetRuntimeState(userstate.SetRuntimeState):
     self._value_coder = value_coder
     self._cleared = False
     self._added_elements: set[Any] = set()
+    # Track outstanding async state requests to await them at commit time.
+    self._futures = []
 
   def _compact_data(self, rewrite=True):
     accumulator = set(
@@ -650,9 +652,11 @@ class 
SynchronousSetRuntimeState(userstate.SetRuntimeState):
             self._added_elements))
 
     if rewrite and accumulator:
-      self._state_handler.clear(self._state_key)
-      self._state_handler.extend(
-          self._state_key, self._value_coder.get_impl(), accumulator)
+      # Compaction writes are asynchronous; queue them so they are not lost.
+      self._futures.append(self._state_handler.clear(self._state_key))
+      self._futures.append(
+          self._state_handler.extend(
+              self._state_key, self._value_coder.get_impl(), accumulator))
 
       # Since everthing is already committed so we can safely reinitialize
       # added_elements here.
@@ -666,7 +670,7 @@ class SynchronousSetRuntimeState(userstate.SetRuntimeState):
   def add(self, value: Any) -> None:
     if self._cleared:
       # This is a good time explicitly clear.
-      self._state_handler.clear(self._state_key)
+      self._futures.append(self._state_handler.clear(self._state_key))
       self._cleared = False
 
     self._added_elements.add(value)
@@ -678,15 +682,26 @@ class 
SynchronousSetRuntimeState(userstate.SetRuntimeState):
     self._added_elements = set()
 
   def commit(self) -> None:
-    to_await = None
     if self._cleared:
-      to_await = self._state_handler.clear(self._state_key)
+      self._futures.append(self._state_handler.clear(self._state_key))
+      self._cleared = False
     if self._added_elements:
-      to_await = self._state_handler.extend(
-          self._state_key, self._value_coder.get_impl(), self._added_elements)
-    if to_await:
-      # To commit, we need to wait on the last state request future to 
complete.
-      to_await.get()
+      self._futures.append(
+          self._state_handler.extend(
+              self._state_key,
+              self._value_coder.get_impl(),
+              self._added_elements))
+      self._added_elements = set()
+
+    # Block on all outstanding async state requests to ensure data is 
committed.
+    # We must swap and clear self._futures before awaiting them. Awaiting a 
future
+    # yields control, during which new futures could be appended to 
self._futures.
+    all_futures = self._futures
+    self._futures = []
+
+    for f in all_futures:
+      if f:
+        f.get()
 
 
 class RangeSet:
diff --git a/sdks/python/apache_beam/transforms/userstate_test.py 
b/sdks/python/apache_beam/transforms/userstate_test.py
index 6a8efd1a536..45dba5c9e9e 100644
--- a/sdks/python/apache_beam/transforms/userstate_test.py
+++ b/sdks/python/apache_beam/transforms/userstate_test.py
@@ -18,6 +18,9 @@
 """Unit tests for the Beam State and Timer API interfaces."""
 # pytype: skip-file
 
+import queue
+import threading
+import time
 import unittest
 from typing import Any
 
@@ -34,6 +37,8 @@ from apache_beam.portability import common_urns
 from apache_beam.portability.api import beam_runner_api_pb2
 from apache_beam.runners import pipeline_context
 from apache_beam.runners.common import DoFnSignature
+from apache_beam.runners.worker.sdk_worker import GrpcStateHandler
+from apache_beam.runners.worker.sdk_worker import _Future
 from apache_beam.testing.test_pipeline import TestPipeline
 from apache_beam.testing.test_stream import TestStream
 from apache_beam.testing.util import assert_that
@@ -719,6 +724,70 @@ class StatefulDoFnOnDirectRunnerTest(unittest.TestCase):
       actual_values = (values | beam.ParDo(SetStatefulDoFn()))
       assert_that(actual_values, equal_to([1, 3, 6, 10, 10]))
 
+  # Mock random to always return 1.0 to force compaction on every add.
+  @mock.patch(
+      'apache_beam.runners.worker.bundle_processor.random.random', lambda: 1.0)
+  def test_stateful_set_state_compaction_race_portably(self):
+    old_request = GrpcStateHandler._request
+    request_queue = queue.Queue()
+
+    def worker():
+      while True:
+        handler, request, future, instruction_id = request_queue.get()
+        time.sleep(0.1)  # Simulate latency for each request sequentially.
+        handler._context.process_instruction_id = instruction_id
+        underlying_future = old_request(handler, request)
+        underlying_future.wait()
+        future.set(underlying_future.get())
+        request_queue.task_done()
+
+    t = threading.Thread(target=worker, daemon=True)
+    t.start()
+
+    def delayed_request(self, request):
+      if request.HasField('append') or request.HasField('clear'):
+        future = _Future()
+        instruction_id = getattr(self._context, 'process_instruction_id', None)
+        request_queue.put((self, request, future, instruction_id))
+        return future
+      else:
+        return old_request(self, request)
+
+    GrpcStateHandler._request = delayed_request
+
+    class SetStatefulDoFn(beam.DoFn):
+
+      SET_STATE = SetStateSpec('buffer', VarIntCoder())
+
+      def process(self, element, set_state=beam.DoFn.StateParam(SET_STATE)):
+        _, value = element
+        aggregated_value = 0
+        set_state.add(value)
+        for saved_value in set_state.read():
+          aggregated_value += saved_value
+        yield aggregated_value
+
+    try:
+      options = PipelineOptions([
+          '--max_cache_memory_usage_mb=100',
+          '--environment_type=LOOPBACK',
+      ])
+      with TestPipeline(options=options) as p:
+        test_stream = (
+            TestStream(
+                coder=beam.coders.TupleCoder((
+                    beam.coders.StrUtf8Coder(), beam.coders.VarIntCoder()
+                ))).advance_watermark_to(10).add_elements([
+                    ('key', 1)
+                ]).advance_watermark_to(20).add_elements([
+                    ('key', 2)
+                ]).advance_watermark_to(30))
+        actual_values = (p | test_stream | beam.ParDo(SetStatefulDoFn()))
+        assert_that(actual_values, equal_to([1, 3]))
+
+    finally:
+      GrpcStateHandler._request = old_request
+
   def test_stateful_set_state_clean_portably(self):
     class SetStateClearingStatefulDoFn(beam.DoFn):
 

Reply via email to