Skip to content

Commit c33bc97

Browse files
Fix race condition in UserPipelineTracker.clear() and various problems (#38537)
* Fix race condition in UserPipelineTracker.clear() * Address comments. * Fix lints. * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Fix failed test RecordingTest.test_describe * Fix failed tests test_instrument_example_pipeline_to_write_cache and test_instrument_example_pipeline_to_read_cache. * Formatting. * Fix InteractiveBeamTest.test_recordings_clear and test_recordings_record. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 2105eaa commit c33bc97

3 files changed

Lines changed: 119 additions & 48 deletions

File tree

sdks/python/apache_beam/runners/interactive/interactive_environment.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,11 +365,17 @@ def set_cache_manager(self, cache_manager, pipeline):
365365
if self.get_cache_manager(pipeline) is cache_manager:
366366
# NOOP if setting to the same cache_manager.
367367
return
368+
# Check if the pipeline is already tracked as a user pipeline before cleanup.
369+
is_user_pipeline = self._tracked_user_pipelines.get_user_pipeline(
370+
pipeline) is pipeline
368371
if self.get_cache_manager(pipeline):
369372
# Invoke cleanup routine when a new cache_manager is forcefully set and
370373
# current cache_manager is not None.
371374
self.cleanup(pipeline)
372375
self._cache_managers[str(id(pipeline))] = cache_manager
376+
if is_user_pipeline:
377+
# Re-track the user pipeline because the self.cleanup() call above evicts it.
378+
self.add_user_pipeline(pipeline)
373379

374380
def get_cache_manager(self, pipeline, create_if_absent=False):
375381
"""Gets the cache manager held by current Interactive Environment for the
@@ -468,8 +474,8 @@ def evict_recording_manager(self, pipeline):
468474
def describe_all_recordings(self):
469475
"""Returns a description of the recording for all watched pipelnes."""
470476
return {
471-
self.pipeline_id_to_pipeline(pid): rm.describe()
472-
for pid, rm in self._recording_managers.items()
477+
rm.user_pipeline: rm.describe()
478+
for rm in self._recording_managers.values()
473479
}
474480

475481
def set_pipeline_result(self, pipeline, result):

sdks/python/apache_beam/runners/interactive/user_pipeline_tracker.py

Lines changed: 63 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"""
2626

2727
import shutil
28+
import threading
2829
from typing import Iterator
2930
from typing import Optional
3031

@@ -39,13 +40,16 @@ class UserPipelineTracker:
3940
derived pipelines.
4041
"""
4142
def __init__(self):
43+
self._lock = threading.RLock()
4244
self._user_pipelines: dict[beam.Pipeline, list[beam.Pipeline]] = {}
43-
self._derived_pipelines: dict[beam.Pipeline] = {}
44-
self._pid_to_pipelines: dict[beam.Pipeline] = {}
45+
self._derived_pipelines: dict[beam.Pipeline, beam.Pipeline] = {}
46+
self._pid_to_pipelines: dict[str, beam.Pipeline] = {}
4547

4648
def __iter__(self) -> Iterator[beam.Pipeline]:
4749
"""Iterates through all the user pipelines."""
48-
for p in self._user_pipelines:
50+
with self._lock:
51+
pipelines = list(self._user_pipelines.keys())
52+
for p in pipelines:
4953
yield p
5054

5155
def _key(self, pipeline: beam.Pipeline) -> str:
@@ -57,45 +61,57 @@ def evict(self, pipeline: beam.Pipeline) -> None:
5761
Removes the given pipeline and derived pipelines if a user pipeline.
5862
Otherwise, removes the given derived pipeline.
5963
"""
60-
user_pipeline = self.get_user_pipeline(pipeline)
61-
if user_pipeline:
62-
for d in self._user_pipelines[user_pipeline]:
63-
del self._derived_pipelines[d]
64-
del self._user_pipelines[user_pipeline]
65-
elif pipeline in self._derived_pipelines:
66-
del self._derived_pipelines[pipeline]
64+
with self._lock:
65+
if pipeline in self._user_pipelines:
66+
for d in self._user_pipelines[pipeline]:
67+
self._derived_pipelines.pop(d, None)
68+
self._pid_to_pipelines.pop(self._key(d), None)
69+
self._user_pipelines.pop(pipeline, None)
70+
elif pipeline in self._derived_pipelines:
71+
user_pipeline = self._derived_pipelines.pop(pipeline, None)
72+
if user_pipeline in self._user_pipelines:
73+
try:
74+
self._user_pipelines[user_pipeline].remove(pipeline)
75+
except ValueError:
76+
pass
77+
self._pid_to_pipelines.pop(self._key(pipeline), None)
6778

6879
def clear(self) -> None:
6980
"""Clears the tracker of all user and derived pipelines."""
7081
# Remove all local_tempdir of created pipelines.
71-
for p in self._pid_to_pipelines.values():
72-
shutil.rmtree(p.local_tempdir, ignore_errors=True)
82+
with self._lock:
83+
pipelines = list(self._pid_to_pipelines.values())
84+
self._user_pipelines.clear()
85+
self._derived_pipelines.clear()
86+
self._pid_to_pipelines.clear()
7387

74-
self._user_pipelines.clear()
75-
self._derived_pipelines.clear()
76-
self._pid_to_pipelines.clear()
88+
for p in pipelines:
89+
shutil.rmtree(p.local_tempdir, ignore_errors=True)
7790

7891
def get_pipeline(self, pid: str) -> Optional[beam.Pipeline]:
7992
"""Returns the pipeline corresponding to the given pipeline id."""
80-
return self._pid_to_pipelines.get(pid, None)
93+
with self._lock:
94+
return self._pid_to_pipelines.get(pid, None)
8195

8296
def add_user_pipeline(self, p: beam.Pipeline) -> beam.Pipeline:
8397
"""Adds a user pipeline with an empty set of derived pipelines."""
84-
self._memoize_pipieline(p)
98+
with self._lock:
99+
self._memoize_pipeline(p)
85100

86-
# Create a new node for the user pipeline if it doesn't exist already.
87-
user_pipeline = self.get_user_pipeline(p)
88-
if not user_pipeline:
89-
user_pipeline = p
90-
self._user_pipelines[p] = []
101+
# Create a new node for the user pipeline if it doesn't exist already.
102+
user_pipeline = self.get_user_pipeline(p)
103+
if not user_pipeline:
104+
user_pipeline = p
105+
self._user_pipelines[p] = []
91106

92-
return user_pipeline
107+
return user_pipeline
93108

94-
def _memoize_pipieline(self, p: beam.Pipeline) -> None:
109+
def _memoize_pipeline(self, p: beam.Pipeline) -> None:
95110
"""Memoizes the pid of the pipeline to the pipeline object."""
96111
pid = self._key(p)
97-
if pid not in self._pid_to_pipelines:
98-
self._pid_to_pipelines[pid] = p
112+
with self._lock:
113+
if pid not in self._pid_to_pipelines:
114+
self._pid_to_pipelines[pid] = p
99115

100116
def add_derived_pipeline(
101117
self, maybe_user_pipeline: beam.Pipeline,
@@ -119,20 +135,21 @@ def add_derived_pipeline(
119135
# Returns p.
120136
ut.get_user_pipeline(derived2)
121137
"""
122-
self._memoize_pipieline(maybe_user_pipeline)
123-
self._memoize_pipieline(derived_pipeline)
138+
with self._lock:
139+
self._memoize_pipeline(maybe_user_pipeline)
140+
self._memoize_pipeline(derived_pipeline)
124141

125-
# Cannot add a derived pipeline twice.
126-
assert derived_pipeline not in self._derived_pipelines
142+
# Cannot add a derived pipeline twice.
143+
assert derived_pipeline not in self._derived_pipelines
127144

128-
# Get the "true" user pipeline. This allows for the user to derive a
129-
# pipeline from another derived pipeline, use both as arguments, and this
130-
# method will still get the correct user pipeline.
131-
user = self.add_user_pipeline(maybe_user_pipeline)
145+
# Get the "true" user pipeline. This allows for the user to derive a
146+
# pipeline from another derived pipeline, use both as arguments, and this
147+
# method will still get the correct user pipeline.
148+
user = self.add_user_pipeline(maybe_user_pipeline)
132149

133-
# Map the derived pipeline to the user pipeline.
134-
self._derived_pipelines[derived_pipeline] = user
135-
self._user_pipelines[user].append(derived_pipeline)
150+
# Map the derived pipeline to the user pipeline.
151+
self._derived_pipelines[derived_pipeline] = user
152+
self._user_pipelines[user].append(derived_pipeline)
136153

137154
def get_user_pipeline(self, p: beam.Pipeline) -> Optional[beam.Pipeline]:
138155
"""Returns the user pipeline of the given pipeline.
@@ -142,14 +159,14 @@ def get_user_pipeline(self, p: beam.Pipeline) -> Optional[beam.Pipeline]:
142159
returns the same pipeline. If the given pipeline is a derived pipeline then
143160
this returns the user pipeline.
144161
"""
162+
with self._lock:
163+
# If `p` is a user pipeline then return it.
164+
if p in self._user_pipelines:
165+
return p
145166

146-
# If `p` is a user pipeline then return it.
147-
if p in self._user_pipelines:
148-
return p
149-
150-
# If `p` exists then return its user pipeline.
151-
if p in self._derived_pipelines:
152-
return self._derived_pipelines[p]
167+
# If `p` exists then return its user pipeline.
168+
if p in self._derived_pipelines:
169+
return self._derived_pipelines[p]
153170

154-
# Otherwise, `p` is not in this tracker.
155-
return None
171+
# Otherwise, `p` is not in this tracker.
172+
return None

sdks/python/apache_beam/runners/interactive/user_pipeline_tracker_test.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
# limitations under the License.
1616
#
1717

18+
import threading
1819
import unittest
20+
from unittest.mock import patch
1921

2022
import apache_beam as beam
2123
from apache_beam.runners.interactive.user_pipeline_tracker import UserPipelineTracker
@@ -202,6 +204,52 @@ def test_can_evict_user_pipeline(self):
202204
self.assertIs(user2, ut.get_user_pipeline(derived21))
203205
self.assertIs(user2, ut.get_user_pipeline(derived22))
204206

207+
def test_clear_race_condition(self):
208+
ut = UserPipelineTracker()
209+
# Add a pipeline so clear() has at least one element to iterate over.
210+
p1 = beam.Pipeline()
211+
derived1 = beam.Pipeline()
212+
ut.add_derived_pipeline(p1, derived1)
213+
214+
# Set by the mock when clear() enters its loop. Signals the background
215+
# worker to mutate.
216+
in_loop_event = threading.Event()
217+
# Set by the worker when mutation is complete. Signals mock that it can
218+
# safely resume clear().
219+
mutate_done_event = threading.Event()
220+
221+
def mock_rmtree(path, ignore_errors=False):
222+
# Signal the worker that clear() is iterating.
223+
in_loop_event.set()
224+
# Pause here to give the worker thread time to perform the mutation.
225+
mutate_done_event.wait(timeout=5)
226+
227+
def worker():
228+
# Wait for clear() to start iterating.
229+
if in_loop_event.wait(timeout=5):
230+
# Concurrently mutate the tracker dictionary.
231+
p2 = beam.Pipeline()
232+
derived2 = beam.Pipeline()
233+
try:
234+
ut.add_derived_pipeline(p2, derived2)
235+
finally:
236+
# Resume the main thread.
237+
mutate_done_event.set()
238+
239+
thread = threading.Thread(target=worker)
240+
thread.start()
241+
242+
try:
243+
# Intercept shutil.rmtree inside clear() to orchestrate the concurrent
244+
# mutation.
245+
with patch('shutil.rmtree', side_effect=mock_rmtree):
246+
ut.clear()
247+
finally:
248+
# Avoid hanging tests if events are missed.
249+
in_loop_event.set()
250+
mutate_done_event.set()
251+
thread.join()
252+
205253

206254
if __name__ == '__main__':
207255
unittest.main()

0 commit comments

Comments
 (0)