Skip to content
Open
2 changes: 1 addition & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
* (Python) Added `equal_to_approx`, an `assert_that` matcher that compares numeric pipeline outputs with a configurable tolerance ([#18028](https://github.com/apache/beam/issues/18028)).
* (Python) `Timestamp` now supports variable subsecond precision, up to nanoseconds. The portable
`beam:logical_type:timestamp:v1` logical type now maps to Python's `Timestamp` ([#39344](https://github.com/apache/beam/issues/39344)).
* X feature added (Java/Python) ([#X](https://github.com/apache/beam/issues/X)).
* (Python) Added support to analyze core dumps created after python worker segmentation faults with `pystack` (or `gdb` if installed) using the `--profiler_agent=coredump` pipeline option. ([#39484](https://github.com/apache/beam/issues/39484)).

## Breaking Changes

Expand Down
4 changes: 4 additions & 0 deletions sdks/python/apache_beam/options/pipeline_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -1743,6 +1743,10 @@ def validate(self, validator):
_LOGGER.info(
'Setting --profile_location to %s since profiling is enabled.',
self.profile_location)

if self.profiler_agent == 'coredump':
debug_options = self.view_as(DebugOptions)
debug_options.add_experiment('core_pattern=/tmp/beam_coredump.%e.%p')
return errors


Expand Down
9 changes: 9 additions & 0 deletions sdks/python/apache_beam/options/pipeline_options_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,15 @@ def test_profiling_agent_is_exclusive_with_legacy_profiling_options(self):
self.assertTrue(
any('--profiler_agent is mutually exclusive' in err for err in errors))

def test_profiling_agent_coredump_adds_core_pattern(self):
options = PipelineOptions(['--profiler_agent=coredump'])
validator = PipelineOptionsValidator(options, None)
self.assertEqual(validator.validate(), [])
debug_options = options.view_as(DebugOptions)
self.assertEqual(
debug_options.lookup_experiment('core_pattern'),
'/tmp/beam_coredump.%e.%p')

def test_profile_location_defaulting_and_opt_out(self):
options = PipelineOptions(
['--profiler_agent=memray', '--temp_location=gs://bucket/temp'])
Expand Down
1 change: 1 addition & 0 deletions sdks/python/container/base_image_requirements_manual.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ guppy3
memray==1.19.3
mmh3 # Optimizes execution of some Beam codepaths. TODO: Make it Beam's dependency.
nltk # Commonly used for natural language processing.
pystack
google-crc32c
scipy
scikit-learn
Expand Down
55 changes: 37 additions & 18 deletions sdks/python/container/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ var (
provisionEndpoint = flag.String("provision_endpoint", "", "Provision endpoint (required).")
controlEndpoint = flag.String("control_endpoint", "", "Control endpoint (required).")
semiPersistDir = flag.String("semi_persist_dir", "/tmp", "Local semi-persistent directory (optional).")

workerMu sync.Mutex
shuttingDown bool
)

const (
Expand Down Expand Up @@ -307,19 +310,12 @@ func launchSDKProcess() error {

workerIds := append([]string{*id}, info.GetSiblingWorkerIds()...)

// Keep track of child PIDs for clean shutdown without zombies
childPids := struct {
v []int
canceled bool
mu sync.Mutex
}{v: make([]int, 0, len(workerIds))}

// Forward trapped signals to child process groups in order to terminate them gracefully and avoid zombies
go func() {
logger.Printf(ctx, "Received signal: %v", <-signalChannel)
childPids.mu.Lock()
childPids.canceled = true
for _, pid := range childPids.v {
workerMu.Lock()
shuttingDown = true
for _, pid := range activePids {
go func(pid int) {
// This goroutine will be canceled if the main process exits before the 5 seconds
// have elapsed, i.e., as soon as all subprocesses have returned from Wait().
Expand All @@ -330,7 +326,7 @@ func launchSDKProcess() error {
}(pid)
syscall.Kill(-pid, syscall.SIGTERM)
}
childPids.mu.Unlock()
workerMu.Unlock()
}()

var wg sync.WaitGroup
Expand All @@ -342,9 +338,9 @@ func launchSDKProcess() error {
bufLogger := tools.NewBufferedLogger(logger)
errorCount := 0
for {
childPids.mu.Lock()
if childPids.canceled {
childPids.mu.Unlock()
workerMu.Lock()
if shuttingDown {
workerMu.Unlock()
return
}

Expand All @@ -369,8 +365,9 @@ func launchSDKProcess() error {

logger.Printf(ctx, "Executing Python (%v): %v %v", envStr, currentProg, strings.Join(currentArgs, " "))
cmd := StartCommandEnv(currentEnv, os.Stdin, bufLogger, bufLogger, currentProg, currentArgs...)
childPids.v = append(childPids.v, cmd.Process.Pid)
childPids.mu.Unlock()
logger.Printf(ctx, "Started worker %s with PID %d", workerId, cmd.Process.Pid)
activePids = append(activePids, cmd.Process.Pid)
workerMu.Unlock()

var timer *time.Timer
var profilingTimedOut atomic.Bool
Expand All @@ -379,8 +376,8 @@ func launchSDKProcess() error {
if profilingActive && pcfg.StopAfterSec > 0 {
duration := time.Duration(pcfg.StopAfterSec) * time.Second
timer = time.AfterFunc(duration, func() {
childPids.mu.Lock()
defer childPids.mu.Unlock()
workerMu.Lock()
defer workerMu.Unlock()
if cmd.Process != nil {
logger.Printf(ctx, "Profiling timeout of %d seconds reached. Sending SIGINT to worker %s",
pcfg.StopAfterSec, workerId)
Expand All @@ -391,6 +388,7 @@ func launchSDKProcess() error {
}

err := cmd.Wait()
unregisterPid(cmd.Process.Pid)
if timer != nil {
timer.Stop()
}
Expand All @@ -417,6 +415,7 @@ func launchSDKProcess() error {
logger.Warnf(ctx, "Python (worker %v) exited %v times: %v\nrestarting SDK process",
workerId, errorCount, err)
} else {
cleanUpProfiler(ctx, logger)
logger.Fatalf(ctx, "Python (worker %v) exited %v times: %v\nout of retries, failing container",
workerId, errorCount, err)
}
Expand Down Expand Up @@ -595,3 +594,23 @@ func logSubmissionEnvDependencies(ctx context.Context, bufLogger *tools.Buffered
bufLogger.Printf(ctx, "Dependencies in submission environment:\n%s", string(content))
return nil
}

var (
activePids []int
)

func unregisterPid(pid int) {
workerMu.Lock()
defer workerMu.Unlock()
activePids = slices.DeleteFunc(activePids, func(p int) bool {
return p == pid
})
}

func getActivePids() []int {
workerMu.Lock()
defer workerMu.Unlock()
pids := make([]int, len(activePids))
copy(pids, activePids)
return pids
}
1 change: 1 addition & 0 deletions sdks/python/container/ml/py310/base_image_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ PyMySQL==1.2.0
pyOpenSSL==26.2.0
pyparsing==3.3.2
pyproject_hooks==1.2.0
pystack==1.7.0
pytest==9.1.1
pytest-timeout==2.4.0
pytest-xdist==3.8.0
Expand Down
1 change: 1 addition & 0 deletions sdks/python/container/ml/py310/gpu_image_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ PyMySQL==1.2.0
pyOpenSSL==26.2.0
pyparsing==3.3.2
pyproject_hooks==1.2.0
pystack==1.7.0
pytest==9.1.1
pytest-timeout==2.4.0
pytest-xdist==3.8.0
Expand Down
1 change: 1 addition & 0 deletions sdks/python/container/ml/py311/base_image_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ PyMySQL==1.2.0
pyOpenSSL==26.2.0
pyparsing==3.3.2
pyproject_hooks==1.2.0
pystack==1.7.0
pytest==9.1.1
pytest-timeout==2.4.0
pytest-xdist==3.8.0
Expand Down
1 change: 1 addition & 0 deletions sdks/python/container/ml/py311/gpu_image_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ PyMySQL==1.2.0
pyOpenSSL==26.2.0
pyparsing==3.3.2
pyproject_hooks==1.2.0
pystack==1.7.0
pytest==9.1.1
pytest-timeout==2.4.0
pytest-xdist==3.8.0
Expand Down
1 change: 1 addition & 0 deletions sdks/python/container/ml/py312/base_image_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ PyMySQL==1.2.0
pyOpenSSL==26.2.0
pyparsing==3.3.2
pyproject_hooks==1.2.0
pystack==1.7.0
pytest==9.1.1
pytest-timeout==2.4.0
pytest-xdist==3.8.0
Expand Down
1 change: 1 addition & 0 deletions sdks/python/container/ml/py312/gpu_image_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ PyMySQL==1.2.0
pyOpenSSL==26.2.0
pyparsing==3.3.2
pyproject_hooks==1.2.0
pystack==1.7.0
pytest==9.1.1
pytest-timeout==2.4.0
pytest-xdist==3.8.0
Expand Down
1 change: 1 addition & 0 deletions sdks/python/container/ml/py313/base_image_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ PyMySQL==1.2.0
pyOpenSSL==26.2.0
pyparsing==3.3.2
pyproject_hooks==1.2.0
pystack==1.7.0
pytest==9.1.1
pytest-timeout==2.4.0
pytest-xdist==3.8.0
Expand Down
Loading
Loading