This is an automated email from the ASF dual-hosted git repository.
tvalentyn 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 0aacd0375f0 Add helpers to interact with pipeline options in boot
entrypoints (#39595)
0aacd0375f0 is described below
commit 0aacd0375f0b7558e845d126334ad9461368ff3d
Author: tvalentyn <[email protected]>
AuthorDate: Fri Aug 7 10:44:36 2026 -0700
Add helpers to interact with pipeline options in boot entrypoints (#39595)
* Create helpers to parse pipeline options in boot entrypoints that work
for Dataflow and Portable runners.
* Better handle the go SDK case.
* Support SDK namespaces for more deterministic parsing.
* fix test.
* Remove the error signature in ParseOptionsFromProto
* Simplify the tests.
* Simplify profiler setting logic in Go's boot.go
* Also support retrieving sliced options as a single commma-separated
string.
* Support float.
* Add a note on v1.
* Move the feature to a new release.
---
CHANGES.md | 3 +-
sdks/go/container/boot.go | 40 ++--
sdks/go/container/boot_test.go | 17 +-
sdks/go/container/tools/pipeline_options.go | 218 ++++++++++++++++++
sdks/go/container/tools/pipeline_options_test.go | 243 +++++++++++++++++++++
sdks/go/pkg/beam/artifact/options.go | 48 ----
sdks/go/pkg/beam/artifact/options_test.go | 78 -------
sdks/java/container/boot.go | 4 +-
.../python/apache_beam/options/pipeline_options.py | 1 +
sdks/python/container/boot.go | 57 +----
sdks/python/container/profiler.go | 69 ++++--
sdks/python/container/profiler_test.go | 19 +-
sdks/typescript/container/boot.go | 4 +-
13 files changed, 579 insertions(+), 222 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 81f7d493677..6f61ef415b4 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -80,7 +80,7 @@
## Bugfixes
-* Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)).
+* (Python) Fixed incorrect profiler options handling on portable runners
([#39613](https://github.com/apache/beam/issues/39613)).
## Security Fixes
@@ -184,7 +184,6 @@
* (Python) Typehints of dataclass fields are honored during type inferences.
To restore the behavior of fallback-to-any,
use pipeline option `--exclude_infer_dataclass_field_type`
([#38797](https://github.com/apache/beam/issues/38797)).
However fixing forward is recommended.
-* X behavior was changed ([#X](https://github.com/apache/beam/issues/X)).
## Bugfixes
diff --git a/sdks/go/container/boot.go b/sdks/go/container/boot.go
index 469285821f7..aeeb87cc811 100644
--- a/sdks/go/container/boot.go
+++ b/sdks/go/container/boot.go
@@ -17,7 +17,6 @@ package main
import (
"context"
- "encoding/json"
"errors"
"flag"
"fmt"
@@ -31,7 +30,6 @@ import (
"github.com/apache/beam/sdks/v2/go/container/pool"
"github.com/apache/beam/sdks/v2/go/container/tools"
"github.com/apache/beam/sdks/v2/go/pkg/beam/artifact"
- "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime"
// Import gcs filesystem so that it can be used to upload heap dumps
_ "github.com/apache/beam/sdks/v2/go/pkg/beam/io/filesystem/gcs"
@@ -61,23 +59,16 @@ const (
workerPoolIdEnv = "BEAM_GO_WORKER_POOL_ID"
)
-func configureGoogleCloudProfilerEnvVars(ctx context.Context, logger
*tools.Logger, metadata map[string]string, options string) error {
+func configureGoogleCloudProfilerEnvVars(ctx context.Context, logger
*tools.Logger, metadata map[string]string, po *tools.PipelineOptions) error {
const profilerKey = "enable_google_cloud_profiler="
- var parsed map[string]interface{}
- if err := json.Unmarshal([]byte(options), &parsed); err != nil {
- panic(err)
- }
-
var profilerServiceName string
- // Try from "beam:option:go_options:v1" -> "options" ->
"dataflow_service_options"
- if goOpts, ok :=
parsed["beam:option:go_options:v1"].(map[string]interface{}); ok {
- if options, ok := goOpts["options"].(map[string]interface{});
ok {
- if profilerServiceNameRaw, ok :=
options["dataflow_service_options"].(string); ok {
- if strings.HasPrefix(profilerServiceNameRaw,
profilerKey) {
- profilerServiceName =
strings.TrimPrefix(profilerServiceNameRaw, profilerKey)
- }
+ if serviceOpts, err := po.GetStringSlice("dataflow_service_options");
err == nil {
+ for _, opt := range serviceOpts {
+ if strings.HasPrefix(opt, profilerKey) {
+ profilerServiceName = strings.TrimPrefix(opt,
profilerKey)
+ break
}
}
}
@@ -159,8 +150,11 @@ func main() {
logger.Fatalf(ctx, "Failed to convert pipeline options: %v",
err)
}
+ // Go SDK wraps pipeline options inside the URN namespace:
"beam:option:go_options:v1".
+ po := tools.ParseOptionsFromProto(info.GetPipelineOptions(),
"go_options")
+
// Inject artifact validation enabled state into context
- ctx = artifact.WithArtifactValidation(ctx,
!artifact.HasExperiment(info.GetPipelineOptions(),
"disable_staged_file_integrity_checks"))
+ ctx = artifact.WithArtifactValidation(ctx,
!po.HasExperiment("disable_staged_file_integrity_checks"))
// (2) Retrieve the staged files.
//
@@ -210,9 +204,11 @@ func main() {
os.Setenv("RUNNER_CAPABILITIES",
strings.Join(info.GetRunnerCapabilities(), " "))
}
- enableGoogleCloudProfiler := strings.Contains(options,
enableGoogleCloudProfilerOption)
+ // Go SDK models multi-value list flags (like dataflow_service_options)
as comma-separated strings.
+ serviceOpts, _ := po.GetString("dataflow_service_options")
+ enableGoogleCloudProfiler := strings.Contains(serviceOpts,
"enable_google_cloud_profiler")
if enableGoogleCloudProfiler {
- err := configureGoogleCloudProfilerEnvVars(ctx, logger,
info.Metadata, options)
+ err := configureGoogleCloudProfilerEnvVars(ctx, logger,
info.Metadata, po)
if err != nil {
logger.Printf(ctx, "could not configure Google Cloud
Profiler variables, got %v", err)
}
@@ -221,12 +217,8 @@ func main() {
err = execx.Execute(prog, args...)
if err != nil {
- var opt runtime.RawOptionsWrapper
- err := json.Unmarshal([]byte(options), &opt)
- if err == nil {
- if tempLocation, ok :=
opt.Options.Options["temp_location"]; ok {
- diagnostics.UploadHeapProfile(ctx,
fmt.Sprintf("%v/heapProfiles/profile-%v-%d", strings.TrimSuffix(tempLocation,
"/"), *id, time.Now().Unix()))
- }
+ if tempLocation, err := po.GetString("temp_location"); err ==
nil && tempLocation != "" {
+ diagnostics.UploadHeapProfile(ctx,
fmt.Sprintf("%v/heapProfiles/profile-%v-%d", strings.TrimSuffix(tempLocation,
"/"), *id, time.Now().Unix()))
}
}
diff --git a/sdks/go/container/boot_test.go b/sdks/go/container/boot_test.go
index bb94aca36be..57aa083245f 100644
--- a/sdks/go/container/boot_test.go
+++ b/sdks/go/container/boot_test.go
@@ -21,11 +21,14 @@ import (
"path/filepath"
"testing"
+ "encoding/json"
+
"github.com/apache/beam/sdks/v2/go/container/tools"
"github.com/apache/beam/sdks/v2/go/pkg/beam/artifact"
fnpb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/fnexecution_v1"
pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1"
"google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/structpb"
)
func TestEnsureEndpointsSet_AllSet(t *testing.T) {
@@ -224,7 +227,7 @@ func TestConfigureGoogleCloudProfilerEnvVars(t *testing.T) {
options: `{
"beam:option:go_options:v1": {
"options": {
- "dataflow_service_options":
"enable_google_cloud_profiler=custom_profiler"
+ "dataflow_service_options":
"enable_google_cloud_profiler=custom_profiler,another_option"
}
}
}`,
@@ -287,7 +290,17 @@ func TestConfigureGoogleCloudProfilerEnvVars(t *testing.T)
{
clearEnvVars()
ctx := context.Background()
- err := configureGoogleCloudProfilerEnvVars(ctx,
&tools.Logger{}, tt.metadata, tt.options)
+ var raw map[string]interface{}
+ if err := json.Unmarshal([]byte(tt.options), &raw); err
!= nil {
+ t.Fatalf("failed to unmarshal JSON for test:
%v", err)
+ }
+ st, err := structpb.NewStruct(raw)
+ if err != nil {
+ t.Fatalf("failed to create structpb for test:
%v", err)
+ }
+ po := tools.ParseOptionsFromProto(st, "go_options")
+
+ err = configureGoogleCloudProfilerEnvVars(ctx,
&tools.Logger{}, tt.metadata, po)
if tt.expectingError {
if err == nil {
diff --git a/sdks/go/container/tools/pipeline_options.go
b/sdks/go/container/tools/pipeline_options.go
index 026fb31b099..dff51540311 100644
--- a/sdks/go/container/tools/pipeline_options.go
+++ b/sdks/go/container/tools/pipeline_options.go
@@ -19,6 +19,10 @@ import (
"encoding/json"
"fmt"
"os"
+ "strconv"
+ "strings"
+
+ structpb "google.golang.org/protobuf/types/known/structpb"
)
// MakePipelineOptionsFileAndEnvVar writes the pipeline options to a file.
@@ -42,3 +46,217 @@ func MakePipelineOptionsFileAndEnvVar(options string) error
{
os.Setenv("PIPELINE_OPTIONS_FILE", f.Name())
return nil
}
+
+// PipelineOptions represents parsed pipeline options as a normalized map.
+type PipelineOptions struct {
+ options map[string]any
+ experiments map[string]string
+}
+
+// ParseOptionsFromProto creates normalized PipelineOptions directly from a
protobuf Struct.
+func ParseOptionsFromProto(opt *structpb.Struct, sdkNamespace string)
*PipelineOptions {
+ if opt == nil {
+ return &PipelineOptions{options: make(map[string]any),
experiments: make(map[string]string)}
+ }
+ raw := opt.AsMap()
+ flat := make(map[string]any)
+
+ // 1. Extract nested options if present (Dataflow runner uses this
structure)
+ if optsVal, ok := raw["options"]; ok {
+ if optsMap, ok := optsVal.(map[string]any); ok {
+ for k, v := range optsMap {
+ flat[k] = v
+ }
+ }
+ }
+
+ // 2. Extract standard URN keys (Portable runners use this structure)
+ for k, v := range raw {
+ if k == "options" || k == "display_data" {
+ continue
+ }
+ if strings.HasPrefix(k, "beam:option:") && strings.HasSuffix(k,
":v1") {
+ name := strings.TrimPrefix(k, "beam:option:")
+ name = strings.TrimSuffix(name, ":v1")
+ flat[name] = v
+ }
+ }
+
+ // 3. Promote specified SDK namespace options (Highest precedence, may
overwrite earlier entries).
+ // Beam Go SDK uses this structure.
+ if sdkNamespace != "" {
+ sdkURN := fmt.Sprintf("beam:option:%s:v1", sdkNamespace)
+ if sdkVal, ok := raw[sdkURN]; ok {
+ if urnMap, ok := sdkVal.(map[string]any); ok {
+ if nestedOpts, ok :=
urnMap["options"].(map[string]any); ok {
+ for nk, nv := range nestedOpts {
+ flat[nk] = nv
+ }
+ }
+ }
+ }
+ }
+
+ po := &PipelineOptions{
+ options: flat,
+ experiments: make(map[string]string),
+ }
+ if exps, err := po.GetStringSlice("experiments"); err == nil {
+ po.experiments = parseExperiments(exps)
+ }
+ return po
+}
+
+func parseExperiments(slice []string) map[string]string {
+ res := make(map[string]string)
+ for _, item := range slice {
+ if strings.Contains(item, "=") {
+ parts := strings.SplitN(item, "=", 2)
+ res[parts[0]] = parts[1]
+ } else {
+ res[item] = ""
+ }
+ }
+ return res
+}
+
+// HasOption returns true if the option is defined and not nil.
+func (po *PipelineOptions) HasOption(name string) bool {
+ val, ok := po.options[name]
+ return ok && val != nil
+}
+
+// GetString returns the value of an option as a string.
+// As a convenience and to maintain compatibility with Go SDK's flags
serialization style,
+// if the option is stored as a string slice/array, GetString will conjoin the
elements
+// into a single comma-separated string (e.g. ["opt1", "opt2"] -> "opt1,opt2").
+func (po *PipelineOptions) GetString(name string) (string, error) {
+ val, ok := po.options[name]
+ if !ok || val == nil {
+ return "", fmt.Errorf("option %q not defined", name)
+ }
+ if str, ok := val.(string); ok {
+ return str, nil
+ }
+ if slice, ok := val.([]any); ok {
+ var parts []string
+ for _, item := range slice {
+ if str, ok := item.(string); ok {
+ parts = append(parts, str)
+ } else {
+ return "", fmt.Errorf("option %q: expected
string slice element, got type %T", name, item)
+ }
+ }
+ return strings.Join(parts, ","), nil
+ }
+ return "", fmt.Errorf("option %q: expected string, got type %T", name,
val)
+}
+
+// GetStringSlice returns the value of an option as a string slice.
+// As a convenience and to maintain compatibility with Go SDK's flags
serialization style,
+// if the option is stored as a single comma-separated string (such as
experiments
+// or dataflow_service_options), GetStringSlice will parse it by splitting the
string
+// by comma (e.g. "opt1,opt2" -> ["opt1", "opt2"]).
+func (po *PipelineOptions) GetStringSlice(name string) ([]string, error) {
+ val, ok := po.options[name]
+ if !ok || val == nil {
+ return nil, fmt.Errorf("option %q not defined", name)
+ }
+ if slice, ok := val.([]any); ok {
+ var res []string
+ for _, item := range slice {
+ if str, ok := item.(string); ok {
+ res = append(res, str)
+ } else {
+ return nil, fmt.Errorf("option %q: expected
string slice element, got type %T", name, item)
+ }
+ }
+ return res, nil
+ }
+ if str, ok := val.(string); ok {
+ // Go SDK models multi-value list flags (like experiments or
dataflow_service_options)
+ // as comma-separated string values.
+ if str == "" {
+ return nil, nil
+ }
+ return strings.Split(str, ","), nil
+ }
+ return nil, fmt.Errorf("option %q: expected string slice, got type %T",
name, val)
+}
+
+// GetInt returns the value of an option as an integer.
+func (po *PipelineOptions) GetInt(name string) (int, error) {
+ val, ok := po.options[name]
+ if !ok || val == nil {
+ return 0, fmt.Errorf("option %q not defined", name)
+ }
+ switch v := val.(type) {
+ case float64:
+ return int(v), nil
+ case string:
+ res, err := strconv.Atoi(v)
+ if err == nil {
+ return res, nil
+ }
+ return 0, fmt.Errorf("option %q: failed to parse %q as int:
%w", name, v, err)
+ default:
+ return 0, fmt.Errorf("option %q: expected int (represented as
number or string), got type %T", name, val)
+ }
+}
+
+// GetBool returns the value of an option as a boolean.
+func (po *PipelineOptions) GetBool(name string) (bool, error) {
+ val, ok := po.options[name]
+ if !ok || val == nil {
+ return false, fmt.Errorf("option %q not defined", name)
+ }
+ switch v := val.(type) {
+ case bool:
+ return v, nil
+ case string:
+ res, err := strconv.ParseBool(v)
+ if err != nil {
+ return false, fmt.Errorf("option %q: failed to parse %q
as bool: %w", name, v, err)
+ }
+ return res, nil
+ case float64:
+ return v != 0, nil
+ default:
+ return false, fmt.Errorf("option %q: expected bool, got type
%T", name, val)
+ }
+}
+
+// GetFloat64 returns the value of an option as a float64.
+func (po *PipelineOptions) GetFloat64(name string) (float64, error) {
+ val, ok := po.options[name]
+ if !ok || val == nil {
+ return 0, fmt.Errorf("option %q not defined", name)
+ }
+ switch v := val.(type) {
+ case float64:
+ return v, nil
+ case string:
+ res, err := strconv.ParseFloat(v, 64)
+ if err == nil {
+ return res, nil
+ }
+ return 0, fmt.Errorf("option %q: failed to parse %q as float64:
%w", name, v, err)
+ default:
+ return 0, fmt.Errorf("option %q: expected float64 (represented
as number or string), got type %T", name, val)
+ }
+}
+
+// LookupExperiment returns the value of an experiment option if present.
+// - If the experiment is present but has no value (e.g., --experiments=foo),
it returns "", true.
+// - If the experiment is present as a key-value pair (e.g.,
--experiments=foo=bar), it returns "bar", true.
+// - If the experiment is not present, it returns "", false.
+func (po *PipelineOptions) LookupExperiment(key string) (string, bool) {
+ val, ok := po.experiments[key]
+ return val, ok
+}
+
+// HasExperiment returns true if the specified experiment is present in the
options (either as a flag or key-value pair).
+func (po *PipelineOptions) HasExperiment(name string) bool {
+ _, ok := po.LookupExperiment(name)
+ return ok
+}
diff --git a/sdks/go/container/tools/pipeline_options_test.go
b/sdks/go/container/tools/pipeline_options_test.go
index 7a0d7ebd5f0..c220a501756 100644
--- a/sdks/go/container/tools/pipeline_options_test.go
+++ b/sdks/go/container/tools/pipeline_options_test.go
@@ -16,10 +16,28 @@
package tools
import (
+ "encoding/json"
"os"
"testing"
+
+ structpb "google.golang.org/protobuf/types/known/structpb"
)
+func parseProtoForTest(t *testing.T, options string) *structpb.Struct {
+ if options == "" {
+ options = "{}"
+ }
+ var raw map[string]interface{}
+ if err := json.Unmarshal([]byte(options), &raw); err != nil {
+ t.Fatalf("failed to unmarshal JSON for test: %v", err)
+ }
+ st, err := structpb.NewStruct(raw)
+ if err != nil {
+ t.Fatalf("failed to create structpb for test: %v", err)
+ }
+ return st
+}
+
func TestMakePipelineOptionsFileAndEnvVar(t *testing.T) {
tests := []struct {
name string
@@ -56,3 +74,228 @@ func TestMakePipelineOptionsFileAndEnvVar(t *testing.T) {
}
os.Remove("pipeline_options.json")
}
+
+func TestParseOptionsFromProto_NestedOptionsNoNamespace(t *testing.T) {
+ p := parseProtoForTest(t, `{
+ "options": {
+ "profiler_agent": "memray",
+ "profile_upload_interval_sec": 10,
+ "profiler_stop_after_crash": true,
+ "profile_sample_rate": 0.5,
+ "experiments": ["beam_fn_api",
"pip_use_build_isolation"]
+ }
+ }`)
+ po := ParseOptionsFromProto(p, "")
+
+ if got, err := po.GetString("profiler_agent"); err != nil || got !=
"memray" {
+ t.Errorf("GetString(profiler_agent) = (%q, %v), want
(\"memray\", nil)", got, err)
+ }
+ if got, err := po.GetInt("profile_upload_interval_sec"); err != nil ||
got != 10 {
+ t.Errorf("GetInt(profile_upload_interval_sec) = (%d, %v), want
(10, nil)", got, err)
+ }
+ if got, err := po.GetBool("profiler_stop_after_crash"); err != nil ||
got != true {
+ t.Errorf("GetBool(profiler_stop_after_crash) = (%t, %v), want
(true, nil)", got, err)
+ }
+ // Sample float option in Beam. Unused for memray in practice.
+ if got, err := po.GetFloat64("profile_sample_rate"); err != nil || got
!= 0.5 {
+ t.Errorf("GetFloat64(profile_sample_rate) = (%f, %v), want
(0.5, nil)", got, err)
+ }
+ experiments, err := po.GetStringSlice("experiments")
+ if err != nil || len(experiments) != 2 || experiments[0] !=
"beam_fn_api" || experiments[1] != "pip_use_build_isolation" {
+ t.Errorf("GetStringSlice(experiments) = (%v, %v), want
([beam_fn_api, pip_use_build_isolation], nil)", experiments, err)
+ }
+ if !po.HasExperiment("beam_fn_api") ||
!po.HasExperiment("pip_use_build_isolation") {
+ t.Errorf("expected experiments beam_fn_api and
pip_use_build_isolation to be present, experiments map: %+v", po.experiments)
+ }
+}
+
+func TestParseOptionsFromProto_FlatOptionsWithURN(t *testing.T) {
+ p := parseProtoForTest(t, `{
+ "beam:option:profiler_agent:v1": "memray",
+ "beam:option:profile_upload_interval_sec:v1": "10",
+ "beam:option:profiler_stop_after_crash:v1": "true",
+ "beam:option:profile_sample_rate:v1": "0.5",
+ "beam:option:experiments:v1": ["beam_fn_api", "another_exp"]
+ }`)
+ po := ParseOptionsFromProto(p, "")
+
+ if got, err := po.GetString("profiler_agent"); err != nil || got !=
"memray" {
+ t.Errorf("GetString(profiler_agent) = (%q, %v), want
(\"memray\", nil)", got, err)
+ }
+ if got, err := po.GetInt("profile_upload_interval_sec"); err != nil ||
got != 10 {
+ t.Errorf("GetInt(profile_upload_interval_sec) = (%d, %v), want
(10, nil)", got, err)
+ }
+ if got, err := po.GetBool("profiler_stop_after_crash"); err != nil ||
got != true {
+ t.Errorf("GetBool(profiler_stop_after_crash) = (%t, %v), want
(true, nil)", got, err)
+ }
+ if got, err := po.GetFloat64("profile_sample_rate"); err != nil || got
!= 0.5 {
+ t.Errorf("GetFloat64(profile_sample_rate) = (%f, %v), want
(0.5, nil)", got, err)
+ }
+ if got, err := po.GetString("experiments"); err != nil || got !=
"beam_fn_api,another_exp" {
+ t.Errorf("GetString(experiments) = (%q, %v), want
(\"beam_fn_api,another_exp\", nil)", got, err)
+ }
+ experiments, err := po.GetStringSlice("experiments")
+ if err != nil || len(experiments) != 2 || experiments[0] !=
"beam_fn_api" || experiments[1] != "another_exp" {
+ t.Errorf("GetStringSlice(experiments) = (%v, %v), want
([beam_fn_api, another_exp], nil)", experiments, err)
+ }
+ if !po.HasExperiment("beam_fn_api") || !po.HasExperiment("another_exp")
{
+ t.Errorf("expected experiments beam_fn_api and another_exp to
be present, experiments map: %+v", po.experiments)
+ }
+}
+
+func TestParseOptionsFromProto_CommaSeparatedExperiments(t *testing.T) {
+ p := parseProtoForTest(t, `{
+ "beam:option:go_options:v1": {
+ "options": {
+ "experiments": "exp1,exp2,exp3",
+ "dataflow_service_options": "opt1"
+ }
+ }
+ }`)
+ po := ParseOptionsFromProto(p, "go_options")
+
+ experiments, err := po.GetStringSlice("experiments")
+ if err != nil || len(experiments) != 3 || experiments[0] != "exp1" ||
experiments[1] != "exp2" || experiments[2] != "exp3" {
+ t.Errorf("GetStringSlice(experiments) = (%v, %v), want ([exp1,
exp2, exp3], nil)", experiments, err)
+ }
+ if !po.HasExperiment("exp1") || !po.HasExperiment("exp2") ||
!po.HasExperiment("exp3") {
+ t.Errorf("expected experiments exp1, exp2, and exp3 to be
present, experiments map: %+v", po.experiments)
+ }
+ serviceOpts, err := po.GetStringSlice("dataflow_service_options")
+ if err != nil || len(serviceOpts) != 1 || serviceOpts[0] != "opt1" {
+ t.Errorf("GetStringSlice(dataflow_service_options) = (%v, %v),
want ([opt1], nil)", serviceOpts, err)
+ }
+}
+
+func TestParseOptionsFromProto_MalformedOptions(t *testing.T) {
+ t.Run("malformed integer", func(t *testing.T) {
+ p := parseProtoForTest(t, `{"options":
{"profile_upload_interval_sec": "invalid"}}`)
+ po := ParseOptionsFromProto(p, "")
+ _, err := po.GetInt("profile_upload_interval_sec")
+ if err == nil {
+ t.Errorf("expected error, got nil")
+ }
+ })
+
+ t.Run("malformed bool", func(t *testing.T) {
+ p := parseProtoForTest(t, `{"options":
{"profiler_stop_after_crash": "maybe"}}`)
+ po := ParseOptionsFromProto(p, "")
+ _, err := po.GetBool("profiler_stop_after_crash")
+ if err == nil {
+ t.Errorf("expected error, got nil")
+ }
+ })
+
+ t.Run("type mismatch int expected got bool", func(t *testing.T) {
+ p := parseProtoForTest(t, `{"options":
{"profile_upload_interval_sec": true}}`)
+ po := ParseOptionsFromProto(p, "")
+ _, err := po.GetInt("profile_upload_interval_sec")
+ if err == nil {
+ t.Errorf("expected error, got nil")
+ }
+ })
+
+ t.Run("missing key returns error", func(t *testing.T) {
+ p := parseProtoForTest(t, `{}`)
+ po := ParseOptionsFromProto(p, "")
+ _, errInt := po.GetInt("profile_upload_interval_sec")
+ _, errBool := po.GetBool("profiler_stop_after_crash")
+ _, errFloat := po.GetFloat64("profile_sample_rate")
+ if errInt == nil || errBool == nil || errFloat == nil {
+ t.Errorf("expected error for missing keys, got:
intErr=%v, boolErr=%v, floatErr=%v", errInt, errBool, errFloat)
+ }
+ })
+}
+
+func TestPipelineOptions_HasOption(t *testing.T) {
+ p := parseProtoForTest(t, `{"options": {"profile_upload_interval_sec":
10}}`)
+ po := ParseOptionsFromProto(p, "")
+ if !po.HasOption("profile_upload_interval_sec") {
+ t.Errorf("HasOption(profile_upload_interval_sec) = false, want
true")
+ }
+ if po.HasOption("profiler_stop_after_crash") {
+ t.Errorf("HasOption(profiler_stop_after_crash) = true, want
false")
+ }
+}
+
+func TestPipelineOptions_HasExperiment(t *testing.T) {
+ p := parseProtoForTest(t, `{"options": {"experiments": ["exp1",
"exp2=val2"]}}`)
+ po := ParseOptionsFromProto(p, "")
+ if !po.HasExperiment("exp1") {
+ t.Errorf("HasExperiment(exp1) = false, want true")
+ }
+ if !po.HasExperiment("exp2") {
+ t.Errorf("HasExperiment(exp2) = false, want true")
+ }
+ if po.HasExperiment("exp3") {
+ t.Errorf("HasExperiment(exp3) = true, want false")
+ }
+}
+
+func TestPipelineOptions_LookupExperiment(t *testing.T) {
+ p := parseProtoForTest(t, `{"options": {"experiments": ["exp1",
"exp2=val2", "exp3=val3=val4"]}}`)
+ po := ParseOptionsFromProto(p, "")
+
+ val, ok := po.LookupExperiment("exp1")
+ if !ok || val != "" {
+ t.Errorf("LookupExperiment(exp1) = (%q, %t), want (\"\",
true)", val, ok)
+ }
+
+ val, ok = po.LookupExperiment("exp2")
+ if !ok || val != "val2" {
+ t.Errorf("LookupExperiment(exp2) = (%q, %t), want (\"val2\",
true)", val, ok)
+ }
+
+ val, ok = po.LookupExperiment("exp3")
+ if !ok || val != "val3=val4" {
+ t.Errorf("LookupExperiment(exp3) = (%q, %t), want
(\"val3=val4\", true)", val, ok)
+ }
+
+ val, ok = po.LookupExperiment("exp4")
+ if ok || val != "" {
+ t.Errorf("LookupExperiment(exp4) = (%q, %t), want (\"\",
false)", val, ok)
+ }
+}
+
+func TestParseOptionsFromProto_SDKOptionsPromotion(t *testing.T) {
+ optionsStruct, err := structpb.NewStruct(map[string]interface{}{
+ "options": map[string]interface{}{
+ "region": "us-central1",
+ },
+ "beam:option:experiments:v1": []interface{}{"expA", "expB"},
+ "beam:option:go_options:v1": map[string]interface{}{
+ "options": map[string]interface{}{
+ "dataflow_service_options":
"enable_google_cloud_profiler,enable_new_custom_feature",
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("failed to create proto Struct: %v", err)
+ }
+
+ po := ParseOptionsFromProto(optionsStruct, "go_options")
+ if got, err := po.GetString("region"); err != nil || got !=
"us-central1" {
+ t.Errorf("GetString(region) = (%q, %v), want (\"us-central1\",
nil)", got, err)
+ }
+ if !po.HasExperiment("expA") || !po.HasExperiment("expB") {
+ t.Errorf("expected experiments expA and expB to be present,
options: %+v", po.options)
+ }
+ if got, err := po.GetString("dataflow_service_options"); err != nil ||
got != "enable_google_cloud_profiler,enable_new_custom_feature" {
+ t.Errorf("GetString(dataflow_service_options) = (%q, %v), want
(\"enable_google_cloud_profiler,enable_new_custom_feature\", nil)", got, err)
+ }
+ serviceOpts, err := po.GetStringSlice("dataflow_service_options")
+ if err != nil || len(serviceOpts) != 2 || serviceOpts[0] !=
"enable_google_cloud_profiler" || serviceOpts[1] != "enable_new_custom_feature"
{
+ t.Errorf("GetStringSlice(dataflow_service_options) = (%v, %v),
want ([enable_google_cloud_profiler, enable_new_custom_feature], nil)",
serviceOpts, err)
+ }
+ goOpts, ok := po.options["go_options"].(map[string]any)
+ if !ok {
+ t.Errorf("expected go_options map to be present, options: %+v",
po.options)
+ }
+ nestedOpts, ok := goOpts["options"].(map[string]any)
+ if !ok {
+ t.Errorf("expected nested options map inside go_options, got:
%+v", goOpts)
+ }
+ if got := nestedOpts["dataflow_service_options"]; got !=
"enable_google_cloud_profiler,enable_new_custom_feature" {
+ t.Errorf("got dataflow_service_options = %v, want
enable_google_cloud_profiler,enable_new_custom_feature", got)
+ }
+}
diff --git a/sdks/go/pkg/beam/artifact/options.go
b/sdks/go/pkg/beam/artifact/options.go
deleted file mode 100644
index 47356433161..00000000000
--- a/sdks/go/pkg/beam/artifact/options.go
+++ /dev/null
@@ -1,48 +0,0 @@
-// 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 artifact
-
-import (
- structpb "google.golang.org/protobuf/types/known/structpb"
-)
-
-// GetExperiments extracts a list of experiments from the pipeline options.
-func GetExperiments(options *structpb.Struct) []string {
- if options == nil {
- return nil
- }
-
- var exps []string
- // Try legacy style
- for _, v := range
options.GetFields()["options"].GetStructValue().GetFields()["experiments"].GetListValue().GetValues()
{
- exps = append(exps, v.GetStringValue())
- }
- // Try URN style
- for _, v := range
options.GetFields()["beam:option:experiments:v1"].GetListValue().GetValues() {
- exps = append(exps, v.GetStringValue())
- }
- return exps
-}
-
-// HasExperiment checks if a specific experiment is enabled in the pipeline
options.
-func HasExperiment(options *structpb.Struct, experiment string) bool {
- for _, exp := range GetExperiments(options) {
- if exp == experiment {
- return true
- }
- }
- return false
-}
diff --git a/sdks/go/pkg/beam/artifact/options_test.go
b/sdks/go/pkg/beam/artifact/options_test.go
deleted file mode 100644
index a9f0e4bb7e3..00000000000
--- a/sdks/go/pkg/beam/artifact/options_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-// 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 artifact
-
-import (
- "testing"
-
- structpb "google.golang.org/protobuf/types/known/structpb"
-)
-
-func TestGetExperiments_Nil(t *testing.T) {
- if got := GetExperiments(nil); got != nil {
- t.Errorf("GetExperiments(nil) = %v, want nil", got)
- }
-}
-
-func TestGetExperiments_Legacy(t *testing.T) {
- options, _ := structpb.NewStruct(map[string]interface{}{
- "options": map[string]interface{}{
- "experiments": []interface{}{"exp1", "exp2"},
- },
- })
- exps := GetExperiments(options)
- if len(exps) != 2 || exps[0] != "exp1" || exps[1] != "exp2" {
- t.Errorf("GetExperiments() = %v, want [exp1 exp2]", exps)
- }
-}
-
-func TestGetExperiments_URN(t *testing.T) {
- urnOptions, _ := structpb.NewStruct(map[string]interface{}{
- "beam:option:experiments:v1": []interface{}{"expA", "expB"},
- })
- expsURN := GetExperiments(urnOptions)
- if len(expsURN) != 2 || expsURN[0] != "expA" || expsURN[1] != "expB" {
- t.Errorf("GetExperiments() = %v, want [expA expB]", expsURN)
- }
-}
-
-func TestHasExperiment(t *testing.T) {
- options, _ := structpb.NewStruct(map[string]interface{}{
- "options": map[string]interface{}{
- "experiments": []interface{}{"exp1", "exp2"},
- },
- })
-
- if !HasExperiment(options, "exp1") {
- t.Errorf("HasExperiment(exp1) = false, want true")
- }
- if HasExperiment(options, "exp3") {
- t.Errorf("HasExperiment(exp3) = true, want false")
- }
-}
-
-func TestGetExperiments_Combined(t *testing.T) {
- options, _ := structpb.NewStruct(map[string]interface{}{
- "options": map[string]interface{}{
- "experiments": []interface{}{"exp1", "exp2"},
- },
- "beam:option:experiments:v1": []interface{}{"expA", "expB"},
- })
- exps := GetExperiments(options)
- if len(exps) != 4 || exps[0] != "exp1" || exps[1] != "exp2" || exps[2]
!= "expA" || exps[3] != "expB" {
- t.Errorf("GetExperiments() = %v, want [exp1 exp2 expA expB]",
exps)
- }
-}
diff --git a/sdks/java/container/boot.go b/sdks/java/container/boot.go
index ad29f8d940a..6322c3004e5 100644
--- a/sdks/java/container/boot.go
+++ b/sdks/java/container/boot.go
@@ -105,8 +105,10 @@ func main() {
logger.Fatalf(ctx, "Failed to convert pipeline options: %v",
err)
}
+ po := tools.ParseOptionsFromProto(info.GetPipelineOptions(), "")
+
// Inject artifact validation enabled state into context
- ctx = artifact.WithArtifactValidation(ctx,
!artifact.HasExperiment(info.GetPipelineOptions(),
"disable_staged_file_integrity_checks"))
+ ctx = artifact.WithArtifactValidation(ctx,
!po.HasExperiment("disable_staged_file_integrity_checks"))
// (2) Retrieve the staged user jars. We ignore any disk limit,
// because the staged jars are mandatory.
diff --git a/sdks/python/apache_beam/options/pipeline_options.py
b/sdks/python/apache_beam/options/pipeline_options.py
index ee7e14f3de2..2ab470e8beb 100644
--- a/sdks/python/apache_beam/options/pipeline_options.py
+++ b/sdks/python/apache_beam/options/pipeline_options.py
@@ -612,6 +612,7 @@ class PipelineOptions(HasDisplayData):
def from_runner_api(cls, proto_options, original_options=None):
def from_urn(key):
assert key.startswith('beam:option:')
+ # Update sdks/go/container/tools/pipeline_options.go if :v1 part changes.
assert key.endswith(':v1')
return key[12:-3]
diff --git a/sdks/python/container/boot.go b/sdks/python/container/boot.go
index 5a8d6da46ab..1e912ebebb7 100644
--- a/sdks/python/container/boot.go
+++ b/sdks/python/container/boot.go
@@ -132,32 +132,6 @@ func main() {
// ],
// }
// }
-type PipelineOptionsData struct {
- Options OptionsData `json:"options"`
-}
-
-type OptionsData struct {
- Experiments []string `json:"experiments"`
- ProfilerAgent string `json:"profiler_agent"`
- ProfilerExtraArgs []string `json:"profiler_extra_args"`
- ProfilerExtraEnvVars []string `json:"profiler_extra_env_vars"`
- ProfileLocation string `json:"profile_location"`
- ProfileTempLocation string `json:"profile_temp_location"`
- ProfileUploadIntervalSec int
`json:"profile_upload_interval_sec"`
- ProfilerStopAfterSec int `json:"profiler_stop_after_sec"`
- ProfilerStopAfterCrash bool
`json:"profiler_stop_after_crash"`
- ProfilePostprocessIntervalSec int
`json:"profile_postprocess_interval_sec"`
- JobId string `json:"jobId,omitempty"`
-}
-
-func getExperiments(options string) []string {
- var opts PipelineOptionsData
- err := json.Unmarshal([]byte(options), &opts)
- if err != nil {
- return nil
- }
- return opts.Options.Experiments
-}
func launchSDKProcess() error {
ctx := grpcx.WriteWorkerID(context.Background(), *id)
@@ -193,31 +167,20 @@ func launchSDKProcess() error {
// (1) Obtain the pipeline options
- options, err := tools.ProtoToJSON(info.GetPipelineOptions())
- if err != nil {
- logger.Fatalf(ctx, "Failed to convert pipeline options: %v",
err)
- }
+ po := tools.ParseOptionsFromProto(info.GetPipelineOptions(), "")
+ logger.Printf(ctx, "Parsed options in boot entrypoint: %v", po)
// Inject artifact validation enabled state into context
- ctx = artifact.WithArtifactValidation(ctx,
!artifact.HasExperiment(info.GetPipelineOptions(),
"disable_staged_file_integrity_checks"))
+ ctx = artifact.WithArtifactValidation(ctx,
!po.HasExperiment("disable_staged_file_integrity_checks"))
- experiments := getExperiments(options)
- logger.Printf(ctx, "Experiments=%v", experiments)
-
- pipNoBuildIsolation = true
- if slices.Contains(experiments, "pip_use_build_isolation") {
- pipNoBuildIsolation = false
- logger.Printf(ctx, "Build isolation enabled when installing
packages with pip")
- } else {
+ pipNoBuildIsolation = !po.HasExperiment("pip_use_build_isolation")
+ if pipNoBuildIsolation {
logger.Printf(ctx, "Build isolation disabled when installing
packages with pip")
+ } else {
+ logger.Printf(ctx, "Build isolation enabled when installing
packages with pip")
}
- var opts PipelineOptionsData
- if err := json.Unmarshal([]byte(options), &opts); err != nil {
- logger.Warnf(ctx, "Failed to unmarshal pipeline options for
profiling config: %v", err)
- }
-
- ctx = setupProfilerConfig(ctx, logger, &opts)
+ ctx = setupProfilerConfig(ctx, logger, po)
startProfilerBackgroundTasks(ctx, logger)
// (2) Retrieve and install the staged packages.
@@ -286,6 +249,10 @@ func launchSDKProcess() error {
// (3) Invoke python
// Write the JSON string of pipeline options into a file to prevent
"argument list too long" error.
+ options, err := tools.ProtoToJSON(info.GetPipelineOptions())
+ if err != nil {
+ logger.Fatalf(ctx, "Failed to convert pipeline options: %v",
err)
+ }
if err := tools.MakePipelineOptionsFileAndEnvVar(options); err != nil {
logger.Fatalf(ctx, "Failed to load pipeline options to worker:
%v", err)
}
diff --git a/sdks/python/container/profiler.go
b/sdks/python/container/profiler.go
index d19923f912c..ff88fc9e64d 100644
--- a/sdks/python/container/profiler.go
+++ b/sdks/python/container/profiler.go
@@ -60,19 +60,25 @@ type ProfilerConfig struct {
GcloudAvailable bool
}
-// setupProfilerConfig parses PipelineOptionsData and stores a resolved
ProfilerConfig in the context.
-func setupProfilerConfig(ctx context.Context, logger *tools.Logger, opts
*PipelineOptionsData) context.Context {
- agent := opts.Options.ProfilerAgent
- if agent == "" {
+// setupProfilerConfig parses PipelineOptions and stores a resolved
ProfilerConfig in the context.
+func setupProfilerConfig(ctx context.Context, logger *tools.Logger, po
*tools.PipelineOptions) context.Context {
+ agent, err := po.GetString("profiler_agent")
+ if err != nil || agent == "" {
return ctx
}
- baseTempDir := opts.Options.ProfileTempLocation
- if baseTempDir == "" {
+ baseTempDir, err := po.GetString("profile_temp_location")
+ if err != nil || baseTempDir == "" {
baseTempDir = filepath.Join(*semiPersistDir, "profiles")
}
- jobId := opts.Options.JobId
+ jobId, err := po.GetString("jobId")
+ if err != nil || jobId == "" {
+ jobId = os.Getenv("JOB_ID")
+ }
+ if jobId == "" {
+ jobId = os.Getenv("JOB_NAME")
+ }
if jobId == "" {
jobId = "BEAM_JOB"
}
@@ -86,8 +92,11 @@ func setupProfilerConfig(ctx context.Context, logger
*tools.Logger, opts *Pipeli
var gcsDestPath string
gcloudAvailable := false
- if strings.HasPrefix(opts.Options.ProfileLocation, "gs://") {
- gcsDestPath = strings.TrimSuffix(opts.Options.ProfileLocation,
"/")
+ profileLocation, err := po.GetString("profile_location")
+ if err != nil || profileLocation == "" {
+ logger.Printf(ctx, "profile_location not specified, profiles
will only be stored locally.")
+ } else if strings.HasPrefix(profileLocation, "gs://") {
+ gcsDestPath = strings.TrimSuffix(profileLocation, "/")
if _, err := exec.LookPath("gcloud"); err == nil {
gcloudAvailable = true
} else {
@@ -95,20 +104,48 @@ func setupProfilerConfig(ctx context.Context, logger
*tools.Logger, opts *Pipeli
}
}
+ profilerExtraArgs, err := po.GetStringSlice("profiler_extra_args")
+ if err != nil {
+ profilerExtraArgs = []string{}
+ }
+ profilerExtraEnvVars, err :=
po.GetStringSlice("profiler_extra_env_vars")
+ if err != nil {
+ profilerExtraEnvVars = []string{}
+ }
+
+ profileUploadIntervalSec, err :=
po.GetInt("profile_upload_interval_sec")
+ if err != nil {
+ profileUploadIntervalSec = 300
+ logger.Printf(ctx, "Using default profile_upload_interval_sec:
%v", profileUploadIntervalSec)
+ }
+ profilerStopAfterSec, err := po.GetInt("profiler_stop_after_sec")
+ if err != nil {
+ profilerStopAfterSec = 0
+ }
+ profilerStopAfterCrash, err := po.GetBool("profiler_stop_after_crash")
+ if err != nil {
+ profilerStopAfterCrash = false
+ }
+ profilePostprocessIntervalSec, err :=
po.GetInt("profile_postprocess_interval_sec")
+ if err != nil {
+ profilePostprocessIntervalSec = 600
+ logger.Printf(ctx, "Using default
profile_postprocess_interval_sec: %v", profilePostprocessIntervalSec)
+ }
+
config := &ProfilerConfig{
Enabled: true,
Agent: agent,
- ExtraArgs: opts.Options.ProfilerExtraArgs,
- ExtraEnvVars: opts.Options.ProfilerExtraEnvVars,
- Location: opts.Options.ProfileLocation,
+ ExtraArgs: profilerExtraArgs,
+ ExtraEnvVars: profilerExtraEnvVars,
+ Location: profileLocation,
BaseTempDir: baseTempDir,
TempLocation: tempLocation,
StopSentinelPath: sentinelPath,
GcsDestPath: gcsDestPath,
- UploadIntervalSec: opts.Options.ProfileUploadIntervalSec,
- StopAfterSec: opts.Options.ProfilerStopAfterSec,
- StopAfterCrash: opts.Options.ProfilerStopAfterCrash,
- PostprocessIntervalSec:
opts.Options.ProfilePostprocessIntervalSec,
+ UploadIntervalSec: profileUploadIntervalSec,
+ StopAfterSec: profilerStopAfterSec,
+ StopAfterCrash: profilerStopAfterCrash,
+ PostprocessIntervalSec: profilePostprocessIntervalSec,
GcloudAvailable: gcloudAvailable,
}
diff --git a/sdks/python/container/profiler_test.go
b/sdks/python/container/profiler_test.go
index 27abf8a2ab3..15f93a2a319 100644
--- a/sdks/python/container/profiler_test.go
+++ b/sdks/python/container/profiler_test.go
@@ -20,6 +20,9 @@ import (
"os"
"path/filepath"
"testing"
+
+ "github.com/apache/beam/sdks/v2/go/container/tools"
+ "google.golang.org/protobuf/types/known/structpb"
)
func TestActivePidsRegistry(t *testing.T) {
@@ -48,13 +51,19 @@ func TestActivePidsRegistry(t *testing.T) {
}
func TestSetupProfilerConfig(t *testing.T) {
- opts := &PipelineOptionsData{
- Options: OptionsData{
- ProfilerAgent: "coredump",
- JobId: "test-job",
+ st, err := structpb.NewStruct(map[string]interface{}{
+ "options": map[string]interface{}{
+ "profiler_agent": "coredump",
+ "jobId": "test-job",
},
+ })
+ if err != nil {
+ t.Fatalf("Failed to create structpb: %v", err)
}
- ctx := setupProfilerConfig(context.Background(), nil, opts)
+
+ po := tools.ParseOptionsFromProto(st, "")
+
+ ctx := setupProfilerConfig(context.Background(), &tools.Logger{}, po)
pcfg := getProfilerConfig(ctx)
if pcfg == nil {
t.Fatal("ProfilerConfig was nil")
diff --git a/sdks/typescript/container/boot.go
b/sdks/typescript/container/boot.go
index 95e26124fac..666b101c225 100644
--- a/sdks/typescript/container/boot.go
+++ b/sdks/typescript/container/boot.go
@@ -91,8 +91,10 @@ func main() {
logger.Fatalf(ctx, "Failed to convert pipeline options: %v",
err)
}
+ po := tools.ParseOptionsFromProto(info.GetPipelineOptions(), "")
+
// Inject artifact validation enabled state into context
- ctx = artifact.WithArtifactValidation(ctx,
!artifact.HasExperiment(info.GetPipelineOptions(),
"disable_staged_file_integrity_checks"))
+ ctx = artifact.WithArtifactValidation(ctx,
!po.HasExperiment("disable_staged_file_integrity_checks"))
// (2) Retrieve and install the staged packages.