Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion sdks/python/apache_beam/runners/worker/bundle_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import base64
import bisect
import collections
import concurrent.futures
import copy
import heapq
import itertools
Expand Down Expand Up @@ -76,6 +77,7 @@
from apache_beam.runners.worker import operation_specs
from apache_beam.runners.worker import operations
from apache_beam.runners.worker import statesampler
from apache_beam.runners.worker.worker_status import thread_dump
from apache_beam.transforms import TimeDomain
from apache_beam.transforms import core
from apache_beam.transforms import environments
Expand Down Expand Up @@ -1130,7 +1132,30 @@ def __init__(
'fnapi-step-%s' % self.process_bundle_descriptor.id,
self.counter_factory)

self.ops = self.create_execution_tree(self.process_bundle_descriptor)
with concurrent.futures.ThreadPoolExecutor(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are going to add timeouts in other places that might be called more frequently, I think we would want some more generic facility to register thread and timeout and then have single background thread observing for timeouts instead of starting a thread for each call. This seems ok here but just something to consider for future.

with WatchdogTimeout(3600, handle_timeout):
  // run code that might get stuck inline on this thread

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That looks like a neat construct, good idea.

max_workers=1, thread_name_prefix='ExecutionTreeCreator') as executor:
future = executor.submit(
self.create_execution_tree, self.process_bundle_descriptor)
try:
self.ops = future.result(timeout=3600)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this be a pipeline option? an hour might be prohibitively long for some cases. Also since this ends up calling user setup function there is possiblity an hour is too short

I think we likely want to add options for how long to cache these bundle processors as well. Currently it is just 60 seconds and then we clear the cache. If this is expensive for streaming pipelines we should evict less aggressively. (that can be a different cl just bringing it up as motivation for a new options class controlling stuff like this)

@tvalentyn tvalentyn Sep 19, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using an option can be doable, we could tie it to the existing per-element timeout option. Note that DoFn.setup is called a bit later, immediately after execution tree creation. Slow Setup operations will not be causing the timeout here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was mostly worried this might break things with very expensive setup, but since that's not included I think we could punt on making it configurable if you just want to cover the unpickling.

It could be nice to cover the setup with a timeout too. I believe we had issues in the past where setup getting stuck was hard to debug since it is covered by the lull logic. An alternative would be to defer calling setup until after lull registration

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am looking at some of those issues since this work is related. I might merge this PR as is before next release but still looking to see if we can get a more general solution here

except concurrent.futures.TimeoutError:
# In rare cases, unpickling a DoFn might get permanently stuck,
# for example when unpickling involves importing a module and
# a subprocess is launched during the import operation.
_LOGGER.error(
'Timed out while reconstructing a pipeline fragment for: %s.\n'
'This is likely a transient error. The SDK harness '
'will self-terminate, and the runner can retry the operation. '
'If the error is frequent, check whether the stuckness happens '
'while deserializing (unpickling) a dependency of your pipeline '
'in the stacktrace below: \n%s\n',
self.process_bundle_descriptor.id,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we terminate this sdk harness later, I think this error should be harmless. So can we mention this somehow?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 ", restarting harness to retry and continue processing."

I think you might also want to map this to concepts the user is more familiar with like unpickling and calling their setup method.
"This may indicate issues unpickling or in the dofn setup function."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ping on this part, I think otherwise we could punt on other comments but this seems good to fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the message, thanks.

thread_dump('ExecutionTreeCreator'))
# Raising an exception here doesn't interrupt the left-over thread.
# Out of caution, terminate the SDK harness process.
from apache_beam.runners.worker.sdk_worker_main import terminate_sdk_harness
terminate_sdk_harness()

for op in reversed(self.ops.values()):
op.setup(self.data_sampler)
self.splitting_lock = threading.Lock()
Expand Down
4 changes: 4 additions & 0 deletions sdks/python/apache_beam/runners/worker/sdk_worker_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ def terminate_sdk_harness():
if _FN_LOG_HANDLER:
_FN_LOG_HANDLER.close()
os.kill(os.getpid(), signal.SIGINT)
# Delay further control flow in the caller until process is terminated.
time.sleep(60)
# Try to force-terminate if still running.
os.kill(os.getpid(), signal.SIGKILL)


def _load_pipeline_options(options_json):
Expand Down
16 changes: 13 additions & 3 deletions sdks/python/apache_beam/runners/worker/worker_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,23 @@ def _current_frames():
return sys._current_frames() # pylint: disable=protected-access


def thread_dump():
"""Get a thread dump for the current SDK worker harness. """
def thread_dump(thread_prefix=None):
"""Get a thread dump for the current SDK harness.

Args:
thread_prefix: (str) An optional prefix to filter threads by.
"""
# deduplicate threads with same stack trace
stack_traces = defaultdict(list)
frames = _current_frames()

for t in threading.enumerate():
threads_to_dump = threading.enumerate()
if thread_prefix:
threads_to_dump = [
t for t in threads_to_dump if t.name.startswith(thread_prefix)
]

for t in threads_to_dump:
try:
stack_trace = ''.join(traceback.format_stack(frames[t.ident]))
except KeyError:
Expand Down
Loading