From 2b4ecc526c5c1788e8ed929b09527504279d4a82 Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:01:43 -0400 Subject: [PATCH 001/108] add WRITE_TRUNCATE_DATA write_disposition to the bigquery python sdk --- sdks/python/apache_beam/io/gcp/bigquery.py | 5 +++++ .../clients/bigquery/bigquery_v2_messages.py | 15 +++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/sdks/python/apache_beam/io/gcp/bigquery.py b/sdks/python/apache_beam/io/gcp/bigquery.py index 2a52b31edf7f..150724cbca15 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery.py +++ b/sdks/python/apache_beam/io/gcp/bigquery.py @@ -590,6 +590,7 @@ class BigQueryDisposition(object): CREATE_NEVER = 'CREATE_NEVER' CREATE_IF_NEEDED = 'CREATE_IF_NEEDED' + WRITE_TRUNCATE_DATA = 'WRITE_TRUNCATE_DATA' WRITE_TRUNCATE = 'WRITE_TRUNCATE' WRITE_APPEND = 'WRITE_APPEND' WRITE_EMPTY = 'WRITE_EMPTY' @@ -606,6 +607,7 @@ def validate_create(disposition): @staticmethod def validate_write(disposition): values = ( + BigQueryDisposition.WRITE_TRUNCATE_DATA, BigQueryDisposition.WRITE_TRUNCATE, BigQueryDisposition.WRITE_APPEND, BigQueryDisposition.WRITE_EMPTY) @@ -1517,6 +1519,7 @@ def __init__( - BigQueryDisposition.CREATE_NEVER: fail the write if does not exist. write_disposition: A string describing what happens if the table has already some data. Possible values are: + - BigQueryDisposition.WRITE_TRUNCATE_DATA: delete table data but preserve table metadata. - BigQueryDisposition.WRITE_TRUNCATE: delete existing rows. - BigQueryDisposition.WRITE_APPEND: add to existing rows. - BigQueryDisposition.WRITE_EMPTY: fail the write if table not empty. @@ -1558,6 +1561,7 @@ def __init__( self.create_disposition = create_disposition self.write_disposition = write_disposition if write_disposition in (BigQueryDisposition.WRITE_EMPTY, + BigQueryDisposition.WRITE_TRUNCATE_DATA, BigQueryDisposition.WRITE_TRUNCATE): raise ValueError( 'Write disposition %s is not supported for' @@ -2126,6 +2130,7 @@ def __init__( write_disposition (BigQueryDisposition): A string describing what happens if the table has already some data. Possible values are: + * :attr:`BigQueryDisposition.WRITE_TRUNCATE_DATA`: delete table data but preserve table metadata. * :attr:`BigQueryDisposition.WRITE_TRUNCATE`: delete existing rows. * :attr:`BigQueryDisposition.WRITE_APPEND`: add to existing rows. * :attr:`BigQueryDisposition.WRITE_EMPTY`: fail the write if table not diff --git a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py index 3392bdaf9592..8b9fa0f883f4 100644 --- a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py +++ b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py @@ -5440,12 +5440,15 @@ class JobConfigurationTableCopy(_messages.Message): destination table already exists. The following values are supported: * WRITE_TRUNCATE: If the table already exists, BigQuery overwrites the table data and uses the schema and table constraints from the source - table. * WRITE_APPEND: If the table already exists, BigQuery appends the - data to the table. * WRITE_EMPTY: If the table already exists and - contains data, a 'duplicate' error is returned in the job result. The - default value is WRITE_EMPTY. Each action is atomic and only occurs if - BigQuery is able to complete the job successfully. Creation, truncation - and append actions occur as one atomic update upon job completion. + table. * WRITE_TRUNCATE_DATA: If the table already exists, BigQuery + overwrites the table data, but does not use schema and table constraints + from the source table.* WRITE_APPEND: If the table already exists, + BigQuery appends the data to the table. * WRITE_EMPTY: If the table + already exists and contains data, a 'duplicate' error is returned in + the job result. The default value is WRITE_EMPTY. Each action is atomic + and only occurs if BigQuery is able to complete the job successfully. + Creation, truncation and append actions occur as one atomic update + upon job completion. """ class OperationTypeValueValuesEnum(_messages.Enum): From 6f9745d83438242a03ee2b546554bdbf33f2e9c1 Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:01:43 -0400 Subject: [PATCH 002/108] changes.md --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 537c3a32447c..7b56e22c9ce6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -66,7 +66,7 @@ * Support for X source added (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). * (Java) FirestoreIO: added `RpcQosOptions.Builder.withRampupThrottlingDisabled()`, mirroring the existing DatastoreIO option, to opt out of the 500/50/5 write ramp-up ([#27170](https://github.com/apache/beam/issues/27170)). - +* (Python) The BigQuery IO writer now allows the `WRITE_TRUNCATE_DATA` write_disposition ## New Features / Improvements From a5a8a77ffd1e8a7576546f652bac3ab12d5bf319 Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:04:54 -0400 Subject: [PATCH 003/108] revert changes to bigquery_v2_messages --- .../clients/bigquery/bigquery_v2_messages.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py index 8b9fa0f883f4..3392bdaf9592 100644 --- a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py +++ b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py @@ -5440,15 +5440,12 @@ class JobConfigurationTableCopy(_messages.Message): destination table already exists. The following values are supported: * WRITE_TRUNCATE: If the table already exists, BigQuery overwrites the table data and uses the schema and table constraints from the source - table. * WRITE_TRUNCATE_DATA: If the table already exists, BigQuery - overwrites the table data, but does not use schema and table constraints - from the source table.* WRITE_APPEND: If the table already exists, - BigQuery appends the data to the table. * WRITE_EMPTY: If the table - already exists and contains data, a 'duplicate' error is returned in - the job result. The default value is WRITE_EMPTY. Each action is atomic - and only occurs if BigQuery is able to complete the job successfully. - Creation, truncation and append actions occur as one atomic update - upon job completion. + table. * WRITE_APPEND: If the table already exists, BigQuery appends the + data to the table. * WRITE_EMPTY: If the table already exists and + contains data, a 'duplicate' error is returned in the job result. The + default value is WRITE_EMPTY. Each action is atomic and only occurs if + BigQuery is able to complete the job successfully. Creation, truncation + and append actions occur as one atomic update upon job completion. """ class OperationTypeValueValuesEnum(_messages.Enum): From d233c0c51f69bb0db3942a54e92daab979836cc7 Mon Sep 17 00:00:00 2001 From: "Gary White Jr." <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:05:29 -0400 Subject: [PATCH 004/108] Update CHANGES.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 7b56e22c9ce6..7ef1c252c060 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -66,7 +66,7 @@ * Support for X source added (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). * (Java) FirestoreIO: added `RpcQosOptions.Builder.withRampupThrottlingDisabled()`, mirroring the existing DatastoreIO option, to opt out of the 500/50/5 write ramp-up ([#27170](https://github.com/apache/beam/issues/27170)). -* (Python) The BigQuery IO writer now allows the `WRITE_TRUNCATE_DATA` write_disposition +* (Python) The BigQuery IO writer now allows the `WRITE_TRUNCATE_DATA` write_disposition ([#40622](https://github.com/apache/beam/pull/40622)). ## New Features / Improvements From c43e2ea3c2ee683712f3fc5ff6e907090b4401da Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:08:03 -0400 Subject: [PATCH 005/108] trim doc line length --- sdks/python/apache_beam/io/gcp/bigquery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/python/apache_beam/io/gcp/bigquery.py b/sdks/python/apache_beam/io/gcp/bigquery.py index 150724cbca15..34051e98a992 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery.py +++ b/sdks/python/apache_beam/io/gcp/bigquery.py @@ -1519,7 +1519,7 @@ def __init__( - BigQueryDisposition.CREATE_NEVER: fail the write if does not exist. write_disposition: A string describing what happens if the table has already some data. Possible values are: - - BigQueryDisposition.WRITE_TRUNCATE_DATA: delete table data but preserve table metadata. + - BigQueryDisposition.WRITE_TRUNCATE_DATA: delete rows, keep schema. - BigQueryDisposition.WRITE_TRUNCATE: delete existing rows. - BigQueryDisposition.WRITE_APPEND: add to existing rows. - BigQueryDisposition.WRITE_EMPTY: fail the write if table not empty. From 9335f60e8b1bf80bf648a864e87ba4edc56c0e31 Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:42:45 -0400 Subject: [PATCH 006/108] add WRITE_TRUNCATE_DATA support to bigqueryIO in java --- .../beam/sdk/io/gcp/bigquery/BigQueryIO.java | 19 +++++++++++++++-- .../gcp/bigquery/UpdateSchemaDestination.java | 3 +++ .../providers/BigQueryWriteConfiguration.java | 3 ++- .../apache_beam/io/gcp/bigquery_file_loads.py | 21 ++++++++++++------- .../clients/bigquery/bigquery_v2_messages.py | 15 +++++++------ 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java index 0cefea4639fd..2f1d6b702691 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java @@ -3038,6 +3038,17 @@ public enum WriteDisposition { */ WRITE_TRUNCATE, + /** + * Specifies that write should replace a table, but not the table schema. + * + *

The replacement may occur in multiple steps - for instance by first removing the + * existing table, then creating a replacement, then filling it in. This is not an atomic + * operation, and external programs may see the table in any of these intermediate steps. + * + *

Note: This write disposition is only supported for the FILE_LOADS write method. + */ + WRITE_TRUNCATE_DATA, + /** Specifies that rows may be appended to an existing table. */ WRITE_APPEND, @@ -3973,8 +3984,9 @@ && getStorageApiTriggeringFrequency(bqOptions) != null) { getBigLakeConfiguration() == null, "bigLakeConfiguration is only supported when using STORAGE_WRITE_API or STORAGE_API_AT_LEAST_ONCE."); } else { - if (getWriteDisposition() == WriteDisposition.WRITE_TRUNCATE) { - LOG.error("The Storage API sink does not support the WRITE_TRUNCATE write disposition."); + WriteDisposition disposition = getWriteDisposition() + if (disposition == WriteDisposition.WRITE_TRUNCATE || disposition == WriteDisposition.WRITE_TRUNCATE_DATA) { + LOG.error("The Storage API sink does not support the "+disposition.toString()+" write disposition."); } Map bigLakeConfiguration = getBigLakeConfiguration(); if (bigLakeConfiguration != null) { @@ -4229,6 +4241,9 @@ private WriteResult continueExpandTyped( checkArgument( getWriteDisposition() != WriteDisposition.WRITE_TRUNCATE, "WriteDisposition.WRITE_TRUNCATE is not supported for an unbounded PCollection."); + checkArgument( + getWriteDisposition() != WriteDisposition.WRITE_TRUNCATE_DATA, + "WriteDisposition.WRITE_TRUNCATE_DATA is not supported for an unbounded PCollection."); InsertRetryPolicy retryPolicy = MoreObjects.firstNonNull(getFailedInsertRetryPolicy(), InsertRetryPolicy.alwaysRetry()); diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java index 536d318f0213..7f801ef38824 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java @@ -257,6 +257,9 @@ public void finishBundle(FinishBundleContext context) throws Exception { if (!loadConfig .getWriteDisposition() .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE.toString()) + && !loadConfig + .getWriteDisposition() + .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE_DATA.toString() && !loadConfig .getWriteDisposition() .equals(BigQueryIO.Write.WriteDisposition.WRITE_APPEND.toString())) { diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/providers/BigQueryWriteConfiguration.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/providers/BigQueryWriteConfiguration.java index ec59db14501b..ec498c66aacc 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/providers/BigQueryWriteConfiguration.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/providers/BigQueryWriteConfiguration.java @@ -138,7 +138,8 @@ public static Builder builder() { @SchemaFieldDescription( "Specifies the action that occurs if the destination table already exists. " + "The following values are supported: " - + "WRITE_TRUNCATE (overwrites the table data), " + + "WRITE_TRUNCATE (overwrites the table data and schema), " + + "WRITE_TRUNCATE_DATA (overwrites the table data), " + "WRITE_APPEND (append the data to the table), " + "WRITE_EMPTY (job must fail if the table is not empty).") @Pure diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py index dabe80c5c27e..db51ecf22403 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py @@ -416,7 +416,11 @@ def process(self, element, schema_mod_job_name_prefix): # When writing to normal tables WRITE_TRUNCATE will overwrite the schema but # when writing to a partition, care needs to be taken to update the schema # even on WRITE_TRUNCATE. - if (self._write_disposition not in ('WRITE_TRUNCATE', 'WRITE_APPEND') or + if (self._write_disposition not in ( + 'WRITE_TRUNCATE', + 'WRITE_TRUNCATE_DATA', + 'WRITE_APPEND' + ) or not additional_parameters or not additional_parameters.get("schemaUpdateOptions")): # No need to modify schema of destination table @@ -613,11 +617,12 @@ def _determine_write_disposition(self, copy_to_reference) -> tuple[bool, str]: Determines the write disposition for a BigQuery copy job, based on destination. - When the write_disposition for a job is WRITE_TRUNCATE, multiple copy jobs - to the same destination can interfere with each other, truncate data, and - write to the BigQuery table repeatedly. To prevent this, the first copy job - runs with the user's specified write_disposition, but subsequent jobs must - always use WRITE_APPEND. This ensures that subsequent copy jobs do not + When the write_disposition for a job is WRITE_TRUNCATE or + WRITE_TRUNCATE_DATA, multiple copy jobs to the same destination can + interfere with each other, truncate data, and write to the BigQuery + table repeatedly. To prevent this, the first copy job runs with the + user's specified write_disposition, but subsequent jobs must always + use WRITE_APPEND. This ensures that subsequent copy jobs do not clear out data appended by previous jobs. Args: @@ -1231,7 +1236,9 @@ def _load_data( load_job_project_id=self.load_job_project_id), schema_mod_job_name_pcv)) - if self.write_disposition in ('WRITE_EMPTY', 'WRITE_TRUNCATE'): + if self.write_disposition in ( + 'WRITE_EMPTY', 'WRITE_TRUNCATE', 'WRITE_TRUNCATE_DATA' + ): # All loads going to the same table must be processed together so that # the truncation happens only once. See # https://github.com/apache/beam/issues/24535. diff --git a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py index 3392bdaf9592..8b9fa0f883f4 100644 --- a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py +++ b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py @@ -5440,12 +5440,15 @@ class JobConfigurationTableCopy(_messages.Message): destination table already exists. The following values are supported: * WRITE_TRUNCATE: If the table already exists, BigQuery overwrites the table data and uses the schema and table constraints from the source - table. * WRITE_APPEND: If the table already exists, BigQuery appends the - data to the table. * WRITE_EMPTY: If the table already exists and - contains data, a 'duplicate' error is returned in the job result. The - default value is WRITE_EMPTY. Each action is atomic and only occurs if - BigQuery is able to complete the job successfully. Creation, truncation - and append actions occur as one atomic update upon job completion. + table. * WRITE_TRUNCATE_DATA: If the table already exists, BigQuery + overwrites the table data, but does not use schema and table constraints + from the source table.* WRITE_APPEND: If the table already exists, + BigQuery appends the data to the table. * WRITE_EMPTY: If the table + already exists and contains data, a 'duplicate' error is returned in + the job result. The default value is WRITE_EMPTY. Each action is atomic + and only occurs if BigQuery is able to complete the job successfully. + Creation, truncation and append actions occur as one atomic update + upon job completion. """ class OperationTypeValueValuesEnum(_messages.Enum): From 86504f71ce90914c30846e90cd4dccaea41b5a67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:34:15 -0400 Subject: [PATCH 007/108] Bump google.golang.org/api from 0.301.0 to 0.302.0 in /sdks (#40627) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.301.0 to 0.302.0. - [Release notes](https://github.com/googleapis/google-api-go-client/releases) - [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md) - [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.301.0...v0.302.0) --- updated-dependencies: - dependency-name: google.golang.org/api dependency-version: 0.302.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- sdks/go.mod | 8 ++++---- sdks/go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/sdks/go.mod b/sdks/go.mod index e59d8b8c7a40..d60a9db1651f 100644 --- a/sdks/go.mod +++ b/sdks/go.mod @@ -60,7 +60,7 @@ require ( golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 golang.org/x/text v0.43.0 - google.golang.org/api v0.301.0 + google.golang.org/api v0.302.0 google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 @@ -171,7 +171,7 @@ require ( github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect github.com/google/renameio/v2 v2.0.0 // indirect github.com/google/s2a-go v0.1.11 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.23 // indirect github.com/googleapis/gax-go/v2 v2.26.2 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect @@ -198,9 +198,9 @@ require ( go.opencensus.io v0.24.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.58.0 // indirect - golang.org/x/mod v0.41.0 // indirect + golang.org/x/mod v0.42.0 // indirect golang.org/x/tools v0.51.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect ) diff --git a/sdks/go.sum b/sdks/go.sum index 0bd5914755f8..1fc97c94e394 100644 --- a/sdks/go.sum +++ b/sdks/go.sum @@ -533,8 +533,8 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= -github.com/googleapis/enterprise-certificate-proxy v0.3.22 h1:NU4XpII6jD+Dxcot94fqjE+AfJoE/lQP9q3faYGzC/c= -github.com/googleapis/enterprise-certificate-proxy v0.3.22/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/enterprise-certificate-proxy v0.3.23 h1:J+VGF1wjAM2dU2kKUXZxJdDppRLReWP7KWcclfngQhc= +github.com/googleapis/enterprise-certificate-proxy v0.3.23/go.mod h1:Y8NO/15GNLkrXI3OR5xOFmeU3XLX1sLSdtlXgJ0zmIw= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= @@ -976,8 +976,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1315,8 +1315,8 @@ google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7 google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= -google.golang.org/api v0.301.0 h1:rTvI8szs17R7b09ggb34Bv4XrmFaFl9uCBncgxskqtw= -google.golang.org/api v0.301.0/go.mod h1:gGUTaOjgsaG/KNyyW1tFO+DSFm3EJw4BwyehlcmDCXs= +google.golang.org/api v0.302.0 h1:/eENsEv3+GE0QiEmXjdaLI84Mt76LNjGz4vp4E7sRVw= +google.golang.org/api v0.302.0/go.mod h1:dcjRWAy1QdjGb3Z67Qu0gdBWH2gL8Qoy/RI2ZVuvdeE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1414,8 +1414,8 @@ google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAf google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4= google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= From 0072521920c2d4219f3191e2ffd748ee4b84babb Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Fri, 16 Oct 2026 08:41:32 -0400 Subject: [PATCH 008/108] [Java] Fix GRPC channel leak (#39663) --- .../fn/harness/ExternalWorkerService.java | 33 +++++++++++-------- .../org/apache/beam/fn/harness/FnHarness.java | 1 + .../harness/control/ProcessBundleHandler.java | 3 ++ .../fn/harness/data/BeamFnDataGrpcClient.java | 11 +++++-- .../state/BeamFnStateGrpcClientCache.java | 20 +++++++++-- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/ExternalWorkerService.java b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/ExternalWorkerService.java index 466d8ef3193b..b1bc462702df 100644 --- a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/ExternalWorkerService.java +++ b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/ExternalWorkerService.java @@ -39,6 +39,7 @@ import org.apache.beam.sdk.util.Sleeper; import org.apache.beam.sdk.util.construction.Environments; import org.apache.beam.sdk.util.construction.PipelineOptionsTranslation; +import org.apache.beam.vendor.grpc.v1p69p0.io.grpc.ManagedChannel; import org.apache.beam.vendor.grpc.v1p69p0.io.grpc.stub.StreamObserver; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Sets; @@ -78,20 +79,24 @@ public void startWorker( .withInterceptors( ImmutableList.of(AddHarnessIdInterceptor.create(request.getWorkerId()))); - ProvisionServiceGrpc.ProvisionServiceBlockingStub provisionStub = - ProvisionServiceGrpc.newBlockingStub( - channelFactory.forDescriptor(request.getProvisionEndpoint())); - ProvisionApi.ProvisionInfo provisionInfo = - provisionStub - .getProvisionInfo(ProvisionApi.GetProvisionInfoRequest.newBuilder().build()) - .getInfo(); - - runnerCapabilites = Sets.newHashSet(provisionInfo.getRunnerCapabilitiesList()); - if (provisionInfo.hasControlEndpoint()) { - controlEndpoint = provisionInfo.getControlEndpoint(); - } - if (provisionInfo.hasLoggingEndpoint()) { - loggingEndpoint = provisionInfo.getLoggingEndpoint(); + ManagedChannel channel = channelFactory.forDescriptor(request.getProvisionEndpoint()); + try { + ProvisionServiceGrpc.ProvisionServiceBlockingStub provisionStub = + ProvisionServiceGrpc.newBlockingStub(channel); + ProvisionApi.ProvisionInfo provisionInfo = + provisionStub + .getProvisionInfo(ProvisionApi.GetProvisionInfoRequest.newBuilder().build()) + .getInfo(); + + runnerCapabilites = Sets.newHashSet(provisionInfo.getRunnerCapabilitiesList()); + if (provisionInfo.hasControlEndpoint()) { + controlEndpoint = provisionInfo.getControlEndpoint(); + } + if (provisionInfo.hasLoggingEndpoint()) { + loggingEndpoint = provisionInfo.getLoggingEndpoint(); + } + } finally { + channel.shutdown(); } } // Lambda closured variables must be final. diff --git a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/FnHarness.java b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/FnHarness.java index 63f6c41b5a71..f547a1eeadff 100644 --- a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/FnHarness.java +++ b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/FnHarness.java @@ -437,6 +437,7 @@ private BeamFnApi.ProcessBundleDescriptor loadDescriptor(String id) { beamFnStatusClient.close(); } processBundleHandler.shutdown(); + channel.shutdown(); } catch (Exception e) { LOG.error("Shutting down harness due to exception", e); e.printStackTrace(); diff --git a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/control/ProcessBundleHandler.java b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/control/ProcessBundleHandler.java index f12340552fd0..9fd1f0f0d68b 100644 --- a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/control/ProcessBundleHandler.java +++ b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/control/ProcessBundleHandler.java @@ -768,6 +768,9 @@ public BeamFnApi.InstructionResponse.Builder trySplit(InstructionRequest request public void shutdown() throws Exception { bundleProcessorCache.shutdown(); beamFnDataClient.close(); + if (beamFnStateGrpcClientCache != null) { + beamFnStateGrpcClientCache.close(); + } } @VisibleForTesting diff --git a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClient.java b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClient.java index 79e34fe5765f..0640cc0d289d 100644 --- a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClient.java +++ b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/data/BeamFnDataGrpcClient.java @@ -76,6 +76,7 @@ public int hashCode() { } private final ConcurrentMap multiplexerCache; + private final ConcurrentMap channelCache; private final Function channelFactory; private final OutboundObserverFactory outboundObserverFactory; @@ -85,6 +86,7 @@ public BeamFnDataGrpcClient( this.channelFactory = channelFactory; this.outboundObserverFactory = outboundObserverFactory; this.multiplexerCache = new ConcurrentHashMap<>(); + this.channelCache = new ConcurrentHashMap<>(); } @Override @@ -128,6 +130,10 @@ public void close() { } } multiplexerCache.clear(); + for (ManagedChannel channel : channelCache.values()) { + channel.shutdown(); + } + channelCache.clear(); } @Override @@ -142,10 +148,11 @@ private BeamFnDataGrpcMultiplexer getMultiplexer( return multiplexerCache.computeIfAbsent( key, k -> { + ManagedChannel channel = + channelCache.computeIfAbsent(k, unused -> channelFactory.apply(apiServiceDescriptor)); OutboundObserverFactory.BasicFactory baseOutboundObserverFactory = inboundObserver -> { - BeamFnDataGrpc.BeamFnDataStub stub = - BeamFnDataGrpc.newStub(channelFactory.apply(apiServiceDescriptor)); + BeamFnDataGrpc.BeamFnDataStub stub = BeamFnDataGrpc.newStub(channel); if (dataStreamId != null && !dataStreamId.isEmpty()) { Metadata headers = new Metadata(); headers.put( diff --git a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/state/BeamFnStateGrpcClientCache.java b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/state/BeamFnStateGrpcClientCache.java index c9adb9d7d142..1a0c4f3fb912 100644 --- a/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/state/BeamFnStateGrpcClientCache.java +++ b/sdks/java/harness/src/main/java/org/apache/beam/fn/harness/state/BeamFnStateGrpcClientCache.java @@ -18,7 +18,9 @@ package org.apache.beam.fn.harness.state; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import org.apache.beam.model.fnexecution.v1.BeamFnApi.StateRequest; @@ -44,7 +46,7 @@ public class BeamFnStateGrpcClientCache { private static final Logger LOG = LoggerFactory.getLogger(BeamFnStateGrpcClientCache.class); - private final Map cache; + private final Map cache; private final ManagedChannelFactory channelFactory; private final OutboundObserverFactory outboundObserverFactory; private final IdGenerator idGenerator; @@ -71,7 +73,7 @@ public synchronized BeamFnStateClient forApiServiceDescriptor( // We specifically are synchronized so that we only create one GrpcStateClient at a time // preventing a race where multiple GrpcStateClient objects might be constructed at the same // for the same ApiServiceDescriptor. - BeamFnStateClient rval; + GrpcStateClient rval; synchronized (cache) { rval = cache.get(apiServiceDescriptor); } @@ -87,6 +89,18 @@ public synchronized BeamFnStateClient forApiServiceDescriptor( return rval; } + /** Shuts down the underlying channels of all cached state clients. */ + public void close() { + List clients; + synchronized (cache) { + clients = new ArrayList<>(cache.values()); + cache.clear(); + } + for (GrpcStateClient client : clients) { + client.channel.shutdown(); + } + } + /** A {@link BeamFnStateClient} for a given {@link ApiServiceDescriptor}. */ private class GrpcStateClient implements BeamFnStateClient { private final Object lock = new Object(); @@ -111,6 +125,7 @@ private GrpcStateClient(ApiServiceDescriptor apiServiceDescriptor) { synchronized (lock) { if (errorDuringConstruction) { outboundObserver.onCompleted(); + channel.shutdown(); } } } @@ -160,6 +175,7 @@ private void closeAndCleanUp(RuntimeException cause) { errorDuringConstruction = true; } else { outboundObserver.onCompleted(); + channel.shutdown(); } } } From 3336b153784ce57ef226acfa6efafd8feccf2bd8 Mon Sep 17 00:00:00 2001 From: pablo rodriguez defino Date: Fri, 16 Oct 2026 07:11:09 -0700 Subject: [PATCH 009/108] [Prism] Guard ClearOrderedListState against uninitialized state maps (#40626) * [Prism] Guard ClearOrderedListState against uninitialized state maps Problem: In TentativeData.ClearOrderedListState (sdks/go/pkg/beam/runners/prism/internal/engine/data.go), Prism indexed directly into d.state[stateID][w] and wrote the cleared slice back to kMap[string(uKey)] without checking whether d.state, d.state[stateID], or winMap[w] had been initialized: winMap := d.state[stateID] w := d.toWindow(wKey) kMap := winMap[w] data := kMap[string(uKey)] ... kMap[string(uKey)] = StateData{Bag: cleared} // panic: assignment to entry in nil map SDK harnesses (such as the Python SDK's SynchronousOrderedListRuntimeState.commit and the Rust SDK's OrderedListCell) buffer ordered-list mutations in memory during a bundle and flush pending StateRequest_Clear range requests before StateRequest_Append requests at bundle commit. When a bundle clears an ordered-list state cell (or range) in a window for which no prior bundle has persisted state on the runner, TentativeData.state has no map for (stateID, window), kMap is nil, and Prism panics on line 336. Fix: Use the existing d.clearState(stateID, wKey) helper in ClearOrderedListState and return early when kMap is nil, matching ClearBagState, ClearMultimapState, and ClearMultimapKeysState. Reproduction (Python SDK on PrismRunner): ```python import apache_beam as beam from apache_beam.coders import VarIntCoder from apache_beam.options.pipeline_options import PipelineOptions from apache_beam.testing.util import assert_that, equal_to from apache_beam.transforms.userstate import OrderedListStateSpec from apache_beam.utils.timestamp import Timestamp class ClearOrderedListDoFn(beam.DoFn): EVENTS = OrderedListStateSpec("events", VarIntCoder()) def process(self, element, events=beam.DoFn.StateParam(EVENTS)): key, (ts, op) = element if op == "CLEAR": events.clear() yield f"cleared:{key}" else: events.add((Timestamp(micros=ts), op)) entries = [f"{int(t.micros)}={v}" for t, v in events.read()] yield f"entry:{key}:{','.join(entries)}" if __name__ == "__main__": options = PipelineOptions(["--runner=PrismRunner", "--environment_type=LOOPBACK"]) with beam.Pipeline(options=options) as p: out = ( p | beam.Create([("k", (30, 300)), ("k", (10, 100)), ("k", (0, "CLEAR"))]) | beam.ParDo(ClearOrderedListDoFn()) ) assert_that( out, equal_to(["entry:k:30=300", "entry:k:10=100,30=300", "cleared:k"]), ) ``` * Refactor comments in data_test.go Updated comments for clarity in data_test.go. --- .../runners/prism/internal/engine/data.go | 7 ++-- .../prism/internal/engine/data_test.go | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/sdks/go/pkg/beam/runners/prism/internal/engine/data.go b/sdks/go/pkg/beam/runners/prism/internal/engine/data.go index 371e3f00b895..23553413f44f 100644 --- a/sdks/go/pkg/beam/runners/prism/internal/engine/data.go +++ b/sdks/go/pkg/beam/runners/prism/internal/engine/data.go @@ -322,9 +322,10 @@ func findRange(bag [][]byte, start, end int64) (int, int) { } func (d *TentativeData) ClearOrderedListState(stateID LinkID, wKey, uKey []byte, start, end int64) { - winMap := d.state[stateID] - w := d.toWindow(wKey) - kMap := winMap[w] + kMap := d.clearState(stateID, wKey) + if kMap == nil { + return + } data := kMap[string(uKey)] lo, hi := findRange(data.Bag, start, end) diff --git a/sdks/go/pkg/beam/runners/prism/internal/engine/data_test.go b/sdks/go/pkg/beam/runners/prism/internal/engine/data_test.go index 1d0497104182..8d800e3cd5f1 100644 --- a/sdks/go/pkg/beam/runners/prism/internal/engine/data_test.go +++ b/sdks/go/pkg/beam/runners/prism/internal/engine/data_test.go @@ -219,4 +219,41 @@ func TestOrderedListState(t *testing.T) { t.Errorf("OrderedList int32 \n%v", d) } }) + t.Run("clear_uninitialized", func(t *testing.T) { + d := TentativeData{ + stateTypeLen: map[LinkID]func([]byte) int{ + linkID: func(_ []byte) int { + return 1 + }, + }, + } + + // d.state is nil. + d.ClearOrderedListState(linkID, wKey, uKey, math.MinInt64, math.MaxInt64) + if d.state != nil { + t.Fatalf("ClearOrderedListState on empty TentativeData allocated state map: %v", d.state) + } + + // d.state is non-nil, but has no entry for linkID. + otherLink := LinkID{Transform: "dofn", Local: "otherState"} + d.AppendBagState(otherLink, wKey, uKey, []byte{1}) + d.ClearOrderedListState(linkID, wKey, uKey, 0, 100) + if _, ok := d.state[linkID]; ok { + t.Fatalf("ClearOrderedListState created entry for uninitialized stateID %v", linkID) + } + + // d.state[linkID] exists for an interval window, but not for the global window. + // An SDK that flushes range clears before appends on the first bundle in a window + // must not panic and must preserve subsequent appends. + iwKey := make([]byte, 16) + binary.BigEndian.PutUint64(iwKey[0:8], uint64(1000)^0x8000000000000000) + d.AppendOrderedListState(linkID, iwKey, uKey, cc(time1, 1)) + d.ClearOrderedListState(linkID, wKey, uKey, 0, 30) + d.AppendOrderedListState(linkID, wKey, uKey, cc(time2, 1)) + got := d.GetOrderedListState(linkID, wKey, uKey, 0, 60) + want := [][]byte{cc(time2, 1)} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("OrderedList after clear_uninitialized + append:\n%v", diff) + } + }) } From f3a5f4f15425fb1979e8843ab089477f1274bf98 Mon Sep 17 00:00:00 2001 From: Goutam Adwant <8672451+goutamadwant@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:33:43 -0700 Subject: [PATCH 010/108] Add stale GCS test bucket cleaner (#40386) --- .test-infra/tools/README.md | 18 ++ .test-infra/tools/build.gradle | 10 + .../tools/stale_gcs_buckets_cleaner.py | 112 ++++++++++ .../tools/test_stale_gcs_buckets_cleaner.py | 202 ++++++++++++++++++ 4 files changed, 342 insertions(+) create mode 100644 .test-infra/tools/stale_gcs_buckets_cleaner.py create mode 100644 .test-infra/tools/test_stale_gcs_buckets_cleaner.py diff --git a/.test-infra/tools/README.md b/.test-infra/tools/README.md index 758c3885a814..f13b672ddde8 100644 --- a/.test-infra/tools/README.md +++ b/.test-infra/tools/README.md @@ -22,6 +22,24 @@ ## Tools - [Python installer](#python-installer) +- [Stale GCS test bucket cleaner](#stale-gcs-test-bucket-cleaner) + +### Stale GCS test bucket cleaner + +`stale_gcs_buckets_cleaner.py` deletes buckets in the `apache-beam-testing` +project whose names match `apache-beam-temp-bucket-` and are older than +24 hours. These buckets are created by `GcsUtil` integration tests and can be +left behind when a test is interrupted before teardown. The cleaner removes +objects from eligible buckets before deleting them. + +The cleaner performs a dry run by default: + +```bash +python3 stale_gcs_buckets_cleaner.py +``` + +The scheduled `Clean Up GCP Resources` workflow runs the cleaner through the +`removeStaleGcsBuckets` Gradle task, which passes the explicit `--delete` flag. ### Python installer diff --git a/.test-infra/tools/build.gradle b/.test-infra/tools/build.gradle index 92b53f2fd560..8c61e357c042 100644 --- a/.test-infra/tools/build.gradle +++ b/.test-infra/tools/build.gradle @@ -40,6 +40,15 @@ task removeStaleSpannerResources(type: Exec) { commandLine './stale_spanner_cleaner.sh' } +task testStaleGcsBucketsCleaner(type: Exec) { + commandLine 'bash', '-c', 'python3 -m pip install -r requirements.txt && python3 -m unittest test_stale_gcs_buckets_cleaner.py' +} + +task removeStaleGcsBuckets(type: Exec) { + dependsOn tasks.testStaleGcsBucketsCleaner + commandLine 'python3', 'stale_gcs_buckets_cleaner.py', '--delete' +} + task runStaleCleaner(type: Exec) { commandLine 'bash', '-c', 'pip install -r requirements.txt && python3 stale_cleaner.py' } @@ -50,5 +59,6 @@ task cleanupOtherStaleResources { finalizedBy tasks.removeStaleCbtInstances finalizedBy tasks.removeStaleK8sWorkload finalizedBy tasks.removeStaleSpannerResources + finalizedBy tasks.removeStaleGcsBuckets finalizedBy tasks.runStaleCleaner } diff --git a/.test-infra/tools/stale_gcs_buckets_cleaner.py b/.test-infra/tools/stale_gcs_buckets_cleaner.py new file mode 100644 index 000000000000..4d475018862e --- /dev/null +++ b/.test-infra/tools/stale_gcs_buckets_cleaner.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python +# +# 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. +# +"""Deletes stale GCS buckets left behind by GcsUtil integration tests.""" + +import argparse +import datetime +import re + +from google.cloud import storage + + +DEFAULT_PROJECT_ID = "apache-beam-testing" +GCS_TEMP_BUCKET_PREFIX = "apache-beam-temp-bucket-" +GCS_TEMP_BUCKET_PATTERN = re.compile( + rf"^{GCS_TEMP_BUCKET_PREFIX}" + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-" + r"[0-9a-f]{12}$") +DEFAULT_MAX_AGE = datetime.timedelta(hours=24) +UTC = datetime.timezone.utc + + +def _as_utc(value): + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def clean_stale_gcs_buckets( + client, + max_age=DEFAULT_MAX_AGE, + now=None, + dry_run=True, +): + """Deletes test buckets whose creation time is older than ``max_age``. + + Returns the names of buckets selected for deletion. If a deletion fails, + the cleaner attempts the remaining buckets before reporting the failures. + """ + current_time = _as_utc(now or datetime.datetime.now(UTC)) + selected = [] + failures = [] + + buckets = client.list_buckets( + project=DEFAULT_PROJECT_ID, prefix=GCS_TEMP_BUCKET_PREFIX) + for bucket in buckets: + # Keep this check even though list_buckets filters server-side. It is + # the final guard before a destructive operation. + if not GCS_TEMP_BUCKET_PATTERN.fullmatch(bucket.name): + continue + if bucket.time_created is None: + print(f"Skipping {bucket.name}: creation time is unavailable") + continue + if current_time - _as_utc(bucket.time_created) <= max_age: + continue + + selected.append(bucket.name) + if dry_run: + print(f"Dry run: would delete gs://{bucket.name}") + continue + + print(f"Deleting stale test bucket gs://{bucket.name}") + try: + # Bucket.delete(force=True) refuses to empty more than 256 objects. + # Delete each listed generation, then delete the empty bucket. + for blob in bucket.list_blobs(versions=True): + blob.delete(if_generation_match=blob.generation) + bucket.delete() + except Exception as error: + failures.append(bucket.name) + print(f"Failed to delete gs://{bucket.name}: {error}") + + if failures: + raise RuntimeError(f"Bucket failure count: {len(failures)}") + + return selected + + +def main(): + parser = argparse.ArgumentParser( + description="Delete stale buckets created by GcsUtil integration tests.") + parser.add_argument( + "--delete", + action="store_true", + help="Delete eligible buckets instead of performing a dry run.", + ) + args = parser.parse_args() + + client = storage.Client(project=DEFAULT_PROJECT_ID) + selected = clean_stale_gcs_buckets(client, dry_run=not args.delete) + action = "Deleted" if args.delete else "Selected" + print(f"{action} {len(selected)} stale GCS test bucket(s)") + + +if __name__ == "__main__": + main() diff --git a/.test-infra/tools/test_stale_gcs_buckets_cleaner.py b/.test-infra/tools/test_stale_gcs_buckets_cleaner.py new file mode 100644 index 000000000000..21cbaa1dd76e --- /dev/null +++ b/.test-infra/tools/test_stale_gcs_buckets_cleaner.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +# +# 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. +# + +import datetime +import unittest +from unittest import mock + +import stale_gcs_buckets_cleaner as cleaner + + +UTC = datetime.timezone.utc +NOW = datetime.datetime(2026, 9, 30, 12, tzinfo=UTC) +STALE_BUCKET = "apache-beam-temp-bucket-123e4567-e89b-42d3-a456-426614174000" +FRESH_BUCKET = "apache-beam-temp-bucket-123e4567-e89b-42d3-a456-426614174001" + + +class FakeBlob: + def __init__(self, bucket, name, generation): + self.bucket = bucket + self.name = name + self.generation = generation + + def delete(self, if_generation_match=None): + if if_generation_match != self.generation: + raise ValueError("blob generation precondition is required") + self.bucket.blobs.remove(self) + + +class FakeBucket: + def __init__(self, name, created, blob_count=0): + self.name = name + self.time_created = created + self.blobs = [FakeBlob(self, f"blob-{i}", i + 1) + for i in range(blob_count)] + self.deleted = False + self.delete_error = None + + def list_blobs(self, versions=False): + if not versions: + raise ValueError("all object generations must be listed") + return iter(list(self.blobs)) + + def delete(self): + if self.delete_error: + raise self.delete_error + if self.blobs: + raise ValueError("bucket is not empty") + self.deleted = True + + +class FakeClient: + def __init__(self): + self.buckets = [] + + def list_buckets(self, project, prefix): + if project != "apache-beam-testing" or prefix != "apache-beam-temp-bucket-": + raise ValueError("unexpected bucket selection") + return iter(self.buckets) + + +def bucket(name, created, blob_count=0): + return FakeBucket(name, created, blob_count) + + +class StaleGcsBucketsCleanerTest(unittest.TestCase): + def setUp(self): + self.client = FakeClient() + + def test_deletes_only_stale_test_buckets(self): + stale = bucket( + STALE_BUCKET, NOW - datetime.timedelta(hours=25)) + fresh = bucket( + FRESH_BUCKET, NOW - datetime.timedelta(hours=23)) + unrelated = bucket("other-bucket", NOW - datetime.timedelta(days=7)) + missing_created = bucket( + "apache-beam-temp-bucket-123e4567-e89b-42d3-a456-426614174002", + None) + self.client.buckets = [ + stale, fresh, unrelated, missing_created] + + deleted = cleaner.clean_stale_gcs_buckets( + self.client, now=NOW, dry_run=False) + + self.assertEqual(deleted, [stale.name]) + self.assertTrue(stale.deleted) + self.assertFalse(fresh.deleted) + self.assertFalse(unrelated.deleted) + self.assertFalse(missing_created.deleted) + + def test_preserves_bucket_at_age_threshold(self): + value = bucket( + STALE_BUCKET, + NOW - datetime.timedelta(hours=24)) + self.client.buckets = [value] + + deleted = cleaner.clean_stale_gcs_buckets( + self.client, now=NOW, dry_run=False) + + self.assertEqual(deleted, []) + self.assertFalse(value.deleted) + + def test_dry_run_does_not_delete_bucket(self): + value = bucket( + STALE_BUCKET, NOW - datetime.timedelta(days=2)) + self.client.buckets = [value] + + selected = cleaner.clean_stale_gcs_buckets(self.client, now=NOW) + + self.assertEqual(selected, [value.name]) + self.assertFalse(value.deleted) + + def test_naive_creation_time_is_treated_as_utc(self): + value = bucket( + STALE_BUCKET, + datetime.datetime(2026, 9, 29, 11)) + self.client.buckets = [value] + + deleted = cleaner.clean_stale_gcs_buckets( + self.client, now=NOW, dry_run=False) + + self.assertEqual(deleted, [value.name]) + self.assertTrue(value.deleted) + + def test_continues_after_delete_failure_and_reports_all_failures(self): + failed = bucket( + STALE_BUCKET, NOW - datetime.timedelta(days=2)) + deleted = bucket( + FRESH_BUCKET, NOW - datetime.timedelta(days=2)) + failed.delete_error = RuntimeError("permission denied") + self.client.buckets = [failed, deleted] + + with self.assertRaisesRegex( + RuntimeError, "Bucket failure count: 1"): + cleaner.clean_stale_gcs_buckets( + self.client, now=NOW, dry_run=False) + + self.assertFalse(failed.deleted) + self.assertTrue(deleted.deleted) + + def test_deletes_bucket_with_more_than_256_objects(self): + value = bucket(STALE_BUCKET, NOW - datetime.timedelta(days=2), 300) + self.client.buckets = [value] + + selected = cleaner.clean_stale_gcs_buckets( + self.client, now=NOW, dry_run=False) + + self.assertEqual(selected, [value.name]) + self.assertTrue(value.deleted) + self.assertEqual(value.blobs, []) + + def test_preserves_prefixed_bucket_without_uuid_name(self): + value = bucket( + "apache-beam-temp-bucket-manual", NOW - datetime.timedelta(days=7)) + self.client.buckets = [value] + + deleted = cleaner.clean_stale_gcs_buckets(self.client, now=NOW) + + self.assertEqual(deleted, []) + self.assertFalse(value.deleted) + + @mock.patch("stale_gcs_buckets_cleaner.clean_stale_gcs_buckets") + @mock.patch("stale_gcs_buckets_cleaner.storage.Client") + def test_main_defaults_to_dry_run(self, client_class, clean): + clean.return_value = [] + + with mock.patch("sys.argv", ["stale_gcs_buckets_cleaner.py"]): + cleaner.main() + + client_class.assert_called_once_with(project="apache-beam-testing") + clean.assert_called_once_with(client_class.return_value, dry_run=True) + + @mock.patch("stale_gcs_buckets_cleaner.clean_stale_gcs_buckets") + @mock.patch("stale_gcs_buckets_cleaner.storage.Client") + def test_main_requires_delete_flag_for_deletion(self, client_class, clean): + clean.return_value = [] + + with mock.patch( + "sys.argv", ["stale_gcs_buckets_cleaner.py", "--delete"]): + cleaner.main() + + clean.assert_called_once_with(client_class.return_value, dry_run=False) + + +if __name__ == "__main__": + unittest.main() From 6572166744efdead363df4a832be35cfc8f40040 Mon Sep 17 00:00:00 2001 From: Maksym Tymoshyk Date: Fri, 16 Oct 2026 17:35:07 +0300 Subject: [PATCH 011/108] [Java] Make non-extensible classes final instead of suppressing CT_CONSTRUCTOR_THROW (#40444) --- .../datatokenization/utils/SchemasUtils.java | 9 +-------- .../subprocess/utils/CallingSubProcessUtils.java | 9 +-------- .../subprocess/utils/ExecutableFile.java | 9 +-------- .../it/clickhouse/ClickHouseResourceManager.java | 11 ++--------- .../it/gcp/bigtable/BigtableResourceManager.java | 9 +-------- .../beam/it/splunk/SplunkResourceManager.java | 9 +-------- .../SerializablePipelineOptions.java | 9 +-------- .../runners/core/metrics/BoundedTrieData.java | 16 ++++++---------- ...FlinkStreamingPortablePipelineTranslator.java | 9 +-------- .../streaming/io/UnboundedSourceWrapper.java | 10 ++-------- .../streaming/state/FlinkStateInternals.java | 9 +-------- ...FlinkStreamingPortablePipelineTranslator.java | 9 +-------- .../streaming/io/UnboundedSourceWrapper.java | 10 ++-------- .../io/source/FlinkSourceSplitEnumerator.java | 9 +-------- .../source/LazyFlinkSourceSplitEnumerator.java | 9 +-------- .../stableinput/BufferingDoFnRunner.java | 9 +-------- .../KeyedBufferingElementsHandler.java | 9 +-------- .../streaming/state/FlinkStateInternals.java | 9 +-------- .../control/DefaultJobBundleFactory.java | 9 +-------- .../org/apache/beam/runners/jet/JetRunner.java | 9 +-------- .../runners/jet/processors/AssignWindowP.java | 9 +-------- .../spark/metrics/SparkBeamMetricSource.java | 9 +-------- .../beam/runners/spark/metrics/sink/CsvSink.java | 9 +-------- .../runners/spark/metrics/sink/GraphiteSink.java | 9 +-------- .../metrics/SparkBeamMetricSource.java | 9 +-------- .../metrics/sink/CodahaleCsvSink.java | 9 +-------- .../metrics/sink/CodahaleGraphiteSink.java | 9 +-------- .../SparkBatchPortablePipelineTranslator.java | 9 +-------- .../SparkDatasetPortablePipelineTranslator.java | 9 +-------- ...SparkStreamingPortablePipelineTranslator.java | 9 +-------- .../org/apache/beam/sdk/transforms/Sample.java | 9 +-------- 31 files changed, 39 insertions(+), 251 deletions(-) diff --git a/examples/java/src/main/java/org/apache/beam/examples/complete/datatokenization/utils/SchemasUtils.java b/examples/java/src/main/java/org/apache/beam/examples/complete/datatokenization/utils/SchemasUtils.java index 6388ed9fa0e7..3f64edee5c44 100644 --- a/examples/java/src/main/java/org/apache/beam/examples/complete/datatokenization/utils/SchemasUtils.java +++ b/examples/java/src/main/java/org/apache/beam/examples/complete/datatokenization/utils/SchemasUtils.java @@ -21,7 +21,6 @@ import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument; import com.google.api.services.bigquery.model.TableSchema; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.io.InputStream; import java.io.Reader; @@ -54,13 +53,7 @@ "argument", "return" }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class SchemasUtils { +public final class SchemasUtils { /* Logger for class.*/ private static final Logger LOG = LoggerFactory.getLogger(SchemasUtils.class); diff --git a/examples/java/src/main/java/org/apache/beam/examples/subprocess/utils/CallingSubProcessUtils.java b/examples/java/src/main/java/org/apache/beam/examples/subprocess/utils/CallingSubProcessUtils.java index e04ad4c5a419..6f22bbe6855b 100644 --- a/examples/java/src/main/java/org/apache/beam/examples/subprocess/utils/CallingSubProcessUtils.java +++ b/examples/java/src/main/java/org/apache/beam/examples/subprocess/utils/CallingSubProcessUtils.java @@ -17,7 +17,6 @@ */ package org.apache.beam.examples.subprocess.utils; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -89,13 +88,7 @@ private static void releaseSemaphore(String binaryName) throws IllegalStateExcep } /** Permit class for access to worker cpu resources. */ - @SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") - public static class Permit implements AutoCloseable { + public static final class Permit implements AutoCloseable { private String binaryName; diff --git a/examples/java/src/main/java/org/apache/beam/examples/subprocess/utils/ExecutableFile.java b/examples/java/src/main/java/org/apache/beam/examples/subprocess/utils/ExecutableFile.java index bc409a89581c..fbff1af4afd0 100644 --- a/examples/java/src/main/java/org/apache/beam/examples/subprocess/utils/ExecutableFile.java +++ b/examples/java/src/main/java/org/apache/beam/examples/subprocess/utils/ExecutableFile.java @@ -17,7 +17,6 @@ */ package org.apache.beam.examples.subprocess.utils; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.apache.beam.examples.subprocess.configuration.SubProcessConfiguration; import org.apache.beam.sdk.coders.DefaultCoder; import org.apache.beam.sdk.extensions.avro.coders.AvroCoder; @@ -27,13 +26,7 @@ @SuppressWarnings({ "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class ExecutableFile { +public final class ExecutableFile { String fileName; diff --git a/it/clickhouse/src/main/java/org/apache/beam/it/clickhouse/ClickHouseResourceManager.java b/it/clickhouse/src/main/java/org/apache/beam/it/clickhouse/ClickHouseResourceManager.java index 9ff7ee92f1f6..cd7cbfa45b6f 100644 --- a/it/clickhouse/src/main/java/org/apache/beam/it/clickhouse/ClickHouseResourceManager.java +++ b/it/clickhouse/src/main/java/org/apache/beam/it/clickhouse/ClickHouseResourceManager.java @@ -17,7 +17,6 @@ */ package org.apache.beam.it.clickhouse; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -45,14 +44,8 @@ * *

The class is thread-safe. */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class ClickHouseResourceManager extends TestContainerResourceManager> - implements ResourceManager { +public final class ClickHouseResourceManager + extends TestContainerResourceManager> implements ResourceManager { private static final Logger LOG = LoggerFactory.getLogger(ClickHouseResourceManager.class); diff --git a/it/google-cloud-platform/src/main/java/org/apache/beam/it/gcp/bigtable/BigtableResourceManager.java b/it/google-cloud-platform/src/main/java/org/apache/beam/it/gcp/bigtable/BigtableResourceManager.java index 8773e2610454..eed85e8d0126 100644 --- a/it/google-cloud-platform/src/main/java/org/apache/beam/it/gcp/bigtable/BigtableResourceManager.java +++ b/it/google-cloud-platform/src/main/java/org/apache/beam/it/gcp/bigtable/BigtableResourceManager.java @@ -49,7 +49,6 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; @@ -78,13 +77,7 @@ * *

The class is thread-safe. */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class BigtableResourceManager implements ResourceManager { +public final class BigtableResourceManager implements ResourceManager { private static final Logger LOG = LoggerFactory.getLogger(BigtableResourceManager.class); private static final String DEFAULT_CLUSTER_ZONE = "us-central1-b"; diff --git a/it/splunk/src/main/java/org/apache/beam/it/splunk/SplunkResourceManager.java b/it/splunk/src/main/java/org/apache/beam/it/splunk/SplunkResourceManager.java index a4bdca3dcc31..48937fa94c06 100644 --- a/it/splunk/src/main/java/org/apache/beam/it/splunk/SplunkResourceManager.java +++ b/it/splunk/src/main/java/org/apache/beam/it/splunk/SplunkResourceManager.java @@ -25,7 +25,6 @@ import com.splunk.ResultsReader; import com.splunk.ResultsReaderXml; import com.splunk.ServiceArgs; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.time.Duration; @@ -61,13 +60,7 @@ *

Note: The Splunk TestContainer will only run on M1 Mac's if the Docker version is >= 4.16.0 * and the "Use Rosetta for x86/amd64 emulation on Apple Silicon" setting is enabled. */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class SplunkResourceManager extends TestContainerResourceManager +public final class SplunkResourceManager extends TestContainerResourceManager implements ResourceManager { private static final Logger LOG = LoggerFactory.getLogger(SplunkResourceManager.class); diff --git a/runners/core-java/src/main/java/org/apache/beam/runners/core/construction/SerializablePipelineOptions.java b/runners/core-java/src/main/java/org/apache/beam/runners/core/construction/SerializablePipelineOptions.java index 8885974c4728..f78913db3ff3 100644 --- a/runners/core-java/src/main/java/org/apache/beam/runners/core/construction/SerializablePipelineOptions.java +++ b/runners/core-java/src/main/java/org/apache/beam/runners/core/construction/SerializablePipelineOptions.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.io.ObjectInputStream; import java.io.Serializable; @@ -33,13 +32,7 @@ * Holds a {@link PipelineOptions} in JSON serialized form and calls {@link * FileSystems#setDefaultPipelineOptions(PipelineOptions)} on construction or on deserialization. */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class SerializablePipelineOptions implements Serializable { +public final class SerializablePipelineOptions implements Serializable { private static final ObjectMapper MAPPER = new ObjectMapper() .registerModules(ObjectMapper.findModules(ReflectHelpers.findClassLoader())); diff --git a/runners/core-java/src/main/java/org/apache/beam/runners/core/metrics/BoundedTrieData.java b/runners/core-java/src/main/java/org/apache/beam/runners/core/metrics/BoundedTrieData.java index f99dc3e0b5de..6e55411c600e 100644 --- a/runners/core-java/src/main/java/org/apache/beam/runners/core/metrics/BoundedTrieData.java +++ b/runners/core-java/src/main/java/org/apache/beam/runners/core/metrics/BoundedTrieData.java @@ -57,13 +57,9 @@ "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) @SuppressFBWarnings( - value = {"IS2_INCONSISTENT_SYNC", "CT_CONSTRUCTOR_THROW"}, - justification = - "Some access on purpose are left unsynchronized." - + " Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class BoundedTrieData implements Serializable { + value = "IS2_INCONSISTENT_SYNC", + justification = "Some access on purpose are left unsynchronized") +public final class BoundedTrieData implements Serializable { private static final int DEFAULT_BOUND = 100; // Default maximum size of the trie @@ -286,7 +282,7 @@ public boolean isEmpty() { } @Override - public final boolean equals(@Nullable Object other) { + public boolean equals(@Nullable Object other) { if (this == other) { return true; } @@ -298,12 +294,12 @@ public final boolean equals(@Nullable Object other) { } @Override - public final int hashCode() { + public int hashCode() { return this.asTrie().hashCode(); } @Override - public final String toString() { + public String toString() { return "BoundedTrieData(" + this.asTrie() + ")"; } diff --git a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java index 9e24bd779de0..6ed13902d92d 100644 --- a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java +++ b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java @@ -26,7 +26,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.auto.service.AutoService; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -131,13 +130,7 @@ "keyfor", "nullness" }) // TODO(https://github.com/apache/beam/issues/20497) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class FlinkStreamingPortablePipelineTranslator +public final class FlinkStreamingPortablePipelineTranslator implements FlinkPortablePipelineTranslator< FlinkStreamingPortablePipelineTranslator.StreamingTranslationContext> { diff --git a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/UnboundedSourceWrapper.java b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/UnboundedSourceWrapper.java index 84790026a762..86a3f406cf0d 100644 --- a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/UnboundedSourceWrapper.java +++ b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/UnboundedSourceWrapper.java @@ -17,7 +17,6 @@ */ package org.apache.beam.runners.flink.translation.wrappers.streaming.io; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; @@ -68,13 +67,8 @@ "rawtypes", // TODO(https://github.com/apache/beam/issues/20447) "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class UnboundedSourceWrapper +public final class UnboundedSourceWrapper< + OutputT, CheckpointMarkT extends UnboundedSource.CheckpointMark> extends RichParallelSourceFunction>> implements BeamStoppableFunction, CheckpointListener, diff --git a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/state/FlinkStateInternals.java b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/state/FlinkStateInternals.java index a388d77c4fb0..33b098564bb5 100644 --- a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/state/FlinkStateInternals.java +++ b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/state/FlinkStateInternals.java @@ -17,7 +17,6 @@ */ package org.apache.beam.runners.flink.translation.wrappers.streaming.state; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.Collections; import java.util.HashSet; @@ -104,13 +103,7 @@ "rawtypes", // TODO(https://github.com/apache/beam/issues/20447) "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class FlinkStateInternals implements StateInternals { +public final class FlinkStateInternals implements StateInternals { private static final StateNamespace globalWindowNamespace = StateNamespaces.window(GlobalWindow.Coder.INSTANCE, GlobalWindow.INSTANCE); diff --git a/runners/flink/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java b/runners/flink/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java index 133693ec8df1..622a7c41e8d9 100644 --- a/runners/flink/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java +++ b/runners/flink/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java @@ -26,7 +26,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.auto.service.AutoService; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -131,13 +130,7 @@ "keyfor", "nullness" }) // TODO(https://github.com/apache/beam/issues/20497) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class FlinkStreamingPortablePipelineTranslator +public final class FlinkStreamingPortablePipelineTranslator implements FlinkPortablePipelineTranslator< FlinkStreamingPortablePipelineTranslator.StreamingTranslationContext> { diff --git a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/UnboundedSourceWrapper.java b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/UnboundedSourceWrapper.java index c5d9b8241ac3..5f1630b7a0fa 100644 --- a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/UnboundedSourceWrapper.java +++ b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/UnboundedSourceWrapper.java @@ -17,7 +17,6 @@ */ package org.apache.beam.runners.flink.translation.wrappers.streaming.io; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; @@ -67,13 +66,8 @@ "rawtypes", // TODO(https://github.com/apache/beam/issues/20447) "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class UnboundedSourceWrapper +public final class UnboundedSourceWrapper< + OutputT, CheckpointMarkT extends UnboundedSource.CheckpointMark> extends RichParallelSourceFunction>> implements BeamStoppableFunction, CheckpointListener, diff --git a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/FlinkSourceSplitEnumerator.java b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/FlinkSourceSplitEnumerator.java index ed20c7fa5259..b3ae5b988627 100644 --- a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/FlinkSourceSplitEnumerator.java +++ b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/FlinkSourceSplitEnumerator.java @@ -17,7 +17,6 @@ */ package org.apache.beam.runners.flink.translation.wrappers.streaming.io.source; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -40,13 +39,7 @@ import org.slf4j.LoggerFactory; /** Splits a Beam source and assigns its splits to Flink source readers round-robin. */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class FlinkSourceSplitEnumerator +public final class FlinkSourceSplitEnumerator implements SplitEnumerator, FlinkSourceEnumeratorState> { private static final Logger LOG = LoggerFactory.getLogger(FlinkSourceSplitEnumerator.class); diff --git a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/LazyFlinkSourceSplitEnumerator.java b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/LazyFlinkSourceSplitEnumerator.java index f298e6534b03..95d789178c0a 100644 --- a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/LazyFlinkSourceSplitEnumerator.java +++ b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/LazyFlinkSourceSplitEnumerator.java @@ -17,7 +17,6 @@ */ package org.apache.beam.runners.flink.translation.wrappers.streaming.io.source; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -35,13 +34,7 @@ import org.slf4j.LoggerFactory; /** Splits a bounded Beam source and assigns one split for each reader request. */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class LazyFlinkSourceSplitEnumerator +public final class LazyFlinkSourceSplitEnumerator implements SplitEnumerator, FlinkSourceEnumeratorState> { private static final Logger LOG = LoggerFactory.getLogger(LazyFlinkSourceSplitEnumerator.class); diff --git a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/stableinput/BufferingDoFnRunner.java b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/stableinput/BufferingDoFnRunner.java index 428ab09f5987..46aa1ee92580 100644 --- a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/stableinput/BufferingDoFnRunner.java +++ b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/stableinput/BufferingDoFnRunner.java @@ -17,7 +17,6 @@ */ package org.apache.beam.runners.flink.translation.wrappers.streaming.stableinput; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -57,13 +56,7 @@ "rawtypes", // TODO(https://github.com/apache/beam/issues/20447) "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class BufferingDoFnRunner implements DoFnRunner { +public final class BufferingDoFnRunner implements DoFnRunner { public static BufferingDoFnRunner create( DoFnRunner doFnRunner, diff --git a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/stableinput/KeyedBufferingElementsHandler.java b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/stableinput/KeyedBufferingElementsHandler.java index 1f26c23209d2..7c2ac9f97166 100644 --- a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/stableinput/KeyedBufferingElementsHandler.java +++ b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/stableinput/KeyedBufferingElementsHandler.java @@ -17,7 +17,6 @@ */ package org.apache.beam.runners.flink.translation.wrappers.streaming.stableinput; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -32,13 +31,7 @@ @SuppressWarnings({ "rawtypes" // TODO(https://github.com/apache/beam/issues/20447) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class KeyedBufferingElementsHandler implements BufferingElementsHandler { +public final class KeyedBufferingElementsHandler implements BufferingElementsHandler { static KeyedBufferingElementsHandler create( KeyedStateBackend backend, ListStateDescriptor stateDescriptor) diff --git a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/state/FlinkStateInternals.java b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/state/FlinkStateInternals.java index ce8dff37b0ea..e2d1217d65bb 100644 --- a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/state/FlinkStateInternals.java +++ b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/state/FlinkStateInternals.java @@ -17,7 +17,6 @@ */ package org.apache.beam.runners.flink.translation.wrappers.streaming.state; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.Collections; import java.util.HashSet; @@ -104,13 +103,7 @@ "rawtypes", // TODO(https://github.com/apache/beam/issues/20447) "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class FlinkStateInternals implements StateInternals { +public final class FlinkStateInternals implements StateInternals { private static final StateNamespace globalWindowNamespace = StateNamespaces.window(GlobalWindow.Coder.INSTANCE, GlobalWindow.INSTANCE); diff --git a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/DefaultJobBundleFactory.java b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/DefaultJobBundleFactory.java index eb8dca84261b..4a1adccd4e69 100644 --- a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/DefaultJobBundleFactory.java +++ b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/DefaultJobBundleFactory.java @@ -18,7 +18,6 @@ package org.apache.beam.runners.fnexecution.control; import com.google.auto.value.AutoValue; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.IdentityHashMap; import java.util.Map; @@ -97,13 +96,7 @@ "rawtypes", // TODO(https://github.com/apache/beam/issues/20447) "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class DefaultJobBundleFactory implements JobBundleFactory { +public final class DefaultJobBundleFactory implements JobBundleFactory { private static final Logger LOG = LoggerFactory.getLogger(DefaultJobBundleFactory.class); private static final IdGenerator factoryIdGenerator = IdGenerators.incrementingLongs(); diff --git a/runners/jet/src/main/java/org/apache/beam/runners/jet/JetRunner.java b/runners/jet/src/main/java/org/apache/beam/runners/jet/JetRunner.java index 77da05d28938..97d0e6dfbc96 100644 --- a/runners/jet/src/main/java/org/apache/beam/runners/jet/JetRunner.java +++ b/runners/jet/src/main/java/org/apache/beam/runners/jet/JetRunner.java @@ -24,7 +24,6 @@ import com.hazelcast.jet.config.JobConfig; import com.hazelcast.jet.core.DAG; import com.hazelcast.map.IMap; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -48,13 +47,7 @@ import org.slf4j.LoggerFactory; /** Jet specific implementation of Beam's {@link PipelineRunner}. */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class JetRunner extends PipelineRunner { +public final class JetRunner extends PipelineRunner { private static final Logger LOG = LoggerFactory.getLogger(JetRunner.class); diff --git a/runners/jet/src/main/java/org/apache/beam/runners/jet/processors/AssignWindowP.java b/runners/jet/src/main/java/org/apache/beam/runners/jet/processors/AssignWindowP.java index 233266cf9fdb..b177c53ccfd6 100644 --- a/runners/jet/src/main/java/org/apache/beam/runners/jet/processors/AssignWindowP.java +++ b/runners/jet/src/main/java/org/apache/beam/runners/jet/processors/AssignWindowP.java @@ -21,7 +21,6 @@ import com.hazelcast.jet.core.AbstractProcessor; import com.hazelcast.jet.core.Processor; import com.hazelcast.jet.core.ResettableSingletonTraverser; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.Collection; import javax.annotation.Nonnull; import org.apache.beam.runners.jet.Utils; @@ -44,13 +43,7 @@ "nullness", "keyfor" }) // TODO(https://github.com/apache/beam/issues/20497) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class AssignWindowP extends AbstractProcessor { +public final class AssignWindowP extends AbstractProcessor { @SuppressWarnings({"FieldCanBeLocal", "unused"}) private final String ownerId; // do not remove, useful for debugging diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/SparkBeamMetricSource.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/SparkBeamMetricSource.java index bd93ee023396..c49094c695b9 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/SparkBeamMetricSource.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/SparkBeamMetricSource.java @@ -18,20 +18,13 @@ package org.apache.beam.runners.spark.metrics; import com.codahale.metrics.MetricRegistry; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.apache.spark.metrics.source.Source; /** * A Spark {@link Source} that is tailored to expose a {@link SparkBeamMetric}, wrapping an * underlying {@link org.apache.beam.sdk.metrics.MetricResults} instance. */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class SparkBeamMetricSource implements Source { +public final class SparkBeamMetricSource implements Source { private static final String METRIC_NAME = "Metrics"; private final String name; diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/sink/CsvSink.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/sink/CsvSink.java index 23635267fda4..e24e7b988caf 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/sink/CsvSink.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/sink/CsvSink.java @@ -18,7 +18,6 @@ package org.apache.beam.runners.spark.metrics.sink; import com.codahale.metrics.MetricRegistry; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.Properties; import org.apache.beam.runners.spark.metrics.WithMetricsSupport; import org.apache.spark.SecurityManager; @@ -37,13 +36,7 @@ * "spark.metrics.conf.*.sink.csv.unit"=seconds * } */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class CsvSink implements Sink { +public final class CsvSink implements Sink { // Initialized reflectively as done by Spark's MetricsSystem private final org.apache.spark.metrics.sink.CsvSink delegate; diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/sink/GraphiteSink.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/sink/GraphiteSink.java index 7ffeb7234002..c25d8c5ac291 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/sink/GraphiteSink.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/metrics/sink/GraphiteSink.java @@ -18,7 +18,6 @@ package org.apache.beam.runners.spark.metrics.sink; import com.codahale.metrics.MetricRegistry; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.Properties; import org.apache.beam.runners.spark.metrics.WithMetricsSupport; import org.apache.spark.SecurityManager; @@ -40,13 +39,7 @@ * "spark.metrics.conf.*.sink.graphite.regex"="" * } */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class GraphiteSink implements Sink { +public final class GraphiteSink implements Sink { // Initialized reflectively as done by Spark's MetricsSystem private final org.apache.spark.metrics.sink.GraphiteSink delegate; diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/SparkBeamMetricSource.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/SparkBeamMetricSource.java index 149e132c56b3..b19be4a8ff70 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/SparkBeamMetricSource.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/SparkBeamMetricSource.java @@ -18,20 +18,13 @@ package org.apache.beam.runners.spark.structuredstreaming.metrics; import com.codahale.metrics.MetricRegistry; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.apache.spark.metrics.source.Source; /** * A Spark {@link Source} that is tailored to expose a {@link SparkBeamMetric}, wrapping an * underlying {@link org.apache.beam.sdk.metrics.MetricResults} instance. */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class SparkBeamMetricSource implements Source { +public final class SparkBeamMetricSource implements Source { private final String name; private final MetricRegistry metricRegistry = new MetricRegistry(); diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/sink/CodahaleCsvSink.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/sink/CodahaleCsvSink.java index b46287acf6fe..905916682ea5 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/sink/CodahaleCsvSink.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/sink/CodahaleCsvSink.java @@ -18,7 +18,6 @@ package org.apache.beam.runners.spark.structuredstreaming.metrics.sink; import com.codahale.metrics.MetricRegistry; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.Properties; import org.apache.beam.runners.spark.structuredstreaming.metrics.WithMetricsSupport; import org.apache.spark.SecurityManager; @@ -37,13 +36,7 @@ * "spark.metrics.conf.*.sink.csv.unit"=seconds * } */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class CodahaleCsvSink implements Sink { +public final class CodahaleCsvSink implements Sink { // Initialized reflectively as done by Spark's MetricsSystem private final org.apache.spark.metrics.sink.CsvSink delegate; diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/sink/CodahaleGraphiteSink.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/sink/CodahaleGraphiteSink.java index 93231a758a3a..2da663ad20dd 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/sink/CodahaleGraphiteSink.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/metrics/sink/CodahaleGraphiteSink.java @@ -18,7 +18,6 @@ package org.apache.beam.runners.spark.structuredstreaming.metrics.sink; import com.codahale.metrics.MetricRegistry; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.Properties; import org.apache.beam.runners.spark.structuredstreaming.metrics.WithMetricsSupport; import org.apache.spark.SecurityManager; @@ -40,13 +39,7 @@ * "spark.metrics.conf.*.sink.graphite.regex"="" * } */ -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class CodahaleGraphiteSink implements Sink { +public final class CodahaleGraphiteSink implements Sink { // Initialized reflectively as done by Spark's MetricsSystem private final org.apache.spark.metrics.sink.GraphiteSink delegate; diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkBatchPortablePipelineTranslator.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkBatchPortablePipelineTranslator.java index 2590fb0f8d5a..540c6d2ce0fa 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkBatchPortablePipelineTranslator.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkBatchPortablePipelineTranslator.java @@ -26,7 +26,6 @@ import static org.apache.beam.runners.fnexecution.translation.PipelineTranslatorUtils.instantiateCoder; import com.google.auto.service.AutoService; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.Collections; import java.util.HashMap; @@ -78,13 +77,7 @@ "rawtypes", // TODO(https://github.com/apache/beam/issues/20447) "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class SparkBatchPortablePipelineTranslator +public final class SparkBatchPortablePipelineTranslator implements SparkPortablePipelineTranslator { private final ImmutableMap urnToTransformTranslator; diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkDatasetPortablePipelineTranslator.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkDatasetPortablePipelineTranslator.java index 8aa110371390..4eaf97fa50a5 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkDatasetPortablePipelineTranslator.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkDatasetPortablePipelineTranslator.java @@ -25,7 +25,6 @@ import static org.apache.beam.runners.fnexecution.translation.PipelineTranslatorUtils.hasUnboundedPCollections; import static org.apache.spark.sql.functions.col; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -82,13 +81,7 @@ "unchecked", "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class SparkDatasetPortablePipelineTranslator +public final class SparkDatasetPortablePipelineTranslator implements SparkPortablePipelineTranslator { private final ImmutableMap urnToTransformTranslator; diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkStreamingPortablePipelineTranslator.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkStreamingPortablePipelineTranslator.java index dbe28ca2e23d..7bfad22d9a52 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkStreamingPortablePipelineTranslator.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/translation/SparkStreamingPortablePipelineTranslator.java @@ -24,7 +24,6 @@ import static org.apache.beam.runners.fnexecution.translation.PipelineTranslatorUtils.getWindowedValueCoder; import static org.apache.beam.runners.fnexecution.translation.PipelineTranslatorUtils.getWindowingStrategy; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -73,13 +72,7 @@ "rawtypes", // TODO(https://github.com/apache/beam/issues/20447) "nullness" // TODO(https://github.com/apache/beam/issues/20497) }) -@SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") -public class SparkStreamingPortablePipelineTranslator +public final class SparkStreamingPortablePipelineTranslator implements SparkPortablePipelineTranslator { private final ImmutableMap urnToTransformTranslator; diff --git a/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/Sample.java b/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/Sample.java index 0d2f35a5a012..e5c18afb9e44 100644 --- a/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/Sample.java +++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/Sample.java @@ -19,7 +19,6 @@ import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -292,13 +291,7 @@ public T extractOutput(List accumulator) { * * @param the type of the elements */ - @SuppressFBWarnings( - value = "CT_CONSTRUCTOR_THROW", - justification = - "Pre-existing finding, not triaged yet." - + " Making the class final or moving the throwing code" - + " into a static factory method may fix it.") - public static class FixedSizedSampleFn + public static final class FixedSizedSampleFn extends CombineFn< T, Top.BoundedHeap, SerializableComparator>>, Iterable> { private final int sampleSize; From 1f76c9c36d642f58ca81b6b1fb757411771323cb Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Fri, 16 Oct 2026 10:45:22 -0400 Subject: [PATCH 012/108] Fix Python unit tests broken by google-api-core arriving via cloud-sql-python-connector 1.23.0 (#40623) --- .../beam_PostCommit_Python_Versions.json | 2 +- .../apache_beam/dataframe/io_it_test.py | 7 ++-- sdks/python/apache_beam/dataframe/io_test.py | 11 ++---- .../apache_beam/io/requestresponse_it_test.py | 5 +++ sdks/python/apache_beam/testing/test_utils.py | 25 ++++++++++++ .../apache_beam/testing/test_utils_test.py | 37 ++++++++++++++++++ .../enrichment_handlers/cloudsql_it_test.py | 3 ++ sdks/python/conftest.py | 38 ++++++++++++++----- 8 files changed, 106 insertions(+), 22 deletions(-) diff --git a/.github/trigger_files/beam_PostCommit_Python_Versions.json b/.github/trigger_files/beam_PostCommit_Python_Versions.json index 8b2c8c445c1f..b5704c67ef1c 100644 --- a/.github/trigger_files/beam_PostCommit_Python_Versions.json +++ b/.github/trigger_files/beam_PostCommit_Python_Versions.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "revision": 5 + "revision": 6 } diff --git a/sdks/python/apache_beam/dataframe/io_it_test.py b/sdks/python/apache_beam/dataframe/io_it_test.py index da88ffb54760..9f750e2ff58c 100644 --- a/sdks/python/apache_beam/dataframe/io_it_test.py +++ b/sdks/python/apache_beam/dataframe/io_it_test.py @@ -33,13 +33,12 @@ _LOGGER = logging.getLogger(__name__) try: - from google.api_core.exceptions import GoogleAPICallError + from apitools.base.py.exceptions import HttpError except ImportError: - GoogleAPICallError = None + HttpError = None -@unittest.skipIf( - GoogleAPICallError is None, 'GCP dependencies are not installed') +@unittest.skipIf(HttpError is None, 'GCP dependencies are not installed') class ReadUsingReadGbqTests(unittest.TestCase): @pytest.mark.it_postcommit def test_ReadGbq(self): diff --git a/sdks/python/apache_beam/dataframe/io_test.py b/sdks/python/apache_beam/dataframe/io_test.py index 051a85b379f7..d9723e1e5bb5 100644 --- a/sdks/python/apache_beam/dataframe/io_test.py +++ b/sdks/python/apache_beam/dataframe/io_test.py @@ -46,9 +46,9 @@ from apache_beam.testing.util import equal_to try: - from google.api_core.exceptions import GoogleAPICallError + from apitools.base.py.exceptions import HttpError except ImportError: - GoogleAPICallError = None + HttpError = None # Get major, minor version PD_VERSION = tuple(map(int, pd.__version__.split('.')[0:2])) @@ -492,15 +492,10 @@ def test_double_write(self): set(self.read_all_lines(output + 'out2.csv*'))) -@unittest.skipIf( - GoogleAPICallError is None, 'GCP dependencies are not installed') +@unittest.skipIf(HttpError is None, 'GCP dependencies are not installed') class ReadGbqTransformTests(unittest.TestCase): @mock.patch.object(BigQueryWrapper, 'get_table') def test_bad_schema_public_api_direct_read(self, get_table): - try: - bigquery.TableFieldSchema - except AttributeError: - raise ValueError('Please install GCP Dependencies.') fields = [ bigquery.TableFieldSchema(name='stn', type='DOUBLE', mode="NULLABLE"), bigquery.TableFieldSchema(name='temp', type='FLOAT64', mode="REPEATED"), diff --git a/sdks/python/apache_beam/io/requestresponse_it_test.py b/sdks/python/apache_beam/io/requestresponse_it_test.py index 072a1dc27b9f..4e4c2b638441 100644 --- a/sdks/python/apache_beam/io/requestresponse_it_test.py +++ b/sdks/python/apache_beam/io/requestresponse_it_test.py @@ -30,6 +30,7 @@ from apache_beam.coders import coders from apache_beam.options.pipeline_options import PipelineOptions from apache_beam.testing.test_pipeline import TestPipeline +from apache_beam.testing.test_utils import require_linux_docker # pylint: disable=ungrouped-imports try: @@ -206,6 +207,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): @pytest.mark.uses_testcontainer class TestRedisCache(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + require_linux_docker() + def setUp(self) -> None: self.retries = 5 self._start_container() diff --git a/sdks/python/apache_beam/testing/test_utils.py b/sdks/python/apache_beam/testing/test_utils.py index 425b4c6dfac4..ee953a902a0d 100644 --- a/sdks/python/apache_beam/testing/test_utils.py +++ b/sdks/python/apache_beam/testing/test_utils.py @@ -31,6 +31,7 @@ import struct import sys import tempfile +from unittest import SkipTest import numpy as np @@ -186,6 +187,30 @@ def block_imports(*packages): sys.modules.update(hidden_modules) +def require_linux_docker(): + """Raises ``unittest.SkipTest`` unless Docker can run Linux containers. + + Intended for tests that start containers via testcontainers, e.g. from + ``setUpClass``. Such tests need a reachable Docker daemon running Linux + containers. Some environments have no daemon at all (e.g. GitHub macOS + runners), while others only run Windows containers (e.g. GitHub Windows + runners). + """ + try: + import docker + client = docker.from_env() + try: + os_type = client.info().get('OSType') + finally: + client.close() + except Exception as e: # pylint: disable=broad-except + raise SkipTest(f'Docker is not available: {e}') + if os_type != 'linux': + raise SkipTest( + f'Docker daemon runs {os_type} containers; ' + 'Linux containers are required.') + + @retry.with_exponential_backoff( num_retries=3, retry_filter=retry.retry_on_beam_io_error_filter) def delete_files(file_paths): diff --git a/sdks/python/apache_beam/testing/test_utils_test.py b/sdks/python/apache_beam/testing/test_utils_test.py index 9c379cf35aab..3b99687c1158 100644 --- a/sdks/python/apache_beam/testing/test_utils_test.py +++ b/sdks/python/apache_beam/testing/test_utils_test.py @@ -126,6 +126,43 @@ def test_block_imports_only_blocks_given_packages(self): self.assertEqual(attempted_imports, []) +class RequireLinuxDockerTest(unittest.TestCase): + def _fake_docker(self, os_type=None, error=None): + fake_docker = mock.MagicMock() + client = fake_docker.from_env.return_value + if error: + client.info.side_effect = error + else: + client.info.return_value = {'OSType': os_type} + return fake_docker, client + + def test_linux_docker_does_not_skip(self): + fake_docker, client = self._fake_docker(os_type='linux') + with mock.patch.dict(sys.modules, {'docker': fake_docker}): + utils.require_linux_docker() + client.close.assert_called_once() + + def test_windows_docker_skips(self): + fake_docker, client = self._fake_docker(os_type='windows') + with mock.patch.dict(sys.modules, {'docker': fake_docker}): + with self.assertRaisesRegex(unittest.SkipTest, 'windows containers'): + utils.require_linux_docker() + client.close.assert_called_once() + + def test_unreachable_docker_skips(self): + fake_docker, client = self._fake_docker( + error=ConnectionError('no such file')) + with mock.patch.dict(sys.modules, {'docker': fake_docker}): + with self.assertRaisesRegex(unittest.SkipTest, 'Docker is not available'): + utils.require_linux_docker() + client.close.assert_called_once() + + def test_missing_docker_sdk_skips(self): + with utils.block_imports('docker'): + with self.assertRaisesRegex(unittest.SkipTest, 'Docker is not available'): + utils.require_linux_docker() + + class LCGeneratorTest(unittest.TestCase): Generator = utils.LCGenerator diff --git a/sdks/python/apache_beam/transforms/enrichment_handlers/cloudsql_it_test.py b/sdks/python/apache_beam/transforms/enrichment_handlers/cloudsql_it_test.py index 04db85a75c29..644dd56978bc 100644 --- a/sdks/python/apache_beam/transforms/enrichment_handlers/cloudsql_it_test.py +++ b/sdks/python/apache_beam/transforms/enrichment_handlers/cloudsql_it_test.py @@ -28,6 +28,7 @@ import apache_beam as beam from apache_beam.coders import coders from apache_beam.testing.test_pipeline import TestPipeline +from apache_beam.testing.test_utils import require_linux_docker from apache_beam.testing.util import assert_that from apache_beam.testing.util import equal_to @@ -589,6 +590,8 @@ def setUpClass(cls): # Skip setup for the base class. raise unittest.SkipTest("Base class - no db_adapter defined") + require_linux_docker() + # Type hint data from subclasses. cls._db_adapter: DatabaseTypeAdapter diff --git a/sdks/python/conftest.py b/sdks/python/conftest.py index 92d90769fb15..0b2db2649a99 100644 --- a/sdks/python/conftest.py +++ b/sdks/python/conftest.py @@ -18,11 +18,11 @@ """Pytest configuration and custom hooks.""" import gc +import math import os import sys import threading import time -from types import SimpleNamespace import pytest @@ -150,6 +150,32 @@ def enhance_mock_stability(): time.sleep(0.05) +def _configure_testcontainers_timeouts(): + """Applies TC_* environment settings to testcontainers' global config. + + testcontainers 4.x computes its wait timeout as ``max_tries * sleep_time`` + from ``testcontainers_config``. It reads ``TC_MAX_TRIES`` and + ``TC_POOLING_INTERVAL`` itself, but not ``TC_TIMEOUT`` or ``TC_SLEEP_TIME``, + which Beam's CI workflows set. Translate them here so that the effective + timeout is at least ``TC_TIMEOUT`` seconds. + """ + from testcontainers.core.config import testcontainers_config + + sleep_time = float( + os.getenv("TC_SLEEP_TIME") or os.getenv("TC_POOLING_INTERVAL") or "1") + timeout = float(os.getenv("TC_TIMEOUT", "120")) + max_tries = int(os.getenv("TC_MAX_TRIES", "0")) + + testcontainers_config.sleep_time = sleep_time + testcontainers_config.max_tries = max( + max_tries, math.ceil(timeout / sleep_time)) + print( + "Set testcontainers max_tries=%d, sleep_time=%s (timeout=%ss)" % ( + testcontainers_config.max_tries, + testcontainers_config.sleep_time, + testcontainers_config.timeout)) + + def pytest_configure(config): """Saves options added in pytest_addoption for later use. This is necessary since pytest-xdist workers do not have the @@ -159,14 +185,8 @@ def pytest_configure(config): # for the entire test session. print("\n--- Applying global testcontainers timeout configuration ---") try: - from testcontainers.core import waiting_utils - waiting_utils.config = SimpleNamespace( - timeout=int(os.getenv("TC_TIMEOUT", "120")), - max_tries=int(os.getenv("TC_MAX_TRIES", "120")), - sleep_time=float(os.getenv("TC_SLEEP_TIME", "1")), - ) - print("Successfully set waiting utils config") - except ModuleNotFoundError: + _configure_testcontainers_timeouts() + except ImportError: print("The testcontainers library is not installed.") TestPipeline.pytest_test_pipeline_options = config.getoption( From 084fb321e579e0c64167f59a1f949f0ed582d400 Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Fri, 16 Oct 2026 12:24:40 -0400 Subject: [PATCH 013/108] Restore Java 11 support in setJavaVerOptions (#40634) * Restore Java 11 support in setJavaVerOptions the '11' branch. Workflows that still pass -PtestJavaVersion=11 now fail while Gradle configures the projects: Unknown Java Version 11 for setting additional java options * Trigger test workflow --- .github/trigger_files/beam_PreCommit_Java_IOs_Direct.json | 4 ++++ .../groovy/org/apache/beam/gradle/BeamModulePlugin.groovy | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .github/trigger_files/beam_PreCommit_Java_IOs_Direct.json diff --git a/.github/trigger_files/beam_PreCommit_Java_IOs_Direct.json b/.github/trigger_files/beam_PreCommit_Java_IOs_Direct.json new file mode 100644 index 000000000000..5abe02fc09c7 --- /dev/null +++ b/.github/trigger_files/beam_PreCommit_Java_IOs_Direct.json @@ -0,0 +1,4 @@ +{ + "comment": "Modify this file in a trivial way to cause this test suite to run.", + "modification": 1 +} diff --git a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy index d699f9efab94..cf5979dd176b 100644 --- a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy +++ b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy @@ -1021,7 +1021,9 @@ class BeamModulePlugin implements Plugin { } options.fork = true options.forkOptions.javaHome = javaVerHome as File - if (ver == '17') { + if (ver == '11') { + // No additional options needed. Error Prone module exports are only required on JDK 16+. + } else if (ver == '17') { options.forkOptions.jvmArgs += errorProneAddModuleOpts.collect { '-J' + it } } else if (ver == '21' || ver == '25') { options.compilerArgs += ['-Xlint:-this-escape'] From d5f6472f73a42cca0bd0bd19ffd17498fdb5f4bb Mon Sep 17 00:00:00 2001 From: tvalentyn Date: Fri, 16 Oct 2026 10:02:41 -0700 Subject: [PATCH 014/108] Enable throttling logic in RemoteInference. (#40598) * Enable throttler. * Preserve while loop instead of if. --- .../apache/beam/sdk/ml/inference/remote/RemoteInference.java | 4 ++-- .../beam/sdk/ml/inference/remote/RemoteInferenceTest.java | 3 --- sdks/python/apache_beam/io/components/adaptive_throttler.py | 4 ++-- sdks/python/apache_beam/ml/inference/base.py | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java b/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java index a998f4d0f077..3dd4158069b0 100644 --- a/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java +++ b/sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java @@ -143,7 +143,7 @@ public Invoke withThrottleDelaySecs(int throttleDelaySecs) { /** * Configures the length of history to consider when setting throttling probability. Defaults to - * a sample period of 1000ms. For more context, see {@link AdaptiveThrottler} + * a sample period of 120000ms. For more context, see {@link AdaptiveThrottler} */ public Invoke withSamplePeriodMs(long samplePeriodMs) { checkArgument(samplePeriodMs > 0, "samplePeriodMs must be positive"); @@ -239,7 +239,7 @@ static class RemoteInferenceFninvoke() .handler(MockThrottlingHandler.class) .withBatchConfig(batchConfig) - // Use large sample periods so the 1s retry delay doesn't flush the history - .withSamplePeriodMs(60000L) - .withSampleUpdateMs(60000L) // Set to 1 second to minimize test wait time while still verifying throttling .withThrottleDelaySecs(1) .withOverloadRatio(1.1) diff --git a/sdks/python/apache_beam/io/components/adaptive_throttler.py b/sdks/python/apache_beam/io/components/adaptive_throttler.py index 3c22891ee8a3..06f2d83d7142 100644 --- a/sdks/python/apache_beam/io/components/adaptive_throttler.py +++ b/sdks/python/apache_beam/io/components/adaptive_throttler.py @@ -131,12 +131,12 @@ class ReactiveThrottler(AdaptiveThrottler): system recovery. capture the timestamp of the attempted request, then execute the request code. On a success, call successful_request(timestamp) to report the success to the throttler. This flow looks like the following: - + def remote_call(): throttler.throttle() try: - timestamp = time.time() + timestamp = time.time() * 1000 result = make_request() throttler.successful_request(timestamp) return result diff --git a/sdks/python/apache_beam/ml/inference/base.py b/sdks/python/apache_beam/ml/inference/base.py index 85a287ad7529..823a4a3fa719 100644 --- a/sdks/python/apache_beam/ml/inference/base.py +++ b/sdks/python/apache_beam/ml/inference/base.py @@ -425,7 +425,7 @@ def __init__( throttle_delay_secs: int = 5, retry_filter: Callable[[Exception], bool] = lambda x: True, *, - window_ms: int = 1 * _MILLISECOND_TO_SECOND, + window_ms: int = 120 * _MILLISECOND_TO_SECOND, bucket_ms: int = 1 * _MILLISECOND_TO_SECOND, overload_ratio: float = 2, rate_limiter: Optional[RateLimiter] = None): From 964d15b47a48b70d3536c33c55edbc95a4a318a3 Mon Sep 17 00:00:00 2001 From: raams95 Date: Fri, 16 Oct 2026 12:07:18 -0500 Subject: [PATCH 015/108] [Python] Document the import-time default of PeriodicImpulse start_timestamp (#40599) The default start_timestamp of PeriodicImpulse and MatchContinuously is evaluated once, when the module is imported. As discussed in #40565 the default stays as is, so document it and point to an explicit timestamp or rebase for starting relative to pipeline execution. Fixes #40564 Co-authored-by: JanakiRamChittineni <341781521+raams95@users.noreply.github.com> --- sdks/python/apache_beam/io/fileio.py | 3 ++- sdks/python/apache_beam/transforms/periodicsequence.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/sdks/python/apache_beam/io/fileio.py b/sdks/python/apache_beam/io/fileio.py index fcce83fa59ec..23d0f8c10c1a 100644 --- a/sdks/python/apache_beam/io/fileio.py +++ b/sdks/python/apache_beam/io/fileio.py @@ -404,7 +404,8 @@ def __init__( file_pattern: The file path to read from. interval: Interval at which to check for files in seconds. has_deduplication: Whether files already read are discarded or not. - start_timestamp: Timestamp for start file checking. + start_timestamp: Timestamp for start file checking. Defaults to the time + this module was first imported, not the time the transform is created. stop_timestamp: Timestamp after which no more files will be checked. match_updated_files: (When has_deduplication is set to True) whether match file with timestamp changes. diff --git a/sdks/python/apache_beam/transforms/periodicsequence.py b/sdks/python/apache_beam/transforms/periodicsequence.py index 7b90fa3bb2fa..2513885e75dc 100644 --- a/sdks/python/apache_beam/transforms/periodicsequence.py +++ b/sdks/python/apache_beam/transforms/periodicsequence.py @@ -299,7 +299,10 @@ def __init__( data: Optional[Sequence[Any]] = None, rebase: RebaseMode = RebaseMode.REBASE_NONE): ''' - :param start_timestamp: Timestamp for first element. + :param start_timestamp: Timestamp for first element. Defaults to the time + this module was first imported, not the time the transform is created + or the pipeline starts. Pass an explicit timestamp, or use `rebase`, to + start relative to pipeline execution. :param stop_timestamp: Timestamp at or after which no elements will be output. :param fire_interval: Interval in seconds at which to output elements. From a3f3c8e01d4380ce2fd56e14adeacf7c09f51313 Mon Sep 17 00:00:00 2001 From: tvalentyn Date: Fri, 16 Oct 2026 10:32:07 -0700 Subject: [PATCH 016/108] Fix handling of cancelled tickets. (#40588) --- .../apache_beam/ml/inference/model_manager.py | 16 +++--- .../ml/inference/model_manager_test.py | 54 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/sdks/python/apache_beam/ml/inference/model_manager.py b/sdks/python/apache_beam/ml/inference/model_manager.py index 69db694cc970..bc16feb6f60f 100644 --- a/sdks/python/apache_beam/ml/inference/model_manager.py +++ b/sdks/python/apache_beam/ml/inference/model_manager.py @@ -470,12 +470,13 @@ def should_spawn_model(self, tag: str, ticket_num: int) -> bool: return False def _wake_next_in_queue(self): + # Clean up cancelled tickets at head of queue + while self._wait_queue and self._wait_queue[ + 0].ticket_num in self._cancelled_tickets: + self._cancelled_tickets.remove(self._wait_queue[0].ticket_num) + heapq.heappop(self._wait_queue) + # The queue may be empty if all remaining tickets were cancelled. if self._wait_queue: - # Clean up cancelled tickets at head of queue - while self._wait_queue and self._wait_queue[ - 0].ticket_num in self._cancelled_tickets: - self._cancelled_tickets.remove(self._wait_queue[0].ticket_num) - heapq.heappop(self._wait_queue) next_inline = self._wait_queue[0] next_inline.wake_event.set() @@ -660,7 +661,10 @@ def _evict_to_make_space( # TODO: Also factor in the active counts to avoid thrashing demand_map = Counter() for item in self._wait_queue: - demand_map[item.tag] += 1 + # Cancelled tickets linger in the queue until they reach the head; + # they no longer represent demand. + if item.ticket_num not in self._cancelled_tickets: + demand_map[item.tag] += 1 my_demand = demand_map[requesting_tag] am_i_starving = len(self._models[requesting_tag]) == 0 diff --git a/sdks/python/apache_beam/ml/inference/model_manager_test.py b/sdks/python/apache_beam/ml/inference/model_manager_test.py index fc84f4ba8598..d1f2b35bc1d9 100644 --- a/sdks/python/apache_beam/ml/inference/model_manager_test.py +++ b/sdks/python/apache_beam/ml/inference/model_manager_test.py @@ -30,6 +30,7 @@ from apache_beam.metrics.metricbase import MetricName from apache_beam.ml.inference.model_manager import GPUMonitor from apache_beam.ml.inference.model_manager import ModelManager + from apache_beam.ml.inference.model_manager import QueueTicket from apache_beam.ml.inference.model_manager import ResourceEstimator except ImportError as e: raise unittest.SkipTest("Model Manager dependencies are not installed") @@ -436,6 +437,32 @@ def run_inference(): est_cost = self.manager._estimator.get_estimate(model_name) self.assertAlmostEqual(est_cost, model_cost, delta=100.0) + def test_wake_next_in_queue_all_cancelled(self): + """Draining a queue of only cancelled tickets must not raise.""" + with self.manager._cv: + for ticket_num in (100, 101): + self.manager._wait_queue.append(QueueTicket(1, ticket_num, "m")) + self.manager._cancelled_tickets.add(ticket_num) + + self.manager._wake_next_in_queue() + + self.assertEqual(self.manager._wait_queue, []) + self.assertEqual(self.manager._cancelled_tickets, set()) + + def test_wake_next_in_queue_skips_cancelled_head(self): + """The first non-cancelled ticket is woken.""" + cancelled = QueueTicket(1, 100, "m") + live = QueueTicket(1, 101, "m") + with self.manager._cv: + self.manager._wait_queue.extend([cancelled, live]) + self.manager._cancelled_tickets.add(100) + + self.manager._wake_next_in_queue() + + self.assertEqual(self.manager._wait_queue, [live]) + self.assertTrue(live.wake_event.is_set()) + self.assertFalse(cancelled.wake_event.is_set()) + class TestModelManagerEviction(unittest.TestCase): def setUp(self): @@ -583,6 +610,33 @@ def acquire_b(): self.manager.release_model("B", model_b) executor.shutdown(wait=True) + def test_cancelled_tickets_do_not_count_as_demand(self): + self.manager._estimator.set_initial_estimate("A", 4000) + self.manager._estimator.set_initial_estimate("B", 4000) + + model_a = self.manager.acquire_model("A", self.create_loader("A", 4000)) + self.manager.release_model("A", model_a) + # B stays active so it is not starving and demand comparison applies. + model_b = self.manager.acquire_model("B", self.create_loader("B", 4000)) + + with self.manager._cv: + # One live request for B, two cancelled (timed out) requests for A. + self.manager._wait_queue.extend([ + QueueTicket(1, 100, "B"), + QueueTicket(1, 101, "A"), + QueueTicket(1, 102, "A"), + ]) + self.manager._cancelled_tickets.update({101, 102}) + + # 8000 used + 6000 requested > 12000, so A must be evicted. Without + # ignoring cancelled tickets, A's demand (2) >= B's (1) protects it. + made_space = self.manager._evict_to_make_space( + limit=12000, est_cost=6000, requesting_tag="B") + + self.assertTrue(made_space) + self.assertTrue(model_a.deleted) + self.manager.release_model("B", model_b) + class TestGPUMonitor(unittest.TestCase): def setUp(self): From 1fc615fcd2b35b82a9b15bb3b260c989a7a64511 Mon Sep 17 00:00:00 2001 From: Derrick Williams Date: Fri, 16 Oct 2026 14:09:36 -0400 Subject: [PATCH 017/108] [typescript] - fix typescript failures (#40635) * [typescript] - fix typescript failures * fix test call --- .../src/apache_beam/internal/serialize.ts | 2 +- .../typescript/src/apache_beam/serialization.ts | 17 +++++++++++++++-- sdks/typescript/test/primitives_test.ts | 6 ++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/sdks/typescript/src/apache_beam/internal/serialize.ts b/sdks/typescript/src/apache_beam/internal/serialize.ts index 6584e41d6af9..29f474ef4858 100644 --- a/sdks/typescript/src/apache_beam/internal/serialize.ts +++ b/sdks/typescript/src/apache_beam/internal/serialize.ts @@ -16,10 +16,10 @@ * limitations under the License. */ +import { requireForSerialization, registeredObjects } from "../serialization"; import * as serialize_closures from "serialize-closures"; import Long from "long"; -import { requireForSerialization, registeredObjects } from "../serialization"; import { packageName } from "../utils/packageJson"; const BIGINT_PREFIX = ":bigint:"; diff --git a/sdks/typescript/src/apache_beam/serialization.ts b/sdks/typescript/src/apache_beam/serialization.ts index 9ca2177926fe..1c7ce53ca64c 100644 --- a/sdks/typescript/src/apache_beam/serialization.ts +++ b/sdks/typescript/src/apache_beam/serialization.ts @@ -16,10 +16,23 @@ * limitations under the License. */ -import * as serialize_closures from "serialize-closures"; +// Polyfill deprecated util helpers removed in Node 24+ that are still used by +// serialize-closures. +const nodeUtil = require("util"); +if (typeof nodeUtil.isPrimitive !== "function") { + nodeUtil.isPrimitive = (val: unknown): boolean => + val === null || (typeof val !== "object" && typeof val !== "function"); +} +if (typeof nodeUtil.isArray !== "function") { + nodeUtil.isArray = Array.isArray; +} +if (typeof nodeUtil.isFunction !== "function") { + nodeUtil.isFunction = (val: unknown): boolean => typeof val === "function"; +} const registeredModules: string[] = []; -export const registeredObjects: serialize_closures.BuiltinRecord[] = []; +export const registeredObjects: import("serialize-closures").BuiltinRecord[] = + []; const registeredObjectSet: Set = new Set(); /** diff --git a/sdks/typescript/test/primitives_test.ts b/sdks/typescript/test/primitives_test.ts index b3f35cce1fbf..b7adb28d9f03 100644 --- a/sdks/typescript/test/primitives_test.ts +++ b/sdks/typescript/test/primitives_test.ts @@ -308,10 +308,8 @@ export function suite(runner: beam.Runner = directRunner()) { return v * 4; }); - assert.deepEqual( - p.context.getPCollectionCoder(res), - new GeneralObjectCoder() - ); + const coder = p.context.getPCollectionCoder(res); + assert.equal(coder.constructor.name, "GeneralObjectCoder"); }); // why doesn't map need types here? it("runs a GroupBy expansion", function () { From a17e73a0da4710a1fee926ccd2b7abac2ea2ec08 Mon Sep 17 00:00:00 2001 From: tvalentyn Date: Fri, 16 Oct 2026 12:17:05 -0700 Subject: [PATCH 018/108] Clarify known restrictions for model updates. (#40600) --- sdks/python/apache_beam/ml/inference/utils.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sdks/python/apache_beam/ml/inference/utils.py b/sdks/python/apache_beam/ml/inference/utils.py index dfbb01a29049..8cbe836e22dd 100644 --- a/sdks/python/apache_beam/ml/inference/utils.py +++ b/sdks/python/apache_beam/ml/inference/utils.py @@ -133,14 +133,17 @@ def __init__( 1. Any previously used filenames cannot be reused. If a file is added or updated to a previously used filename, this transform will ignore that update. To trigger a model update, always upload a file with - unique name. - 2. Initially, before the pipeline startup time, WatchFilePattern expects + unique name. Do not preserve old modification timestamps when copying + or uploading a file (for example, when rolling back to an older model), + as files with timestamps older than the pipeline start time are ignored. + 2. Do not upload more than one model update per polling ``interval``, as + matching multiple new files in the same poll can cause undefined + ordering or singleton side-input errors. + 3. Initially, before the pipeline startup time, WatchFilePattern expects at least one file present that matches the file_pattern. - 3. This transform is supported in streaming mode since + 4. This transform is supported in streaming mode since MatchContinuously produces an unbounded source. Running in batch mode can lead to undesired results or result in pipeline being stuck. - - """ self.file_pattern = file_pattern self.interval = interval From a74f328f26eb748ab47f2d4647c28667447cd551 Mon Sep 17 00:00:00 2001 From: Derrick Williams Date: Fri, 16 Oct 2026 15:50:19 -0400 Subject: [PATCH 019/108] [yaml] - fix post commit yaml test (#40633) * [yaml] - fix post commit yaml test * trigger postcommit --- .../beam_PostCommit_Yaml_Xlang_Direct.json | 2 +- .../beam_PreCommit_Yaml_Xlang_Direct.json | 2 +- .../extended_tests/e2e/gcs_avro_to_cloud_bigtable.yaml | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json b/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json index a975cd1cd104..541dc4ea8e87 100644 --- a/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json +++ b/.github/trigger_files/beam_PostCommit_Yaml_Xlang_Direct.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "revision": 1 + "revision": 2 } diff --git a/.github/trigger_files/beam_PreCommit_Yaml_Xlang_Direct.json b/.github/trigger_files/beam_PreCommit_Yaml_Xlang_Direct.json index 8c604b0a135c..840d064bdbb0 100644 --- a/.github/trigger_files/beam_PreCommit_Yaml_Xlang_Direct.json +++ b/.github/trigger_files/beam_PreCommit_Yaml_Xlang_Direct.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "revision": 2 + "revision": 3 } diff --git a/sdks/python/apache_beam/yaml/extended_tests/e2e/gcs_avro_to_cloud_bigtable.yaml b/sdks/python/apache_beam/yaml/extended_tests/e2e/gcs_avro_to_cloud_bigtable.yaml index e492e697af3a..86c1a4660613 100644 --- a/sdks/python/apache_beam/yaml/extended_tests/e2e/gcs_avro_to_cloud_bigtable.yaml +++ b/sdks/python/apache_beam/yaml/extended_tests/e2e/gcs_avro_to_cloud_bigtable.yaml @@ -64,7 +64,7 @@ pipelines: key: callable: | def convert_to_bytes(row): - return bytes(row.key, 'utf-8') if "key" in row._fields else None + return bytes(row.key, 'utf-8') if hasattr(row, "key") else None type: type family_name: @@ -72,11 +72,11 @@ pipelines: column_qualifier: callable: | def convert_to_bytes(row): - return bytes(row.column_qualifier, 'utf-8') if 'column_qualifier' in row._fields else None + return bytes(row.column_qualifier, 'utf-8') if hasattr(row, 'column_qualifier') else None value: callable: | def convert_to_bytes(row): - return bytes(row.value, 'utf-8') if 'value' in row._fields else None + return bytes(row.value, 'utf-8') if hasattr(row, 'value') else None timestamp_micros: timestamp_micros - type: WriteToBigTable @@ -102,13 +102,13 @@ pipelines: key: callable: | def convert_to_string(row): - return row.key.decode("utf-8") if "key" in row._fields else None + return row.key.decode("utf-8") if hasattr(row, "key") else None family_name: family_name column_qualifier: callable: | def convert_to_string(row): - return row.column_qualifier.decode("utf-8") if "column_qualifier" in row._fields else None + return row.column_qualifier.decode("utf-8") if hasattr(row, "column_qualifier") else None cells: callable: | def convert_to_string(row): From e41f0ce1d1d5526190e7e3de1820f5a7b22e05d3 Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Fri, 16 Oct 2026 15:50:33 -0400 Subject: [PATCH 020/108] [Python] Add examples for AWS credentials from a secret manager (#40592) * Add examples for AWS credentials from a secret manager Add examples under apache_beam/examples/secret_manager that set --aws_credentials_secret_manager to read AWS credentials from Google Cloud Secret Manager: * aws_credentials_s3_read.py: reads an S3 file outside a pipeline. * aws_credentials_s3_pipeline.py: reads an S3 file in a pipeline (DirectRunner or Dataflow). * aws_credentials_secret_read.py: reads a secret from AWS Secrets Manager with Secret.parse_secret_option and Secret.from_json. * Add a use case of arn for reading AWS secrets * Reformat * Added a requirements file for the example. --- .../examples/secret_manager/__init__.py | 18 ++++ .../aws_credentials_requirements.txt | 21 +++++ .../aws_credentials_s3_pipeline.py | 75 ++++++++++++++++ .../secret_manager/aws_credentials_s3_read.py | 60 +++++++++++++ .../aws_credentials_secret_read.py | 87 +++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 sdks/python/apache_beam/examples/secret_manager/__init__.py create mode 100644 sdks/python/apache_beam/examples/secret_manager/aws_credentials_requirements.txt create mode 100644 sdks/python/apache_beam/examples/secret_manager/aws_credentials_s3_pipeline.py create mode 100644 sdks/python/apache_beam/examples/secret_manager/aws_credentials_s3_read.py create mode 100644 sdks/python/apache_beam/examples/secret_manager/aws_credentials_secret_read.py diff --git a/sdks/python/apache_beam/examples/secret_manager/__init__.py b/sdks/python/apache_beam/examples/secret_manager/__init__.py new file mode 100644 index 000000000000..92dc81c246f6 --- /dev/null +++ b/sdks/python/apache_beam/examples/secret_manager/__init__.py @@ -0,0 +1,18 @@ +# +# 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. +# + +"""Examples for SecretManager integration in Apache Beam.""" diff --git a/sdks/python/apache_beam/examples/secret_manager/aws_credentials_requirements.txt b/sdks/python/apache_beam/examples/secret_manager/aws_credentials_requirements.txt new file mode 100644 index 000000000000..e0db2ef261db --- /dev/null +++ b/sdks/python/apache_beam/examples/secret_manager/aws_credentials_requirements.txt @@ -0,0 +1,21 @@ +# +# 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. +# + +# Worker dependencies for aws_credentials_s3_pipeline.py: boto3 for the S3 +# filesystem, and google-cloud-secret-manager to resolve the AWS credentials. +boto3>=1.9,<2 +google-cloud-secret-manager>=2.0,<3 diff --git a/sdks/python/apache_beam/examples/secret_manager/aws_credentials_s3_pipeline.py b/sdks/python/apache_beam/examples/secret_manager/aws_credentials_s3_pipeline.py new file mode 100644 index 000000000000..535444653499 --- /dev/null +++ b/sdks/python/apache_beam/examples/secret_manager/aws_credentials_s3_pipeline.py @@ -0,0 +1,75 @@ +# +# 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. +# + +"""A pipeline that reads an S3 file and logs each line, using AWS credentials +stored in Google Cloud Secret Manager. + +Usage (DirectRunner): + python -m apache_beam.examples.secret_manager.aws_credentials_s3_pipeline \ + --input=s3://my-bucket/my-file.txt \ + --aws_region_name=us-east-1 \ + --aws_credentials_secret_manager=GoogleCloudSecretManager \ + --aws_access_key_id='{"name": "my-aws-key-id", "project": "my-project"}' \ + --aws_secret_access_key='{"name": "my-aws-secret-key", "project": "my-project"}' + +Usage (DataflowRunner): add the following flags to the command above. + --runner=DataflowRunner \ + --project=my-project \ + --region=us-central1 \ + --temp_location=gs://my-gcs-bucket/temp \ + --requirements_file=apache_beam/examples/secret_manager/aws_credentials_requirements.txt + +where aws_credentials_requirements.txt (in the same directory as this example) +lists the packages the workers need: boto3 and google-cloud-secret-manager. + +Required permissions: + * The AWS IAM user that owns the access key needs ``s3:ListBucket`` on the + bucket (``arn:aws:s3:::my-bucket``, used to match the input path) and + ``s3:GetObject`` on the object (``arn:aws:s3:::my-bucket/my-file.txt``), + for example through the ``AmazonS3ReadOnlyAccess`` managed policy or a + narrower custom policy. If the object is encrypted with a customer managed + KMS key, it also needs ``kms:Decrypt`` on that key. + * The Google Cloud account that resolves the credentials needs the Secret + Manager Secret Accessor role (``roles/secretmanager.secretAccessor``) on + the two secrets that hold the access key ID and secret access key. The + credentials are resolved both when the pipeline is constructed and where + the file is read, so this is the account running the script and, on + Dataflow, also the worker service account. +""" + +import argparse +import logging + +import apache_beam as beam +from apache_beam.options.pipeline_options import PipelineOptions + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument('--input', required=True, help='S3 path to read.') + known_args, pipeline_args = parser.parse_known_args(argv) + + with beam.Pipeline(options=PipelineOptions(pipeline_args)) as p: + _ = ( + p + | 'Read' >> beam.io.ReadFromText(known_args.input) + | 'Log' >> beam.Map(logging.info)) + + +if __name__ == '__main__': + logging.getLogger().setLevel(logging.INFO) + main() diff --git a/sdks/python/apache_beam/examples/secret_manager/aws_credentials_s3_read.py b/sdks/python/apache_beam/examples/secret_manager/aws_credentials_s3_read.py new file mode 100644 index 000000000000..6ef1c1ba8049 --- /dev/null +++ b/sdks/python/apache_beam/examples/secret_manager/aws_credentials_s3_read.py @@ -0,0 +1,60 @@ +# +# 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. +# + +"""Reads an S3 file outside of a pipeline, using AWS credentials stored in +Google Cloud Secret Manager. + +Usage: + python -m apache_beam.examples.secret_manager.aws_credentials_s3_read \ + --input=s3://my-bucket/my-file.txt \ + --aws_region_name=us-east-1 \ + --aws_credentials_secret_manager=GoogleCloudSecretManager \ + --aws_access_key_id='{"name": "my-aws-key-id", "project": "my-project"}' \ + --aws_secret_access_key='{"name": "my-aws-secret-key", "project": "my-project"}' + +Required permissions: + * The AWS IAM user that owns the access key needs ``s3:GetObject`` on the + object (e.g. ``arn:aws:s3:::my-bucket/my-file.txt``), for example through + the ``AmazonS3ReadOnlyAccess`` managed policy or a narrower custom policy. + If the object is encrypted with a customer managed KMS key, it also needs + ``kms:Decrypt`` on that key. + * The Google Cloud account running this script (e.g. your Application + Default Credentials) needs the Secret Manager Secret Accessor role + (``roles/secretmanager.secretAccessor``) on the two secrets that hold the + access key ID and secret access key. +""" + +import argparse + +from apache_beam.io.filesystems import FileSystems +from apache_beam.options.pipeline_options import PipelineOptions + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument('--input', required=True, help='S3 path to read.') + known_args, pipeline_args = parser.parse_known_args(argv) + + # The S3 filesystem resolves the --aws_* credentials through the secret + # manager given by --aws_credentials_secret_manager. + FileSystems.set_options(PipelineOptions(pipeline_args)) + with FileSystems.open(known_args.input) as f: + print(f.read().decode('utf-8')) + + +if __name__ == '__main__': + main() diff --git a/sdks/python/apache_beam/examples/secret_manager/aws_credentials_secret_read.py b/sdks/python/apache_beam/examples/secret_manager/aws_credentials_secret_read.py new file mode 100644 index 000000000000..bf3dadedeb2d --- /dev/null +++ b/sdks/python/apache_beam/examples/secret_manager/aws_credentials_secret_read.py @@ -0,0 +1,87 @@ +# +# 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. +# + +"""Reads a secret from AWS Secrets Manager outside of a pipeline, using AWS +credentials stored in Google Cloud Secret Manager. + +Usage: + python -m apache_beam.examples.secret_manager.aws_credentials_secret_read \ + --secret_id=my-aws-secret \ + --secret_arn=arn:aws:secretsmanager:us-east-1::secret:my-aws-secret-AbCdEf \ + --aws_region_name=us-east-1 \ + --aws_credentials_secret_manager=GoogleCloudSecretManager \ + --aws_access_key_id='{"name": "my-aws-key-id", "project": "my-project"}' \ + --aws_secret_access_key='{"name": "my-aws-secret-key", "project": "my-project"}' + +Required permissions: + * The AWS IAM user that owns the access key needs + ``secretsmanager:GetSecretValue`` on the secret (e.g. + ``arn:aws:secretsmanager:us-east-1::secret:my-aws-secret-*``), + for example through the ``AWSSecretsManagerClientReadOnlyAccess`` managed + policy or a narrower custom policy. If the secret is encrypted with a + customer managed KMS key instead of the default ``aws/secretsmanager`` key, + it also needs ``kms:Decrypt`` on that key. + * The Google Cloud account running this script (e.g. your Application + Default Credentials) needs the Secret Manager Secret Accessor role + (``roles/secretmanager.secretAccessor``) on the two secrets that hold the + access key ID and secret access key. +""" + +import argparse + +from apache_beam.options.pipeline_options import PipelineOptions +from apache_beam.utils.secret import Secret + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument( + '--secret_id', + required=True, + help='Name of the secret in AWS Secrets Manager.') + parser.add_argument( + '--secret_arn', help='Optional ARN of the secret in AWS Secrets Manager.') + known_args, pipeline_args = parser.parse_known_args(argv) + options = PipelineOptions(pipeline_args) + + # The AWS Secrets Manager client uses the --aws_* options, with credentials + # resolved through --aws_credentials_secret_manager. + + # Option 1: a 'type:;:' secret string. + secret = Secret.parse_secret_option( + f'type:awssecret;name:{known_args.secret_id}', options) + print(f'Retrieved secret of length {len(secret.get_str())}') + + # Option 2: a JSON spec and a secret manager name. + secret = Secret.from_json( + f'{{"name": "{known_args.secret_id}"}}', 'AwsSecretsManager', options) + print(f'Retrieved secret of length {len(secret.get_str())}') + + # Options 3 and 4: the same as options 1 and 2, but identifying the secret by + # its ARN. + if known_args.secret_arn: + secret = Secret.parse_secret_option( + f'type:awssecret;arn:{known_args.secret_arn}', options) + print(f'Retrieved secret of length {len(secret.get_str())}') + + secret = Secret.from_json( + f'{{"arn": "{known_args.secret_arn}"}}', 'AwsSecretsManager', options) + print(f'Retrieved secret of length {len(secret.get_str())}') + + +if __name__ == '__main__': + main() From 8eda31b281f292854e83e3f6a3d84218007eacfb Mon Sep 17 00:00:00 2001 From: Divyansh Yadav Date: Sat, 17 Oct 2026 02:47:40 +0530 Subject: [PATCH 021/108] [Python] Fix max_models_per_worker_hint not being enforced in KeyedModelHandler (#40500) * [Python] Fix max_models_per_worker_hint not being enforced in KeyedModelHandler * Guard increment_max_models with a lock on the model handler manager --- CHANGES.md | 1 + sdks/python/apache_beam/ml/inference/base.py | 31 +++++++++++----- .../apache_beam/ml/inference/base_test.py | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 537c3a32447c..d9ca6f0b83dd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -104,6 +104,7 @@ * (Go) Fixed the harness leaking Data/State gRPC streams after the worker stops, and a deadlock when Send returns EOF ([#40260](https://github.com/apache/beam/issues/40260)). * (Go) Wrap last dataWriter/timerWriter send errors on Close ([#40405](https://github.com/apache/beam/issues/40405)). * (Go) Fixed a deadlock recreating a data channel while holding the channel lock ([#40414](https://github.com/apache/beam/issues/40414)). +* (Python) Fixed `KeyedModelHandler` not enforcing `max_models_per_worker_hint` when several copies of the model handler run in the same SDK process ([#40468](https://github.com/apache/beam/issues/40468)). * (Java) Fixed the declared schema of the error output of the Kafka write SchemaTransform, which wrapped the error schema a second time and did not match the rows it emits ([#39760](https://github.com/apache/beam/issues/39760)). * (Go) Fixed pubsubio importing a `google.golang.org/genproto` package removed in recent releases, which broke builds of Go modules depending on a current `genproto` version ([#40018](https://github.com/apache/beam/issues/40018)). * (Python) `VertexAIFeatureStoreEnrichmentHandler` now logs a warning instead of failing when the caller lacks permission to read the feature store metadata ([#40442](https://github.com/apache/beam/issues/40442)). diff --git a/sdks/python/apache_beam/ml/inference/base.py b/sdks/python/apache_beam/ml/inference/base.py index 823a4a3fa719..ce014e4b04f9 100644 --- a/sdks/python/apache_beam/ml/inference/base.py +++ b/sdks/python/apache_beam/ml/inference/base.py @@ -577,6 +577,11 @@ def __init__(self, mh_map: dict[str, ModelHandler]): model. """ self._max_models = None + # Process ids that have already contributed to _max_models + self._incremented_process_ids: set[int] = set() + # Guards _max_models and _incremented_process_ids, since the shared + # manager serves calls from several processes on separate threads. + self._max_models_lock = threading.Lock() # Map keys to model handlers self._mh_map: dict[str, ModelHandler] = mh_map # Map keys to the last updated model path for that key @@ -631,17 +636,26 @@ def load(self, key: str) -> _ModelLoadStats: return _ModelLoadStats( tag, end_time - start_time, memory_after - memory_before) - def increment_max_models(self, increment: int): + def increment_max_models( + self, increment: int, process_id: Optional[int] = None): """ Increments the number of models that this instance of a _ModelHandlerManager is able to hold. If it is never called, no limit is imposed. Args: increment: the amount by which we are incrementing the number of models. + process_id: the id of the process requesting the increment. If set, + only the first increment from each process is applied, since many + copies of the model handler can share this manager within a process. """ - if self._max_models is None: - self._max_models = 0 - self._max_models += increment + with self._max_models_lock: + if process_id is not None: + if process_id in self._incremented_process_ids: + return + self._incremented_process_ids.add(process_id) + if self._max_models is None: + self._max_models = 0 + self._max_models += increment def update_model_handler(self, key: str, model_path: str, previous_key: str): """ @@ -867,11 +881,12 @@ def run_inference( self._unkeyed.run_inference(unkeyed_batch, model, inference_args)) # The first time a MultiProcessShared ModelManager is used for inference - # from this process, we should increment its max model count + # from this process, we should increment its max model count. The manager + # may live in another process and be shared by several deserialized copies + # of this handler, so it dedupes increments by process id. if self._max_models_per_worker_hint is not None: - lock = threading.Lock() - if lock.acquire(blocking=False): - model.increment_max_models(self._max_models_per_worker_hint) + model.increment_max_models( + self._max_models_per_worker_hint, process_id=os.getpid()) self._max_models_per_worker_hint = None batch_by_key = defaultdict(list) diff --git a/sdks/python/apache_beam/ml/inference/base_test.py b/sdks/python/apache_beam/ml/inference/base_test.py index 568bdf7ea129..64f5c3b1525d 100644 --- a/sdks/python/apache_beam/ml/inference/base_test.py +++ b/sdks/python/apache_beam/ml/inference/base_test.py @@ -1978,6 +1978,41 @@ def test_model_handler_manager_evicts_models_after_being_incremented(self): mh3.load_model, tag=tag3).acquire() self.assertEqual(8, model3.predict(10)) + def test_model_handler_manager_increments_once_per_process(self): + mhs = { + 'key1': FakeModelHandler(state=1), + 'key2': FakeModelHandler(state=2), + 'key3': FakeModelHandler(state=3) + } + mm = base._ModelHandlerManager(mh_map=mhs) + mm.increment_max_models(1, process_id=1) + mm.increment_max_models(1, process_id=1) + mm.load('key1') + mm.load('key2') + mm.load('key3') + self.assertEqual(['key3'], list(mm._tag_map.keys())) + + mm.increment_max_models(1, process_id=2) + mm.load('key1') + self.assertEqual(['key3', 'key1'], list(mm._tag_map.keys())) + + def test_keyed_model_handler_max_models_hint_with_handler_copies(self): + mhs = [ + base.KeyModelMapping([k], + FakeModelHandler( + state=i, multi_process_shared=True)) + for i, k in enumerate(['a', 'b', 'c']) + ] + keyed_mh = base.KeyedModelHandler(mhs, max_models_per_worker_hint=1) + mm = keyed_mh.load_model() + # Each DoFn instance in a process deserializes its own copy of the model + # handler, but they all share the same _ModelHandlerManager. + for _ in range(5): + mh_copy = pickle.loads(pickle.dumps(keyed_mh)) + mh_copy.override_metrics('test_namespace') + list(mh_copy.run_inference([('a', 1), ('b', 2), ('c', 3)], mm)) + self.assertEqual(1, len(mm._tag_map)) + def test_run_inference_loads_different_models(self): mh1 = FakeModelHandler(incrementing=True, min_batch_size=3) with TestPipeline() as pipeline: From 2be52540457e3887080a67641240b243ea6dad45 Mon Sep 17 00:00:00 2001 From: pablo rodriguez defino Date: Fri, 16 Oct 2026 17:00:26 -0700 Subject: [PATCH 022/108] [Prism] Finalize bundles of DoFns fused after another transform (#40640) * [Prism] Finalize bundles of DoFns fused after another transform Problem: In finalizeStage (sdks/go/pkg/beam/runners/prism/internal/preprocess.go), Prism sets stage.finalize only while it inspects the consumers of the stage's input PCollections. The loop skips every PCollection that a transform of the same stage produces: for pid, plinks := range stageFacts.PcolConsumers { if _, ok := stageFacts.PcolProducers[pid]; ok { continue } ... if pardo.GetRequestsFinalization() { stg.finalize = true } So a ParDo that sets requests_finalization but is fused after another transform (a Map after Create, the expand step of a Reshuffle, any element-wise chain) never marks its stage. The SDK harness registers the callbacks and returns requires_finalization, but Prism never sends FinalizeBundleRequest, and the callbacks never run. This affects every SDK. The existing TestParDoBundleFinalizer cases apply the finalizing DoFn directly to Impulse, so the DoFn always consumes the stage input and the tests pass. Fix: Set stage.finalize from every ParDo-like transform of the stage (ParDo and the splittable DoFn stage URNs), not only from the consumers of the stage inputs. Statefulness, the on-window-expiration timer and side inputs still come from the input consumers. Add a TestParDoBundleFinalizer case with the finalizing DoFn fused after another ParDo. Reproduction (Python SDK on PrismRunner): ```python import os import tempfile import apache_beam as beam from apache_beam.options.pipeline_options import PipelineOptions class MarkFinalization(beam.DoFn): """Registers a bundle finalization callback that creates the file `path`.""" def __init__(self, path): self._path = path def process(self, element, bundle_finalizer=beam.DoFn.BundleFinalizerParam): path = self._path bundle_finalizer.register(lambda: open(path, "w").close()) yield element if __name__ == "__main__": markers = tempfile.mkdtemp() at_input = os.path.join(markers, "at_stage_input") fused = os.path.join(markers, "after_fused_map") options = PipelineOptions(["--runner=PrismRunner", "--environment_type=LOOPBACK"]) with beam.Pipeline(options=options) as p: impulse = p | beam.Impulse() # Consumes the input of its stage: Prism finalizes the bundle. _ = impulse | "AtStageInput" >> beam.ParDo(MarkFinalization(at_input)) # Fused after a Map in the same stage: Prism never finalizes the bundle. _ = ( impulse | "Map" >> beam.Map(lambda x: x) | "AfterFusedMap" >> beam.ParDo(MarkFinalization(fused)) ) print("finalized:", sorted(os.listdir(markers))) assert os.path.exists(at_input), "the DoFn at the stage input was not finalized" assert os.path.exists(fused), "the DoFn fused after Map was not finalized" ``` Before the fix: `finalized: ['at_stage_input']`, and the second assertion fails. After the fix: `finalized: ['after_fused_map', 'at_stage_input']`. * [Prism] Test the finalization flag of a stage, and drop an unreachable error Move the check of requests_finalization into requestsFinalization. finalizeStage runs computeFacts first, which already rejects a ParDo payload that does not decode, so the error return of the earlier loop could not run. Add TestFinalizeStage_RequestsFinalization: the flag stays false when no transform asks for it, and is set when the transform at the stage input, a ParDo fused after another transform, or a fused splittable DoFn process stage asks for it. --- .../beam/runners/prism/internal/preprocess.go | 26 +++++++- .../runners/prism/internal/preprocess_test.go | 65 +++++++++++++++++++ sdks/go/test/integration/primitives/pardo.go | 15 +++++ .../test/integration/primitives/pardo_test.go | 5 ++ 4 files changed, 108 insertions(+), 3 deletions(-) diff --git a/sdks/go/pkg/beam/runners/prism/internal/preprocess.go b/sdks/go/pkg/beam/runners/prism/internal/preprocess.go index 3311bcced9f4..8f6000276a7f 100644 --- a/sdks/go/pkg/beam/runners/prism/internal/preprocess.go +++ b/sdks/go/pkg/beam/runners/prism/internal/preprocess.go @@ -446,6 +446,15 @@ func finalizeStage(stg *stage, comps *pipepb.Components, pipelineFacts *fusionFa transformSet[tid] = true } + // Any ParDo in the stage can register bundle finalization callbacks, not only the + // transforms that consume the stage's inputs, so every one of them must be checked. + for _, tid := range stg.transforms { + if requestsFinalization(comps.GetTransforms()[tid]) { + stg.finalize = true + break + } + } + // Now we can see which consumers (inputs) aren't covered by the producers (outputs). mainInputs := map[string]string{} var sideInputs []engine.LinkID @@ -468,9 +477,6 @@ func finalizeStage(stg *stage, comps *pipepb.Components, pipelineFacts *fusionFa if err := (proto.UnmarshalOptions{}).Unmarshal(t.GetSpec().GetPayload(), pardo); err != nil { return fmt.Errorf("unable to decode ParDoPayload for %v", link.Transform) } - if pardo.GetRequestsFinalization() { - stg.finalize = true - } if len(pardo.GetTimerFamilySpecs())+len(pardo.GetStateSpecs())+len(pardo.GetOnWindowExpirationTimerFamilySpec()) > 0 { stg.stateful = true } @@ -535,6 +541,20 @@ func finalizeStage(stg *stage, comps *pipepb.Components, pipelineFacts *fusionFa return nil } +// requestsFinalization reports whether t is a ParDo, or a stage of an expanded splittable +// DoFn, whose payload requests bundle finalization. computeFacts has already rejected +// undecodable ParDo payloads, so a payload that does not decode reports false. +func requestsFinalization(t *pipepb.PTransform) bool { + switch t.GetSpec().GetUrn() { + case urns.TransformParDo, urns.TransformProcessSizedElements, urns.TransformPairWithRestriction, urns.TransformSplitAndSize, urns.TransformTruncate: + pardo := &pipepb.ParDoPayload{} + err := (proto.UnmarshalOptions{}).Unmarshal(t.GetSpec().GetPayload(), pardo) + return err == nil && pardo.GetRequestsFinalization() + default: + return false + } +} + // greedyFusion produces a pipeline as tightly fused as possible. // // Fusion is a critical optimization for performance of pipeline execution. diff --git a/sdks/go/pkg/beam/runners/prism/internal/preprocess_test.go b/sdks/go/pkg/beam/runners/prism/internal/preprocess_test.go index 8baaa8538690..f4064e814b39 100644 --- a/sdks/go/pkg/beam/runners/prism/internal/preprocess_test.go +++ b/sdks/go/pkg/beam/runners/prism/internal/preprocess_test.go @@ -789,3 +789,68 @@ func TestComputeFacts(t *testing.T) { }) } } + +func TestFinalizeStage_RequestsFinalization(t *testing.T) { + pardo := func(urn string, finalize bool) *pipepb.FunctionSpec { + return &pipepb.FunctionSpec{ + Urn: urn, + Payload: protox.MustEncode(&pipepb.ParDoPayload{RequestsFinalization: finalize}), + } + } + // first consumes the stage input n0; second is fused after it and consumes n1. + stageOf := func(first, second *pipepb.FunctionSpec) *pipepb.Components { + return &pipepb.Components{ + Transforms: map[string]*pipepb.PTransform{ + "first": { + Spec: first, + Inputs: map[string]string{"i0": "n0"}, + Outputs: map[string]string{"o0": "n1"}, + }, + "second": { + Spec: second, + Inputs: map[string]string{"i0": "n1"}, + Outputs: map[string]string{"o0": "n2"}, + }, + }, + } + } + + tests := []struct { + name string + comps *pipepb.Components + wantFinalize bool + }{ + { + name: "no_transform_requests_finalization", + comps: stageOf(&pipepb.FunctionSpec{Urn: urns.TransformAssignWindows}, pardo(urns.TransformParDo, false)), + }, { + name: "transform_at_stage_input", + comps: stageOf(pardo(urns.TransformParDo, true), pardo(urns.TransformParDo, false)), + wantFinalize: true, + }, { + name: "pardo_fused_after_another_transform", + comps: stageOf(pardo(urns.TransformParDo, false), pardo(urns.TransformParDo, true)), + wantFinalize: true, + }, { + name: "sdf_process_fused_after_another_transform", + comps: stageOf(pardo(urns.TransformParDo, false), pardo(urns.TransformProcessSizedElements, true)), + wantFinalize: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + topological := []string{"first", "second"} + facts, err := computeFacts(topological, test.comps) + if err != nil { + t.Fatalf("computeFacts() = %v", err) + } + stg := &stage{transforms: topological} + if err := finalizeStage(stg, test.comps, facts); err != nil { + t.Fatalf("finalizeStage() = %v", err) + } + if stg.finalize != test.wantFinalize { + t.Errorf("stage.finalize = %v, want %v", stg.finalize, test.wantFinalize) + } + }) + } +} diff --git a/sdks/go/test/integration/primitives/pardo.go b/sdks/go/test/integration/primitives/pardo.go index dc59d8f67b80..a799f142745a 100644 --- a/sdks/go/test/integration/primitives/pardo.go +++ b/sdks/go/test/integration/primitives/pardo.go @@ -34,6 +34,7 @@ func init() { register.Function3x2(asymJoinFn) register.Function5x0(splitByName) register.Function2x0(emitPipelineOptions) + register.Function1x1(passBytesFn) register.DoFn2x0[beam.BundleFinalization, []byte]((*processElemBundleFinalizer)(nil)) register.DoFn2x0[beam.BundleFinalization, []byte]((*finalizerInFinishBundle)(nil)) register.DoFn2x0[beam.BundleFinalization, []byte]((*finalizerInAll)(nil)) @@ -213,6 +214,20 @@ func ParDoProcessElementBundleFinalizer(s beam.Scope) { beam.ParDo0(s, &processElemBundleFinalizer{}, imp) } +// ParDoBundleFinalizerAfterFusedParDo creates a beam.Pipeline with a beam.ParDo0 that processes +// a DoFn with a beam.BundleFinalization in its ProcessElement method, after another ParDo. +// The runner fuses both ParDos into one stage, so the DoFn that requests finalization does not +// consume the input of the stage. +func ParDoBundleFinalizerAfterFusedParDo(s beam.Scope) { + imp := beam.Impulse(s) + passed := beam.ParDo(s, passBytesFn, imp) + beam.ParDo0(s, &processElemBundleFinalizer{}, passed) +} + +func passBytesFn(b []byte) []byte { + return b +} + type processElemBundleFinalizer struct { } diff --git a/sdks/go/test/integration/primitives/pardo_test.go b/sdks/go/test/integration/primitives/pardo_test.go index aa6cb3de2008..1c169272b049 100644 --- a/sdks/go/test/integration/primitives/pardo_test.go +++ b/sdks/go/test/integration/primitives/pardo_test.go @@ -64,6 +64,11 @@ func TestParDoBundleFinalizer(t *testing.T) { pipelineFn: ParDoProcessElementBundleFinalizer, want: BundleFinalizerProcess, }, + { + name: "InProcessElementAfterFusedParDo", + pipelineFn: ParDoBundleFinalizerAfterFusedParDo, + want: BundleFinalizerProcess, + }, { name: "InFinishBundle", pipelineFn: ParDoFinishBundleFinalizer, From 8ef717637e9718e86e06ddcd14179b2b559c5df1 Mon Sep 17 00:00:00 2001 From: Jack McCluskey <34928439+jrmccluskey@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:55:20 -0400 Subject: [PATCH 023/108] Bump minimum cryptography bounds beyond known severe CVEs (#40591) --- sdks/python/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/python/setup.py b/sdks/python/setup.py index 24e9440361ca..08c5964898de 100644 --- a/sdks/python/setup.py +++ b/sdks/python/setup.py @@ -416,7 +416,7 @@ def get_portability_package_data(): }, ext_modules=extensions, install_requires=[ - 'cryptography>=39.0.0', + 'cryptography>=50.0.0', 'fastavro>=0.23.6,<2', 'fasteners>=0.3,<1.0', 'grpcio>=1.33.1,<2,!=1.48.0,!=1.59.*,!=1.60.*,!=1.61.*,!=1.62.0,!=1.62.1,!=1.66.*,!=1.67.*,!=1.68.*,!=1.69.*,!=1.70.*', # pylint: disable=line-too-long From 40df757ea39be36ac71beebf849a5a7997ae0ff9 Mon Sep 17 00:00:00 2001 From: Shahar Epstein <60007259+shahar1@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:09:18 +0300 Subject: [PATCH 024/108] Support attributing BigQuery quota and billing to a custom GCP project (Python, Java, Go) (#39203) * [Python] Support a custom quota project for BigQuery IO Add a quota_project_id parameter to ReadFromBigQuery and a --quota_project_id pipeline option (GoogleCloudOptions) that attribute BigQuery API quota and billing to a project other than the one the data resides in. The quota project is sent as the x-goog-user-project header on the apitools BigQuery client, and through client_options on the google-cloud-bigquery Client (streaming inserts) and the BigQuery Storage Read client. The shared pipeline credentials are left untouched instead of being copied with with_quota_project(), which for some credential types drops the cached token. The header is only added to requests to the BigQuery API hosts: the credentials refresh their token through the same Http, and the token endpoint, metadata server and IAM Credentials API must not receive it. It is honored by ReadFromBigQuery (EXPORT and DIRECT_READ, including temporary dataset cleanup), ReadAllFromBigQuery, WriteToBigQuery with FILE_LOADS or STREAMING_INSERTS, and ReadBigQueryChangeHistory. The value is resolved once at expand() time and passed to each DoFn so it reaches separately deserialized SDF copies. STORAGE_WRITE_API is Java-backed and not covered yet. Generated-By: Claude Opus 5.5 * [Java] Add bigQueryQuotaProjectId pipeline option to BigQueryIO Add BigQueryOptions.bigQueryQuotaProjectId to attribute BigQuery API quota and billing to a project other than the one the data resides in. For the REST client, the x-goog-user-project header is set by an execute interceptor, so it is re-applied on every attempt, including after HttpCredentialsAdapter re-initializes headers on a 401 refresh. The Storage Read and Write clients set it through setQuotaProjectId. Generated-By: Claude Opus 5.5 * [Go] Add WithQuotaProject option to bigqueryio Add a WithQuotaProject query option that attributes BigQuery API quota and billing to a project other than the one the data resides in, via option.WithQuotaProject on the BigQuery client. Read now accepts query options too. Only WithQuotaProject applies to it; UseStandardSQL and WithQueryParameters panic, because Read generates a legacy SQL statement without parameters. Generated-By: Claude Opus 5.5 * Add CHANGES.md entry for BigQuery quota project support Generated-By: Claude Opus 5.5 --- CHANGES.md | 2 +- sdks/go/pkg/beam/io/bigqueryio/bigquery.go | 54 ++- .../pkg/beam/io/bigqueryio/bigquery_test.go | 125 ++++++ .../sdk/io/gcp/bigquery/BigQueryOptions.java | 10 + .../io/gcp/bigquery/BigQueryServicesImpl.java | 23 +- .../bigquery/BigQueryServicesImplTest.java | 54 +++ sdks/python/apache_beam/io/gcp/bigquery.py | 121 ++++-- .../io/gcp/bigquery_change_history.py | 42 ++- .../io/gcp/bigquery_change_history_test.py | 75 ++++ .../apache_beam/io/gcp/bigquery_file_loads.py | 46 ++- .../io/gcp/bigquery_file_loads_test.py | 47 +++ .../io/gcp/bigquery_read_internal.py | 11 +- .../apache_beam/io/gcp/bigquery_test.py | 355 ++++++++++++++++++ .../apache_beam/io/gcp/bigquery_tools.py | 162 +++++++- .../apache_beam/io/gcp/bigquery_tools_test.py | 232 +++++++++++- .../apache_beam/options/pipeline_options.py | 11 + 16 files changed, 1282 insertions(+), 88 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d9ca6f0b83dd..c10b7262209c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -66,7 +66,7 @@ * Support for X source added (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). * (Java) FirestoreIO: added `RpcQosOptions.Builder.withRampupThrottlingDisabled()`, mirroring the existing DatastoreIO option, to opt out of the 500/50/5 write ramp-up ([#27170](https://github.com/apache/beam/issues/27170)). - +* Added support for attributing BigQuery API quota and billing to a specific GCP project (quota project): `quota_project_id` parameter in `ReadFromBigQuery` or `--quota_project_id` pipeline option, honored by the Python BigQuery IO transforms (`WriteToBigQuery` with `FILE_LOADS` or `STREAMING_INSERTS`; not yet `STORAGE_WRITE_API`, which is Java-backed), `--bigQueryQuotaProjectId` pipeline option (Java), and `bigqueryio.WithQuotaProject` read/query option (Go) ([#37431](https://github.com/apache/beam/issues/37431)). ## New Features / Improvements diff --git a/sdks/go/pkg/beam/io/bigqueryio/bigquery.go b/sdks/go/pkg/beam/io/bigqueryio/bigquery.go index bbe951969b92..98818ccf1af1 100644 --- a/sdks/go/pkg/beam/io/bigqueryio/bigquery.go +++ b/sdks/go/pkg/beam/io/bigqueryio/bigquery.go @@ -34,6 +34,7 @@ import ( bq "google.golang.org/api/bigquery/v2" "google.golang.org/api/googleapi" "google.golang.org/api/iterator" + "google.golang.org/api/option" ) // writeSizeLimit is the maximum number of rows allowed by BQ in a write. @@ -88,14 +89,22 @@ func NewQualifiedTableName(s string) (QualifiedTableName, error) { // Read reads all rows from the given table. The table must have a schema // compatible with the given type, t, and Read returns a PCollection. If the // table has more rows than t, then Read is implicitly a projection. -func Read(s beam.Scope, project, table string, t reflect.Type) beam.PCollection { +// Of the options, only WithQuotaProject applies to Read; UseStandardSQL and +// WithQueryParameters panic, because the generated statement is legacy SQL and +// takes no parameters. +func Read(s beam.Scope, project, table string, t reflect.Type, options ...func(*QueryOptions) error) beam.PCollection { mustParseTable(table) s = s.Scope("bigquery.Read") + qo := applyQueryOptions(options) + if qo.UseStandardSQL || len(qo.parameters) > 0 { + panic("bigqueryio.Read: UseStandardSQL and WithQueryParameters are only supported by Query") + } + stmt := constructSelectStatement(t, bigQueryTag, table) - return query(s, project, stmt, t) + return queryWithOptions(s, project, stmt, t, qo) } func constructSelectStatement(t reflect.Type, tagKey string, table string) string { @@ -114,6 +123,9 @@ func constructSelectStatement(t reflect.Type, tagKey string, table string) strin type QueryOptions struct { // UseStandardSQL enables BigQuery's Standard SQL dialect when executing a query. UseStandardSQL bool + // QuotaProject is the GCP project ID used for quota and billing attribution + // of the BigQuery API calls, if different from the project the data resides in. + QuotaProject string // Parameters are the query parameters for parameterized queries. // In the current implementation, user-defines types are not supported in Value field. // Use *bigquery.QueryParameterValue to build STRUCT/ARRAY parameters @@ -129,6 +141,17 @@ func UseStandardSQL() func(qo *QueryOptions) error { } } +// WithQuotaProject sets the GCP project ID used for quota and billing +// attribution of the BigQuery API calls, if different from the project the +// data resides in. The credentials used must have the +// serviceusage.services.use permission on that project. +func WithQuotaProject(project string) func(qo *QueryOptions) error { + return func(qo *QueryOptions) error { + qo.QuotaProject = project + return nil + } +} + // WithQueryParameters sets the query parameters for parameterized queries. // See QueryOptions.parameters for the list of supported Value types. func WithQueryParameters(params ...bigquery.QueryParameter) func(qo *QueryOptions) error { @@ -138,6 +161,15 @@ func WithQueryParameters(params ...bigquery.QueryParameter) func(qo *QueryOption } } +// clientOptions returns the BigQuery client options implied by qo. +func clientOptions(qo QueryOptions) []option.ClientOption { + var opts []option.ClientOption + if qo.QuotaProject != "" { + opts = append(opts, option.WithQuotaProject(qo.QuotaProject)) + } + return opts +} + // Query executes a query. The output must have a schema compatible with the given // type, t. It returns a PCollection. func Query(s beam.Scope, project, q string, t reflect.Type, options ...func(*QueryOptions) error) beam.PCollection { @@ -145,15 +177,22 @@ func Query(s beam.Scope, project, q string, t reflect.Type, options ...func(*Que return query(s, project, q, t, options...) } -func query(s beam.Scope, project, query string, t reflect.Type, options ...func(*QueryOptions) error) beam.PCollection { - mustInferSchema(t) - +func applyQueryOptions(options []func(*QueryOptions) error) QueryOptions { queryOptions := QueryOptions{} for _, opt := range options { if err := opt(&queryOptions); err != nil { panic(err) } } + return queryOptions +} + +func query(s beam.Scope, project, query string, t reflect.Type, options ...func(*QueryOptions) error) beam.PCollection { + return queryWithOptions(s, project, query, t, applyQueryOptions(options)) +} + +func queryWithOptions(s beam.Scope, project, query string, t reflect.Type, queryOptions QueryOptions) beam.PCollection { + mustInferSchema(t) imp := beam.Impulse(s) queryParameters, err := encodeQueryParameters(queryOptions.parameters) @@ -181,8 +220,11 @@ type queryFn struct { Options QueryOptions `json:"options"` } +// newClient creates the BigQuery client used by queryFn. Tests replace it. +var newClient = bigquery.NewClient + func (f *queryFn) ProcessElement(ctx context.Context, _ []byte, emit func(beam.X)) error { - client, err := bigquery.NewClient(ctx, f.Project) + client, err := newClient(ctx, f.Project, clientOptions(f.Options)...) if err != nil { return err } diff --git a/sdks/go/pkg/beam/io/bigqueryio/bigquery_test.go b/sdks/go/pkg/beam/io/bigqueryio/bigquery_test.go index 9f83eeb847f7..4cf0c46b9f62 100644 --- a/sdks/go/pkg/beam/io/bigqueryio/bigquery_test.go +++ b/sdks/go/pkg/beam/io/bigqueryio/bigquery_test.go @@ -16,11 +16,16 @@ package bigqueryio import ( + "context" + "errors" + "fmt" "reflect" + "strings" "testing" "cloud.google.com/go/bigquery" "github.com/apache/beam/sdks/v2/go/pkg/beam" + "google.golang.org/api/option" ) func TestNewQualifiedTableName(t *testing.T) { @@ -149,6 +154,88 @@ func Test_mustInferSchema(t *testing.T) { } } +func TestWithQuotaProject(t *testing.T) { + qo := QueryOptions{} + if err := WithQuotaProject("quota-project")(&qo); err != nil { + t.Fatalf("WithQuotaProject() returned err: %v", err) + } + if got, want := qo.QuotaProject, "quota-project"; got != want { + t.Errorf("qo.QuotaProject = %q, want %q", got, want) + } +} + +func TestClientOptions(t *testing.T) { + if got := clientOptions(QueryOptions{}); len(got) != 0 { + t.Errorf("clientOptions(no quota) = %v, want none", got) + } + got := clientOptions(QueryOptions{QuotaProject: "quota-project"}) + if want := []option.ClientOption{option.WithQuotaProject("quota-project")}; !reflect.DeepEqual(got, want) { + t.Errorf("clientOptions(with quota) = %v, want %v", got, want) + } +} + +func TestQueryFn_passesQuotaProjectToClient(t *testing.T) { + var gotProject string + var gotOpts []option.ClientOption + errStop := errors.New("stop") + defer func(orig func(context.Context, string, ...option.ClientOption) (*bigquery.Client, error)) { + newClient = orig + }(newClient) + newClient = func(_ context.Context, project string, opts ...option.ClientOption) (*bigquery.Client, error) { + gotProject, gotOpts = project, opts + return nil, errStop + } + + fn := &queryFn{Project: "project", Options: QueryOptions{QuotaProject: "quota-project"}} + if err := fn.ProcessElement(context.Background(), nil, nil); !errors.Is(err, errStop) { + t.Fatalf("ProcessElement() err = %v, want %v", err, errStop) + } + if gotProject != "project" { + t.Errorf("client project = %q, want %q", gotProject, "project") + } + if want := []option.ClientOption{option.WithQuotaProject("quota-project")}; !reflect.DeepEqual(gotOpts, want) { + t.Errorf("client options = %v, want %v", gotOpts, want) + } +} + +// queryFnOf returns the queryFn in pipeline p. +func queryFnOf(t *testing.T, p *beam.Pipeline) *queryFn { + t.Helper() + edges, _, err := p.Build() + if err != nil { + t.Fatalf("Build() failed: %v", err) + } + for _, e := range edges { + if e.DoFn != nil { + if fn, ok := e.DoFn.Recv.(*queryFn); ok { + return fn + } + } + } + t.Fatal("no queryFn in pipeline") + return nil +} + +func TestReadAndQuery_passQuotaProjectToQueryFn(t *testing.T) { + type quotaSchema struct { + Name bigquery.NullString `bigquery:"name"` + } + beam.RegisterType(reflect.TypeOf((*quotaSchema)(nil)).Elem()) + typ := reflect.TypeOf(quotaSchema{}) + + p, s := beam.NewPipelineWithRoot() + Read(s, "project", "project:dataset.table", typ, WithQuotaProject("quota-project")) + if got := queryFnOf(t, p).Options.QuotaProject; got != "quota-project" { + t.Errorf("Read: queryFn.Options.QuotaProject = %q, want %q", got, "quota-project") + } + + p, s = beam.NewPipelineWithRoot() + Query(s, "project", "SELECT 1", typ, WithQuotaProject("quota-project")) + if got := queryFnOf(t, p).Options.QuotaProject; got != "quota-project" { + t.Errorf("Query: queryFn.Options.QuotaProject = %q, want %q", got, "quota-project") + } +} + func TestWithQueryParameters(t *testing.T) { t.Run("WithQueryParameters sets parameters correctly", func(t *testing.T) { params := []bigquery.QueryParameter{ @@ -183,3 +270,41 @@ func TestWithQueryParameters(t *testing.T) { } }) } + +func TestRead_queryOnlyOptions(t *testing.T) { + type readSchema struct { + Name bigquery.NullString `bigquery:"name"` + } + beam.RegisterType(reflect.TypeOf((*readSchema)(nil)).Elem()) + typ := reflect.TypeOf(readSchema{}) + + mustPanic := func(t *testing.T, opt func(*QueryOptions) error) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Errorf("Read() with query-only option does not panic") + } else if !strings.Contains(fmt.Sprint(r), "only supported by Query") { + t.Errorf("Read() panicked with unexpected value: %v", r) + } + }() + _, s := beam.NewPipelineWithRoot() + Read(s, "project", "project:dataset.table", typ, opt) + } + + t.Run("UseStandardSQL panics", func(t *testing.T) { + mustPanic(t, UseStandardSQL()) + }) + t.Run("WithQueryParameters panics", func(t *testing.T) { + mustPanic(t, WithQueryParameters(bigquery.QueryParameter{Name: "p", Value: 1})) + }) + t.Run("WithQuotaProject does not panic", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Read() with WithQuotaProject panicked: %v", r) + } + }() + _, s := beam.NewPipelineWithRoot() + Read(s, "project", "project:dataset.table", typ, WithQuotaProject("quota-project")) + }) +} diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryOptions.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryOptions.java index 1826f4a1fd21..64382d64bb51 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryOptions.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryOptions.java @@ -197,6 +197,16 @@ public interface BigQueryOptions void setBigQueryProject(String value); + @Description( + "GCP project ID used for quota and billing attribution of BigQuery API requests " + + "(sets the X-Goog-User-Project header), if different from the project the data " + + "resides in. If unspecified, the project associated with the credentials is used. " + + "The credentials used must have the serviceusage.services.use permission on this " + + "project.") + String getBigQueryQuotaProjectId(); + + void setBigQueryQuotaProjectId(String value); + @Description("Maximum (best effort) size of a single append to the storage API.") @Default.Integer(2 * 1024 * 1024) Integer getStorageApiAppendThresholdBytes(); diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java index 7a924c6aa021..a921be3700ec 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java @@ -1800,7 +1800,8 @@ private static boolean nextBackOff(Sleeper sleeper, BackOff backoff) throws Inte } /** Returns a BigQuery client builder using the specified {@link BigQueryOptions}. */ - private static Bigquery.Builder newBigQueryClient(BigQueryOptions options) { + @VisibleForTesting + static Bigquery.Builder newBigQueryClient(BigQueryOptions options) { // Do not log 404. It clutters the output and is possibly even required by the // caller. RetryHttpRequestInitializer httpRequestInitializer = @@ -1818,6 +1819,18 @@ private static Bigquery.Builder newBigQueryClient(BigQueryOptions options) { initBuilder.add(new LatencyRecordingHttpRequestInitializer(API_METRIC_LABEL)); initBuilder.add(httpRequestInitializer); + // Set the quota project as a request header instead of deriving credentials: a derived + // credential inherits the shared credential's cached request metadata, which lacks + // x-goog-user-project until the next token refresh. Applied by an execute interceptor so it + // is re-applied on every attempt; HttpCredentialsAdapter re-initializes the request headers + // from the credential after a 401 refresh, which would otherwise override it. + @Nullable String quotaProjectId = options.getBigQueryQuotaProjectId(); + if (!Strings.isNullOrEmpty(quotaProjectId)) { + initBuilder.add( + request -> + request.setInterceptor( + r -> r.getHeaders().set("x-goog-user-project", quotaProjectId))); + } HttpRequestInitializer chainInitializer = new ChainingHttpRequestInitializer( Iterables.toArray(initBuilder.build(), HttpRequestInitializer.class)); @@ -1848,6 +1861,10 @@ private static BigQueryWriteClient newBigQueryWriteClient(BigQueryOptions option if (!Strings.isNullOrEmpty(endpoint)) { builder.setEndpoint(trimSchemaIfNecessary(endpoint)); } + @Nullable String quotaProjectId = options.getBigQueryQuotaProjectId(); + if (!Strings.isNullOrEmpty(quotaProjectId)) { + builder.setQuotaProjectId(quotaProjectId); + } return BigQueryWriteClient.create( builder .setCredentialsProvider(() -> options.as(GcpOptions.class).getGcpCredential()) @@ -1973,6 +1990,10 @@ public void onRetryAttempt(Status status, Metadata metadata) { if (!Strings.isNullOrEmpty(endpoint)) { settingsBuilder.setEndpoint(trimSchemaIfNecessary(endpoint)); } + @Nullable String quotaProjectId = options.getBigQueryQuotaProjectId(); + if (!Strings.isNullOrEmpty(quotaProjectId)) { + settingsBuilder.setQuotaProjectId(quotaProjectId); + } UnaryCallSettings.Builder createReadSessionSettings = settingsBuilder.getStubSettingsBuilder().createReadSessionSettings(); diff --git a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java index 90b2e1fb8723..642d35b43905 100644 --- a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java +++ b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java @@ -41,6 +41,8 @@ import com.google.api.client.googleapis.json.GoogleJsonError.ErrorInfo; import com.google.api.client.googleapis.json.GoogleJsonErrorContainer; import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.LowLevelHttpResponse; import com.google.api.client.json.GenericJson; @@ -2311,6 +2313,58 @@ public RetryInfo parseBytes(byte[] serialized) { assertEquals(123456, (long) container.getCounter(metricName).getCumulative()); } + @Test + public void testQuotaProjectIdOverrides() throws IOException { + BigQueryOptions options = PipelineOptionsFactory.create().as(BigQueryOptions.class); + options.setBigQueryQuotaProjectId("my-quota-project"); + + assertEquals( + "my-quota-project", + new BigQueryServicesImpl.StorageClientImpl(options) + .getClient() + .getSettings() + .getQuotaProjectId()); + assertEquals( + "my-quota-project", + new BigQueryServicesImpl.WriteStreamServiceImpl(options) + .getClient() + .getSettings() + .getQuotaProjectId()); + } + + @Test + public void testQuotaProjectIdSetsRequestHeader() throws IOException { + BigQueryOptions options = PipelineOptionsFactory.create().as(BigQueryOptions.class); + options.setGcpCredential(null); + GenericUrl url = new GenericUrl("https://bigquery.googleapis.com/bigquery/v2/projects"); + + HttpRequest request = + BigQueryServicesImpl.newBigQueryClient(options) + .build() + .getRequestFactory() + .buildGetRequest(url); + request.getInterceptor().intercept(request); + assertNull(request.getHeaders().getFirstHeaderStringValue("x-goog-user-project")); + + options.setBigQueryQuotaProjectId("my-quota-project"); + request = + BigQueryServicesImpl.newBigQueryClient(options) + .build() + .getRequestFactory() + .buildGetRequest(url); + // The interceptor runs before every attempt, including retries. + request.getInterceptor().intercept(request); + assertEquals( + "my-quota-project", request.getHeaders().getFirstHeaderStringValue("x-goog-user-project")); + + // Simulate HttpCredentialsAdapter re-initializing headers from a credential that carries + // its own quota project after a 401 refresh: the configured value must win on the retry. + request.getHeaders().set("x-goog-user-project", "credential-project"); + request.getInterceptor().intercept(request); + assertEquals( + "my-quota-project", request.getHeaders().getFirstHeaderStringValue("x-goog-user-project")); + } + @Test public void testEndpointOverrides() throws IOException { BigQueryOptions options = PipelineOptionsFactory.create().as(BigQueryOptions.class); diff --git a/sdks/python/apache_beam/io/gcp/bigquery.py b/sdks/python/apache_beam/io/gcp/bigquery.py index 2a52b31edf7f..f3d64bfcfa6c 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery.py +++ b/sdks/python/apache_beam/io/gcp/bigquery.py @@ -720,7 +720,8 @@ def __init__( step_name=None, unique_id=None, temp_dataset=None, - query_priority=BigQueryQueryPriority.BATCH): + query_priority=BigQueryQueryPriority.BATCH, + quota_project_id=None): if table is not None and query is not None: raise ValueError( 'Both a BigQuery table and a query were specified.' @@ -754,6 +755,7 @@ def __init__( self.use_json_exports = use_json_exports self.temp_dataset = temp_dataset self.query_priority = query_priority + self.quota_project_id = quota_project_id self._job_name = job_name or 'BQ_EXPORT_JOB' self._step_name = step_name self._source_uuid = unique_id @@ -773,12 +775,15 @@ def display_data(self): 'use_legacy_sql': self.use_legacy_sql, 'bigquery_job_labels': json.dumps(self.bigquery_job_labels), 'export_file_format': export_format, + 'quota_project_id': DisplayDataItem( + self._get_quota_project_id(), + label='BigQuery Quota Project').drop_if_none(), 'launchesBigQueryJobs': DisplayDataItem( True, label="This Dataflow job launches bigquery jobs."), } def estimate_size(self): - bq = bigquery_tools.BigQueryWrapper.from_pipeline_options(self.options) + bq = self._create_bq_wrapper() if self.table_reference is not None: table_ref = self.table_reference if (isinstance(self.table_reference, vp.ValueProvider) and @@ -844,6 +849,23 @@ def _get_project(self): project = self.project return project + def _get_quota_project_id(self): + """Returns the explicit quota project ID, else the pipeline option.""" + quota_project_id = bigquery_tools._quota_project_id_from_options( + self.options) + return self.quota_project_id or quota_project_id + + def _create_bq_wrapper(self, **kwargs): + """Creates a BigQueryWrapper for the API calls made by this source. + + Every BigQuery client this source uses is built here, so that quota + attribution cannot be forgotten at an individual call site. + """ + return bigquery_tools.BigQueryWrapper( + pipeline_options=self.options, + quota_project_id=self.quota_project_id, + **kwargs) + def _create_source(self, path, coder): if not self.use_json_exports: return create_avro_source(path, validate=self.validate) @@ -858,10 +880,9 @@ def _create_source(self, path, coder): def split(self, desired_bundle_size, start_position=None, stop_position=None): if self.export_result is None: - bq = bigquery_tools.BigQueryWrapper( + bq = self._create_bq_wrapper( temp_dataset_id=( - self.temp_dataset.datasetId if self.temp_dataset else None), - client=bigquery_tools.BigQueryWrapper._bigquery_client(self.options)) + self.temp_dataset.datasetId if self.temp_dataset else None)) if self.query is not None: self._setup_temporary_dataset(bq) @@ -1051,7 +1072,8 @@ def __init__( temp_dataset: Optional[DatasetReference] = None, temp_table: Optional[TableReference] = None, use_native_datetime: Optional[bool] = False, - timeout: Optional[float] = None): + timeout: Optional[float] = None, + quota_project_id: Optional[str] = None): if table is not None and query is not None: raise ValueError( @@ -1090,6 +1112,7 @@ def __init__( self._job_name = job_name or 'BQ_DIRECT_READ_JOB' self._step_name = step_name self._source_uuid = unique_id + self.quota_project_id = quota_project_id def _get_project(self): """Returns the project that queries and exports will be billed to.""" @@ -1101,6 +1124,23 @@ def _get_project(self): return project return self.project + def _get_quota_project_id(self): + """Returns the explicit quota project ID, else the pipeline option.""" + quota_project_id = bigquery_tools._quota_project_id_from_options( + self.pipeline_options) + return self.quota_project_id or quota_project_id + + def _create_bq_wrapper(self, **kwargs): + """Creates a BigQueryWrapper for the API calls made by this source. + + Every BigQuery client this source uses is built here, so that quota + attribution cannot be forgotten at an individual call site. + """ + return bigquery_tools.BigQueryWrapper( + pipeline_options=self.pipeline_options, + quota_project_id=self.quota_project_id, + **kwargs) + def _get_parent_project(self): """Returns the project that will be billed.""" if self.temp_table: @@ -1188,14 +1228,16 @@ def display_data(self): 'use_native_datetime': self.use_native_datetime, 'selected_fields': str(self.selected_fields), 'row_restriction': str(self.row_restriction), + 'quota_project_id': DisplayDataItem( + self._get_quota_project_id(), + label='BigQuery Quota Project').drop_if_none(), 'launchesBigQueryJobs': DisplayDataItem( True, label="This Dataflow job launches bigquery jobs."), } def estimate_size(self): # Returns the pre-filtering size of the (temporary) table being read. - bq = bigquery_tools.BigQueryWrapper.from_pipeline_options( - self.pipeline_options) + bq = self._create_bq_wrapper() if self.table_reference is not None: table_ref = self.table_reference if (isinstance(self.table_reference, vp.ValueProvider) and @@ -1242,10 +1284,8 @@ def estimate_size(self): def split(self, desired_bundle_size, start_position=None, stop_position=None): if self.split_result is None: - bq = bigquery_tools.BigQueryWrapper( - temp_table_ref=(self.temp_table if self.temp_table else None), - client=bigquery_tools.BigQueryWrapper._bigquery_client( - self.pipeline_options)) + bq = self._create_bq_wrapper( + temp_table_ref=(self.temp_table if self.temp_table else None)) if self.query is not None: self._setup_temporary_dataset(bq) @@ -1278,7 +1318,8 @@ def split(self, desired_bundle_size, start_position=None, stop_position=None): if self.row_restriction is not None: requested_session.read_options.row_restriction = self.row_restriction - storage_client = bq_storage.BigQueryReadClient() + storage_client = bigquery_tools.create_bq_storage_client( + self._get_quota_project_id()) stream_count = self._get_stream_count(bq, desired_bundle_size) parent = 'projects/{}'.format(self.table_reference.projectId) @@ -1302,8 +1343,10 @@ def split(self, desired_bundle_size, start_position=None, stop_position=None): self.split_result = [ _CustomBigQueryStorageStreamSource( - stream.name, self.use_native_datetime, self.timeout) - for stream in read_session.streams + stream.name, + self.use_native_datetime, + self.timeout, + self._get_quota_project_id()) for stream in read_session.streams ] for source in self.split_result: @@ -1337,10 +1380,12 @@ def __init__( self, read_stream_name: str, use_native_datetime: Optional[bool] = True, - timeout: Optional[float] = None): + timeout: Optional[float] = None, + quota_project_id: Optional[str] = None): self.read_stream_name = read_stream_name self.use_native_datetime = use_native_datetime self.timeout = timeout + self.quota_project_id = quota_project_id def display_data(self): return { @@ -1363,7 +1408,10 @@ def split(self, desired_bundle_size, start_position=None, stop_position=None): return SourceBundle( weight=1.0, source=_CustomBigQueryStorageStreamSource( - self.read_stream_name, self.use_native_datetime), + self.read_stream_name, + self.use_native_datetime, + self.timeout, + self.quota_project_id), start_position=None, stop_position=None) @@ -1399,7 +1447,8 @@ def retry_delay_callback(delay): def read_arrow(self): - storage_client = bq_storage.BigQueryReadClient() + storage_client = bigquery_tools.create_bq_storage_client( + self.quota_project_id) read_rows_kwargs = {'retry_delay_callback': self.retry_delay_callback} if self.timeout is not None: read_rows_kwargs['timeout'] = self.timeout @@ -1418,7 +1467,8 @@ def read_arrow(self): yield py_row def read_avro(self): - storage_client = bq_storage.BigQueryReadClient() + storage_client = bigquery_tools.create_bq_storage_client( + self.quota_project_id) read_rows_kwargs = {'retry_delay_callback': self.retry_delay_callback} if self.timeout is not None: read_rows_kwargs['timeout'] = self.timeout @@ -1499,7 +1549,8 @@ def __init__( with_batched_input=False, ignore_unknown_columns=False, max_retries=MAX_INSERT_RETRIES, - max_insert_payload_size=MAX_INSERT_PAYLOAD_SIZE): + max_insert_payload_size=MAX_INSERT_PAYLOAD_SIZE, + quota_project_id=None): """Initialize a WriteToBigQuery transform. Args: @@ -1552,9 +1603,12 @@ def __init__( backoffs (effectively retry forever). max_insert_payload_size: The maximum byte size for a BigQuery legacy streaming insert payload. + quota_project_id: Optional GCP project ID to use for quota and billing of + BigQuery API requests. """ self.schema = schema self.test_client = test_client + self.quota_project_id = quota_project_id self.create_disposition = create_disposition self.write_disposition = write_disposition if write_disposition in (BigQueryDisposition.WRITE_EMPTY, @@ -1633,7 +1687,7 @@ def start_bundle(self): if not self.bigquery_wrapper: self.bigquery_wrapper = bigquery_tools.BigQueryWrapper( - client=self.test_client) + client=self.test_client, quota_project_id=self.quota_project_id) ( bigquery_tools.BigQueryWrapper.HISTOGRAM_METRIC_LOGGER. @@ -1958,6 +2012,8 @@ def process(self, element): yield (key, (value, insert_id)) def expand(self, input): + quota_project_id = bigquery_tools._quota_project_id_from_options( + input.pipeline.options) bigquery_write_fn = BigQueryWriteFn( schema=self.schema, batch_size=self.batch_size, @@ -1971,7 +2027,8 @@ def expand(self, input): ignore_unknown_columns=self.ignore_unknown_columns, with_batched_input=self.with_auto_sharding, max_retries=self._max_retries, - max_insert_payload_size=self._max_insert_payload_size) + max_insert_payload_size=self._max_insert_payload_size, + quota_project_id=quota_project_id) def _add_random_shard(element): key = element[0] @@ -3043,6 +3100,12 @@ class ReadFromBigQuery(PTransform): PCollection with a schema and yielding Beam Rows via the option `BEAM_ROW`. For more information on schemas, see https://beam.apache.org/documentation/programming-guide/#what-is-a-schema) + quota_project_id (str): The GCP project ID to use for quota and billing + of BigQuery API requests issued by this transform, if different from + the project the data resides in. Falls back to the + ``--quota_project_id`` pipeline option if not set. The credentials + used must have the ``serviceusage.services.use`` permission on that + project. query_output_schema: Required when output_type is 'BEAM_ROW' and a query is specified. A BigQuery schema describing the query result columns, since the schema cannot be auto-derived from an existing table when @@ -3143,10 +3206,12 @@ def _expand_output_type(self, output_pcollection): '%s: table must be of type string' '; got a callable instead' % self.__class__.__name__) return output_pcollection | bigquery_schema_tools.convert_to_usertype( - bigquery_tools.BigQueryWrapper().get_table( - project_id=table_details.projectId, - dataset_id=table_details.datasetId, - table_id=table_details.tableId).schema, + bigquery_tools.BigQueryWrapper( + pipeline_options=output_pcollection.pipeline.options, + quota_project_id=self._kwargs.get('quota_project_id')).get_table( + project_id=table_details.projectId, + dataset_id=table_details.datasetId, + table_id=table_details.tableId).schema, self._kwargs.get('selected_fields', None)) else: raise ValueError( @@ -3228,7 +3293,9 @@ def _get_pipeline_details(unused_elm): bigquery_dataset_labels=self.bigquery_dataset_labels, *self._args, **self._kwargs)) - | _PassThroughThenCleanupTempDatasets(project_to_cleanup_pcoll)) + | _PassThroughThenCleanupTempDatasets( + project_to_cleanup_pcoll, + quota_project_id=self._kwargs.get('quota_project_id'))) class ReadFromBigQueryRequest: diff --git a/sdks/python/apache_beam/io/gcp/bigquery_change_history.py b/sdks/python/apache_beam/io/gcp/bigquery_change_history.py index 1e90c2974778..9f836190d9ca 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_change_history.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_change_history.py @@ -416,7 +416,8 @@ def __init__( start_time: Timestamp, stop_time: Timestamp, poll_interval: Duration, - location: Optional[str] = None) -> None: + location: Optional[str] = None, + quota_project_id: Optional[str] = None) -> None: self._table = table self._project = project self._change_function = change_function @@ -425,9 +426,11 @@ def __init__( self._stop_time = stop_time self._poll_interval = poll_interval self._location = location + self._quota_project_id = quota_project_id def setup(self) -> None: - self._bq_wrapper = bigquery_tools.BigQueryWrapper() + self._bq_wrapper = bigquery_tools.BigQueryWrapper( + quota_project_id=self._quota_project_id) if self._location is None: table_ref = bigquery_tools.parse_table_reference( self._table, project=self._project) @@ -570,7 +573,8 @@ def __init__( change_type_column: str = 'change_type', change_timestamp_column: str = 'change_timestamp', columns: Optional[list[str]] = None, - row_filter: Optional[str] = None) -> None: + row_filter: Optional[str] = None, + quota_project_id: Optional[str] = None) -> None: self._table = table self._project = project self._change_function = change_function @@ -580,9 +584,11 @@ def __init__( self._change_timestamp_column = change_timestamp_column self._columns = columns self._row_filter = row_filter + self._quota_project_id = quota_project_id def setup(self) -> None: - self._bq_wrapper = bigquery_tools.BigQueryWrapper() + self._bq_wrapper = bigquery_tools.BigQueryWrapper( + quota_project_id=self._quota_project_id) if self._location is None: table_ref = bigquery_tools.parse_table_reference( self._table, project=self._project) @@ -711,11 +717,13 @@ def __init__( batch_arrow_read: bool = True, change_timestamp_column: str = 'change_timestamp', max_split_rounds: int = 1, - emit_raw_batches: bool = False) -> None: + emit_raw_batches: bool = False, + quota_project_id: Optional[str] = None) -> None: self._batch_arrow_read = batch_arrow_read self._change_timestamp_column = change_timestamp_column self._max_split_rounds = max_split_rounds self._emit_raw_batches = emit_raw_batches + self._quota_project_id = quota_project_id self._storage_client = None def _ensure_client(self) -> None: @@ -727,7 +735,8 @@ def _ensure_client(self) -> None: """ if self._storage_client is None: _LOGGER.info('[Read] creating BigQueryReadClient') - self._storage_client = bq_storage.BigQueryReadClient() + self._storage_client = bigquery_tools.create_bq_storage_client( + self._quota_project_id) def setup(self) -> None: self._ensure_client() @@ -1072,9 +1081,13 @@ class _CleanupTempTablesFn(beam.DoFn): STREAMS_READ = beam.transforms.userstate.CombiningValueStateSpec( 'streams_read', sum) + def __init__(self, quota_project_id: Optional[str] = None) -> None: + self._quota_project_id = quota_project_id + def setup(self) -> None: _LOGGER.info('[Cleanup] setup: creating BigQueryWrapper') - self._bq_wrapper = bigquery_tools.BigQueryWrapper() + self._bq_wrapper = bigquery_tools.BigQueryWrapper( + quota_project_id=self._quota_project_id) def process( self, @@ -1247,6 +1260,9 @@ def expand(self, pbegin: beam.pvalue.PBegin) -> beam.PCollection: 'project must be specified either in ReadBigQueryChangeHistory ' 'or in pipeline options (--project)') + quota_project_id = bigquery_tools._quota_project_id_from_options( + pbegin.pipeline.options) + start_time = Timestamp(self._start_time or time.time()) stop_time = ( Timestamp(self._stop_time) @@ -1290,7 +1306,8 @@ def expand(self, pbegin: beam.pvalue.PBegin) -> beam.PCollection: start_time=start_time, stop_time=stop_time, poll_interval=poll_interval, - location=self._location))) + location=self._location, + quota_project_id=quota_project_id))) # CommitQueryResults: Reshuffle commits _QueryResult (temp table ref) # so that if the Read SDF retries, it re-reads the existing temp table @@ -1311,7 +1328,8 @@ def expand(self, pbegin: beam.pvalue.PBegin) -> beam.PCollection: change_type_column=self._change_type_column, change_timestamp_column=self._change_timestamp_column, columns=self._columns, - row_filter=self._row_filter)) + row_filter=self._row_filter, + quota_project_id=quota_project_id)) | 'CommitQueryResults' >> beam.Reshuffle()) emit_raw = self._reshuffle_decompress @@ -1321,7 +1339,8 @@ def expand(self, pbegin: beam.pvalue.PBegin) -> beam.PCollection: batch_arrow_read=self._batch_arrow_read, change_timestamp_column=self._change_timestamp_column, max_split_rounds=self._max_split_rounds, - emit_raw_batches=emit_raw)) + emit_raw_batches=emit_raw, + quota_project_id=quota_project_id)) if emit_raw: read_sdf = read_sdf.with_output_types(tuple[bytes, bytes]) else: @@ -1334,7 +1353,8 @@ def expand(self, pbegin: beam.pvalue.PBegin) -> beam.PCollection: _ = ( read_outputs[_CLEANUP_TAG] - | 'CleanupTempTables' >> beam.ParDo(_CleanupTempTablesFn())) + | 'CleanupTempTables' >> beam.ParDo( + _CleanupTempTablesFn(quota_project_id=quota_project_id))) if emit_raw: # Reshuffle raw Arrow batches for fan-out, then decompress and diff --git a/sdks/python/apache_beam/io/gcp/bigquery_change_history_test.py b/sdks/python/apache_beam/io/gcp/bigquery_change_history_test.py index 11b467f26d49..e1b1eb69ae38 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_change_history_test.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_change_history_test.py @@ -23,12 +23,18 @@ - ReadBigQueryChangeHistory validation """ +import contextlib import datetime import unittest +from unittest import mock +from apache_beam.io.gcp import bigquery_change_history as bqch +from apache_beam.io.gcp import bigquery_tools from apache_beam.io.gcp.bigquery_change_history import ReadBigQueryChangeHistory from apache_beam.io.gcp.bigquery_change_history import build_changes_query from apache_beam.io.gcp.bigquery_change_history import compute_ranges +from apache_beam.options.pipeline_options import PipelineOptions +from apache_beam.testing.test_pipeline import TestPipeline from apache_beam.utils.timestamp import Duration from apache_beam.utils.timestamp import Timestamp @@ -205,5 +211,74 @@ def test_default_buffer(self): self.assertEqual(t._buffer_sec, 10) +@unittest.skipIf(HttpError is None, 'GCP dependencies are not installed') +class QuotaProjectTest(unittest.TestCase): + """Tests that the quota project reaches every BigQuery client.""" + _QUOTA = 'my-billing-project' + + def _assert_wrapper_quota(self, fn): + with mock.patch.object(bigquery_tools, 'BigQueryWrapper') as mock_wrapper: + fn.setup() + self.assertEqual( + mock_wrapper.call_args.kwargs['quota_project_id'], self._QUOTA) + + def test_poll_fn_setup_passes_quota_project(self): + fn = bqch._PollChangeHistoryFn( + table='proj:dataset.table', + project='p', + change_function='APPENDS', + buffer=Duration(seconds=10), + start_time=Timestamp(0), + stop_time=Timestamp(100), + poll_interval=Duration(seconds=60), + quota_project_id=self._QUOTA) + self._assert_wrapper_quota(fn) + + def test_execute_query_fn_setup_passes_quota_project(self): + fn = bqch._ExecuteQueryFn( + table='proj:dataset.table', + project='p', + change_function='APPENDS', + temp_dataset='tmp', + location='US', + quota_project_id=self._QUOTA) + self._assert_wrapper_quota(fn) + + def test_cleanup_fn_setup_passes_quota_project(self): + self._assert_wrapper_quota(bqch._CleanupTempTablesFn(self._QUOTA)) + + def test_read_sdf_creates_storage_client_with_quota_project(self): + fn = bqch._ReadStorageStreamsSDF(quota_project_id=self._QUOTA) + with mock.patch.object(bigquery_tools, + 'create_bq_storage_client') as mock_create: + fn.setup() + mock_create.assert_called_once_with(self._QUOTA) + + def test_expand_resolves_quota_project_from_options(self): + options = PipelineOptions( + ['--quota_project_id=' + self._QUOTA, '--no_pipeline_type_check']) + classes = [ + '_PollChangeHistoryFn', + '_ExecuteQueryFn', + '_ReadStorageStreamsSDF', + '_CleanupTempTablesFn' + ] + p = TestPipeline(options=options) + with contextlib.ExitStack() as stack: + mocks = [ + stack.enter_context( + mock.patch.object( + getattr(bqch, name), + '__init__', + autospec=True, + side_effect=getattr(bqch, name).__init__)) for name in classes + ] + _ = p | ReadBigQueryChangeHistory( + table='p:d.t', project='p', start_time=0, temp_dataset='tmp') + for name, m in zip(classes, mocks): + self.assertEqual( + m.call_args.kwargs['quota_project_id'], self._QUOTA, name) + + if __name__ == '__main__': unittest.main() diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py index dabe80c5c27e..79940d109f07 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py @@ -383,16 +383,19 @@ def __init__( test_client=None, additional_bq_parameters=None, step_name=None, - load_job_project_id=None): + load_job_project_id=None, + quota_project_id=None): self.project = project self._test_client = test_client + self._quota_project_id = quota_project_id self._write_disposition = write_disposition self._additional_bq_parameters = additional_bq_parameters or {} self._step_name = step_name self._load_job_project_id = load_job_project_id def start_bundle(self): - self.bq_wrapper = bigquery_tools.BigQueryWrapper(client=self._test_client) + self.bq_wrapper = bigquery_tools.BigQueryWrapper( + client=self._test_client, quota_project_id=self._quota_project_id) self._bq_io_metadata = create_bigquery_io_metadata(self._step_name) self.pending_jobs = [] @@ -525,11 +528,13 @@ def __init__( write_disposition=None, test_client=None, step_name=None, - load_job_project_id=None): + load_job_project_id=None, + quota_project_id=None): self.project = project self.create_disposition = create_disposition self.write_disposition = write_disposition self.test_client = test_client + self.quota_project_id = quota_project_id self._observed_tables = set() self.bq_io_metadata = None self._step_name = step_name @@ -545,7 +550,8 @@ def setup(self): self._observed_tables = set() def start_bundle(self): - self.bq_wrapper = bigquery_tools.BigQueryWrapper(client=self.test_client) + self.bq_wrapper = bigquery_tools.BigQueryWrapper( + client=self.test_client, quota_project_id=self.quota_project_id) if not self.bq_io_metadata: self.bq_io_metadata = create_bigquery_io_metadata(self._step_name) self.pending_jobs = [] @@ -677,10 +683,12 @@ def __init__( additional_bq_parameters=None, source_format=None, step_name=None, - load_job_project_id=None): + load_job_project_id=None, + quota_project_id=None): self.schema = schema self.project = project self.test_client = test_client + self.quota_project_id = quota_project_id self.temporary_tables = temporary_tables self.additional_bq_parameters = additional_bq_parameters or {} self.source_format = source_format @@ -709,7 +717,8 @@ def display_data(self): return result def start_bundle(self): - self.bq_wrapper = bigquery_tools.BigQueryWrapper(client=self.test_client) + self.bq_wrapper = bigquery_tools.BigQueryWrapper( + client=self.test_client, quota_project_id=self.quota_project_id) if not self.bq_io_metadata: self.bq_io_metadata = create_bigquery_io_metadata(self._step_name) self.pending_jobs = [] @@ -928,11 +937,13 @@ def process(self, element): class DeleteTablesFn(beam.DoFn): - def __init__(self, test_client=None): + def __init__(self, test_client=None, quota_project_id=None): self.test_client = test_client + self.quota_project_id = quota_project_id def start_bundle(self): - self.bq_wrapper = bigquery_tools.BigQueryWrapper(client=self.test_client) + self.bq_wrapper = bigquery_tools.BigQueryWrapper( + client=self.test_client, quota_project_id=self.quota_project_id) def process(self, table_reference): _LOGGER.info("Deleting table %s", table_reference) @@ -998,6 +1009,7 @@ def __init__( self.test_client = test_client self.schema = schema + self.quota_project_id = None self._temp_file_format = temp_file_format or bigquery_tools.FileFormat.JSON # If we have multiple destinations, then we will have multiple load jobs, @@ -1207,7 +1219,8 @@ def _load_data( additional_bq_parameters=self.additional_bq_parameters, source_format=self._temp_file_format, step_name=step_name, - load_job_project_id=self.load_job_project_id), + load_job_project_id=self.load_job_project_id, + quota_project_id=self.quota_project_id), load_job_name_pcv, *self.schema_side_inputs).with_outputs( TriggerLoadJobs.TEMP_TABLES, @@ -1228,7 +1241,8 @@ def _load_data( test_client=self.test_client, additional_bq_parameters=self.additional_bq_parameters, step_name=step_name, - load_job_project_id=self.load_job_project_id), + load_job_project_id=self.load_job_project_id, + quota_project_id=self.quota_project_id), schema_mod_job_name_pcv)) if self.write_disposition in ('WRITE_EMPTY', 'WRITE_TRUNCATE'): @@ -1259,7 +1273,8 @@ def _load_data( write_disposition=self.write_disposition, test_client=self.test_client, step_name=step_name, - load_job_project_id=self.load_job_project_id), + load_job_project_id=self.load_job_project_id, + quota_project_id=self.quota_project_id), copy_job_name_pcv, pvalue.AsIter(schema_mod_job_ids_pc)).with_outputs( TriggerCopyJobs.TRIGGER_DELETE_TEMP_TABLES, main='main')) @@ -1275,7 +1290,9 @@ def _load_data( | "RemoveTempTables/DeduplicateTables" >> beam.GroupByKey() | "RemoveTempTables/GetTableNames" >> beam.Keys() | "RemoveTempTables/Delete" >> beam.ParDo( - DeleteTablesFn(self.test_client))) + DeleteTablesFn( + test_client=self.test_client, + quota_project_id=self.quota_project_id))) # Load data directly to destination table destination_load_job_ids_pc = ( @@ -1290,7 +1307,8 @@ def _load_data( additional_bq_parameters=self.additional_bq_parameters, source_format=self._temp_file_format, step_name=step_name, - load_job_project_id=self.load_job_project_id), + load_job_project_id=self.load_job_project_id, + quota_project_id=self.quota_project_id), load_job_name_pcv, *self.schema_side_inputs).with_outputs( TriggerLoadJobs.ONGOING_JOBS, main='main') @@ -1305,6 +1323,8 @@ def _load_data( def expand(self, pcoll): p = pcoll.pipeline self.project = self.project or p.options.view_as(GoogleCloudOptions).project + self.quota_project_id = bigquery_tools._quota_project_id_from_options( + p.options) try: step_name = self.label except AttributeError: diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py b/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py index 7ae0ccc8e32f..7ccda7ecad8d 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py @@ -19,6 +19,7 @@ # pytype: skip-file +import contextlib import logging import os import secrets @@ -1259,6 +1260,52 @@ def __call__(self): assert_that(jobs, equal_to(expected_jobs), label='CheckJobs') +class TestFileLoadsQuotaProject(_TestCaseWithTempDirCleanUp): + """Tests that the file loads DoFns attribute quota to the quota project.""" + @parameterized.expand([ + param(dofn=bqfl.UpdateDestinationSchema, quota_attr='_quota_project_id'), + param(dofn=bqfl.TriggerCopyJobs, quota_attr='quota_project_id'), + param(dofn=bqfl.TriggerLoadJobs, quota_attr='quota_project_id'), + param(dofn=bqfl.DeleteTablesFn, quota_attr='quota_project_id'), + ]) + def test_start_bundle_passes_quota_project(self, dofn, quota_attr): + fn = dofn(quota_project_id='my-billing-project') + self.assertEqual(getattr(fn, quota_attr), 'my-billing-project') + with mock.patch.object(bigquery_tools, 'BigQueryWrapper') as mock_wrapper: + fn.start_bundle() + self.assertEqual( + mock_wrapper.call_args.kwargs['quota_project_id'], 'my-billing-project') + + def test_expand_resolves_quota_project_from_options(self): + options = PipelineOptions(['--quota_project_id=my-billing-project']) + transform = bqfl.BigQueryBatchFileLoads( + 'project:dataset.table', + custom_gcs_temp_location=self._new_tempdir(), + test_client=mock.Mock(), + validate=False, + temp_file_format=bigquery_tools.FileFormat.JSON) + p = TestPipeline(options=options) + dofns = [ + bqfl.UpdateDestinationSchema, + bqfl.TriggerCopyJobs, + bqfl.TriggerLoadJobs, + bqfl.DeleteTablesFn + ] + with contextlib.ExitStack() as stack: + mocks = [ + stack.enter_context( + mock.patch.object( + dofn, '__init__', autospec=True, side_effect=dofn.__init__)) + for dofn in dofns + ] + _ = p | beam.Create(_ELEMENTS) | transform + self.assertEqual(transform.quota_project_id, 'my-billing-project') + for dofn, m in zip(dofns, mocks): + self.assertTrue(m.call_args_list, dofn.__name__) + for c in m.call_args_list: + self.assertEqual(c.kwargs['quota_project_id'], 'my-billing-project') + + class BigQueryFileLoadsIT(unittest.TestCase): BIG_QUERY_SCHEMA = ( diff --git a/sdks/python/apache_beam/io/gcp/bigquery_read_internal.py b/sdks/python/apache_beam/io/gcp/bigquery_read_internal.py index 136b3cc56b7e..2fa95a3d1794 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_read_internal.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_read_internal.py @@ -142,11 +142,13 @@ class _PassThroughThenCleanupTempDatasets(PTransform): Utilizes readiness of PCollection to trigger DoFn. """ - def __init__(self, side_input=None): + def __init__(self, side_input=None, quota_project_id=None): self.side_input = side_input + self.quota_project_id = quota_project_id def expand(self, input): pipeline_options = input.pipeline.options + quota_project_id = self.quota_project_id class PassThrough(beam.DoFn): def process(self, element): @@ -154,8 +156,9 @@ def process(self, element): class CleanUpProjects(beam.DoFn): def process(self, unused_element, unused_signal, pipeline_details): - bq = bigquery_tools.BigQueryWrapper.from_pipeline_options( - pipeline_options) + bq = bigquery_tools.BigQueryWrapper( + pipeline_options=pipeline_options, + quota_project_id=quota_project_id) pipeline_details = pipeline_details[0] if 'temp_table_ref' in pipeline_details.keys(): temp_table_ref = pipeline_details['temp_table_ref'] @@ -251,7 +254,7 @@ def _get_temp_dataset_project(self): def start_bundle(self): self.bq = bigquery_tools.BigQueryWrapper( temp_dataset_id=self._get_temp_dataset_id(), - client=bigquery_tools.BigQueryWrapper._bigquery_client(self.options)) + pipeline_options=self.options) def process(self, element: 'ReadFromBigQueryRequest') -> Iterable[BoundedSource]: diff --git a/sdks/python/apache_beam/io/gcp/bigquery_test.py b/sdks/python/apache_beam/io/gcp/bigquery_test.py index 22b617fa8741..ae03104612c9 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_test.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_test.py @@ -53,6 +53,7 @@ from apache_beam.io.gcp.bigquery import _StreamToBigQuery from apache_beam.io.gcp.bigquery_read_internal import _BigQueryReadSplit from apache_beam.io.gcp.bigquery_read_internal import _JsonToDictCoder +from apache_beam.io.gcp.bigquery_read_internal import _PassThroughThenCleanupTempDatasets from apache_beam.io.gcp.bigquery_read_internal import bigquery_export_destination_uri from apache_beam.io.gcp.bigquery_tools import JSON_COMPLIANCE_ERROR from apache_beam.io.gcp.bigquery_tools import BigQueryWrapper @@ -65,6 +66,7 @@ from apache_beam.io.gcp.tests.bigquery_matcher import BigQueryTableMatcher from apache_beam.metrics.metric import Lineage from apache_beam.options import value_provider +from apache_beam.options.pipeline_options import GoogleCloudOptions from apache_beam.options.pipeline_options import PipelineOptions from apache_beam.options.pipeline_options import StandardOptions from apache_beam.options.value_provider import RuntimeValueProvider @@ -338,6 +340,10 @@ def test_repeatable_field_is_properly_converted(self): class TestReadFromBigQuery(unittest.TestCase): @classmethod def setUpClass(cls): + cls.env_patch = mock.patch.dict( + os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'}) + cls.env_patch.start() + class UserDefinedOptions(PipelineOptions): @classmethod def _add_argparse_args(cls, parser): @@ -351,6 +357,7 @@ def tearDown(self): @classmethod def tearDownClass(cls): + cls.env_patch.stop() # Unset the option added in setupClass to avoid interfere with other tests. # Force a gc so PipelineOptions.__subclass__() no longer contains it. del cls.UserDefinedOptions @@ -879,6 +886,343 @@ def test_expand_output_type_uses_query_schema(self): mock_convert.assert_called_once_with(schema, None) +@unittest.skipIf( + HttpError is None or gcp_bigquery is None, + 'GCP dependencies are not installed') +class TestReadFromBigQueryQuotaProject(unittest.TestCase): + """Tests for quota_project_id in ReadFromBigQuery sources.""" + def test_quota_project_id_from_pipeline_options(self): + """Test that quota_project_id is read from GoogleCloudOptions.""" + options = PipelineOptions(['--quota_project_id=my-billing-project']) + gcp_options = options.view_as(GoogleCloudOptions) + self.assertEqual(gcp_options.quota_project_id, 'my-billing-project') + + def test_quota_project_id_none_by_default_in_options(self): + """Test that quota_project_id is None by default in options.""" + options = PipelineOptions([]) + gcp_options = options.view_as(GoogleCloudOptions) + self.assertIsNone(gcp_options.quota_project_id) + + def test_export_source_explicit_quota_project(self): + """Test that explicit quota_project_id is stored in + _CustomBigQuerySource.""" + source = beam_bq._CustomBigQuerySource( + method=ReadFromBigQuery.Method.EXPORT, + table='project:dataset.table', + quota_project_id='my-billing-project') + self.assertEqual(source.quota_project_id, 'my-billing-project') + self.assertEqual(source._get_quota_project_id(), 'my-billing-project') + + def test_export_source_gets_quota_from_options(self): + """Test that _CustomBigQuerySource falls back to options for + quota_project_id.""" + options = PipelineOptions(['--quota_project_id=my-billing-project']) + source = beam_bq._CustomBigQuerySource( + method=ReadFromBigQuery.Method.EXPORT, + table='project:dataset.table', + pipeline_options=options) + self.assertEqual(source._get_quota_project_id(), 'my-billing-project') + + def test_export_source_explicit_overrides_options(self): + """Test that explicit quota_project_id overrides options.""" + options = PipelineOptions(['--quota_project_id=options-project']) + source = beam_bq._CustomBigQuerySource( + method=ReadFromBigQuery.Method.EXPORT, + table='project:dataset.table', + pipeline_options=options, + quota_project_id='explicit-project') + self.assertEqual(source._get_quota_project_id(), 'explicit-project') + + def test_storage_source_explicit_quota_project(self): + """Test that explicit quota_project_id is stored in + _CustomBigQueryStorageSource.""" + source = beam_bq._CustomBigQueryStorageSource( + method=ReadFromBigQuery.Method.DIRECT_READ, + table='project:dataset.table', + quota_project_id='my-billing-project') + self.assertEqual(source.quota_project_id, 'my-billing-project') + self.assertEqual(source._get_quota_project_id(), 'my-billing-project') + + def test_storage_source_gets_quota_from_options(self): + """Test that _CustomBigQueryStorageSource falls back to options.""" + options = PipelineOptions(['--quota_project_id=my-billing-project']) + source = beam_bq._CustomBigQueryStorageSource( + method=ReadFromBigQuery.Method.DIRECT_READ, + table='project:dataset.table', + pipeline_options=options) + self.assertEqual(source._get_quota_project_id(), 'my-billing-project') + + @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client') + def test_export_source_split_passes_quota_to_client(self, mock_bq_client): + """split() must pass the transform-level quota_project_id to the BigQuery + client, not only the --quota_project_id pipeline option.""" + mock_bq_client.side_effect = RuntimeError('stop') + source = beam_bq._CustomBigQuerySource( + method=ReadFromBigQuery.Method.EXPORT, + table='project:dataset.table', + quota_project_id='my-billing-project') + with self.assertRaises(RuntimeError): + list(source.split(desired_bundle_size=0)) + mock_bq_client.assert_called_once() + self.assertIsInstance(mock_bq_client.call_args.args[0], PipelineOptions) + self.assertEqual( + mock_bq_client.call_args.kwargs['quota_project_id'], + 'my-billing-project') + + @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client') + def test_storage_source_split_passes_quota_to_client(self, mock_bq_client): + """split() must pass the transform-level quota_project_id to the BigQuery + client, not only the --quota_project_id pipeline option.""" + mock_bq_client.side_effect = RuntimeError('stop') + source = beam_bq._CustomBigQueryStorageSource( + method=ReadFromBigQuery.Method.DIRECT_READ, + table='project:dataset.table', + quota_project_id='my-billing-project') + with self.assertRaises(RuntimeError): + list(source.split(desired_bundle_size=0)) + mock_bq_client.assert_called_once() + self.assertIsInstance(mock_bq_client.call_args.args[0], PipelineOptions) + self.assertEqual( + mock_bq_client.call_args.kwargs['quota_project_id'], + 'my-billing-project') + + @mock.patch.object(bigquery_tools, 'create_bq_storage_client') + @mock.patch.object(bigquery_tools.BigQueryWrapper, 'get_table') + @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client') + def test_storage_source_split_passes_quota_to_storage_client( + self, unused_mock_bq_client, mock_get_table, mock_create_storage_client): + """split() must create the Storage Read client with the quota project + and pass it on to each stream source.""" + mock_get_table.return_value = mock.Mock(numBytes=5) + stream = mock.Mock() + stream.name = 'projects/p/locations/l/sessions/s/streams/stream1' + mock_create_storage_client.return_value.create_read_session.return_value = ( + mock.Mock(streams=[stream])) + source = beam_bq._CustomBigQueryStorageSource( + method=ReadFromBigQuery.Method.DIRECT_READ, + table='project:dataset.table', + quota_project_id='my-billing-project') + + bundles = list(source.split(desired_bundle_size=1 << 20)) + + mock_create_storage_client.assert_called_once_with('my-billing-project') + self.assertEqual(len(bundles), 1) + self.assertEqual(bundles[0].source.quota_project_id, 'my-billing-project') + + @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client') + def test_export_source_estimate_size_passes_quota_to_client( + self, mock_bq_client): + """estimate_size() must attribute quota too, not just split().""" + mock_bq_client.side_effect = RuntimeError('stop') + source = beam_bq._CustomBigQuerySource( + method=ReadFromBigQuery.Method.EXPORT, + table='project:dataset.table', + quota_project_id='my-billing-project') + with self.assertRaises(RuntimeError): + source.estimate_size() + self.assertEqual( + mock_bq_client.call_args.kwargs['quota_project_id'], + 'my-billing-project') + + @mock.patch.object(bigquery_tools.BigQueryWrapper, '_bigquery_client') + def test_storage_source_estimate_size_passes_quota_to_client( + self, mock_bq_client): + """estimate_size() must attribute quota too, not just split().""" + mock_bq_client.side_effect = RuntimeError('stop') + source = beam_bq._CustomBigQueryStorageSource( + method=ReadFromBigQuery.Method.DIRECT_READ, + table='project:dataset.table', + quota_project_id='my-billing-project') + with self.assertRaises(RuntimeError): + source.estimate_size() + self.assertEqual( + mock_bq_client.call_args.kwargs['quota_project_id'], + 'my-billing-project') + + @mock.patch( + 'apache_beam.io.gcp.bigquery.bigquery_schema_tools' + '.convert_to_usertype') + @mock.patch.object(bigquery_tools, 'BigQueryWrapper') + def test_output_type_schema_fetch_passes_quota_to_client( + self, mock_wrapper, mock_convert): + """The BEAM_ROW schema lookup is a BigQuery API call of this transform, so + it must be attributed to the quota project as well.""" + mock_convert.return_value = beam.Map(lambda x: x) + transform = ReadFromBigQuery( + table='project:dataset.table', + output_type='BEAM_ROW', + quota_project_id='my-billing-project') + transform._expand_output_type(mock.MagicMock()) + + self.assertEqual( + mock_wrapper.call_args.kwargs['quota_project_id'], 'my-billing-project') + + @mock.patch.object(bigquery_tools, 'BigQueryWrapper') + def test_direct_read_cleanup_passes_quota_to_client(self, mock_wrapper): + """The DIRECT_READ temp-dataset cleanup is a BigQuery API call of this + transform, so it must be attributed to the quota project as well.""" + pipeline_details = { + 'project_id': 'project', 'bigquery_dataset_labels': { + 'k': 'v' + } + } + with TestPipeline() as p: + side_input = beam.pvalue.AsList( + p | 'Details' >> beam.Create([pipeline_details])) + _ = ( + p | beam.Create([1]) + | _PassThroughThenCleanupTempDatasets( + side_input, quota_project_id='my-billing-project')) + + self.assertEqual( + mock_wrapper.call_args.kwargs['quota_project_id'], 'my-billing-project') + self.assertIsNotNone(mock_wrapper.call_args.kwargs['pipeline_options']) + + @mock.patch('apache_beam.io.gcp.bigquery._PassThroughThenCleanupTempDatasets') + def test_direct_read_wires_quota_project_into_cleanup(self, mock_cleanup): + mock_cleanup.return_value = beam.Map(lambda x: x) + transform = ReadFromBigQuery( + table='project:dataset.table', + method=ReadFromBigQuery.Method.DIRECT_READ, + quota_project_id='my-billing-project') + p = TestPipeline() + transform._expand_direct_read(beam.pvalue.PBegin(p)) + + self.assertEqual( + mock_cleanup.call_args.kwargs['quota_project_id'], 'my-billing-project') + + def test_quota_project_id_in_export_source_display_data(self): + """Test that quota_project_id appears in display data for export source.""" + source = beam_bq._CustomBigQuerySource( + method=ReadFromBigQuery.Method.EXPORT, + table='project:dataset.table', + quota_project_id='my-billing-project') + items = {i.key: i.value for i in DisplayData.create_from(source).items} + self.assertEqual(items['quota_project_id'], 'my-billing-project') + + def test_quota_project_id_in_direct_read_source_display_data(self): + """Test that quota_project_id appears in display data for DIRECT_READ.""" + source = beam_bq._CustomBigQueryStorageSource( + method=ReadFromBigQuery.Method.DIRECT_READ, + table='project:dataset.table', + quota_project_id='my-billing-project') + items = {i.key: i.value for i in DisplayData.create_from(source).items} + self.assertEqual(items['quota_project_id'], 'my-billing-project') + + def test_quota_project_id_absent_from_display_data_when_not_set(self): + """Test that quota_project_id is dropped from display data when not set.""" + options = PipelineOptions([]) + sources = [ + beam_bq._CustomBigQuerySource( + method=ReadFromBigQuery.Method.EXPORT, + table='project:dataset.table', + pipeline_options=options), + beam_bq._CustomBigQueryStorageSource( + method=ReadFromBigQuery.Method.DIRECT_READ, + table='project:dataset.table', + pipeline_options=options) + ] + for source in sources: + keys = {i.key for i in DisplayData.create_from(source).items} + self.assertNotIn('quota_project_id', keys) + + def test_stream_source_stores_quota_project_id(self): + """Test that quota_project_id is stored in + _CustomBigQueryStorageStreamSource.""" + stream_source = beam_bq._CustomBigQueryStorageStreamSource( + read_stream_name='projects/p/locations/l/sessions/s/streams/stream1', + use_native_datetime=True, + timeout=30.0, + quota_project_id='my-billing-project') + self.assertEqual(stream_source.quota_project_id, 'my-billing-project') + + def test_stream_source_quota_project_id_none_by_default(self): + """Test that quota_project_id is None by default in stream source.""" + stream_source = beam_bq._CustomBigQueryStorageStreamSource( + read_stream_name='projects/p/locations/l/sessions/s/streams/stream1') + self.assertIsNone(stream_source.quota_project_id) + + def test_stream_source_split_preserves_quota_project_id(self): + """Test that split() preserves quota_project_id.""" + stream_source = beam_bq._CustomBigQueryStorageStreamSource( + read_stream_name='projects/p/locations/l/sessions/s/streams/stream1', + use_native_datetime=True, + timeout=30.0, + quota_project_id='my-billing-project') + bundle = stream_source.split(desired_bundle_size=0) + self.assertEqual(bundle.source.quota_project_id, 'my-billing-project') + self.assertEqual(bundle.source.timeout, 30.0) + self.assertEqual(bundle.source.use_native_datetime, True) + + @mock.patch('apache_beam.io.gcp.bigquery_tools.create_bq_storage_client') + def test_stream_source_read_arrow_uses_quota_project( + self, mock_create_client): + """Test that read_arrow() uses create_bq_storage_client + with quota_project_id.""" + mock_client = mock.MagicMock() + mock_create_client.return_value = mock_client + # Mock read_rows to return empty iterator + mock_client.read_rows.return_value.rows.return_value = iter([]) + + stream_source = beam_bq._CustomBigQueryStorageStreamSource( + read_stream_name='projects/p/locations/l/sessions/s/streams/stream1', + use_native_datetime=True, + quota_project_id='my-billing-project') + # Consume the iterator + list(stream_source.read_arrow()) + + mock_create_client.assert_called_once_with('my-billing-project') + + @mock.patch('apache_beam.io.gcp.bigquery_tools.create_bq_storage_client') + def test_stream_source_read_avro_uses_quota_project(self, mock_create_client): + """Test that read_avro() uses create_bq_storage_client + with quota_project_id.""" + mock_client = mock.MagicMock() + mock_create_client.return_value = mock_client + # Mock read_rows to return empty iterator + mock_client.read_rows.return_value = iter([]) + + stream_source = beam_bq._CustomBigQueryStorageStreamSource( + read_stream_name='projects/p/locations/l/sessions/s/streams/stream1', + use_native_datetime=False, + quota_project_id='my-billing-project') + # Consume the iterator + list(stream_source.read_avro()) + + mock_create_client.assert_called_once_with('my-billing-project') + + +@unittest.skipIf(HttpError is None, 'GCP dependencies are not installed') +class TestWriteToBigQueryQuotaProject(unittest.TestCase): + """Tests for quota_project_id in the BigQuery streaming insert path.""" + @mock.patch.object(bigquery_tools, 'BigQueryWrapper') + def test_write_fn_start_bundle_passes_quota_project(self, mock_wrapper): + fn = beam_bq.BigQueryWriteFn( + batch_size=1, quota_project_id='my-billing-project') + fn.start_bundle() + self.assertEqual( + mock_wrapper.call_args.kwargs['quota_project_id'], 'my-billing-project') + + def test_stream_to_bigquery_resolves_quota_project_from_options(self): + options = PipelineOptions(['--quota_project_id=my-billing-project']) + p = TestPipeline(options=options) + with mock.patch.object( + beam_bq.BigQueryWriteFn, + '__init__', + autospec=True, + side_effect=beam_bq.BigQueryWriteFn.__init__) as mock_write_fn: + _ = ( + p + | beam.Create([]) + | WriteToBigQuery( + 'project:dataset.table', + schema='a:STRING', + method=WriteToBigQuery.Method.STREAMING_INSERTS)) + self.assertEqual( + mock_write_fn.call_args.kwargs['quota_project_id'], + 'my-billing-project') + + @unittest.skipIf(HttpError is None, 'GCP dependencies are not installed') class TestBigQuerySink(unittest.TestCase): def test_table_spec_display_data(self): @@ -920,10 +1264,14 @@ def _cleanup_files(self): os.remove('insert_calls2') def setUp(self): + self.env_patch = mock.patch.dict( + os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'}) + self.env_patch.start() self._cleanup_files() def tearDown(self): self._cleanup_files() + self.env_patch.stop() def test_noop_schema_parsing(self): expected_table_schema = None @@ -1481,6 +1829,13 @@ def test_copy_load_job_exception(self, exception_type, error_message): HttpError is None or exceptions is None, 'GCP dependencies are not installed') class BigQueryStreamingInsertsErrorHandling(unittest.TestCase): + def setUp(self): + self.env_patch = mock.patch.dict( + os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'}) + self.env_patch.start() + + def tearDown(self): + self.env_patch.stop() # Running tests with a variety of exceptions from https://googleapis.dev # /python/google-api-core/latest/_modules/google/api_core/exceptions.html. diff --git a/sdks/python/apache_beam/io/gcp/bigquery_tools.py b/sdks/python/apache_beam/io/gcp/bigquery_tools.py index cc1a16a659fe..d1412d0f855e 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_tools.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_tools.py @@ -36,6 +36,7 @@ import re import sys import time +import urllib.parse import uuid from json.decoder import JSONDecodeError from typing import Optional @@ -59,6 +60,7 @@ from apache_beam.metrics import monitoring_infos from apache_beam.metrics.metric import Metrics from apache_beam.options import value_provider +from apache_beam.options.pipeline_options import GoogleCloudOptions from apache_beam.options.pipeline_options import PipelineOptions from apache_beam.transforms import DoFn from apache_beam.typehints.row_type import RowTypeConstraint @@ -80,6 +82,11 @@ gcp_bigquery = None pass +try: + from google.cloud import bigquery_storage_v1 as bq_storage +except ImportError: + bq_storage = None + try: from orjson import dumps as fast_json_dumps from orjson import loads as fast_json_loads @@ -419,6 +426,58 @@ def _build_dataset_encryption_config(kms_key): return bigquery.EncryptionConfiguration(kmsKeyName=kms_key) +def _quota_project_id_from_options(pipeline_options): + """Returns --quota_project_id from pipeline options, if any.""" + if pipeline_options is None: + return None + return pipeline_options.view_as(GoogleCloudOptions).quota_project_id + + +def create_bq_storage_client(quota_project_id=None): + """Create a BigQueryReadClient with optional quota project. + + Args: + quota_project_id: Optional GCP project ID to use for quota and billing. + The client applies it to the credentials it resolves itself. + + Returns: + A BigQueryReadClient instance. + """ + if not quota_project_id: + return bq_storage.BigQueryReadClient() + return bq_storage.BigQueryReadClient( + client_options={'quota_project_id': quota_project_id}) + + +class _HttpWithHeaders(object): + """Wraps an httplib2.Http to add fixed headers to requests to given hosts. + + Sits below the credentials layer, so the headers are applied on every + attempt and take precedence over headers set by the credentials. The + credentials refresh their token through the same Http, so requests to other + hosts (token endpoint, metadata server, IAM Credentials API) are passed + through unchanged. + """ + def __init__(self, http, headers, hosts): + object.__setattr__(self, '_http', http) + object.__setattr__(self, '_headers', headers) + object.__setattr__(self, '_hosts', frozenset(hosts)) + + def request(self, uri, method='GET', body=None, headers=None, **kwargs): + if urllib.parse.urlsplit(uri).hostname in self._hosts: + headers = dict(headers or {}) + headers.update(self._headers) + return self._http.request(uri, method, body=body, headers=headers, **kwargs) + + def __getattr__(self, name): + if name == '_http': # object created without __init__ (copy/unpickle) + raise AttributeError(name) + return getattr(self._http, name) + + def __setattr__(self, name, value): + setattr(self._http, name, value) + + class BigQueryWrapper(object): """BigQuery client wrapper with utilities for querying. @@ -427,8 +486,10 @@ class BigQueryWrapper(object): In addition, it offers various functions used both in sources and sinks (e.g., find and create tables, query a table, etc.). - Note that client parameter in constructor is only for testing purposes and - should not be used in production code. + Note that the client and gcp_client parameters in the constructor are only + for testing purposes and should not be used in production code. Production + code should pass pipeline_options (and optionally quota_project_id) so that + every client is built here with the right quota attribution. """ # If updating following names, also update the corresponding pydocs in @@ -438,16 +499,29 @@ class BigQueryWrapper(object): HISTOGRAM_METRIC_LOGGER = MetricLogger() - def __init__(self, client=None, temp_dataset_id=None, temp_table_ref=None): - self.client = client or BigQueryWrapper._bigquery_client(PipelineOptions()) - self.gcp_bq_client = client or gcp_bigquery.Client( - client_info=ClientInfo( - user_agent="apache-beam-%s" % apache_beam.__version__)) + def __init__( + self, + client=None, + temp_dataset_id=None, + temp_table_ref=None, + quota_project_id=None, + *, + gcp_client=None, + pipeline_options=None): + self.quota_project_id = ( + quota_project_id or _quota_project_id_from_options(pipeline_options)) + self.client = client or BigQueryWrapper._bigquery_client( + pipeline_options or PipelineOptions(), + quota_project_id=self.quota_project_id) + # Test-only overrides. `client` also stands in for the google-cloud-bigquery + # client, which several tests rely on; pass `gcp_client` as well as `client` + # to override only that one. + self._gcp_bq_client = gcp_client or client self._unique_row_id = 0 # For testing scenarios where we pass in a client we do not want a # randomized prefix for row IDs. - self._row_id_prefix = '' if client else uuid.uuid4() + self._row_id_prefix = '' if (client or gcp_client) else uuid.uuid4() self._latency_histogram_metric = Metrics.histogram( self.__class__, 'latency_histogram_ms', @@ -474,6 +548,17 @@ def __init__(self, client=None, temp_dataset_id=None, temp_table_ref=None): self.created_temp_dataset = False + @property + def gcp_bq_client(self): + """The google-cloud-bigquery client, created on first use. + + Only the streaming insert path needs it, so it is not built eagerly. + """ + if self._gcp_bq_client is None: + self._gcp_bq_client = BigQueryWrapper._gcp_bigquery_client( + quota_project_id=self.quota_project_id) + return self._gcp_bq_client + @property def unique_row_id(self): """Returns a unique row ID (str) used to avoid multiple insertions. @@ -1490,19 +1575,70 @@ def convert_row_to_dict(self, row, schema): @staticmethod def from_pipeline_options(pipeline_options: PipelineOptions): - return BigQueryWrapper( - client=BigQueryWrapper._bigquery_client(pipeline_options)) + """Create a BigQueryWrapper from pipeline options. + + Args: + pipeline_options: Pipeline options containing GCP configuration. + The quota_project_id is read from GoogleCloudOptions if set. + """ + return BigQueryWrapper(pipeline_options=pipeline_options) @staticmethod - def _bigquery_client(pipeline_options: PipelineOptions): + def _bigquery_client( + pipeline_options: PipelineOptions, quota_project_id: str = None): + """Create a BigQuery API client from pipeline options. + + Args: + pipeline_options: Pipeline options for credentials. + quota_project_id: Optional quota project ID. If not provided, will be + extracted from pipeline_options. + """ + credentials = auth.get_service_credentials(pipeline_options) + # Use explicit quota_project_id if provided, otherwise get from options + quota_project_id = quota_project_id or _quota_project_id_from_options( + pipeline_options) + http = get_new_http() + if quota_project_id: + if credentials is None: + # Ignoring the request would silently bill a different project. + raise ValueError( + 'quota_project_id was set to %r, but no credentials were found to ' + 'apply it to.' % quota_project_id) + # Send the quota project as a request header rather than deriving new + # credentials: credentials.with_quota_project() returns a copy without + # the cached token, and this keeps the shared credentials untouched. + # The header is set below the credentials layer so that it is applied on + # every attempt and wins over a quota project the credentials carry. + http = _HttpWithHeaders( + http, {'x-goog-user-project': quota_project_id}, + hosts=[ + urllib.parse.urlsplit(url).hostname for url in ( + bigquery.BigqueryV2.BASE_URL, + bigquery.BigqueryV2.MTLS_BASE_URL) + ]) return bigquery.BigqueryV2( - http=get_new_http(), - credentials=auth.get_service_credentials(pipeline_options), + http=http, + credentials=credentials, response_encoding='utf8', additional_http_headers={ "user-agent": "apache-beam-%s" % apache_beam.__version__ }) + @staticmethod + def _gcp_bigquery_client(quota_project_id: str = None): + """Create a google-cloud-bigquery Client with optional quota project. + + The client applies the quota project to the credentials it resolves + itself, so no credentials are derived here. + """ + client_options = None + if quota_project_id: + client_options = {'quota_project_id': quota_project_id} + return gcp_bigquery.Client( + client_options=client_options, + client_info=ClientInfo( + user_agent="apache-beam-%s" % apache_beam.__version__)) + class RowAsDictJsonCoder(coders.Coder): """A coder for a table row (represented as a dict) to/from a JSON string. diff --git a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py index 9f8b78ee5c3b..de4f3e4066af 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py @@ -23,6 +23,7 @@ import json import logging import math +import os import re import unittest from typing import Optional @@ -35,10 +36,12 @@ from parameterized import parameterized import apache_beam as beam +from apache_beam.io.gcp import bigquery_tools from apache_beam.io.gcp import resource_identifiers from apache_beam.io.gcp.bigquery_tools import JSON_COMPLIANCE_ERROR from apache_beam.io.gcp.bigquery_tools import AvroRowWriter from apache_beam.io.gcp.bigquery_tools import BigQueryJobTypes +from apache_beam.io.gcp.bigquery_tools import BigQueryWrapper from apache_beam.io.gcp.bigquery_tools import JsonRowWriter from apache_beam.io.gcp.bigquery_tools import RowAsDictJsonCoder from apache_beam.io.gcp.bigquery_tools import beam_row_from_dict @@ -299,14 +302,16 @@ def test_delete_dataset_retries_for_timeouts(self, patched_time_sleep): @mock.patch('google.cloud._http.JSONConnection.http') def test_user_agent_insert_all( self, http_mock, patched_skip_get_credentials, patched_sleep): - wrapper = beam.io.gcp.bigquery_tools.BigQueryWrapper() - try: - wrapper._insert_all_rows('p', 'd', 't', [{'name': 'any'}], None) - except: # pylint: disable=bare-except - # Ignore errors. The errors come from the fact that we did not mock - # the response from the API, so the overall insert_all_rows call fails - # soon after the BQ API is called. - pass + # Set GOOGLE_CLOUD_PROJECT to ensure Client creation succeeds in test env + with mock.patch.dict(os.environ, {'GOOGLE_CLOUD_PROJECT': 'test-project'}): + wrapper = beam.io.gcp.bigquery_tools.BigQueryWrapper() + try: + wrapper._insert_all_rows('p', 'd', 't', [{'name': 'any'}], None) + except: # pylint: disable=bare-except + # Ignore errors. The errors come from the fact that we did not mock + # the response from the API, so the overall insert_all_rows call fails + # soon after the BQ API is called. + pass call = http_mock.request.mock_calls[-2] self.assertIn('apache-beam-', call[2]['headers']['User-Agent']) @@ -1199,8 +1204,6 @@ def test_geography_in_bigquery_type_mapping(self): def test_geography_field_conversion(self): """Test that GEOGRAPHY fields are converted correctly.""" - from apache_beam.io.gcp.bigquery_tools import BigQueryWrapper - # Create a mock field with GEOGRAPHY type field = bigquery.TableFieldSchema() field.type = 'GEOGRAPHY' @@ -1322,8 +1325,6 @@ def test_geography_json_encoding(self): def test_geography_with_special_characters(self): """Test GEOGRAPHY values with special characters and geometries.""" - from apache_beam.io.gcp.bigquery_tools import BigQueryWrapper - field = bigquery.TableFieldSchema() field.type = 'GEOGRAPHY' field.name = 'complex_geo' @@ -1502,6 +1503,213 @@ def test_type_overrides_json_to_dict(self): self.assertEqual(typehints_dict, [("data", Optional[dict])]) +@unittest.skipIf(HttpError is None, 'GCP dependencies are not installed') +class TestBigQueryWrapperQuotaProject(unittest.TestCase): + """Tests for quota_project_id in BigQueryWrapper.""" + @mock.patch( + 'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._bigquery_client') + @mock.patch( + 'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._gcp_bigquery_client') + def test_quota_project_id_stored(self, mock_gcp_client, mock_bq_client): + """Test that quota_project_id is stored in BigQueryWrapper.""" + mock_bq_client.return_value = mock.Mock() + mock_gcp_client.return_value = mock.Mock() + + wrapper = BigQueryWrapper(quota_project_id='my-billing-project') + self.assertEqual(wrapper.quota_project_id, 'my-billing-project') + + @mock.patch( + 'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._bigquery_client') + @mock.patch( + 'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._gcp_bigquery_client') + def test_from_pipeline_options_reads_quota_from_options( + self, mock_gcp_client, mock_bq_client): + """Test from_pipeline_options reads quota_project_id from + GoogleCloudOptions.""" + from apache_beam.options.pipeline_options import PipelineOptions + + mock_bq_client.return_value = mock.Mock() + mock_gcp_client.return_value = mock.Mock() + + options = PipelineOptions(['--quota_project_id=my-billing-project']) + wrapper = BigQueryWrapper.from_pipeline_options(options) + + self.assertEqual(wrapper.quota_project_id, 'my-billing-project') + + @mock.patch( + 'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._bigquery_client') + @mock.patch( + 'apache_beam.io.gcp.bigquery_tools.BigQueryWrapper._gcp_bigquery_client') + def test_from_pipeline_options_none_when_not_set( + self, mock_gcp_client, mock_bq_client): + """Test from_pipeline_options returns None when quota_project_id not set.""" + from apache_beam.options.pipeline_options import PipelineOptions + + mock_bq_client.return_value = mock.Mock() + mock_gcp_client.return_value = mock.Mock() + + options = PipelineOptions([]) + wrapper = BigQueryWrapper.from_pipeline_options(options) + + self.assertIsNone(wrapper.quota_project_id) + + def test_http_with_headers_sets_headers_and_delegates(self): + from apache_beam.io.gcp.bigquery_tools import _HttpWithHeaders + inner = mock.MagicMock() + inner.request.return_value = ('response', b'content') + inner.redirect_codes = {301, 302} + http = _HttpWithHeaders( + inner, {'x-goog-user-project': 'p'}, hosts=['example.com']) + + result = http.request( + 'https://example.com', + method='GET', + headers={ + 'x-goog-user-project': 'other', 'accept': 'json' + }, + redirections=3) + + self.assertEqual(result, ('response', b'content')) + inner.request.assert_called_once_with( + 'https://example.com', + 'GET', + body=None, + headers={ + 'x-goog-user-project': 'p', 'accept': 'json' + }, + redirections=3) + # Attribute reads and writes reach the wrapped http. + self.assertEqual(http.redirect_codes, {301, 302}) + http.redirect_codes = {301} + self.assertEqual(inner.redirect_codes, {301}) + + def test_http_with_headers_survives_copy(self): + import copy + + from apache_beam.io.gcp.bigquery_tools import _HttpWithHeaders + inner = mock.MagicMock() + http = copy.copy( + _HttpWithHeaders( + inner, {'x-goog-user-project': 'p'}, hosts=['example.com'])) + + http.request('https://example.com') + inner.request.assert_called_once_with( + 'https://example.com', + 'GET', + body=None, + headers={'x-goog-user-project': 'p'}) + + def test_http_with_headers_skips_other_hosts(self): + from apache_beam.io.gcp.bigquery_tools import _HttpWithHeaders + inner = mock.MagicMock() + http = _HttpWithHeaders( + inner, {'x-goog-user-project': 'p'}, hosts=['example.com']) + + http.request('https://oauth2.googleapis.com/token', headers={'a': 'b'}) + inner.request.assert_called_once_with( + 'https://oauth2.googleapis.com/token', + 'GET', + body=None, + headers={'a': 'b'}) + + @mock.patch('apache_beam.io.gcp.bigquery_tools.bq_storage') + def test_create_bq_storage_client_with_quota_project(self, mock_bq_storage): + """The quota project is passed as a client option; the client applies it + to the credentials it resolves itself.""" + bigquery_tools.create_bq_storage_client('my-billing-project') + mock_bq_storage.BigQueryReadClient.assert_called_once_with( + client_options={'quota_project_id': 'my-billing-project'}) + + @mock.patch('apache_beam.io.gcp.bigquery_tools.bq_storage') + def test_create_bq_storage_client_without_quota_project( + self, mock_bq_storage): + """Without a quota project the client is created with defaults.""" + bigquery_tools.create_bq_storage_client(None) + mock_bq_storage.BigQueryReadClient.assert_called_once_with() + + @mock.patch('apache_beam.io.gcp.bigquery_tools.auth.get_service_credentials') + def test_bigquery_client_raises_without_credentials( + self, mock_get_credentials): + """An explicit quota project must fail loudly rather than fall back to + apitools' own credential discovery, which would bill another project.""" + from apache_beam.options.pipeline_options import PipelineOptions + mock_get_credentials.return_value = None + + with self.assertRaisesRegex(ValueError, 'no credentials were found'): + BigQueryWrapper._bigquery_client( + PipelineOptions(), quota_project_id='requested-project') + + @mock.patch('apache_beam.io.gcp.bigquery_tools.get_new_http') + @mock.patch('apache_beam.io.gcp.bigquery_tools.auth.get_service_credentials') + def test_bigquery_client_quota_project_wins_over_credentials( + self, mock_get_credentials, mock_get_new_http): + """The requested quota project must reach every BigQuery request, + including the retry after a 401 refresh, even when the credentials carry + their own, but not the token refresh requests.""" + import httplib2 + from google.auth import credentials as ga_credentials + + from apache_beam.internal.gcp import auth + from apache_beam.options.pipeline_options import PipelineOptions + + class CredentialsWithQuotaProject(ga_credentials.Credentials): + def __init__(self): + super().__init__() + self._quota_project_id = 'adc-project' + + def refresh(self, request): + # Token refreshes go through the same Http as the API requests. + request('https://oauth2.googleapis.com/token', method='POST') + self.token = 'token' + + seen_headers = [] + token_request_headers = [] + statuses = iter([401, 200]) + + class FakeHttp(object): + connections = {} + redirect_codes = set() + + def request(self, uri, method='GET', body=None, headers=None, **kwargs): + if uri.startswith('https://oauth2.googleapis.com/'): + token_request_headers.append(dict(headers or {})) + return httplib2.Response({'status': 200}), b'{}' + seen_headers.append(dict(headers)) + response = httplib2.Response({ + 'status': next(statuses), 'content-type': 'application/json' + }) + return response, b'{}' + + mock_get_new_http.return_value = FakeHttp() + mock_get_credentials.return_value = auth._ApitoolsCredentialsAdapter( + CredentialsWithQuotaProject()) + + client = BigQueryWrapper._bigquery_client( + PipelineOptions(), quota_project_id='requested-project') + client.projects.List(bigquery.BigqueryProjectsListRequest()) + + self.assertEqual(len(seen_headers), 2) + for headers in seen_headers: + self.assertEqual(headers['x-goog-user-project'], 'requested-project') + self.assertEqual(headers['authorization'], 'Bearer token') + # The quota project must not be sent to the token endpoint. + self.assertTrue(token_request_headers) + for headers in token_request_headers: + self.assertNotIn('x-goog-user-project', headers) + + @mock.patch('apache_beam.io.gcp.bigquery_tools.gcp_bigquery.Client') + def test_gcp_bigquery_client_passes_quota_project(self, mock_client): + BigQueryWrapper._gcp_bigquery_client(quota_project_id='my-billing-project') + self.assertEqual( + mock_client.call_args.kwargs['client_options'], + {'quota_project_id': 'my-billing-project'}) + + @mock.patch('apache_beam.io.gcp.bigquery_tools.gcp_bigquery.Client') + def test_gcp_bigquery_client_no_quota_project_by_default(self, mock_client): + BigQueryWrapper._gcp_bigquery_client() + self.assertIsNone(mock_client.call_args.kwargs['client_options']) + + if __name__ == '__main__': logging.getLogger().setLevel(logging.INFO) unittest.main() diff --git a/sdks/python/apache_beam/options/pipeline_options.py b/sdks/python/apache_beam/options/pipeline_options.py index 08ba34dabee4..f0406e2505bc 100644 --- a/sdks/python/apache_beam/options/pipeline_options.py +++ b/sdks/python/apache_beam/options/pipeline_options.py @@ -1197,6 +1197,17 @@ def _add_argparse_args(cls, parser): action='store_true', help='Throttling counter in GcsIO is enabled by default. Set ' '--no_gcsio_throttling_counter to avoid it.') + parser.add_argument( + '--quota_project_id', + default=None, + help='GCP project ID used for quota and billing attribution of Google ' + 'Cloud API requests (sets the x-goog-user-project header), if ' + 'different from the project the data resides in. Currently honored ' + 'by BigQuery IO transforms (ReadFromBigQuery, ReadAllFromBigQuery, ' + 'WriteToBigQuery with FILE_LOADS or STREAMING_INSERTS, ' + 'ReadBigQueryChangeHistory); STORAGE_WRITE_API (Java-backed) and ' + 'other GCP IOs do not use it yet. The credentials used must have the ' + 'serviceusage.services.use permission on this project.') parser.add_argument( '--enable_gcsio_blob_generation', default=False, From 707b951e5e04015d195a62b5ab51d2088391fc7f Mon Sep 17 00:00:00 2001 From: GAURAV THAKUR Date: Mon, 19 Oct 2026 08:44:19 +0530 Subject: [PATCH 025/108] Preserve HTTP errors when quota response is not JSON (#40631) --- sdks/python/apache_beam/utils/retry.py | 2 +- sdks/python/apache_beam/utils/retry_test.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/sdks/python/apache_beam/utils/retry.py b/sdks/python/apache_beam/utils/retry.py index 03e42829b843..063446472fe5 100644 --- a/sdks/python/apache_beam/utils/retry.py +++ b/sdks/python/apache_beam/utils/retry.py @@ -183,7 +183,7 @@ def retry_on_server_errors_timeout_or_quota_issues_filter(exception): if not isinstance(content, dict): content = json.loads(exception.content) return content["error"]["errors"][0]["reason"] in _RETRYABLE_REASONS - except (KeyError, IndexError, TypeError) as e: + except (KeyError, IndexError, TypeError, ValueError) as e: _LOGGER.warning( "Could not determine if HttpError is transient. " "Will not retry: %s", diff --git a/sdks/python/apache_beam/utils/retry_test.py b/sdks/python/apache_beam/utils/retry_test.py index 05cc6e6a7f40..8d9e006e00c5 100644 --- a/sdks/python/apache_beam/utils/retry_test.py +++ b/sdks/python/apache_beam/utils/retry_test.py @@ -141,6 +141,24 @@ def test_with_http_error_that_should_be_retried(self): 500) self.assertEqual(len(self.clock.calls), 10) + @unittest.skipIf(HttpError is None, 'google-apitools is not installed') + def test_quota_filter_preserves_http_error_with_non_json_content(self): + error = HttpError({'status': '403'}, + 'upstream error', + 'https://example.test') + + @retry.with_exponential_backoff( + clock=self.clock, + retry_filter=retry.retry_on_server_errors_timeout_or_quota_issues_filter + ) + def fail(): + raise error + + with self.assertRaises(HttpError) as raised: + fail() + self.assertIs(raised.exception, error) + self.assertEqual(self.clock.calls, []) + def test_with_explicit_initial_delay(self): self.assertRaises( NotImplementedError, From 5fc82a67af69229a4a1597d91de8acd5b98b3569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Stankiewicz?= Date: Mon, 19 Oct 2026 09:57:13 +0200 Subject: [PATCH 026/108] [Spanner Change Streams] Match single-RPC deadline to total timeout in SpannerIO.ReadChangeStream (#40616) Set initialRpcTimeout and maxRpcTimeout to 5 minutes to match totalTimeout in SpannerIO.ReadChangeStream.buildChangeStreamSpannerConfig() instead of using a 1-minute per-RPC deadline. --- .../beam/sdk/io/gcp/spanner/SpannerIO.java | 4 +- .../SpannerIOReadChangeStreamTest.java | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIO.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIO.java index cc573686357a..c14bb6dfdcdc 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIO.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIO.java @@ -2538,8 +2538,8 @@ SpannerConfig buildChangeStreamSpannerConfig() { .setExecuteStreamingSqlRetrySettings( RetrySettings.newBuilder() .setTotalTimeout(org.threeten.bp.Duration.ofMinutes(5)) - .setInitialRpcTimeout(org.threeten.bp.Duration.ofMinutes(1)) - .setMaxRpcTimeout(org.threeten.bp.Duration.ofMinutes(1)) + .setInitialRpcTimeout(org.threeten.bp.Duration.ofMinutes(5)) + .setMaxRpcTimeout(org.threeten.bp.Duration.ofMinutes(5)) .build()) .build(); } diff --git a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIOReadChangeStreamTest.java b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIOReadChangeStreamTest.java index 13fd343f5c83..3c003e5cbd4b 100644 --- a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIOReadChangeStreamTest.java +++ b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIOReadChangeStreamTest.java @@ -26,6 +26,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.api.gax.retrying.RetrySettings; +import com.google.api.gax.rpc.StatusCode.Code; import com.google.auth.Credentials; import com.google.cloud.Timestamp; import com.google.cloud.spanner.DatabaseClient; @@ -41,6 +43,7 @@ import org.apache.beam.sdk.extensions.gcp.options.GcpOptions; import org.apache.beam.sdk.io.gcp.spanner.changestreams.MetadataSpannerConfigFactory; import org.apache.beam.sdk.testing.TestPipeline; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -175,6 +178,55 @@ public void testSetDirectedReadOptions() { .getReplicaSelections(0) .getLocation()); } + + @Test + public void testDefaultRetrySettings() { + assertNull(spannerConfig.getRetryableCodes()); + assertNull(spannerConfig.getExecuteStreamingSqlRetrySettings()); + SpannerConfig changeStreamSpannerConfig = readChangeStream.buildChangeStreamSpannerConfig(); + SpannerConfig metadataSpannerConfig = + MetadataSpannerConfigFactory.create( + changeStreamSpannerConfig, TEST_METADATA_INSTANCE, TEST_METADATA_DATABASE); + ImmutableSet expectedRetryableCodes = ImmutableSet.of(Code.UNAVAILABLE, Code.ABORTED); + RetrySettings expectedRetrySettings = + RetrySettings.newBuilder() + .setTotalTimeout(org.threeten.bp.Duration.ofMinutes(5)) + .setInitialRpcTimeout(org.threeten.bp.Duration.ofMinutes(5)) + .setMaxRpcTimeout(org.threeten.bp.Duration.ofMinutes(5)) + .build(); + assertEquals(expectedRetryableCodes, changeStreamSpannerConfig.getRetryableCodes()); + assertEquals(expectedRetryableCodes, metadataSpannerConfig.getRetryableCodes()); + assertEquals( + expectedRetrySettings, changeStreamSpannerConfig.getExecuteStreamingSqlRetrySettings()); + assertEquals( + expectedRetrySettings, metadataSpannerConfig.getExecuteStreamingSqlRetrySettings()); + } + + @Test + public void testCustomRetrySettings() { + ImmutableSet customRetryableCodes = ImmutableSet.of(Code.UNAVAILABLE); + RetrySettings customRetrySettings = + RetrySettings.newBuilder() + .setTotalTimeout(org.threeten.bp.Duration.ofMinutes(10)) + .setInitialRpcTimeout(org.threeten.bp.Duration.ofMinutes(10)) + .setMaxRpcTimeout(org.threeten.bp.Duration.ofMinutes(10)) + .build(); + spannerConfig = + spannerConfig + .withRetryableCodes(customRetryableCodes) + .withExecuteStreamingSqlRetrySettings(customRetrySettings); + readChangeStream = readChangeStream.withSpannerConfig(spannerConfig); + SpannerConfig changeStreamSpannerConfig = readChangeStream.buildChangeStreamSpannerConfig(); + SpannerConfig metadataSpannerConfig = + MetadataSpannerConfigFactory.create( + changeStreamSpannerConfig, TEST_METADATA_INSTANCE, TEST_METADATA_DATABASE); + assertEquals(customRetryableCodes, changeStreamSpannerConfig.getRetryableCodes()); + assertEquals(customRetryableCodes, metadataSpannerConfig.getRetryableCodes()); + assertEquals( + customRetrySettings, changeStreamSpannerConfig.getExecuteStreamingSqlRetrySettings()); + assertEquals( + customRetrySettings, metadataSpannerConfig.getExecuteStreamingSqlRetrySettings()); + } } /** Parameterized tests for Dialect and Partition Mode combinations. */ From 10d3b8d316d76bc19b8f19508063388dd3896728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Stankiewicz?= Date: Mon, 19 Oct 2026 10:00:47 +0200 Subject: [PATCH 027/108] Fix temp table cleanup for continuous read in BigQueryIO (#37535) * Fix temp table cleanup for continuous read in BigQueryIO * Fix missing coder for CleanupOperationMessage side outputs * Fix BQ streaming input tasks: move Javadoc, fix empty table edge case, and add test case. --- .../beam/sdk/io/gcp/bigquery/BigQueryIO.java | 242 ++++++++++++++++-- .../bigquery/BigQueryStorageStreamSource.java | 21 +- .../bigquery/CleanupTempTableDoFnTest.java | 153 +++++++++++ 3 files changed, 396 insertions(+), 20 deletions(-) create mode 100644 sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/CleanupTempTableDoFnTest.java diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java index 0cefea4639fd..07fa1a320e92 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java @@ -23,6 +23,7 @@ import static org.apache.beam.sdk.transforms.errorhandling.BadRecordRouter.RECORDING_ROUTER; import static org.apache.beam.sdk.util.Preconditions.checkArgumentNotNull; import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull; +import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.MoreObjects.firstNonNull; import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument; import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState; @@ -117,9 +118,14 @@ import org.apache.beam.sdk.schemas.FieldAccessDescriptor; import org.apache.beam.sdk.schemas.ProjectionProducer; import org.apache.beam.sdk.schemas.Schema; +import org.apache.beam.sdk.state.StateSpec; +import org.apache.beam.sdk.state.StateSpecs; +import org.apache.beam.sdk.state.ValueState; import org.apache.beam.sdk.transforms.Create; import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.sdk.transforms.DoFn.MultiOutputReceiver; +import org.apache.beam.sdk.transforms.DoFn.StateId; +import org.apache.beam.sdk.transforms.Flatten; import org.apache.beam.sdk.transforms.MapElements; import org.apache.beam.sdk.transforms.PTransform; import org.apache.beam.sdk.transforms.ParDo; @@ -141,6 +147,7 @@ import org.apache.beam.sdk.values.PBegin; import org.apache.beam.sdk.values.PCollection; import org.apache.beam.sdk.values.PCollection.IsBounded; +import org.apache.beam.sdk.values.PCollectionList; import org.apache.beam.sdk.values.PCollectionTuple; import org.apache.beam.sdk.values.PCollectionView; import org.apache.beam.sdk.values.Row; @@ -957,11 +964,18 @@ abstract Builder setBadRecordErrorHandler( DynamicRead() {} class CreateBoundedSourceForTable - extends DoFn, BigQueryStorageStreamSource> { + extends DoFn< + KV, KV>> { + + private final TupleTag> cleanupInfoTag; + + CreateBoundedSourceForTable(TupleTag> cleanupInfoTag) { + this.cleanupInfoTag = cleanupInfoTag; + } @ProcessElement public void processElement( - OutputReceiver> receiver, + MultiOutputReceiver receiver, @Element KV kv, PipelineOptions options) throws Exception { @@ -986,7 +1000,14 @@ public void processElement( // shards long desiredChunkSize = getDesiredChunkSize(options, output); List> split = output.split(desiredChunkSize, options); - split.stream().forEach(source -> receiver.output(source)); + split.stream() + .forEach( + source -> + receiver + .get( + new TupleTag>>( + "mainOutput")) + .output(KV.of(kv.getKey(), source))); } else { // run query BigQueryStorageQuerySource querySource = @@ -1023,7 +1044,21 @@ public void processElement( // shards long desiredChunkSize = getDesiredChunkSize(options, output); List> split = output.split(desiredChunkSize, options); - split.stream().forEach(source -> receiver.output(source)); + split.stream() + .forEach( + source -> + receiver + .get( + new TupleTag>>( + "mainOutput")) + .output(KV.of(kv.getKey(), source.withFromQuery()))); + boolean datasetCreatedByBeam = getQueryTempDataset() == null; + CleanupInfo cleanupInfo = + new CleanupInfo( + queryResultTable.getTableReference(), datasetCreatedByBeam, split.size()); + receiver + .get(cleanupInfoTag) + .output(KV.of(kv.getKey(), CleanupOperationMessage.initialize(cleanupInfo))); } } @@ -1037,6 +1072,9 @@ private long getDesiredChunkSize( @Override public PCollection expand(PCollection input) { TupleTag rowTag = new TupleTag<>(); + TupleTag>> streamTag = new TupleTag<>("mainOutput"); + TupleTag> cleanupInfoTag = new TupleTag<>(); + PCollection> addJobId = input .apply( @@ -1051,21 +1089,193 @@ public String apply(BigQueryDynamicReadDescriptor input) { .apply("Checkpoint", Redistribute.byKey()); PCollectionTuple resultTuple = - addJobId - .apply("Create streams", ParDo.of(new CreateBoundedSourceForTable())) + addJobId.apply( + "Create streams", + ParDo.of(new CreateBoundedSourceForTable(cleanupInfoTag)) + .withOutputTags(streamTag, TupleTagList.of(cleanupInfoTag))); + + PCollection>> streams = + resultTuple + .get(streamTag) .setCoder( - SerializableCoder.of(new TypeDescriptor>() {})) - .apply("Redistribute", Redistribute.arbitrarily()) - .apply( - "Read Streams with storage read api", - ParDo.of( - new TypedRead.ReadTableSource( - rowTag, getParseFn(), getBadRecordRouter())) - .withOutputTags(rowTag, TupleTagList.of(BAD_RECORD_TAG))); + KvCoder.of( + StringUtf8Coder.of(), + SerializableCoder.of( + new TypeDescriptor>() {}))) + .apply("Redistribute", Redistribute.arbitrarily()); + + PCollectionTuple readResultTuple = + streams.apply( + "Read Streams with storage read api", + ParDo.of( + new ReadDynamicStreamSource( + rowTag, getParseFn(), getBadRecordRouter(), cleanupInfoTag)) + .withOutputTags(rowTag, TupleTagList.of(BAD_RECORD_TAG).and(cleanupInfoTag))); + + PCollection> cleanupMessages1 = + resultTuple + .get(cleanupInfoTag) + .setCoder( + KvCoder.of( + StringUtf8Coder.of(), SerializableCoder.of(CleanupOperationMessage.class))); + + PCollection> cleanupMessages2 = + readResultTuple + .get(cleanupInfoTag) + .setCoder( + KvCoder.of( + StringUtf8Coder.of(), SerializableCoder.of(CleanupOperationMessage.class))); + + PCollectionList.of(cleanupMessages1) + .and(cleanupMessages2) + .apply(Flatten.pCollections()) + .apply("CleanupTempTables", ParDo.of(new CleanupTempTableDoFn(getBigQueryServices()))); + getBadRecordErrorHandler() .addErrorCollection( - resultTuple.get(BAD_RECORD_TAG).setCoder(BadRecord.getCoder(input.getPipeline()))); - return resultTuple.get(rowTag).setCoder(getOutputCoder()); + readResultTuple + .get(BAD_RECORD_TAG) + .setCoder(BadRecord.getCoder(input.getPipeline()))); + return readResultTuple.get(rowTag).setCoder(getOutputCoder()); + } + } + + static class CleanupInfo implements Serializable { + private final String projectId; + private final String datasetId; + private final String tableId; + private final boolean datasetCreatedByBeam; + private final int totalStreams; + + public CleanupInfo(TableReference tableRef, boolean datasetCreatedByBeam, int totalStreams) { + this.projectId = tableRef.getProjectId(); + this.datasetId = tableRef.getDatasetId(); + this.tableId = tableRef.getTableId(); + this.datasetCreatedByBeam = datasetCreatedByBeam; + this.totalStreams = totalStreams; + } + + public TableReference getTableReference() { + return new TableReference() + .setProjectId(projectId) + .setDatasetId(datasetId) + .setTableId(tableId); + } + + public boolean isDatasetCreatedByBeam() { + return datasetCreatedByBeam; + } + + public int getTotalStreams() { + return totalStreams; + } + } + + static class CleanupOperationMessage implements Serializable { + private final @Nullable CleanupInfo cleanupInfo; + private final boolean isStreamCompletion; + + private CleanupOperationMessage(@Nullable CleanupInfo cleanupInfo, boolean isStreamCompletion) { + this.cleanupInfo = cleanupInfo; + this.isStreamCompletion = isStreamCompletion; + } + + public static CleanupOperationMessage streamComplete() { + return new CleanupOperationMessage(null, true); + } + + public static CleanupOperationMessage initialize(CleanupInfo cleanupInfo) { + return new CleanupOperationMessage(cleanupInfo, false); + } + + public @Nullable CleanupInfo getCleanupInfo() { + return cleanupInfo; + } + + public boolean isStreamCompletion() { + return isStreamCompletion; + } + } + + static class CleanupTempTableDoFn extends DoFn, Void> { + private final BigQueryServices bqServices; + private static final Logger LOG = LoggerFactory.getLogger(CleanupTempTableDoFn.class); + + @StateId("cleanupInfo") + private final StateSpec> cleanupInfoSpec = StateSpecs.value(); + + @StateId("completedStreams") + private final StateSpec> completedStreamsSpec = StateSpecs.value(); + + CleanupTempTableDoFn(BigQueryServices bqServices) { + this.bqServices = bqServices; + } + + @ProcessElement + public void processElement( + @Element KV element, + @StateId("cleanupInfo") ValueState cleanupInfoState, + @StateId("completedStreams") ValueState completedStreamsState, + PipelineOptions options) + throws Exception { + + CleanupOperationMessage msg = element.getValue(); + CleanupInfo cleanupInfo = cleanupInfoState.read(); + int completed = firstNonNull(completedStreamsState.read(), 0); + + if (msg.isStreamCompletion()) { + completed += 1; + completedStreamsState.write(completed); + } else { + cleanupInfoState.write(Preconditions.checkNotNull(msg.getCleanupInfo())); + cleanupInfo = msg.getCleanupInfo(); + } + + if (cleanupInfo != null && completed == cleanupInfo.getTotalStreams()) { + TableReference tempTable = cleanupInfo.getTableReference(); + try (DatasetService datasetService = + bqServices.getDatasetService(options.as(BigQueryOptions.class))) { + LOG.info("Deleting temporary table with query results {}", tempTable); + datasetService.deleteTable(tempTable); + if (cleanupInfo.isDatasetCreatedByBeam()) { + LOG.info("Deleting temporary dataset with query results {}", tempTable.getDatasetId()); + datasetService.deleteDataset(tempTable.getProjectId(), tempTable.getDatasetId()); + } + } catch (Exception e) { + LOG.warn("Failed to delete temporary BigQuery table {}", tempTable, e); + } + cleanupInfoState.clear(); + completedStreamsState.clear(); + } + } + } + + private static class ReadDynamicStreamSource + extends DoFn>, T> { + private final TypedRead.ReadTableSource readTableSource; + private final TupleTag> streamCompletionTag; + + ReadDynamicStreamSource( + TupleTag rowTag, + SerializableFunction parseFn, + BadRecordRouter badRecordRouter, + TupleTag> streamCompletionTag) { + this.readTableSource = new TypedRead.ReadTableSource<>(rowTag, parseFn, badRecordRouter); + this.streamCompletionTag = streamCompletionTag; + } + + @ProcessElement + public void processElement( + @Element KV> element, + MultiOutputReceiver receiver, + PipelineOptions options) + throws Exception { + readTableSource.processElement(element.getValue(), receiver, options); + if (element.getValue().getFromQuery()) { + receiver + .get(streamCompletionTag) + .output(KV.of(element.getKey(), CleanupOperationMessage.streamComplete())); + } } } diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageStreamSource.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageStreamSource.java index 1bc0b2895d9a..6376af7d816f 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageStreamSource.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryStorageStreamSource.java @@ -77,7 +77,13 @@ public static BigQueryStorageStreamSource create( toJsonString(Preconditions.checkArgumentNotNull(tableSchema, "tableSchema")), parseFn, outputCoder, - bqServices); + bqServices, + false); + } + + public BigQueryStorageStreamSource withFromQuery() { + return new BigQueryStorageStreamSource<>( + readSession, readStream, jsonTableSchema, parseFn, outputCoder, bqServices, true); } @Override @@ -106,13 +112,13 @@ public int hashCode() { */ public BigQueryStorageStreamSource fromExisting(ReadStream newReadStream) { return new BigQueryStorageStreamSource<>( - readSession, newReadStream, jsonTableSchema, parseFn, outputCoder, bqServices); + readSession, newReadStream, jsonTableSchema, parseFn, outputCoder, bqServices, fromQuery); } public BigQueryStorageStreamSource fromExisting( SerializableFunction parseFn) { return new BigQueryStorageStreamSource<>( - readSession, readStream, jsonTableSchema, parseFn, outputCoder, bqServices); + readSession, readStream, jsonTableSchema, parseFn, outputCoder, bqServices, fromQuery); } private final ReadSession readSession; @@ -121,6 +127,7 @@ public BigQueryStorageStreamSource fromExisting( private final SerializableFunction parseFn; private final Coder outputCoder; private final BigQueryServices bqServices; + private final boolean fromQuery; private BigQueryStorageStreamSource( ReadSession readSession, @@ -128,13 +135,19 @@ private BigQueryStorageStreamSource( String jsonTableSchema, SerializableFunction parseFn, Coder outputCoder, - BigQueryServices bqServices) { + BigQueryServices bqServices, + boolean fromQuery) { this.readSession = Preconditions.checkArgumentNotNull(readSession, "readSession"); this.readStream = Preconditions.checkArgumentNotNull(readStream, "stream"); this.jsonTableSchema = Preconditions.checkArgumentNotNull(jsonTableSchema, "jsonTableSchema"); this.parseFn = Preconditions.checkArgumentNotNull(parseFn, "parseFn"); this.outputCoder = Preconditions.checkArgumentNotNull(outputCoder, "outputCoder"); this.bqServices = Preconditions.checkArgumentNotNull(bqServices, "bqServices"); + this.fromQuery = fromQuery; + } + + public boolean getFromQuery() { + return fromQuery; } @Override diff --git a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/CleanupTempTableDoFnTest.java b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/CleanupTempTableDoFnTest.java new file mode 100644 index 000000000000..b1fa3dc8e576 --- /dev/null +++ b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/CleanupTempTableDoFnTest.java @@ -0,0 +1,153 @@ +/* + * 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 org.apache.beam.sdk.io.gcp.bigquery; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.api.services.bigquery.model.TableReference; +import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO.CleanupInfo; +import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO.CleanupOperationMessage; +import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO.CleanupTempTableDoFn; +import org.apache.beam.sdk.io.gcp.testing.FakeBigQueryServices; +import org.apache.beam.sdk.io.gcp.testing.FakeDatasetService; +import org.apache.beam.sdk.testing.TestPipeline; +import org.apache.beam.sdk.transforms.Create; +import org.apache.beam.sdk.transforms.ParDo; +import org.apache.beam.sdk.values.KV; +import org.apache.beam.sdk.values.PCollection; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class CleanupTempTableDoFnTest { + + @Rule public transient TestPipeline p = TestPipeline.create(); + + @Before + public void setUp() { + FakeDatasetService.setUp(); + } + + @Test + public void testCleanupTempTableDoFn() throws Exception { + FakeDatasetService fakeDatasetService = new FakeDatasetService(); + FakeBigQueryServices fakeBqServices = + new FakeBigQueryServices().withDatasetService(fakeDatasetService); + + TableReference tableRef = + new TableReference().setProjectId("project").setDatasetId("dataset").setTableId("table"); + + fakeDatasetService.createDataset( + tableRef.getProjectId(), tableRef.getDatasetId(), "", "", null); + fakeDatasetService.createTable( + new com.google.api.services.bigquery.model.Table().setTableReference(tableRef)); + + assertTrue( + fakeDatasetService.getDataset(tableRef.getProjectId(), tableRef.getDatasetId()) != null); + assertTrue(fakeDatasetService.getTable(tableRef) != null); + + CleanupInfo cleanupInfo = new CleanupInfo(tableRef, true, 2); + + PCollection> input = + p.apply( + Create.of( + KV.of("job1", CleanupOperationMessage.initialize(cleanupInfo)), + KV.of("job1", CleanupOperationMessage.streamComplete()), + KV.of("job1", CleanupOperationMessage.streamComplete()))); + + input.apply(ParDo.of(new CleanupTempTableDoFn(fakeBqServices))); + + p.run().waitUntilFinish(); + + // The dataset is deleted, so getDataset and getTable should throw a 404 Exception or return + // null + try { + fakeDatasetService.getDataset(tableRef.getProjectId(), tableRef.getDatasetId()); + fail("Dataset should have been deleted"); + } catch (Exception e) { + assertTrue( + e.getMessage().contains("Tried to get a dataset") + || e.getMessage().contains("Not Found")); + } + + try { + TableReference deletedRef = + new TableReference().setProjectId("project").setDatasetId("dataset").setTableId("table"); + Object table = fakeDatasetService.getTable(deletedRef); + assertTrue(table == null); + } catch (Exception e) { + assertTrue( + e.getMessage().contains("Tried to get a dataset") + || e.getMessage().contains("Not Found")); + } + } + + @Test + public void testCleanupTempTableDoFnWithZeroStreams() throws Exception { + FakeDatasetService fakeDatasetService = new FakeDatasetService(); + FakeBigQueryServices fakeBqServices = + new FakeBigQueryServices().withDatasetService(fakeDatasetService); + + TableReference tableRef = + new TableReference().setProjectId("project").setDatasetId("dataset").setTableId("table"); + + fakeDatasetService.createDataset( + tableRef.getProjectId(), tableRef.getDatasetId(), "", "", null); + fakeDatasetService.createTable( + new com.google.api.services.bigquery.model.Table().setTableReference(tableRef)); + + assertTrue( + fakeDatasetService.getDataset(tableRef.getProjectId(), tableRef.getDatasetId()) != null); + assertTrue(fakeDatasetService.getTable(tableRef) != null); + + CleanupInfo cleanupInfo = new CleanupInfo(tableRef, true, 0); + + PCollection> input = + p.apply( + "CreateInputWithZeroStreams", + Create.of(KV.of("job1", CleanupOperationMessage.initialize(cleanupInfo)))); + + input.apply("CleanupWithZeroStreams", ParDo.of(new CleanupTempTableDoFn(fakeBqServices))); + + p.run().waitUntilFinish(); + + try { + fakeDatasetService.getDataset(tableRef.getProjectId(), tableRef.getDatasetId()); + fail("Dataset should have been deleted"); + } catch (Exception e) { + assertTrue( + e.getMessage().contains("Tried to get a dataset") + || e.getMessage().contains("Not Found")); + } + + try { + TableReference deletedRef = + new TableReference().setProjectId("project").setDatasetId("dataset").setTableId("table"); + Object table = fakeDatasetService.getTable(deletedRef); + assertTrue(table == null); + } catch (Exception e) { + assertTrue( + e.getMessage().contains("Tried to get a dataset") + || e.getMessage().contains("Not Found")); + } + } +} From 49d41adc89549aea325378e0007873b7b352c2b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:47:37 -0400 Subject: [PATCH 028/108] Bump google.golang.org/api from 0.302.0 to 0.304.0 in /sdks (#40650) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.302.0 to 0.304.0. - [Release notes](https://github.com/googleapis/google-api-go-client/releases) - [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md) - [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.302.0...v0.304.0) --- updated-dependencies: - dependency-name: google.golang.org/api dependency-version: 0.304.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- sdks/go.mod | 2 +- sdks/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdks/go.mod b/sdks/go.mod index d60a9db1651f..f6b6d047f8f1 100644 --- a/sdks/go.mod +++ b/sdks/go.mod @@ -60,7 +60,7 @@ require ( golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 golang.org/x/text v0.43.0 - google.golang.org/api v0.302.0 + google.golang.org/api v0.304.0 google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 diff --git a/sdks/go.sum b/sdks/go.sum index 1fc97c94e394..52ab74e2243e 100644 --- a/sdks/go.sum +++ b/sdks/go.sum @@ -1315,8 +1315,8 @@ google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7 google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= -google.golang.org/api v0.302.0 h1:/eENsEv3+GE0QiEmXjdaLI84Mt76LNjGz4vp4E7sRVw= -google.golang.org/api v0.302.0/go.mod h1:dcjRWAy1QdjGb3Z67Qu0gdBWH2gL8Qoy/RI2ZVuvdeE= +google.golang.org/api v0.304.0 h1:2R1dYSiIVQRGcKN901u9yPFEdJu5h90oGY5X4TcgbmA= +google.golang.org/api v0.304.0/go.mod h1:dcjRWAy1QdjGb3Z67Qu0gdBWH2gL8Qoy/RI2ZVuvdeE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= From e4bc725b42d388cf19f6781f6ee196f57cb52289 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:48:20 -0400 Subject: [PATCH 029/108] Bump zizmorcore/zizmor-action from 0.6.4 to 0.7.1 (#40652) Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.6.4 to 0.7.1. - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/cc914d7f3750a2d13d75c7f184a1060aa0e9d482...85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e) --- updated-dependencies: - dependency-name: zizmorcore/zizmor-action dependency-version: 0.7.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/beam_PreCommit_GHA.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/beam_PreCommit_GHA.yml b/.github/workflows/beam_PreCommit_GHA.yml index 04dd4d098783..0fabd369611f 100644 --- a/.github/workflows/beam_PreCommit_GHA.yml +++ b/.github/workflows/beam_PreCommit_GHA.yml @@ -96,7 +96,7 @@ jobs: - name: Validate GHA Allowlist uses: apache/infrastructure-actions/allowlist-check@main # zizmor: ignore[unpinned-uses] - name: Run zizmor - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1 with: advanced-security: true - name: run GHA PreCommit script From 5fa8c2c878e553ee12aee8ca114a5124a9187ab8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:06:57 -0400 Subject: [PATCH 030/108] Bump github/codeql-action from 4.38.3 to 4.39.0 (#40651) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.38.3 to 4.39.0. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.38.3...v4.39.0) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.39.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 883f2ca317a5..804f38d09922 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -154,7 +154,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.38.3 + uses: github/codeql-action/init@v4.39.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -193,6 +193,6 @@ jobs: fi - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.38.3 + uses: github/codeql-action/analyze@v4.39.0 with: category: "/language:${{matrix.language}}" From 8648b89152356fa843dea19a0ad8a20e1bf7522b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Stankiewicz?= Date: Mon, 19 Oct 2026 15:44:08 +0200 Subject: [PATCH 031/108] Update users.yml (#40654) --- infra/iam/users.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/infra/iam/users.yml b/infra/iam/users.yml index 6c37b4c1fef0..29e55c624cb6 100644 --- a/infra/iam/users.yml +++ b/infra/iam/users.yml @@ -745,6 +745,11 @@ - role: roles/cloudfunctions.invoker - role: roles/datastore.user - role: roles/storage.objectViewer +- username: radoslaws + email: radoslaws@google.com + member_type: user + permissions: + - role: roles/editor - username: rajkumargupta email: rajkumargupta@google.com member_type: user @@ -1236,4 +1241,4 @@ member_type: user permissions: - role: projects/apache-beam-testing/roles/beam_viewer - - role: projects/apache-beam-testing/roles/beam_writer \ No newline at end of file + - role: projects/apache-beam-testing/roles/beam_writer From 590abac114546ec33cedff26e44f37857150eb71 Mon Sep 17 00:00:00 2001 From: raams95 Date: Mon, 19 Oct 2026 09:53:25 -0500 Subject: [PATCH 032/108] [Python] Avoid ZeroDivisionError in ConcatRangeTracker for zero weights (#40549) * [Python] Avoid ZeroDivisionError in ConcatRangeTracker for zero weights When every SourceBundle passed to a ConcatSource had weight 0, the average used to fill in missing weights was also 0, so computing the cumulative weights divided by zero. Treat zero weights like unknown weights when computing that average. Fixes #21316 * Add CHANGES.md entry * Test zero-weight ConcatSource through a pipeline read --------- Co-authored-by: JanakiRamChittineni <341781521+raams95@users.noreply.github.com> Co-authored-by: Jack McCluskey <34928439+jrmccluskey@users.noreply.github.com> --- CHANGES.md | 1 + sdks/python/apache_beam/io/concat_source.py | 4 ++-- sdks/python/apache_beam/io/concat_source_test.py | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c10b7262209c..1ec1dbe80864 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -115,6 +115,7 @@ * (Python) `Duration` built from float seconds now rounds to the nearest microsecond instead of truncating, which could lose a microsecond ([#40263](https://github.com/apache/beam/issues/40263)). * (Python) `ReadFromText` with `escapechar` no longer skips a delimiter that directly follows an escaped delimiter, which merged two records into one ([#40459](https://github.com/apache/beam/issues/40459)). * (Python) DataFrame API: `agg` with a list that includes `'count'` or `'size'` now labels those rows `count`/`size` instead of `sum` ([#40504](https://github.com/apache/beam/issues/40504)). +* (Python) `ConcatSource` no longer raises `ZeroDivisionError` when every source bundle reports a weight of 0, e.g. bundles smaller than 1 MB ([#21316](https://github.com/apache/beam/issues/21316)). * (Python) DataFrame API: `Series.value_counts(bins=...)` no longer always fails with a `TypeError` ([#40503](https://github.com/apache/beam/issues/40503)). * Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). diff --git a/sdks/python/apache_beam/io/concat_source.py b/sdks/python/apache_beam/io/concat_source.py index 35ae6fe27317..bd6b7396d5d3 100644 --- a/sdks/python/apache_beam/io/concat_source.py +++ b/sdks/python/apache_beam/io/concat_source.py @@ -135,8 +135,8 @@ def _compute_cumulative_weights(source_bundles): min_diff = 1e-5 # For the computation below, we need weights for all sources. # Substitute average weights for those whose weights are - # unspecified (or 1.0 for everything if none are known). - known = [s.weight for s in source_bundles if s.weight is not None] + # unspecified or zero (or 1.0 for everything if none are known). + known = [s.weight for s in source_bundles if s.weight] avg = sum(known) / len(known) if known else 1.0 weights = [s.weight or avg for s in source_bundles] diff --git a/sdks/python/apache_beam/io/concat_source_test.py b/sdks/python/apache_beam/io/concat_source_test.py index efa24b3975fc..3cd169df5069 100644 --- a/sdks/python/apache_beam/io/concat_source_test.py +++ b/sdks/python/apache_beam/io/concat_source_test.py @@ -171,6 +171,15 @@ def test_position_at_fration(self): self.assertEqual(range_tracker.position_at_fraction(.601), (2, 17)) self.assertEqual(range_tracker.position_at_fraction(1), (3, None)) + def test_read_zero_weight_bundles_in_pipeline(self): + source = ConcatSource([ + iobase.SourceBundle(0, RangeSource(0, 4), None, None), + iobase.SourceBundle(0, RangeSource(4, 8), None, None), + ]) + with TestPipeline() as pipeline: + pcoll = pipeline | beam.io.Read(source) + assert_that(pcoll, equal_to(list(range(8)))) + def test_empty_source(self): read_all = source_test_utils.read_from_source From 14575dcd143edd5f6f22fdff703671bda4aaf457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Stankiewicz?= Date: Mon, 19 Oct 2026 16:55:00 +0200 Subject: [PATCH 033/108] Update api key for tour of beam (#40655) --- learning/tour-of-beam/frontend/lib/firebase_options.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/learning/tour-of-beam/frontend/lib/firebase_options.dart b/learning/tour-of-beam/frontend/lib/firebase_options.dart index e64b594af433..fb6c35ce66f8 100644 --- a/learning/tour-of-beam/frontend/lib/firebase_options.dart +++ b/learning/tour-of-beam/frontend/lib/firebase_options.dart @@ -53,7 +53,7 @@ class DefaultFirebaseOptions { } static const FirebaseOptions web = FirebaseOptions( - apiKey: 'AIzaSyAg7ZLslQRrEhwVVVzZb1OFMRMHL8kNL38', + apiKey: 'AIzaSyA4MXDLiEEdY-Bm_Iyxrg3W_cmdCSa4lvg', authDomain: 'apache-beam-testing.firebaseapp.com', projectId: 'apache-beam-testing', storageBucket: 'apache-beam-testing.appspot.com', From 2c60b1996e7c671104f98314ec70101f382c4158 Mon Sep 17 00:00:00 2001 From: Vitaly Terentyev Date: Mon, 19 Oct 2026 18:55:39 +0400 Subject: [PATCH 034/108] Fix postgres database for grafana (#40656) --- .../grafana/provisioning/datasources/beampostgresql.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.test-infra/metrics/grafana/provisioning/datasources/beampostgresql.yaml b/.test-infra/metrics/grafana/provisioning/datasources/beampostgresql.yaml index dd9a5d764a6e..496914bbd415 100644 --- a/.test-infra/metrics/grafana/provisioning/datasources/beampostgresql.yaml +++ b/.test-infra/metrics/grafana/provisioning/datasources/beampostgresql.yaml @@ -29,8 +29,8 @@ datasources: secureJsonData: password: ${PSQL_DB_DBPWD} user: ${PSQL_DB_DBUSERNAME} - database: ${PSQL_DB_DBNAME} jsonData: + database: ${PSQL_DB_DBNAME} sslmode: disable editable: false From bd102581f7f8fd9f446a22ff490cf272be9f4077 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ibrahim Date: Mon, 19 Oct 2026 18:29:50 +0300 Subject: [PATCH 035/108] Run portable ValidatesRunner tests one at a time (#40632) * run portable ValidatesRunner tests one at a time * delay flink teardown --- .../beam_PostCommit_Java_PVR_Flink_Batch.json | 2 +- ...m_PostCommit_Java_PVR_Flink_Streaming.json | 2 +- .../FlinkExecutableStageContextFactory.java | 51 ++++++++++++++----- .../flink/job-server/flink_job_server.gradle | 2 + .../FlinkExecutableStageContextFactory.java | 51 ++++++++++++++----- 5 files changed, 78 insertions(+), 30 deletions(-) diff --git a/.github/trigger_files/beam_PostCommit_Java_PVR_Flink_Batch.json b/.github/trigger_files/beam_PostCommit_Java_PVR_Flink_Batch.json index 6f486a1f8c14..73e12ca8961b 100644 --- a/.github/trigger_files/beam_PostCommit_Java_PVR_Flink_Batch.json +++ b/.github/trigger_files/beam_PostCommit_Java_PVR_Flink_Batch.json @@ -1,6 +1,6 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", "https://github.com/apache/beam/pull/32440": "test new datastream runner for batch", - "modification": 2, + "modification": 8, "https://github.com/apache/beam/pull/39990": "removing dead code from FnApiDoFnRunner" } diff --git a/.github/trigger_files/beam_PostCommit_Java_PVR_Flink_Streaming.json b/.github/trigger_files/beam_PostCommit_Java_PVR_Flink_Streaming.json index 626e4fea5c8b..ebd004055319 100644 --- a/.github/trigger_files/beam_PostCommit_Java_PVR_Flink_Streaming.json +++ b/.github/trigger_files/beam_PostCommit_Java_PVR_Flink_Streaming.json @@ -1,6 +1,6 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 2, + "modification": 3, "https://github.com/apache/beam/pull/32440": "test new datastream runner for batch", "https://github.com/apache/beam/pull/39990": "removing dead code from FnApiDoFnRunner" } diff --git a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageContextFactory.java b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageContextFactory.java index 3f42eb93e4e6..afcfb9d67300 100644 --- a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageContextFactory.java +++ b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageContextFactory.java @@ -22,7 +22,9 @@ import org.apache.beam.runners.fnexecution.control.DefaultExecutableStageContext; import org.apache.beam.runners.fnexecution.control.ExecutableStageContext; import org.apache.beam.runners.fnexecution.control.ReferenceCountingExecutableStageContextFactory; +import org.apache.beam.runners.fnexecution.control.StageBundleFactory; import org.apache.beam.runners.fnexecution.provisioning.JobInfo; +import org.apache.beam.sdk.util.construction.graph.ExecutableStage; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; /** Singleton class that contains one {@link ExecutableStageContext.Factory} per job. */ @@ -30,10 +32,6 @@ public class FlinkExecutableStageContextFactory implements ExecutableStageContex private static final FlinkExecutableStageContextFactory instance = new FlinkExecutableStageContextFactory(); - // This map should only ever have a single element, as each job will have its own - // classloader and therefore its own instance of FlinkExecutableStageContextFactory. This - // code supports multiple JobInfos in order to provide a sensible implementation of - // Factory.get(JobInfo), which in theory could be called with different JobInfos. private static final ConcurrentMap jobFactories = new ConcurrentHashMap<>(); @@ -48,16 +46,41 @@ public ExecutableStageContext get(JobInfo jobInfo) { ExecutableStageContext.Factory jobFactory = jobFactories.computeIfAbsent( jobInfo.jobId(), - k -> { - return ReferenceCountingExecutableStageContextFactory.create( - DefaultExecutableStageContext::create, - // Clean up context immediately if its class is not loaded on Flink parent - // classloader. - (caller) -> - caller.getClass().getClassLoader() - != StreamExecutionEnvironment.class.getClassLoader()); - }); - + k -> + ReferenceCountingExecutableStageContextFactory.create( + info -> new JobScopedExecutableStageContext(info, k), + // Release immediately when the factory is not on the Flink parent classloader + // (so cleanup still works after that classloader is discarded). Otherwise keep + // the default environment cache delay so DoFn @Teardown can finish first. + caller -> + caller.getClass().getClassLoader() + != StreamExecutionEnvironment.class.getClassLoader())); return jobFactory.get(jobInfo); } + + private static class JobScopedExecutableStageContext implements ExecutableStageContext { + private final DefaultExecutableStageContext delegate; + private final String jobId; + + private JobScopedExecutableStageContext(JobInfo jobInfo, String jobId) { + this.delegate = DefaultExecutableStageContext.create(jobInfo); + this.jobId = jobId; + } + + @Override + public StageBundleFactory getStageBundleFactory(ExecutableStage executableStage) { + return delegate.getStageBundleFactory(executableStage); + } + + @Override + public void close() throws Exception { + try { + delegate.close(); + } finally { + // Drop the per-job factory entry so a long-lived JVM that runs many jobs does not + // accumulate one entry per finished job. + jobFactories.remove(jobId); + } + } + } } diff --git a/runners/flink/job-server/flink_job_server.gradle b/runners/flink/job-server/flink_job_server.gradle index 6f39b67abf74..edf55807503f 100644 --- a/runners/flink/job-server/flink_job_server.gradle +++ b/runners/flink/job-server/flink_job_server.gradle @@ -129,6 +129,7 @@ runShadow { jvmArgs += ["-Dorg.slf4j.simpleLogger.defaultLogLevel=${project.property('logLevel')}"] } + def portableValidatesRunnerTask(String name, String mode, boolean checkpointing, boolean docker) { def pipelineOptions = [ // Limit resource consumption via parallelism @@ -257,6 +258,7 @@ def flinkJobServerJvmArgs() { [validatesPortableRunnerDocker, validatesPortableRunnerBatchDataSet, validatesPortableRunnerBatch, validatesPortableRunnerStreaming, validatesPortableRunnerStreamingCheckpoint].each { task -> task.configure { jvmArgs += flinkJobServerJvmArgs() + systemProperty "beam.validatesRunner.parallelThreads", "1" } } diff --git a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageContextFactory.java b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageContextFactory.java index a2e3bb3142bf..5a11336add14 100644 --- a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageContextFactory.java +++ b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageContextFactory.java @@ -22,7 +22,9 @@ import org.apache.beam.runners.fnexecution.control.DefaultExecutableStageContext; import org.apache.beam.runners.fnexecution.control.ExecutableStageContext; import org.apache.beam.runners.fnexecution.control.ReferenceCountingExecutableStageContextFactory; +import org.apache.beam.runners.fnexecution.control.StageBundleFactory; import org.apache.beam.runners.fnexecution.provisioning.JobInfo; +import org.apache.beam.sdk.util.construction.graph.ExecutableStage; import org.apache.flink.api.java.ExecutionEnvironment; /** Singleton class that contains one {@link ExecutableStageContext.Factory} per job. */ @@ -30,10 +32,6 @@ public class FlinkExecutableStageContextFactory implements ExecutableStageContex private static final FlinkExecutableStageContextFactory instance = new FlinkExecutableStageContextFactory(); - // This map should only ever have a single element, as each job will have its own - // classloader and therefore its own instance of FlinkExecutableStageContextFactory. This - // code supports multiple JobInfos in order to provide a sensible implementation of - // Factory.get(JobInfo), which in theory could be called with different JobInfos. private static final ConcurrentMap jobFactories = new ConcurrentHashMap<>(); @@ -48,16 +46,41 @@ public ExecutableStageContext get(JobInfo jobInfo) { ExecutableStageContext.Factory jobFactory = jobFactories.computeIfAbsent( jobInfo.jobId(), - k -> { - return ReferenceCountingExecutableStageContextFactory.create( - DefaultExecutableStageContext::create, - // Clean up context immediately if its class is not loaded on Flink parent - // classloader. - (caller) -> - caller.getClass().getClassLoader() - != ExecutionEnvironment.class.getClassLoader()); - }); - + k -> + ReferenceCountingExecutableStageContextFactory.create( + info -> new JobScopedExecutableStageContext(info, k), + // Release immediately when the factory is not on the Flink parent classloader + // (so cleanup still works after that classloader is discarded). Otherwise keep + // the default environment cache delay so DoFn @Teardown can finish first. + caller -> + caller.getClass().getClassLoader() + != ExecutionEnvironment.class.getClassLoader())); return jobFactory.get(jobInfo); } + + private static class JobScopedExecutableStageContext implements ExecutableStageContext { + private final DefaultExecutableStageContext delegate; + private final String jobId; + + private JobScopedExecutableStageContext(JobInfo jobInfo, String jobId) { + this.delegate = DefaultExecutableStageContext.create(jobInfo); + this.jobId = jobId; + } + + @Override + public StageBundleFactory getStageBundleFactory(ExecutableStage executableStage) { + return delegate.getStageBundleFactory(executableStage); + } + + @Override + public void close() throws Exception { + try { + delegate.close(); + } finally { + // Drop the per-job factory entry so a long-lived JVM that runs many jobs does not + // accumulate one entry per finished job. + jobFactories.remove(jobId); + } + } + } } From 62357960fc3114da572ef549287da4566699f770 Mon Sep 17 00:00:00 2001 From: tvalentyn Date: Mon, 19 Oct 2026 08:35:00 -0700 Subject: [PATCH 036/108] Remove a race with another file write that is not essential to test presence of duplicates. (#40596) * Remove a race with another file write that is not essential to test presence of duplicates. * Replace removed functionality. --- sdks/python/apache_beam/io/fileio_test.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/sdks/python/apache_beam/io/fileio_test.py b/sdks/python/apache_beam/io/fileio_test.py index 1c650653804c..3bb250a34bd2 100644 --- a/sdks/python/apache_beam/io/fileio_test.py +++ b/sdks/python/apache_beam/io/fileio_test.py @@ -366,13 +366,6 @@ def test_without_deduplication(self): file = self._create_temp_file(dir=tempdir) # Add file twice, since it will be matched for every interval files += [file, file] - # Add file name that will be created mid-pipeline - files.append(FileSystems.join(tempdir, 'extra')) - - def _create_extra_file(element): - writer = FileSystems.create(FileSystems.join(tempdir, 'extra')) - writer.close() - return element.path with TestPipeline() as p: match_continiously = ( @@ -383,8 +376,7 @@ def _create_extra_file(element): has_deduplication=False, start_timestamp=start, stop_timestamp=stop) - | beam.Map(_create_extra_file)) - + | beam.Map(lambda element: element.path)) assert_that(match_continiously, equal_to(files)) def test_match_updated_files(self): From a8ee2b84d37d014c3de8934b2e3ef8702260a2b2 Mon Sep 17 00:00:00 2001 From: Tobias Kaymak Date: Mon, 19 Oct 2026 17:37:24 +0200 Subject: [PATCH 037/108] [Spark 4] Group the streaming stateful ParDo by encoded key bytes to support null keys (#40516) * [Spark 4] Group the streaming stateful ParDo by encoded key bytes to support null keys A null Beam key became a null Spark grouping key and transformWithState failed with INTERNAL_ERROR_TWS. Grouping by the coder encoded key bytes is null safe and matches Beam key equality. Part of #36841. * Retrigger CI, flaky StatefulStreamingParDoEvaluatorTest on Spark 3.4 * Retrigger CI, flaky legacy Spark streaming tests in Spark Versions * Retrigger CI, KafkaToPubsubE2ETest container startup flake in PreCommit Java * Use the structured streaming CoderHelpers in the stateful ParDo * Merge the structured streaming CoderHelpers into the common one * Decode with the common CoderHelpers in BeamMicroBatchSourceTest --- ...ostCommit_Java_ValidatesRunner_Spark4.json | 2 +- ...idatesRunner_SparkStructuredStreaming.json | 2 +- .../io/streaming/BeamReaderCache.java | 2 +- .../io/streaming/UnboundedSourceDataset.java | 2 +- .../batch/GroupByKeyTranslatorBatch.java | 2 +- .../translation/helpers/EncoderHelpers.java | 1 + .../streaming/ReadUnboundedTranslator.java | 2 +- .../StatefulParDoStreamingTranslator.java | 10 ++-- .../state/BeamStatefulProcessor.java | 13 ++-- .../streaming/BeamMicroBatchSourceTest.java | 12 ++-- .../streaming/StatefulParDoStreamingTest.java | 36 +++++++++++ .../batch/GroupByKeyTranslatorBatch.java | 2 +- .../batch/functions/SideInputValues.java | 2 +- .../translation/helpers/CoderHelpers.java | 59 ------------------- .../translation/helpers/EncoderHelpers.java | 1 + 15 files changed, 64 insertions(+), 84 deletions(-) delete mode 100644 runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/CoderHelpers.java diff --git a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json index c537844dc84a..e0266d62f2e0 100644 --- a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json +++ b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Spark4.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 3 + "modification": 4 } diff --git a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json index bd93fd4e9881..c0d5a77072b2 100644 --- a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json +++ b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json @@ -12,5 +12,5 @@ "https://github.com/apache/beam/pull/40103": "noting that PR #40103 should run this test", "https://github.com/apache/beam/issues/40427": "Spark ValidatesRunner tests run serially", "https://github.com/apache/beam/pull/40281": "noting that PR #40281 should run this test", - "https://github.com/apache/beam/issues/36841": "Spark 4 streaming watermark lag" + "https://github.com/apache/beam/issues/36841": "Spark 4 streaming null keys" } diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamReaderCache.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamReaderCache.java index 1eb31e766e78..e961ff2f3807 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamReaderCache.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamReaderCache.java @@ -27,7 +27,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.LongSupplier; -import org.apache.beam.runners.spark.structuredstreaming.translation.helpers.CoderHelpers; +import org.apache.beam.runners.spark.coders.CoderHelpers; import org.apache.beam.sdk.io.UnboundedSource; import org.apache.beam.sdk.io.UnboundedSource.CheckpointMark; import org.apache.beam.sdk.io.UnboundedSource.UnboundedReader; diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/UnboundedSourceDataset.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/UnboundedSourceDataset.java index 0368f95ba6db..33106824c107 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/UnboundedSourceDataset.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/UnboundedSourceDataset.java @@ -29,9 +29,9 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.apache.beam.runners.core.construction.SerializablePipelineOptions; +import org.apache.beam.runners.spark.coders.CoderHelpers; import org.apache.beam.runners.spark.structuredstreaming.SparkStructuredStreamingPipelineOptions; import org.apache.beam.runners.spark.structuredstreaming.io.streaming.BeamReaderCache.CachedReader; -import org.apache.beam.runners.spark.structuredstreaming.translation.helpers.CoderHelpers; import org.apache.beam.sdk.coders.Coder; import org.apache.beam.sdk.io.UnboundedSource; import org.apache.beam.sdk.io.UnboundedSource.CheckpointMark; diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/GroupByKeyTranslatorBatch.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/GroupByKeyTranslatorBatch.java index 7caf06cb38fd..2b930c579d00 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/GroupByKeyTranslatorBatch.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/GroupByKeyTranslatorBatch.java @@ -18,13 +18,13 @@ package org.apache.beam.runners.spark.structuredstreaming.translation.batch; import static org.apache.beam.repackaged.core.org.apache.commons.lang3.ArrayUtils.EMPTY_BYTE_ARRAY; +import static org.apache.beam.runners.spark.coders.CoderHelpers.toByteArray; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.eligibleForGlobalGroupBy; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.eligibleForGroupByWindow; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.explodeWindowedKey; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.valueKey; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.valueValue; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.windowedKV; -import static org.apache.beam.runners.spark.structuredstreaming.translation.helpers.CoderHelpers.toByteArray; import static org.apache.beam.runners.spark.structuredstreaming.translation.helpers.EncoderHelpers.collectionEncoder; import static org.apache.beam.runners.spark.structuredstreaming.translation.helpers.EncoderHelpers.encoderOf; import static org.apache.beam.runners.spark.structuredstreaming.translation.helpers.EncoderHelpers.kvEncoder; diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/EncoderHelpers.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/EncoderHelpers.java index 173b4653a19b..4cd92da0a15f 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/EncoderHelpers.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/EncoderHelpers.java @@ -36,6 +36,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; +import org.apache.beam.runners.spark.coders.CoderHelpers; import org.apache.beam.sdk.coders.Coder; import org.apache.beam.sdk.transforms.windowing.BoundedWindow; import org.apache.beam.sdk.transforms.windowing.GlobalWindow; diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/ReadUnboundedTranslator.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/ReadUnboundedTranslator.java index fb1691108b71..eb1421b45540 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/ReadUnboundedTranslator.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/ReadUnboundedTranslator.java @@ -19,10 +19,10 @@ import static org.apache.spark.sql.functions.col; +import org.apache.beam.runners.spark.coders.CoderHelpers; import org.apache.beam.runners.spark.structuredstreaming.SparkStructuredStreamingPipelineOptions; import org.apache.beam.runners.spark.structuredstreaming.io.streaming.UnboundedSourceDataset; import org.apache.beam.runners.spark.structuredstreaming.translation.TransformTranslator; -import org.apache.beam.runners.spark.structuredstreaming.translation.helpers.CoderHelpers; import org.apache.beam.sdk.coders.Coder; import org.apache.beam.sdk.io.UnboundedSource; import org.apache.beam.sdk.transforms.windowing.GlobalWindow; diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTranslator.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTranslator.java index 1709ec570038..199570ae64a1 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTranslator.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTranslator.java @@ -17,6 +17,7 @@ */ package org.apache.beam.runners.spark.structuredstreaming.translation.streaming; +import org.apache.beam.runners.spark.coders.CoderHelpers; import org.apache.beam.runners.spark.structuredstreaming.metrics.MetricsAccumulator; import org.apache.beam.runners.spark.structuredstreaming.translation.TransformTranslator; import org.apache.beam.runners.spark.structuredstreaming.translation.batch.DoFnRunnerFactory; @@ -35,7 +36,7 @@ import org.apache.beam.sdk.values.WindowingStrategy; import org.apache.spark.api.java.function.MapFunction; import org.apache.spark.sql.Dataset; -import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; import org.apache.spark.sql.streaming.OutputMode; import org.apache.spark.sql.streaming.TimeMode; @@ -57,7 +58,7 @@ protected void translate(ParDo.MultiOutput, OutputT> transform, Context DoFn, OutputT> doFn = transform.getFn(); KvCoder inputCoder = (KvCoder) input.getCoder(); - Encoder keyEnc = cxt.keyEncoderOf(inputCoder); + Coder keyCoder = inputCoder.getKeyCoder(); TupleTag mainOutputTag = transform.getMainOutputTag(); PCollection output = cxt.getOutput(mainOutputTag); Coder outputCoder = output.getCoder(); @@ -71,10 +72,11 @@ protected void translate(ParDo.MultiOutput, OutputT> transform, Context new BeamStatefulProcessor<>( doFn, runnerFactory, inputCoder, windowing, cxt.getOptionsSupplier(), metrics); - MapFunction>, K> keyFn = v -> v.getValue().getKey(); + MapFunction>, byte[]> keyFn = + v -> CoderHelpers.toByteArray(v.getValue().getKey(), keyCoder); Dataset> result = cxt.getDataset(input) - .groupByKey(keyFn, keyEnc) + .groupByKey(keyFn, Encoders.BINARY()) .transformWithState( processor, TimeMode.EventTime(), diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java index e359ed6bd58b..2649f883af3f 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java @@ -41,6 +41,7 @@ import org.apache.beam.runners.spark.structuredstreaming.translation.batch.StatefulTaskRunner; import org.apache.beam.runners.spark.structuredstreaming.translation.utils.ScalaInterop; import org.apache.beam.sdk.coders.Coder; +import org.apache.beam.sdk.coders.KvCoder; import org.apache.beam.sdk.coders.ListCoder; import org.apache.beam.sdk.options.PipelineOptions; import org.apache.beam.sdk.state.TimeDomain; @@ -68,7 +69,7 @@ /** Runs a stateful {@link DoFn} within Spark 4 Structured Streaming {@code transformWithState}. */ public class BeamStatefulProcessor - extends StatefulProcessor>, WindowedValue> { + extends StatefulProcessor>, WindowedValue> { private static final String BEAM_STATE = "beamState"; private static final String BEAM_TIMERS = "beamTimers"; @@ -76,7 +77,7 @@ public class BeamStatefulProcessor private final DoFn, OutputT> doFn; private final DoFnRunnerFactory, OutputT> runnerFactory; - private final Coder> inputCoder; + private final KvCoder inputCoder; private final WindowingStrategy windowingStrategy; private final Supplier optionsSupplier; private final MetricsAccumulator metrics; @@ -93,7 +94,7 @@ public class BeamStatefulProcessor public BeamStatefulProcessor( DoFn, OutputT> doFn, DoFnRunnerFactory, OutputT> runnerFactory, - Coder> inputCoder, + KvCoder inputCoder, WindowingStrategy windowingStrategy, Supplier optionsSupplier, MetricsAccumulator metrics) { @@ -138,7 +139,8 @@ private SparkTimerInternals timerInternals(TimerValues timerValues) { @Override public Iterator> handleInputRows( - K key, Iterator>> rows, TimerValues timerValues) { + byte[] keyBytes, Iterator>> rows, TimerValues timerValues) { + K key = CoderHelpers.fromByteArray(keyBytes, inputCoder.getKeyCoder()); List> outputs = new ArrayList<>(); SparkTimerInternals timerInternals = timerInternals(timerValues); @@ -159,7 +161,8 @@ public Iterator> handleInputRows( @Override public Iterator> handleExpiredTimer( - K key, TimerValues timerValues, ExpiredTimerInfo expiredTimerInfo) { + byte[] keyBytes, TimerValues timerValues, ExpiredTimerInfo expiredTimerInfo) { + K key = CoderHelpers.fromByteArray(keyBytes, inputCoder.getKeyCoder()); List> outputs = new ArrayList<>(); SparkTimerInternals timerInternals = timerInternals(timerValues); timerInternals.advanceWatermark(); diff --git a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamMicroBatchSourceTest.java b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamMicroBatchSourceTest.java index 3b2e36bf5ef7..559deb808036 100644 --- a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamMicroBatchSourceTest.java +++ b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/io/streaming/BeamMicroBatchSourceTest.java @@ -46,6 +46,7 @@ import javax.annotation.Nullable; import org.apache.beam.runners.core.construction.SerializablePipelineOptions; import org.apache.beam.runners.spark.StreamingTest; +import org.apache.beam.runners.spark.coders.CoderHelpers; import org.apache.beam.runners.spark.structuredstreaming.SparkSessionRule; import org.apache.beam.runners.spark.structuredstreaming.SparkStructuredStreamingPipelineOptions; import org.apache.beam.runners.spark.structuredstreaming.io.streaming.UnboundedSourceDataset.BeamInputPartition; @@ -60,7 +61,6 @@ import org.apache.beam.sdk.io.UnboundedSource; import org.apache.beam.sdk.options.PipelineOptionsFactory; import org.apache.beam.sdk.transforms.windowing.GlobalWindow; -import org.apache.beam.sdk.util.CoderUtils; import org.apache.beam.sdk.values.WindowedValue; import org.apache.beam.sdk.values.WindowedValues; import org.apache.hadoop.conf.Configuration; @@ -209,7 +209,7 @@ public void testReadsElementsFromUnboundedSource() throws Exception { List values = new ArrayList<>(); for (List batch : batches(tag)) { for (Row row : batch) { - WindowedValue value = CoderUtils.decodeFromByteArray(CODER, row.getAs(COL_PAYLOAD)); + WindowedValue value = CoderHelpers.fromByteArray(row.getAs(COL_PAYLOAD), CODER); values.add(value.getValue()); assertEquals( value.getTimestamp().getMillis(), row.getAs(COL_EVENT_TS).getTime()); @@ -593,11 +593,7 @@ private static List values(List> batches) { } private static String decode(byte[] payload) { - try { - return CoderUtils.decodeFromByteArray(CODER, payload).getValue(); - } catch (IOException e) { - throw new IllegalStateException(e); - } + return CoderHelpers.fromByteArray(payload, CODER).getValue(); } private static List nonEmptySizes(List> batches) { @@ -748,7 +744,7 @@ private static int committedPosition(File checkpointDir, int shard) throws IOExc new BeamSourceCheckpoint(sourceDir(checkpointDir).getAbsolutePath(), new Configuration()); byte[] coded = checkpoint.readMark(shard, epoch); assertNotNull("no mark at committed epoch " + epoch + " for shard " + shard, coded); - return CoderUtils.decodeFromByteArray(TestUnboundedSource.MARK_CODER, coded).next; + return CoderHelpers.fromByteArray(coded, TestUnboundedSource.MARK_CODER).next; } // --------------------------------------------------------------------------------------------- diff --git a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java index 2ff0bfc2bf05..2d3c127d72bc 100644 --- a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java +++ b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java @@ -33,6 +33,7 @@ import org.apache.beam.sdk.PipelineResult; import org.apache.beam.sdk.coders.KvCoder; import org.apache.beam.sdk.coders.StringUtf8Coder; +import org.apache.beam.sdk.coders.VoidCoder; import org.apache.beam.sdk.io.Read; import org.apache.beam.sdk.state.StateSpec; import org.apache.beam.sdk.state.StateSpecs; @@ -43,6 +44,7 @@ import org.apache.beam.sdk.state.ValueState; import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.sdk.transforms.ParDo; +import org.apache.beam.sdk.transforms.WithKeys; import org.apache.beam.sdk.transforms.windowing.FixedWindows; import org.apache.beam.sdk.transforms.windowing.Window; import org.apache.beam.sdk.values.KV; @@ -69,12 +71,14 @@ public class StatefulParDoStreamingTest implements Serializable { private static final String TAG_BATCHES = "stateful-batches"; private static final String TAG_RESTART = "stateful-restart"; private static final String TAG_CHAINED = "stateful-chained"; + private static final String TAG_NULL_KEY = "stateful-null-key"; @After public void tearDown() { TestUnboundedSource.forget(TAG_BATCHES); TestUnboundedSource.forget(TAG_RESTART); TestUnboundedSource.forget(TAG_CHAINED); + TestUnboundedSource.forget(TAG_NULL_KEY); } private static Pipeline pipeline( @@ -167,6 +171,25 @@ public void testChainedStatefulParDoTimerOutputSurvives() throws Exception { assertTrue(collected.contains("timer-output")); } + @Test + public void testNullKey() throws Exception { + String collectorId = StreamingTestUtils.newCollectorId(TAG_NULL_KEY); + SparkStructuredStreamingPipelineOptions options = + StreamingTestUtils.streamingOptions(tempFolder); + options.setMaxRecordsPerBatch(1L); + Pipeline pipeline = Pipeline.create(options); + pipeline + .apply("Read", Read.from(new TestUnboundedSource(TAG_NULL_KEY, 1, 5))) + .apply("WithKeys", WithKeys.of((Void) null)) + .setCoder(KvCoder.of(VoidCoder.of(), StringUtf8Coder.of())) + .apply("StatefulParDo", ParDo.of(new NullKeyCountFn())) + .apply("Collect", ParDo.of(new StreamingTestUtils.CollectDoFn<>(collectorId))); + + assertEquals(PipelineResult.State.DONE, StreamingTestUtils.run(pipeline).getState()); + Set collected = StreamingTestUtils.collected(collectorId); + assertTrue(collected.contains(5)); + } + private static final class KeySelectorFn extends DoFn> { @ProcessElement public void process(@Element String element, OutputReceiver> out) { @@ -301,4 +324,17 @@ public void process( out.output(element.getValue()); } } + + private static final class NullKeyCountFn extends DoFn, Integer> { + @StateId("count") + private final StateSpec> countSpec = StateSpecs.value(); + + @ProcessElement + public void process( + @StateId("count") ValueState countState, OutputReceiver out) { + int count = (countState.read() == null ? 0 : countState.read()) + 1; + countState.write(count); + out.output(count); + } + } } diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/GroupByKeyTranslatorBatch.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/GroupByKeyTranslatorBatch.java index c55781ff84a8..c67ea3ad3e5b 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/GroupByKeyTranslatorBatch.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/GroupByKeyTranslatorBatch.java @@ -18,13 +18,13 @@ package org.apache.beam.runners.spark.structuredstreaming.translation.batch; import static org.apache.beam.repackaged.core.org.apache.commons.lang3.ArrayUtils.EMPTY_BYTE_ARRAY; +import static org.apache.beam.runners.spark.coders.CoderHelpers.toByteArray; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.eligibleForGlobalGroupBy; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.eligibleForGroupByWindow; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.explodeWindowedKey; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.valueKey; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.valueValue; import static org.apache.beam.runners.spark.structuredstreaming.translation.batch.GroupByKeyHelpers.windowedKV; -import static org.apache.beam.runners.spark.structuredstreaming.translation.helpers.CoderHelpers.toByteArray; import static org.apache.beam.runners.spark.structuredstreaming.translation.helpers.EncoderHelpers.collectionEncoder; import static org.apache.beam.runners.spark.structuredstreaming.translation.helpers.EncoderHelpers.encoderOf; import static org.apache.beam.runners.spark.structuredstreaming.translation.helpers.EncoderHelpers.kvEncoder; diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/functions/SideInputValues.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/functions/SideInputValues.java index 23c8d49c3091..0017f95b2a05 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/functions/SideInputValues.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/batch/functions/SideInputValues.java @@ -34,8 +34,8 @@ import java.util.Map; import java.util.function.Function; import javax.annotation.Nullable; +import org.apache.beam.runners.spark.coders.CoderHelpers; import org.apache.beam.runners.spark.structuredstreaming.translation.EvaluationContext; -import org.apache.beam.runners.spark.structuredstreaming.translation.helpers.CoderHelpers; import org.apache.beam.sdk.annotations.Internal; import org.apache.beam.sdk.coders.Coder; import org.apache.beam.sdk.transforms.windowing.BoundedWindow; diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/CoderHelpers.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/CoderHelpers.java deleted file mode 100644 index f8c63bc34f14..000000000000 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/CoderHelpers.java +++ /dev/null @@ -1,59 +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 org.apache.beam.runners.spark.structuredstreaming.translation.helpers; - -import java.io.IOException; -import org.apache.beam.sdk.coders.Coder; -import org.apache.beam.sdk.util.CoderUtils; - -/** Serialization utility class. */ -public final class CoderHelpers { - private CoderHelpers() {} - - /** - * Utility method for serializing an object using the specified coder. - * - * @param value Value to serialize. - * @param coder Coder to serialize with. - * @param type of value that is serialized - * @return Byte array representing serialized object. - */ - public static byte[] toByteArray(T value, Coder coder) { - try { - return CoderUtils.encodeToByteArray(coder, value); - } catch (IOException e) { - throw new IllegalStateException("Error encoding value: " + value, e); - } - } - - /** - * Utility method for deserializing a byte array using the specified coder. - * - * @param serialized bytearray to be deserialized. - * @param coder Coder to deserialize with. - * @param Type of object to be returned. - * @return Deserialized object. - */ - public static T fromByteArray(byte[] serialized, Coder coder) { - try { - return CoderUtils.decodeFromByteArray(coder, serialized); - } catch (IOException e) { - throw new IllegalStateException("Error decoding bytes for coder: " + coder, e); - } - } -} diff --git a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/EncoderHelpers.java b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/EncoderHelpers.java index 29f01c84c02e..08ce47c67bb8 100644 --- a/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/EncoderHelpers.java +++ b/runners/spark/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/helpers/EncoderHelpers.java @@ -36,6 +36,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; +import org.apache.beam.runners.spark.coders.CoderHelpers; import org.apache.beam.sdk.coders.Coder; import org.apache.beam.sdk.transforms.windowing.BoundedWindow; import org.apache.beam.sdk.transforms.windowing.GlobalWindow; From 59a8647c15a669d55a676d6ab94e7b49b791eb6a Mon Sep 17 00:00:00 2001 From: Ryan McGinnis Date: Mon, 19 Oct 2026 08:52:58 -0700 Subject: [PATCH 038/108] Update nlp_tensorflow_streaming.ipynb (#40641) * Update nlp_tensorflow_streaming.ipynb Update link to YouTube Data API. Also, removes link to code samples because they aren't actively being maintained and/or they're in the process of being updated. * Update examples/notebooks/beam-ml/nlp_tensorflow_streaming.ipynb Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- examples/notebooks/beam-ml/nlp_tensorflow_streaming.ipynb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/notebooks/beam-ml/nlp_tensorflow_streaming.ipynb b/examples/notebooks/beam-ml/nlp_tensorflow_streaming.ipynb index 6f5048e7e8ee..7785041253e6 100644 --- a/examples/notebooks/beam-ml/nlp_tensorflow_streaming.ipynb +++ b/examples/notebooks/beam-ml/nlp_tensorflow_streaming.ipynb @@ -54,7 +54,7 @@ "source": [ "Natural Language Processing or NLP is a field of Artifical Intelligence that enables computers to interpret and understand human language. It involves multiple steps such as applying various preprocessing functions, getting predictions from a model, storing the predictions in a useful format, etc.\n", "Sentiment Analysis is a popular use case of NLP, which allows computers to analyze the sentiment of a text. This notebook demonstrates the use of streaming pipelines in NLP.\n", - "* Extracts comments using [Youtube API](https://developers.google.com/youtube/v3) and publishing them to Pub/Sub\n", + "* Extracts comments using [Youtube API](https://developers.google.com/youtube/data) and publishing them to Pub/Sub\n", "* Trains a TensorFlow model to predict the sentiment of text\n", "* Stores the model in Google Cloud and creates a model handler\n", "* Builds a Beam pipeline to:\n", @@ -724,9 +724,7 @@ }, "source": [ "## Extracting and sending comments to Pub/Sub\n", - "YouTube API provides an interface for accessing YouTube data. First, we need to enable YouTube API on the Google Cloud Console project. After that, we need to create a credential, which will further provide an API key. This API key, along with a video ID can be used to access data of that YouTube video. The Publisher created earlier is used to publish each comment to Pub/Sub.\n", - "\n", - "See examples of using the YouTube API [here](https://developers.google.com/youtube/v3/code_samples/code_snippets)." + "YouTube API provides an interface for accessing YouTube data. First, we need to enable YouTube API on the Google Cloud Console project. After that, we need to create a credential, which will further provide an API key. This API key, along with a video ID can be used to access data of that YouTube video. The Publisher created earlier is used to publish each comment to Pub/Sub.\n" ] }, { From 5dc12703dc95fa123456fa843807c174c0843208 Mon Sep 17 00:00:00 2001 From: pablo rodriguez defino Date: Mon, 19 Oct 2026 11:30:05 -0700 Subject: [PATCH 039/108] [Prism] Keep a stateful key in progress while a split primary holds it (#40624) When the SDK accepts a channel split of a bundle of a stateful stage, stageState.splitBundle removed the key of every residual element from inprogressKeys and inprogressKeysByBundle, even when elements of the primary still carry that key. The residual of that key was then scheduled at once in a second bundle that ran concurrently with the primary. Prism gives each bundle a copy of the key's state at bundle start and replaces the key's state with the bundle's copy at commit, so the last bundle to commit wins. State updates, and the outputs that depend on them, are lost without any error. Serializing the processing of a key is the runner's job. The model defines state per key and window: ReadModifyWriteState "holds a single value per key and window. The value can be read, modified, and written back atomically" (beam_runner_api.proto). The stateful processing post says the runner "cannot parallelize processing of elements for a particular key+window", and the programming guide says "State is always fully scoped only to the current processing key". Splitting is valid SDK behaviour (beam_fn_api.proto), and the Java and Python harnesses accept channel splits on stateful stages. splitBundle now collects the keys of the primary elements and releases a residual's key only if no primary element has it. A held key is released when the primary bundle is persisted, as before. The handling of residual timer holds is unchanged. Reproduction with the Python SDK: all elements share one key, and a per-element sleep makes Prism split the bundle. class CountOnOneKey(beam.DoFn): COUNT = ReadModifyWriteStateSpec('count', VarIntCoder()) DONE = TimerSpec('done', TimeDomain.WATERMARK) def process(self, element, count=beam.DoFn.StateParam(COUNT), done=beam.DoFn.TimerParam(DONE)): time.sleep(0.25) count.write((count.read() or 0) + 1) done.set(GlobalWindow().max_timestamp()) @on_timer(DONE) def report(self, count=beam.DoFn.StateParam(COUNT)): yield count.read() options = PipelineOptions(['--runner=PrismRunner', '--prism_location=', '--environment_type=LOOPBACK']) with beam.Pipeline(options=options) as p: (p | beam.Create(range(40)) | beam.Map(lambda i: ('k', i)) | beam.ParDo(CountOnOneKey()) | beam.Map(print)) A correct runner prints 40. Prism built from master printed 20 in 10 of 10 runs; with this change it printed 40 in 10 of 10 runs. With --prism_log_level=debug, the same pipeline on master logs one split at element 20 of 40, and the residual bundle starts before the primary commits. TestElementManager_SplitStatefulBundle covers a split inside the only key, a split inside one of two keys, and a split between keys. The first two fail without this change. Not fixed here, follow-ups: - splitBundle slices the bundle's elements by the data channel index. A stateful bundle can hold data of one key and timers of another, and timers are not counted in the data channel, so the slice can misattribute elements. See #35771. - startTriggeredBundle, for aggregate stages, does not check inprogressKeys before it injects a bundle. --- .../prism/internal/engine/elementmanager.go | 15 +- .../engine/elementmanager_split_test.go | 194 ++++++++++++++++++ 2 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager_split_test.go diff --git a/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager.go b/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager.go index 80b4a75ac725..2ed73e2c6639 100644 --- a/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager.go +++ b/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager.go @@ -2318,9 +2318,20 @@ func (ss *stageState) splitBundle(rb RunBundle, firstResidual int, em *ElementMa es.es = prim + // A key stays assigned to this bundle while any primary element still + // carries it. Releasing it would let a residual of the same key run in a + // second bundle while the primary is still processing it, so both bundles + // would start from the same state and the last commit would drop the + // other's state changes. + primKeys := set[string]{} + for _, e := range prim { + primKeys.insert(string(e.keyBytes)) + } for _, e := range res { - delete(ss.inprogressKeysByBundle[rb.BundleID], string(e.keyBytes)) - delete(ss.inprogressKeys, string(e.keyBytes)) + if k := string(e.keyBytes); !primKeys.present(k) { + delete(ss.inprogressKeysByBundle[rb.BundleID], k) + delete(ss.inprogressKeys, k) + } if e.IsTimer() { slog.Warn("Unexpected split on a bundle with timers. See https://github.com/apache/beam/issues/35771 for information.") diff --git a/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager_split_test.go b/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager_split_test.go new file mode 100644 index 000000000000..d13552b0a363 --- /dev/null +++ b/sdks/go/pkg/beam/runners/prism/internal/engine/elementmanager_split_test.go @@ -0,0 +1,194 @@ +// 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 engine + +import ( + "fmt" + "testing" + + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph/mtime" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph/window" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/typex" +) + +// splitInsideKey returns a split point that keeps the first keep elements of +// key in the primary and returns the remaining elements of key, and every +// element after them, as the residual. +func splitInsideKey(key string, keep int) func(t *testing.T, bundleKeys []string) int { + return func(t *testing.T, bundleKeys []string) int { + t.Helper() + for i, k := range bundleKeys { + if k == key { + return i + keep + } + } + t.Fatalf("key %q not in bundle %v", key, bundleKeys) + return 0 + } +} + +// splitAfterFirstKey returns a split point that keeps exactly the elements of +// the first key of the bundle in the primary. +func splitAfterFirstKey(t *testing.T, bundleKeys []string) int { + t.Helper() + i := 0 + for i < len(bundleKeys) && bundleKeys[i] == bundleKeys[0] { + i++ + } + if i == len(bundleKeys) { + t.Fatalf("bundle %v has a single key, want at least two", bundleKeys) + } + return i +} + +// TestElementManager_SplitStatefulBundle checks that a channel split of a +// stateful stage's bundle keeps every key held by a primary element assigned +// to the primary bundle, so no other bundle processes that key concurrently. +// +// Prism gives each bundle a copy of the key's state at bundle start and +// replaces the key's state with the bundle's copy at commit. If two bundles +// process the same key at the same time, the last commit discards the state +// changes of the other. +func TestElementManager_SplitStatefulBundle(t *testing.T) { + tests := []struct { + name string + // keys has one entry per input element, all in the global window. + keys []string + // firstResidual picks the split point from the bundle's key order, + // which depends on map iteration order. + firstResidual func(t *testing.T, bundleKeys []string) int + }{ + { + name: "split inside the only key", + keys: []string{"K", "K", "K"}, + firstResidual: splitInsideKey("K", 1), + }, { + name: "split inside one of two keys", + keys: []string{"A", "B", "B"}, + firstResidual: splitInsideKey("B", 1), + }, { + name: "split between keys", + keys: []string{"A", "A", "B", "B"}, + firstResidual: splitAfterFirstKey, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + em := NewElementManager(Config{}) + em.AddStage("impulse", nil, []string{"input"}, nil) + em.AddStage("stateful", []string{"input"}, nil, nil) + em.StageStateful("stateful", nil) + ss := em.stages["stateful"] + + var es []element + for i, k := range test.keys { + es = append(es, element{ + window: window.GlobalWindow{}, + timestamp: mtime.Time(i), + pane: typex.NoFiringPane(), + elmBytes: []byte(fmt.Sprintf("%s%d", k, i)), + keyBytes: []byte(k), + sequence: i, + }) + } + em.addPending(ss.AddPending(em, es)) + + var nextID int + genBundID := func() string { + defer func() { nextID++ }() + return fmt.Sprintf("inst%03d", nextID) + } + // startBundle builds the next event time bundle, if any, and + // returns it with the key of each of its elements. + startBundle := func() (RunBundle, []string, bool) { + bundID, ok, _, _ := ss.startEventTimeBundle(mtime.MaxTimestamp, genBundID) + if !ok { + return RunBundle{}, nil, false + } + var keys []string + for _, e := range ss.inprogress[bundID].es { + keys = append(keys, string(e.keyBytes)) + } + return RunBundle{StageID: ss.ID, BundleID: bundID, Watermark: mtime.MaxTimestamp}, keys, true + } + + primary, bundleKeys, ok := startBundle() + if !ok { + t.Fatal("startEventTimeBundle() built no bundle, want one") + } + if got, want := len(bundleKeys), len(test.keys); got != want { + t.Fatalf("bundle %v has %v elements, want %v", bundleKeys, got, want) + } + split := test.firstResidual(t, bundleKeys) + wantHeld := set[string]{} + for _, k := range bundleKeys[:split] { + wantHeld.insert(k) + } + + em.ReturnResiduals(primary, split, PColInfo{}, Residuals{}) + + checked := set[string]{} + for _, k := range bundleKeys { + if checked.present(k) { + continue + } + checked.insert(k) + want := wantHeld.present(k) + if got := ss.inprogressKeys.present(k); got != want { + t.Errorf("after split of %v at %v: key %v in inprogressKeys = %v, want %v", bundleKeys, split, k, got, want) + } + if got := ss.inprogressKeysByBundle[primary.BundleID].present(k); got != want { + t.Errorf("after split of %v at %v: key %v in inprogressKeysByBundle[%v] = %v, want %v", bundleKeys, split, k, primary.BundleID, got, want) + } + } + + // While the primary runs, only residuals of released keys may + // be scheduled. + processed := split + for { + rb, keys, ok := startBundle() + if !ok { + break + } + for _, k := range keys { + if wantHeld.present(k) { + t.Fatalf("bundle %v has key %v while primary bundle %v still processes it", rb.BundleID, k, primary.BundleID) + } + } + processed += len(keys) + em.PersistBundle(rb, nil, TentativeData{}, PColInfo{}, Residuals{}) + } + + // Once the primary commits, the residuals of its keys run. + em.PersistBundle(primary, nil, TentativeData{}, PColInfo{}, Residuals{}) + for { + rb, keys, ok := startBundle() + if !ok { + break + } + processed += len(keys) + em.PersistBundle(rb, nil, TentativeData{}, PColInfo{}, Residuals{}) + } + + if got, want := processed, len(test.keys); got != want { + t.Errorf("processed %v elements, want %v", got, want) + } + if got := em.livePending.Load(); got != 0 { + t.Errorf("live pending = %v after all bundles persisted, want 0", got) + } + }) + } +} From 05c48794a860fea77b640812b2d469da2310e7ed Mon Sep 17 00:00:00 2001 From: Ahmed Abualsaud <65791736+ahmedabu98@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:36:54 -0700 Subject: [PATCH 040/108] allow dropping pk or watermark column (#40457) --- .../sdk/io/iceberg/IcebergScanConfig.java | 78 +++++++++++++---- .../cdc/IncrementalChangelogSource.java | 34 +++++++- ...ergCdcReadSchemaTransformProviderTest.java | 7 +- .../sdk/io/iceberg/IcebergIOReadTest.java | 18 ---- .../sdk/io/iceberg/IcebergScanConfigTest.java | 77 ++++++++++++---- .../cdc/IncrementalChangelogSourceTest.java | 87 ++++++++++++++++++- 6 files changed, 239 insertions(+), 62 deletions(-) diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java index bcd574afdaba..6aebf0aa9b26 100644 --- a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java +++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java @@ -65,6 +65,7 @@ public abstract class IcebergScanConfig implements Serializable { private transient @MonotonicNonNull Table cachedTable; private transient org.apache.iceberg.@MonotonicNonNull Schema cachedProjectedSchema; + private transient org.apache.iceberg.@MonotonicNonNull Schema cachedRequestedSchema; private transient org.apache.iceberg.@MonotonicNonNull Schema cachedRequiredSchema; private transient @MonotonicNonNull Expression cachedFilter; private transient org.apache.iceberg.@MonotonicNonNull Schema cachedRecordIdSchema; @@ -136,14 +137,25 @@ static org.apache.iceberg.Schema resolveSchema( return selectedFields.isEmpty() ? schema : schema.select(selectedFields); } - /** Returns the projected Schema after applying column pruning. */ + /** + * Returns the projected Schema after applying column pruning. For CDC reads, this also includes + * the primary key and watermark columns, even when they are not requested. + */ public org.apache.iceberg.Schema getProjectedSchema() { if (cachedProjectedSchema == null) { - cachedProjectedSchema = resolveSchema(getTable().schema(), getKeepFields(), getDropFields()); + cachedProjectedSchema = withCdcFields(getRequestedSchema()); } return cachedProjectedSchema; } + /** Returns the Schema of the columns selected by 'keep' or 'drop'. */ + public org.apache.iceberg.Schema getRequestedSchema() { + if (cachedRequestedSchema == null) { + cachedRequestedSchema = resolveSchema(getTable().schema(), getKeepFields(), getDropFields()); + } + return cachedRequestedSchema; + } + /** * Returns a Schema that includes all the fields required for a successful read. This includes * explicitly selected fields and fields referenced in the filter statement. @@ -151,15 +163,54 @@ public org.apache.iceberg.Schema getProjectedSchema() { public org.apache.iceberg.Schema getRequiredSchema() { if (cachedRequiredSchema == null) { cachedRequiredSchema = - resolveSchema( - getTable().schema(), - getKeepFields(), - getDropFields(), - FilterUtils.getReferencedFieldNames(getFilterString())); + withCdcFields( + resolveSchema( + getTable().schema(), + getKeepFields(), + getDropFields(), + FilterUtils.getReferencedFieldNames(getFilterString()))); } return cachedRequiredSchema; } + /** + * CDC reads also need the primary key, whose column stats the changelog scan uses to find update + * pairs, and the watermark column, which timestamps rows. Adds the top-level columns holding them + * if the projection leaves them out. + */ + private org.apache.iceberg.Schema withCdcFields(org.apache.iceberg.Schema projection) { + if (!getUseCdc()) { + return projection; + } + org.apache.iceberg.Schema tableSchema = getTable().schema(); + Set missing = new HashSet<>(tableSchema.identifierFieldIds()); + @Nullable String watermarkColumn = getWatermarkColumn(); + @Nullable NestedField watermarkField = + watermarkColumn != null ? tableSchema.findField(watermarkColumn) : null; + if (watermarkField != null) { + missing.add(watermarkField.fieldId()); + } + missing.removeAll(TypeUtil.getProjectedIds(projection)); + if (missing.isEmpty()) { + return projection; + } + + Set addedColumnIds = new HashSet<>(); + for (NestedField column : TypeUtil.select(tableSchema, missing).columns()) { + addedColumnIds.add(column.fieldId()); + } + List columns = new ArrayList<>(); + for (NestedField column : tableSchema.columns()) { + @Nullable NestedField projected = projection.asStruct().field(column.fieldId()); + if (addedColumnIds.contains(column.fieldId())) { + columns.add(column); + } else if (projected != null) { + columns.add(projected); + } + } + return new org.apache.iceberg.Schema(columns); + } + public org.apache.iceberg.Schema recordIdSchema() { if (cachedRecordIdSchema == null) { org.apache.iceberg.Schema fullSchema = getTable().schema(); @@ -438,17 +489,9 @@ void validate(Table table) { + invalidOptions)); } } else { - Set primaryKeyIds = new HashSet<>(table.schema().identifierFieldIds()); checkState( - !primaryKeyIds.isEmpty(), + !table.schema().identifierFieldIds().isEmpty(), "Cannot read CDC records as the table schema does not specified any primary key fields."); - Set projectedFieldIds = TypeUtil.getProjectedIds(getProjectedSchema()); - primaryKeyIds.removeAll(projectedFieldIds); - checkArgument( - primaryKeyIds.isEmpty(), - "When reading CDC records, the projected schema must not drop primary key fields. " - + "The specified configuration drops the following PK fields: %s", - primaryKeyIds); validateMetadataColumns(table); } @@ -527,9 +570,6 @@ void validate(Table table) { error("'watermark_column' must be a timestamp-typed column, but '%s' has type %s"), watermarkColumn, field.type().typeId()); - checkArgumentNotNull( - getProjectedSchema().findField(watermarkColumn), - "'watermark_column' column should not be dropped."); } @Nullable String watermarkColumnTimeUnit = getWatermarkColumnTimeUnit(); diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/IncrementalChangelogSource.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/IncrementalChangelogSource.java index fe6240d5dc53..9a5d2413717c 100644 --- a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/IncrementalChangelogSource.java +++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/IncrementalChangelogSource.java @@ -31,7 +31,9 @@ import org.apache.beam.sdk.io.iceberg.IcebergUtils; import org.apache.beam.sdk.io.iceberg.ReadUtils; import org.apache.beam.sdk.schemas.Schema; +import org.apache.beam.sdk.schemas.transforms.Cast; import org.apache.beam.sdk.transforms.Create; +import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.sdk.transforms.Flatten; import org.apache.beam.sdk.transforms.PTransform; import org.apache.beam.sdk.transforms.ParDo; @@ -167,7 +169,21 @@ public PCollection expand(PBegin input) { watermarkColumn, scanConfig.getWatermarkColumnTimeUnit()))); } - return merged.setRowSchema(outputRowSchema); + Schema requestedRowSchema = + CdcOutputUtils.outputSchema( + scanConfig, + IcebergUtils.icebergSchemaToBeamSchema( + scanConfig.getRequestedSchema(), scanConfig.getUpdateCompatibilityVersion())); + if (requestedRowSchema.equals(outputRowSchema)) { + return merged.setRowSchema(outputRowSchema); + } + // the primary key or watermark column was read only for internal use + return merged + .setRowSchema(outputRowSchema) + .apply( + "Drop Internal Columns", + ParDo.of(new DropInternalColumns(outputRowSchema, requestedRowSchema))) + .setRowSchema(requestedRowSchema); } /** @@ -208,4 +224,20 @@ private PCollection boundedSnapshots(PBegin input) { .collect(Collectors.toList()); return input.apply("Create Snapshot Range", Create.timestamped(timestamped)); } + + /** Drops columns read only for internal use. Rows keep their ValueKind and timestamp. */ + private static class DropInternalColumns extends DoFn { + private final Schema inputSchema; + private final Schema outputSchema; + + DropInternalColumns(Schema inputSchema, Schema outputSchema) { + this.inputSchema = inputSchema; + this.outputSchema = outputSchema; + } + + @ProcessElement + public void process(@Element Row row, OutputReceiver out) { + out.output(Cast.castRow(row, inputSchema, outputSchema)); + } + } } diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergCdcReadSchemaTransformProviderTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergCdcReadSchemaTransformProviderTest.java index 3a21566c3056..7a489b59a0b7 100644 --- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergCdcReadSchemaTransformProviderTest.java +++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergCdcReadSchemaTransformProviderTest.java @@ -230,7 +230,8 @@ public void testManagedReadWithProjectionFilterWatermarkAndSnapshotRange() throw configMap.put("catalog_properties", properties); configMap.put("from_snapshot", table.currentSnapshot().snapshotId()); configMap.put("to_snapshot", table.currentSnapshot().snapshotId()); - configMap.put("keep", ImmutableList.of("id", "data", "event_micros")); + // leaves out the primary key and the watermark column + configMap.put("keep", ImmutableList.of("data")); configMap.put("filter", "\"category\" = 'include'"); configMap.put("watermark_column", "event_micros"); configMap.put( @@ -241,7 +242,7 @@ public void testManagedReadWithProjectionFilterWatermarkAndSnapshotRange() throw IcebergCdcMetadataColumns.ROW_ID, IcebergCdcMetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER)); - org.apache.iceberg.Schema projectedSchema = table.schema().select("id", "data", "event_micros"); + org.apache.iceberg.Schema projectedSchema = table.schema().select("data"); Schema recordSchema = IcebergUtils.icebergSchemaToBeamSchema(projectedSchema); Schema outputSchema = Schema.builder() @@ -263,9 +264,7 @@ public void testManagedReadWithProjectionFilterWatermarkAndSnapshotRange() throw Row record = IcebergUtils.icebergRecordToBeamRow(recordSchema, records.get(i)); return Row.withSchema(outputSchema) .addValues( - record.getInt64("id"), record.getString("data"), - record.getInt64("event_micros"), snapshotId, sequenceNumber, firstRowId + i, diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergIOReadTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergIOReadTest.java index 7920fc37c84b..9a8076bbb7dd 100644 --- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergIOReadTest.java +++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergIOReadTest.java @@ -238,19 +238,6 @@ public void testCdcFailsWhenTableHasNoIdentifierFields() { read.expand(PBegin.in(testPipeline)); } - @Test - public void testCdcFailsWhenProjectionDropsIdentifierFields() { - assumeTrue(useIncrementalScan); - TableIdentifier tableId = TableIdentifier.of("default", testName.getMethodName()); - warehouse.createTable(tableId, schemaForMode(TestFixtures.SCHEMA, 1)); - IcebergIO.ReadRows read = - IcebergIO.readRows(catalogConfig()).from(tableId).withCdc().dropping(singletonList("id")); - - thrown.expect(IllegalArgumentException.class); - thrown.expectMessage("projected schema must not drop primary key fields"); - read.expand(PBegin.in(testPipeline)); - } - @Test public void testFailWhenDropAndKeepAreSet() { TableIdentifier tableId = TableIdentifier.of("default", testName.getMethodName()); @@ -410,11 +397,6 @@ public void testScanSelectedFields() throws Exception { return null; }); - if (useIncrementalScan) { - testPipeline.run(); - return; - } - // test drop fields read = read.keeping(null).dropping(singletonList("id")); PCollection outputDrop = diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfigTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfigTest.java index e928d3cf8f01..989e08dd35d8 100644 --- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfigTest.java +++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfigTest.java @@ -20,6 +20,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import java.util.List; @@ -76,31 +77,77 @@ public void cdcValidationRequiresIdentifierFields() { } @Test - public void cdcValidationRejectsProjectionDroppingIdentifierFields() { + public void cdcProjectionReadsKeyAndWatermarkLeftOutOfKeepOrDrop() { TableIdentifier tableId = uniqueTableId(); Table table = warehouse.createTable(tableId, CDC_SCHEMA); - IcebergScanConfig keepWithoutPk = + IcebergScanConfig keep = scanConfigBuilder(tableId, CDC_SCHEMA) .setUseCdc(true) .setKeepFields(ImmutableList.of("data")) + .setWatermarkColumn("event_time") + .setFilterString("\"category\" = 'x'") .build(); - IllegalArgumentException keepException = - assertThrows(IllegalArgumentException.class, () -> keepWithoutPk.validate(table)); - assertThat( - keepException.getMessage(), - containsString("projected schema must not drop primary key fields")); + keep.validate(table); + assertEquals(ImmutableList.of("data"), fieldNames(keep.getRequestedSchema())); + assertEquals( + ImmutableList.of("id", "data", "event_time"), fieldNames(keep.getProjectedSchema())); + assertEquals( + ImmutableList.of("id", "data", "category", "event_time"), + fieldNames(keep.getRequiredSchema())); + + IcebergScanConfig drop = + scanConfigBuilder(tableId, CDC_SCHEMA) + .setUseCdc(true) + .setDropFields(ImmutableList.of("id", "event_time")) + .setWatermarkColumn("event_time") + .build(); + drop.validate(table); + assertEquals( + ImmutableList.of( + "data", "category", "event_micros", "optional_time", "required_text", "nested"), + fieldNames(drop.getRequestedSchema())); + assertEquals(fieldNames(CDC_SCHEMA), fieldNames(drop.getProjectedSchema())); - IcebergScanConfig dropPk = + // a projection that already has both is unchanged + IcebergScanConfig unchanged = scanConfigBuilder(tableId, CDC_SCHEMA) .setUseCdc(true) - .setDropFields(ImmutableList.of("id")) + .setKeepFields(ImmutableList.of("id", "event_time")) + .setWatermarkColumn("event_time") .build(); - IllegalArgumentException dropException = - assertThrows(IllegalArgumentException.class, () -> dropPk.validate(table)); - assertThat( - dropException.getMessage(), - containsString("projected schema must not drop primary key fields")); + assertSame(unchanged.getRequestedSchema(), unchanged.getProjectedSchema()); + + // batch reads only project the requested columns + IcebergScanConfig batch = + scanConfigBuilder(tableId, CDC_SCHEMA).setKeepFields(ImmutableList.of("data")).build(); + assertEquals(ImmutableList.of("data"), fieldNames(batch.getProjectedSchema())); + } + + @Test + public void cdcProjectionReadsWholeColumnHoldingNestedKey() { + TableIdentifier tableId = uniqueTableId(); + Table table = + warehouse.createTable( + tableId, + new org.apache.iceberg.Schema( + ImmutableList.of( + Types.NestedField.required( + 1, + "key", + Types.StructType.of( + Types.NestedField.required(2, "id", Types.LongType.get()), + Types.NestedField.optional(3, "region", Types.StringType.get()))), + Types.NestedField.optional(4, "data", Types.StringType.get())), + ImmutableSet.of(2))); + IcebergScanConfig scanConfig = + scanConfigBuilder(tableId, table.schema()) + .setUseCdc(true) + .setKeepFields(ImmutableList.of("data")) + .build(); + + // whole columns, as ResolveChanges emits them + assertEquals(table.schema().asStruct(), scanConfig.getProjectedSchema().asStruct()); } @Test @@ -219,8 +266,6 @@ public void watermarkColumnRejectsInvalidConfigurations() { true, ImmutableList.of("id", "required_text"), "must be a timestamp-typed column"); - assertInvalidWatermark( - tableId, table, "event_time", true, ImmutableList.of("id"), "should not be dropped"); } private void assertInvalidWatermark( diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/IncrementalChangelogSourceTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/IncrementalChangelogSourceTest.java index 27a65a0be8fd..c055e609f765 100644 --- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/IncrementalChangelogSourceTest.java +++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/IncrementalChangelogSourceTest.java @@ -460,6 +460,79 @@ public void watermarkColumnRestampsProjectedRows() throws Exception { pipeline.run().waitUntilFinish(); } + /** + * 'keep' and 'drop' may leave out the primary key and watermark column. The source still reads + * them to pair updates and timestamp rows. + */ + @Test + public void keyAndWatermarkCanBeLeftOutOfOutput() throws Exception { + LocalDateTime t1 = + LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(System.currentTimeMillis() - 3_000L), ZoneOffset.UTC); + LocalDateTime t2 = t1.plusSeconds(1); + LocalDateTime t3 = t1.plusSeconds(2); + // SPLIT_SIZE=1 resolves updates through the CoGroupByKey, the default resolves them locally + Map reads = + ImmutableMap.of( + "Keep", + eventChanges("1", t1, t2, t3).setKeepFields(ImmutableList.of("data")).build(), + "Drop", + eventChanges(String.valueOf(TableProperties.SPLIT_SIZE_DEFAULT), t1, t2, t3) + .setDropFields(ImmutableList.of("id", "event_time")) + .build()); + + for (Map.Entry read : reads.entrySet()) { + PCollection rows = + pipeline.apply(read.getKey(), new IncrementalChangelogSource(read.getValue())); + assertEquals( + Schema.builder().addNullableField("data", Schema.FieldType.STRING).build(), + rows.getSchema()); + PAssert.that( + rows.apply(read.getKey() + " Format", ParDo.of(new FormatValueKindAndValues())) + .apply(read.getKey() + " Timestamps", Reify.timestamps())) + .containsInAnyOrder( + TimestampedValue.of("INSERT:[a]", millis(t1)), + TimestampedValue.of("INSERT:[b]", millis(t2)), + // only event_time changed, so pairing compared full rows + TimestampedValue.of("UPDATE_BEFORE:[a]", millis(t1)), + TimestampedValue.of("UPDATE_AFTER:[a]", millis(t3)), + TimestampedValue.of("DELETE:[b]", millis(t2)), + TimestampedValue.of("INSERT:[c]", millis(t3))); + } + + pipeline.run().waitUntilFinish(); + } + + /** + * Commits id 1 and 2, then an overwrite that changes only id 1's event_time, deletes 2, adds 3. + */ + private IcebergScanConfig.Builder eventChanges( + String splitSize, LocalDateTime t1, LocalDateTime t2, LocalDateTime t3) throws IOException { + TableIdentifier tableId = TableIdentifier.of("default", testName.getMethodName() + splitSize); + Table table = + warehouse.createTable( + tableId, + EVENT_SCHEMA, + null, + ImmutableMap.of( + TableProperties.FORMAT_VERSION, "2", TableProperties.SPLIT_SIZE, splitSize)); + DataFile v1 = + commitAppend( + table, + splitSize + "-v1.parquet", + ImmutableList.of(eventRecord(1L, "a", t1), eventRecord(2L, "b", t2))); + commitOverwrite( + table, splitSize + "-v2.parquet", v1, eventRecord(1L, "a", t3), eventRecord(3L, "c", t3)); + return baseConfigBuilder(table, tableId) + .setWatermarkColumn("event_time") + .setStartingStrategy(StartingStrategy.EARLIEST) + .setToSnapshot(table.currentSnapshot().snapshotId()); + } + + private static Instant millis(LocalDateTime time) { + return new Instant(time.toInstant(ZoneOffset.UTC).toEpochMilli()); + } + /** * Bi-directional rows are windowed per snapshot with zero allowed lateness, so a record that * reaches the CoGroupByKey after the watermark has passed its snapshot's window is dropped @@ -516,12 +589,10 @@ private void assertNoRecordsDroppedAcrossSnapshots(boolean streaming) throws Exc } private DataFile commitOverwrite( - Table table, String fileName, DataFile replaced, Record replacement) throws IOException { + Table table, String fileName, DataFile replaced, Record... replacements) throws IOException { DataFile file = warehouse.writeRecords( - testName.getMethodName() + "-" + fileName, - table.schema(), - ImmutableList.of(replacement)); + testName.getMethodName() + "-" + fileName, table.schema(), Arrays.asList(replacements)); table.newOverwrite().deleteFile(replaced).addFile(file).commit(); table.refresh(); return file; @@ -583,6 +654,14 @@ public void process( } } + private static final class FormatValueKindAndValues extends DoFn { + @ProcessElement + public void process( + @Element Row row, ValueKind valueKind, OutputReceiver outputReceiver) { + outputReceiver.output(valueKind.name() + ":" + row.getValues()); + } + } + private static final class FormatDefaultedChange extends DoFn { @ProcessElement public void process( From 3bef0c0cb0835fc7eaf43c27e5f4627cfbca1dd3 Mon Sep 17 00:00:00 2001 From: Bruno Volpato Date: Mon, 19 Oct 2026 14:47:20 -0400 Subject: [PATCH 041/108] [Python] Fail bundles on state write errors (#39992) * [Python] Fail bundles on state write errors * [Python] Link state fix in changelog --- CHANGES.md | 1 + .../runners/worker/bundle_processor.py | 35 +++--- .../runners/worker/bundle_processor_test.py | 111 ++++++++++++++++++ 3 files changed, 133 insertions(+), 14 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 1ec1dbe80864..c49c27c929c7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -100,6 +100,7 @@ * (Python) DaskRunner now preserves outputs emitted by `DoFn.finish_bundle()` instead of silently dropping them ([#40519](https://github.com/apache/beam/issues/40519)). * (Python) BigQuery streaming inserts now reset the retry budget and backoff for each batch instead of sharing them across a bundle ([#39995](https://github.com/apache/beam/pull/39995)). +* (Python) State write failures reported by portable runners now fail the bundle instead of silently losing state updates ([#39992](https://github.com/apache/beam/pull/39992)). * (Go) Fixed a data race on the Prism runner's artifact cache map in JobServices ([#32656](https://github.com/apache/beam/issues/32656)). * (Go) Fixed the harness leaking Data/State gRPC streams after the worker stops, and a deadlock when Send returns EOF ([#40260](https://github.com/apache/beam/issues/40260)). * (Go) Wrap last dataWriter/timerWriter send errors on Close ([#40405](https://github.com/apache/beam/issues/40405)). diff --git a/sdks/python/apache_beam/runners/worker/bundle_processor.py b/sdks/python/apache_beam/runners/worker/bundle_processor.py index e62e522c00cf..c9f80ce063fd 100644 --- a/sdks/python/apache_beam/runners/worker/bundle_processor.py +++ b/sdks/python/apache_beam/runners/worker/bundle_processor.py @@ -620,6 +620,17 @@ def __iter__(self) -> Iterator[Any]: coder_impl.FastPrimitivesCoderImpl.register_iterable_like_type(_ConcatIterable) +def _await_state_commit(futures) -> None: + # Resolve every write before reporting errors, including clears followed by + # appends. State futures return error responses rather than raising them. + responses = [future.get() for future in futures if future] + errors = [ + response.error for response in responses if response and response.error + ] + if errors: + raise RuntimeError('\n'.join(errors)) + + class SynchronousBagRuntimeState(userstate.BagRuntimeState): def __init__( self, @@ -647,15 +658,16 @@ def clear(self) -> None: self._added_elements = [] def commit(self) -> None: - to_await = None + futures = [] if self._cleared: - to_await = self._state_handler.clear(self._state_key) + futures.append(self._state_handler.clear(self._state_key)) 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() + futures.append( + self._state_handler.extend( + self._state_key, + self._value_coder.get_impl(), + self._added_elements)) + _await_state_commit(futures) class SynchronousSetRuntimeState(userstate.SetRuntimeState): @@ -727,9 +739,7 @@ def commit(self) -> None: all_futures = self._futures self._futures = [] - for f in all_futures: - if f: - f.get() + _await_state_commit(all_futures) class RangeSet: @@ -898,10 +908,7 @@ def commit(self) -> None: self._state_key, self._elem_coder.get_impl(), items_to_add)) self._pending_adds = SortedDict() - if len(futures): - # To commit, we need to wait on every state request futures to complete. - for to_await in futures: - to_await.get() + _await_state_commit(futures) self._cleared = False diff --git a/sdks/python/apache_beam/runners/worker/bundle_processor_test.py b/sdks/python/apache_beam/runners/worker/bundle_processor_test.py index ad2089c99383..ffe76c1eb819 100644 --- a/sdks/python/apache_beam/runners/worker/bundle_processor_test.py +++ b/sdks/python/apache_beam/runners/worker/bundle_processor_test.py @@ -31,6 +31,7 @@ from apache_beam.runners.portability.fn_api_runner.worker_handlers import StateServicer from apache_beam.runners.worker import bundle_processor from apache_beam.runners.worker import operations +from apache_beam.runners.worker import sdk_worker from apache_beam.runners.worker.bundle_processor import BeamTransformFactory from apache_beam.runners.worker.bundle_processor import BundleProcessor from apache_beam.runners.worker.bundle_processor import DataInputOperation @@ -430,6 +431,116 @@ def test_user_modified_sdks_need_to_be_installed_in_runtime_env(self): "beam:version:sdk_base:apache/beam_python3.5_sdk:2.1.0-custom")) +class RuntimeStateCommitTest(unittest.TestCase): + STATE_TYPES = ( + bundle_processor.SynchronousBagRuntimeState, + bundle_processor.SynchronousSetRuntimeState, + SynchronousOrderedListRuntimeState) + + def setUp(self): + # Keep set compaction deterministic; it is exercised separately below. + patcher = mock.patch.object(random, 'random', return_value=0) + patcher.start() + self.addCleanup(patcher.stop) + + def _create_state(self, state_type, append_errors=('', ), clear_error=''): + futures = [] + + def response_future(error): + future = sdk_worker._Future().set( + beam_fn_api_pb2.StateResponse(error=error)) + future.get = mock.Mock(wraps=future.get) + futures.append(future) + return future + + append_errors = iter(append_errors) + underlying = mock.Mock(spec=sdk_worker.StateHandler) + underlying.append_raw.side_effect = ( + lambda *args: response_future(next(append_errors))) + underlying.clear.side_effect = lambda *args: response_future(clear_error) + underlying.get_raw.return_value = (b'', None) + handler = GlobalCachingStateHandler(StateCache(0), underlying) + if state_type is SynchronousOrderedListRuntimeState: + key = beam_fn_api_pb2.StateKey( + ordered_list_user_state=beam_fn_api_pb2.StateKey.OrderedListUserState( + )) + else: + key = beam_fn_api_pb2.StateKey( + bag_user_state=beam_fn_api_pb2.StateKey.BagUserState()) + return state_type(handler, key, StrUtf8Coder()), futures + + def _add(self, state, value): + if isinstance(state, SynchronousOrderedListRuntimeState): + state.add((timestamp.Timestamp(1), value)) + else: + state.add(value) + + def test_commit_rejects_failed_append(self): + for state_type in self.STATE_TYPES: + with self.subTest(state_type=state_type): + state, _ = self._create_state( + state_type, append_errors=('append failed', )) + self._add(state, 'value') + with self.assertRaisesRegex(RuntimeError, 'append failed'): + state.commit() + + def test_commit_rejects_failed_clear(self): + for state_type in self.STATE_TYPES: + with self.subTest(state_type=state_type): + state, _ = self._create_state(state_type, clear_error='clear failed') + state.clear() + with self.assertRaisesRegex(RuntimeError, 'clear failed'): + state.commit() + + def test_clear_then_append_checks_every_response(self): + for state_type in self.STATE_TYPES: + for clear_error, append_error in ( + ('', ''), ('clear failed', ''), ('clear failed', 'append failed')): + with self.subTest(state_type=state_type, + clear_error=clear_error, + append_error=append_error): + state, futures = self._create_state( + state_type, (append_error, ), clear_error) + state.clear() + self._add(state, 'replacement') + if clear_error: + with self.assertRaises(RuntimeError) as raised: + state.commit() + self.assertEqual( + str(raised.exception), + '\n'.join( + error for error in (clear_error, append_error) if error)) + else: + state.commit() + self.assertEqual(len(futures), 2) + for future in futures: + future.get.assert_called_once() + + def test_commit_rejects_failure_in_earlier_append_chunk(self): + state, futures = self._create_state( + bundle_processor.SynchronousBagRuntimeState, + append_errors=('first append failed', '')) + state.add('first') + state.add('second') + with mock.patch.object(sdk_worker.data_plane, + '_DEFAULT_SIZE_FLUSH_THRESHOLD', + 1): + with self.assertRaisesRegex(RuntimeError, 'first append failed'): + state.commit() + self.assertEqual(len(futures), 2) + for future in futures: + future.get.assert_called_once() + + def test_commit_rejects_failed_set_compaction(self): + state, futures = self._create_state( + bundle_processor.SynchronousSetRuntimeState, clear_error='clear failed') + with mock.patch.object(random, 'random', return_value=1): + state.add('value') + with self.assertRaisesRegex(RuntimeError, 'clear failed'): + state.commit() + self.assertEqual(len(futures), 2) + + class OrderedListStateTest(unittest.TestCase): class NoStateCache(StateCache): def __init__(self): From 3ab18d061df31be46c619da107e66cfb3ccbd996 Mon Sep 17 00:00:00 2001 From: Elia Liu Date: Tue, 20 Oct 2026 05:48:44 +1100 Subject: [PATCH 042/108] [Go SDK] Add portable logical type registration and standard date and micros_instant types (#40154) * [Go SDK] Add portable logical type registration and standard date and micros_instant types schema.RegisterLogicalTypeConversion registers a logical type identified by a URN together with the conversions between its Go type and its storage type, and derives the row coder from the storage type. schema.RegisterLogicalTypeCoder does the same for logical types whose wire encoding differs from the row encoding of the storage type. The standard beam:logical_type:date:v1 and beam:logical_type:micros_instant:v1 logical types are registered by default as schema.Date and schema.MicrosInstant. The standard coder regression test now builds the expected row from the schema in the coder payload, decodes payload bytes above 0x7f, counts encoding mismatches as failures, and runs the micros_instant case. Part of #39684. * [Go SDK] Replace deprecated reflect.PtrTo in row coder test * [Go SDK] Handle the zero Date, add MicrosInstant String and Equals, check duplicate registrations The zero Date now stands for the epoch, MicrosInstant has String and Equals methods, and RegisterLogicalType panics when a Go type or an identifier and argument are already registered for a different logical type. --- CHANGES.md | 1 + sdks/go/pkg/beam/core/graph/coder/row.go | 16 + .../pkg/beam/core/graph/coder/row_decoder.go | 37 ++ .../pkg/beam/core/graph/coder/row_encoder.go | 21 + sdks/go/pkg/beam/core/graph/coder/row_test.go | 105 +++++ .../runtime/graphx/schema/logicaltypes.go | 142 +++++- .../core/runtime/graphx/schema/schema_test.go | 30 ++ .../graphx/schema/standard_logical_types.go | 156 +++++++ .../schema/standard_logical_types_test.go | 421 ++++++++++++++++++ .../core/runtime/graphx/serialize_test.go | 16 + .../regression/coders/fromyaml/fromyaml.go | 210 ++++++--- 11 files changed, 1079 insertions(+), 76 deletions(-) create mode 100644 sdks/go/pkg/beam/core/runtime/graphx/schema/standard_logical_types.go create mode 100644 sdks/go/pkg/beam/core/runtime/graphx/schema/standard_logical_types_test.go diff --git a/CHANGES.md b/CHANGES.md index c49c27c929c7..d50b14cadd1a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -80,6 +80,7 @@ * (Python) Added `AwsOptions` with `--aws_*` pipeline options (e.g. `--aws_access_key_id`, `--aws_region_name`, `--aws_endpoint_url`) that set defaults for all AWS service clients. The existing `--s3_*` options in `S3Options` override these defaults for the S3 client only ([#40528](https://github.com/apache/beam/issues/40528)). * (Python) Added `--aws_credentials_secret_manager` so AWS credentials (`--aws_access_key_id`, `--aws_secret_access_key`, `--aws_session_token` and their `--s3_*` overrides) can be secret specs resolved through a secret manager such as `GoogleCloudSecretManager`, instead of plain-text values ([#40528](https://github.com/apache/beam/issues/40528)). * (Python) Added `AwsSecret` (`AwsSecretsManager` / `type:awssecret`) to retrieve secrets from AWS Secrets Manager, configured from `AwsOptions` (`--aws_*`) with optional per-secret connection overrides ([#40528](https://github.com/apache/beam/issues/40528)). +* (Go) Added `schema.RegisterLogicalTypeConversion` and `schema.RegisterLogicalTypeCoder` to register portable logical types in one call, and Go types for the standard `beam:logical_type:date:v1` and `beam:logical_type:micros_instant:v1` logical types, so schemas from other SDKs with these types can be used in cross-language pipelines ([#39684](https://github.com/apache/beam/issues/39684)). ## Breaking Changes diff --git a/sdks/go/pkg/beam/core/graph/coder/row.go b/sdks/go/pkg/beam/core/graph/coder/row.go index f958d84154c1..0c2a6872dd69 100644 --- a/sdks/go/pkg/beam/core/graph/coder/row.go +++ b/sdks/go/pkg/beam/core/graph/coder/row.go @@ -226,3 +226,19 @@ func ReadSimpleRowHeader(fields int, r io.Reader) error { } return nil } + +// RowFieldEncoderForType returns an encoding function for values of the given +// type, using the encoding of that type as a field of a beam schema row. +// +// Returns an error if the given type cannot be a field of a beam schema row. +func RowFieldEncoderForType(rt reflect.Type) (func(any, io.Writer) error, error) { + return defaultEnc.fieldEncoderForType(rt) +} + +// RowFieldDecoderForType returns a decoding function for values of the given +// type, using the encoding of that type as a field of a beam schema row. +// +// Returns an error if the given type cannot be a field of a beam schema row. +func RowFieldDecoderForType(rt reflect.Type) (func(io.Reader) (any, error), error) { + return defaultDec.fieldDecoderForType(rt) +} diff --git a/sdks/go/pkg/beam/core/graph/coder/row_decoder.go b/sdks/go/pkg/beam/core/graph/coder/row_decoder.go index 90938c237fe3..2402cafe0e2b 100644 --- a/sdks/go/pkg/beam/core/graph/coder/row_decoder.go +++ b/sdks/go/pkg/beam/core/graph/coder/row_decoder.go @@ -95,6 +95,23 @@ func (b *RowDecoderBuilder) decoderForType(t reflect.Type) (func(io.Reader) (any if t.Kind() == reflect.Ptr { isPtr = true t = t.Elem() + // A provider for the value type also handles a top level pointer. + f, addr, err := b.customFunc(t) + if err != nil { + return nil, err + } + if f != nil && !addr { + vt := t + return func(r io.Reader) (any, error) { + v, err := f(r) + if err != nil { + return nil, err + } + rv := reflect.New(vt) + rv.Elem().Set(reflect.ValueOf(v)) + return rv.Interface(), nil + }, nil + } } dec, err := b.decoderForStructReflect(t) if err != nil { @@ -435,3 +452,23 @@ type typeDecoderFieldReflect struct { // of the field value (i.e. &foo.bar) and not the field value (i.e. foo.bar). addr bool } + +// fieldDecoderForType returns a decoder for values of t, using the encoding +// of t as a row field. +func (b *RowDecoderBuilder) fieldDecoderForType(t reflect.Type) (func(io.Reader) (any, error), error) { + decf, err := b.decoderForSingleTypeReflect(t) + if err != nil { + return nil, err + } + return func(r io.Reader) (any, error) { + rv := reflect.New(t) + v := rv.Elem() + if decf.addr { + v = rv + } + if err := decf.decode(v, r); err != nil { + return nil, err + } + return rv.Elem().Interface(), nil + }, nil +} diff --git a/sdks/go/pkg/beam/core/graph/coder/row_encoder.go b/sdks/go/pkg/beam/core/graph/coder/row_encoder.go index 6f5c4c04e81c..05d3e85b2d98 100644 --- a/sdks/go/pkg/beam/core/graph/coder/row_encoder.go +++ b/sdks/go/pkg/beam/core/graph/coder/row_encoder.go @@ -363,3 +363,24 @@ func (b *RowEncoderBuilder) encoderForStructReflect(t reflect.Type) (func(reflec return nil }, nil } + +// fieldEncoderForType returns an encoder for values of t, using the encoding +// of t as a row field. +func (b *RowEncoderBuilder) fieldEncoderForType(t reflect.Type) (func(any, io.Writer) error, error) { + encf, err := b.encoderForSingleTypeReflect(t) + if err != nil { + return nil, err + } + return func(v any, w io.Writer) error { + rv := reflect.ValueOf(v) + if !rv.IsValid() || (rv.Kind() == reflect.Ptr && rv.IsNil()) { + return errors.Errorf("cannot encode a nil value as a row field of type %v", t) + } + if encf.addr { + p := reflect.New(t) + p.Elem().Set(rv) + rv = p + } + return encf.encode(rv, w) + }, nil +} diff --git a/sdks/go/pkg/beam/core/graph/coder/row_test.go b/sdks/go/pkg/beam/core/graph/coder/row_test.go index c2a925efd306..8b6782457d81 100644 --- a/sdks/go/pkg/beam/core/graph/coder/row_test.go +++ b/sdks/go/pkg/beam/core/graph/coder/row_test.go @@ -945,3 +945,108 @@ func TestReflectionRowCoderGeneration_Int16BigEndian(t *testing.T) { t.Fatalf("dec(enc(%v)) = %v\ndiff (-want, +got): %v", want, got, d) } } + +func TestRowFieldCoderForType(t *testing.T) { + type pair struct { + A int64 + B string + } + tests := []struct { + value any + wantBytes []byte + }{ + {value: int64(999), wantBytes: []byte{0xe7, 0x07}}, + {value: int16(999), wantBytes: []byte{0x03, 0xe7}}, + {value: "abc", wantBytes: []byte{0x03, 'a', 'b', 'c'}}, + {value: []byte{1, 2}, wantBytes: []byte{0x02, 1, 2}}, + {value: true, wantBytes: []byte{0x01}}, + {value: []int32{1, 2}, wantBytes: []byte{0x00, 0x00, 0x00, 0x02, 0x01, 0x02}}, + {value: pair{A: 7, B: "x"}, wantBytes: []byte{0x02, 0x00, 0x07, 0x01, 'x'}}, + } + for _, test := range tests { + rt := reflect.TypeOf(test.value) + t.Run(rt.String(), func(t *testing.T) { + enc, err := RowFieldEncoderForType(rt) + if err != nil { + t.Fatalf("RowFieldEncoderForType(%v) = %v, want nil error", rt, err) + } + var buf bytes.Buffer + if err := enc(test.value, &buf); err != nil { + t.Fatalf("enc(%v) = %v, want nil error", test.value, err) + } + if got := buf.Bytes(); !bytes.Equal(got, test.wantBytes) { + t.Fatalf("enc(%v) = %v, want %v", test.value, got, test.wantBytes) + } + dec, err := RowFieldDecoderForType(rt) + if err != nil { + t.Fatalf("RowFieldDecoderForType(%v) = %v, want nil error", rt, err) + } + got, err := dec(bytes.NewBuffer(test.wantBytes)) + if err != nil { + t.Fatalf("dec(%v) = %v, want nil error", test.wantBytes, err) + } + if d := cmp.Diff(test.value, got); d != "" { + t.Fatalf("dec(enc(%v)) = %v\ndiff (-want, +got): %v", test.value, got, d) + } + }) + } + if _, err := RowFieldEncoderForType(reflect.TypeOf(func() {})); err == nil { + t.Errorf("RowFieldEncoderForType(func) = nil error, want error") + } + enc, err := RowFieldEncoderForType(reflect.TypeOf(int64(0))) + if err != nil { + t.Fatalf("RowFieldEncoderForType(int64) = %v, want nil error", err) + } + if err := enc(nil, &bytes.Buffer{}); err == nil { + t.Errorf("enc(nil) = nil error, want error") + } + ptrEnc, err := RowFieldEncoderForType(reflect.TypeOf((*int64)(nil))) + if err != nil { + t.Fatalf("RowFieldEncoderForType(*int64) = %v, want nil error", err) + } + if err := ptrEnc((*int64)(nil), &bytes.Buffer{}); err == nil { + t.Errorf("enc((*int64)(nil)) = nil error, want error") + } + if _, err := RowFieldDecoderForType(reflect.TypeOf(func() {})); err == nil { + t.Errorf("RowFieldDecoderForType(func) = nil error, want error") + } +} + +// A provider for a value type also encodes and decodes a top level pointer to +// that type. +func TestRowCoder_TopLevelPointerWithValueProvider(t *testing.T) { + type tenths struct{ V int64 } + rt := reflect.TypeOf(tenths{}) + var encB RowEncoderBuilder + encB.Register(rt, func(reflect.Type) (func(any, io.Writer) error, error) { + return func(v any, w io.Writer) error { return EncodeVarInt(v.(tenths).V*10, w) }, nil + }) + var decB RowDecoderBuilder + decB.Register(rt, func(reflect.Type) (func(io.Reader) (any, error), error) { + return func(r io.Reader) (any, error) { + v, err := DecodeVarInt(r) + return tenths{V: v / 10}, err + }, nil + }) + prt := reflect.PointerTo(rt) + enc, err := encB.Build(prt) + if err != nil { + t.Fatalf("RowEncoderBuilder.Build(%v) = %v, want nil error", prt, err) + } + dec, err := decB.Build(prt) + if err != nil { + t.Fatalf("RowDecoderBuilder.Build(%v) = %v, want nil error", prt, err) + } + want := &tenths{V: 42} + var buf bytes.Buffer + if err := enc(want, &buf); err != nil { + t.Fatalf("enc(%v) = %v, want nil error", want, err) + } + got, err := dec(&buf) + if err != nil { + t.Fatalf("dec = %v, want nil error", err) + } + if d := cmp.Diff(want, got); d != "" { + t.Errorf("dec(enc(%v)) diff (-want, +got): %v", want, d) + } +} diff --git a/sdks/go/pkg/beam/core/runtime/graphx/schema/logicaltypes.go b/sdks/go/pkg/beam/core/runtime/graphx/schema/logicaltypes.go index ee08bbb8f549..bf54fddbc722 100644 --- a/sdks/go/pkg/beam/core/runtime/graphx/schema/logicaltypes.go +++ b/sdks/go/pkg/beam/core/runtime/graphx/schema/logicaltypes.go @@ -17,9 +17,11 @@ package schema import ( "fmt" + "io" "reflect" "sync" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph/coder" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/util/reflectx" pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1" ) @@ -32,7 +34,9 @@ var ( // A logical type is a type that has distinct representations and storage. // // RegisterLogicalType will panic if the storage type of the LogicalType -// instance is not a valid storage type. +// instance is not a valid storage type, or if its Go type or its identifier +// and argument are already registered for a different logical type. +// Registering the same logical type again has no effect. func RegisterLogicalType(lt LogicalType) { defaultRegistry.RegisterLogicalType(lt) } @@ -94,7 +98,8 @@ func NewRegistry() *Registry { } } -// RegisterLogicalType a single logical type. +// RegisterLogicalType registers a single logical type. See the package level +// RegisterLogicalType for the conditions it panics on. func (r *Registry) RegisterLogicalType(lt LogicalType) { // Validates that the storage type has known handling. st := lt.StorageType() @@ -113,7 +118,12 @@ func (r *Registry) RegisterLogicalType(lt LogicalType) { panic(fmt.Sprintf("LogicalType[%v] has the argument %v which does not compare equal to itself", lt.ID(), lt.argV)) } } - // TODO add duplication checks. + if prev, ok := r.logicalTypeByGoType[lt.GoType()]; ok && !prev.equals(lt) { + panic(fmt.Sprintf("Go type %v is already registered as %v -> %v, cannot register it as %v -> %v", lt.GoType(), prev.describe(), prev.StorageType(), lt.describe(), lt.StorageType())) + } + if prev, ok := r.logicalTypes[lt.key()]; ok && !prev.equals(lt) { + panic(fmt.Sprintf("%v is already registered for Go type %v, cannot register it for Go type %v", lt.describe(), prev.GoType(), lt.GoType())) + } r.logicalTypeByGoType[lt.GoType()] = lt r.logicalTypes[lt.key()] = lt } @@ -213,6 +223,21 @@ func (l *LogicalType) key() logicalTypeKey { return logicalTypeKey{identifier: l.identifier, argument: l.argV.Interface()} } +// equals reports whether l and o have the same identifier, argument, Go type +// and storage type. +func (l *LogicalType) equals(o LogicalType) bool { + return l.goT == o.goT && l.storageT == o.storageT && l.argT == o.argT && l.key() == o.key() +} + +// describe returns the identifier of the logical type and its argument, if +// any, for messages. +func (l *LogicalType) describe() string { + if l.argT == nil { + return fmt.Sprintf("LogicalType[%v]", l.identifier) + } + return fmt.Sprintf("LogicalType[%v, %v]", l.identifier, l.argV.Interface()) +} + func preRegLogicalTypes(r *Registry) { r.RegisterLogicalType(ToLogicalType("int", reflectx.Int, reflectx.Int64)) r.RegisterLogicalType(ToLogicalType("int8", reflectx.Int8, reflectx.Int64)) @@ -224,4 +249,115 @@ func preRegLogicalTypes(r *Registry) { func init() { preRegLogicalTypes(defaultRegistry) + registerStandardLogicalTypes(defaultRegistry) +} + +// RegisterLogicalTypeConversion registers a portable logical type identified +// by urn. Values of GoT are stored in schema rows as values of StorageT, +// converted with toStorage and fromStorage, and encoded with the row field +// encoding of StorageT. StorageT must have a schema representation: an atomic +// type, a struct of such types, or a registered logical type. GoT must be a +// concrete type. A struct kind GoT with a non row StorageT can be used as a +// field of a row, but not as a top level schema type. +// +// RegisterLogicalTypeConversion must be called before beam.Init(), and +// conventionally is called in a package init() function. +func RegisterLogicalTypeConversion[GoT, StorageT any](urn string, toStorage func(GoT) (StorageT, error), fromStorage func(StorageT) (GoT, error)) { + registerLogicalTypeConversion(defaultRegistry, ToLogicalType(urn, typeOf[GoT](), typeOf[StorageT]()), toStorage, fromStorage) +} + +// RegisterParameterizedLogicalTypeConversion is RegisterLogicalTypeConversion +// for a logical type with an argument, such as a timestamp and its precision. +// Each argument value is registered with its own Go type, and a schema field +// with the urn and that argument maps to GoT. See ToLogicalTypeWithArgument +// for the supported argument types. +func RegisterParameterizedLogicalTypeConversion[GoT, StorageT any](urn string, argument any, toStorage func(GoT) (StorageT, error), fromStorage func(StorageT) (GoT, error)) { + registerLogicalTypeConversion(defaultRegistry, ToLogicalTypeWithArgument(urn, typeOf[GoT](), typeOf[StorageT](), argument), toStorage, fromStorage) +} + +// RegisterLogicalTypeCoder registers a portable logical type identified by +// urn whose wire encoding differs from the row field encoding of StorageT. +// StorageT is only used as the schema representation of the logical type. +// enc and dec produce and consume the encoded bytes of a value directly, in +// the wire encoding that the urn defines. GoT must be a concrete type. +// StorageT cannot be inferred, so both type parameters are given explicitly, +// for example +// RegisterLogicalTypeCoder[Money, []byte]("example:money:v1", encodeMoney, decodeMoney). +// +// RegisterLogicalTypeCoder must be called before beam.Init(), and +// conventionally is called in a package init() function. +func RegisterLogicalTypeCoder[GoT, StorageT any](urn string, enc func(GoT, io.Writer) error, dec func(io.Reader) (GoT, error)) { + registerLogicalTypeCoder(defaultRegistry, ToLogicalType(urn, typeOf[GoT](), typeOf[StorageT]()), enc, dec) +} + +func typeOf[T any]() reflect.Type { + return reflect.TypeOf((*T)(nil)).Elem() +} + +// registerLogicalTypeConversion registers lt with the registry and registers +// row coder providers for its Go type, built from the row field coder of the +// storage type and the conversion functions. +func registerLogicalTypeConversion[GoT, StorageT any](r *Registry, lt LogicalType, toStorage func(GoT) (StorageT, error), fromStorage func(StorageT) (GoT, error)) { + st := lt.StorageType() + checkLogicalTypeKinds(lt) + r.RegisterLogicalType(lt) + coder.RegisterSchemaProviders(lt.GoType(), + func(reflect.Type) (func(any, io.Writer) error, error) { + enc, err := coder.RowFieldEncoderForType(st) + if err != nil { + return nil, err + } + return func(v any, w io.Writer) error { + s, err := toStorage(v.(GoT)) + if err != nil { + return err + } + return enc(s, w) + }, nil + }, + func(reflect.Type) (func(io.Reader) (any, error), error) { + dec, err := coder.RowFieldDecoderForType(st) + if err != nil { + return nil, err + } + return func(rd io.Reader) (any, error) { + s, err := dec(rd) + if err != nil { + return nil, err + } + return fromStorage(s.(StorageT)) + }, nil + }) +} + +// registerLogicalTypeCoder registers lt with the registry and registers enc +// and dec as the row coder providers for its Go type. +func registerLogicalTypeCoder[GoT any](r *Registry, lt LogicalType, enc func(GoT, io.Writer) error, dec func(io.Reader) (GoT, error)) { + checkLogicalTypeKinds(lt) + r.RegisterLogicalType(lt) + coder.RegisterSchemaProviders(lt.GoType(), + func(reflect.Type) (func(any, io.Writer) error, error) { + return func(v any, w io.Writer) error { + return enc(v.(GoT), w) + }, nil + }, + func(reflect.Type) (func(io.Reader) (any, error), error) { + return func(rd io.Reader) (any, error) { + return dec(rd) + }, nil + }) +} + +// checkLogicalTypeKinds panics when the Go type of lt is an interface or its +// storage type is a pointer. The row coder matches an interface by all of its +// implementations, and the schema matches only the interface itself. The row +// coders decide nullability from the Go type of a field, so a pointer storage +// type behind a non pointer Go type would fail at encoding time. +func checkLogicalTypeKinds(lt LogicalType) { + if lt.GoType().Kind() == reflect.Interface { + panic(fmt.Sprintf("LogicalType[%v] has the interface Go type %v, Go types must be concrete", lt.ID(), lt.GoType())) + } + if lt.StorageType().Kind() == reflect.Ptr { + panic(fmt.Sprintf("LogicalType[%v] has the pointer storage type %v, storage types must not be pointers", lt.ID(), lt.StorageType())) + } } diff --git a/sdks/go/pkg/beam/core/runtime/graphx/schema/schema_test.go b/sdks/go/pkg/beam/core/runtime/graphx/schema/schema_test.go index 08916e3898d0..09caa1417e74 100644 --- a/sdks/go/pkg/beam/core/runtime/graphx/schema/schema_test.go +++ b/sdks/go/pkg/beam/core/runtime/graphx/schema/schema_test.go @@ -1163,3 +1163,33 @@ func TestRegisterLogicalType_Validation(t *testing.T) { }) } } + +func TestRegisterLogicalType_Duplicate(t *testing.T) { + reg := NewRegistry() + lt := ToLogicalType("schema.anyString", anyStringType, reflectx.String) + reg.RegisterLogicalType(lt) + // Registering the same logical type again has no effect. + reg.RegisterLogicalType(lt) + + for name, dup := range map[string]LogicalType{ + "identifier for another Go type": ToLogicalType("schema.anyString", parameterizedStringType, reflectx.String), + "Go type with another identifier": ToLogicalType("schema.otherString", anyStringType, reflectx.String), + "Go type with another storage type": ToLogicalType("schema.anyString", anyStringType, reflectx.ByteSlice), + "Go type with an argument": ToLogicalTypeWithArgument("schema.anyString", anyStringType, reflectx.String, int32(5)), + } { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Errorf("RegisterLogicalType did not panic for a duplicate %v", name) + } + }() + reg.RegisterLogicalType(dup) + }) + } + if got, ok := reg.lookupLogicalType("schema.anyString", reflect.Value{}); !ok || got.GoType() != anyStringType { + t.Errorf("lookupLogicalType(schema.anyString) = %v, %v, want %v, true", got.GoType(), ok, anyStringType) + } + if _, ok := reg.logicalTypeByGoType[parameterizedStringType]; ok { + t.Errorf("logicalTypeByGoType has %v after a rejected registration", parameterizedStringType) + } +} diff --git a/sdks/go/pkg/beam/core/runtime/graphx/schema/standard_logical_types.go b/sdks/go/pkg/beam/core/runtime/graphx/schema/standard_logical_types.go new file mode 100644 index 000000000000..dcc12a45e69f --- /dev/null +++ b/sdks/go/pkg/beam/core/runtime/graphx/schema/standard_logical_types.go @@ -0,0 +1,156 @@ +// 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 schema + +import ( + "time" + + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/util/reflectx" + "github.com/apache/beam/sdks/v2/go/pkg/beam/internal/errors" +) + +// URNs of the standard logical types defined in schema.proto. +const ( + // URNDate identifies the Date logical type. + URNDate = "beam:logical_type:date:v1" + // URNMicrosInstant identifies the MicrosInstant logical type. + URNMicrosInstant = "beam:logical_type:micros_instant:v1" +) + +// Date is a calendar date without a time zone or time of day, the +// beam:logical_type:date:v1 logical type. It is stored as the number of days +// since 1970-01-01 in an INT64 field. The zero Date is the epoch, 1970-01-01. +// Time and String normalize out of range fields as time.Date does. Encoding +// fails unless the fields form a valid date with a year from -999999999 to +// 999999999, and decoding fails for a day count outside that range. +type Date struct { + Year int + Month time.Month + Day int +} + +// DateOf returns the Date of t in t's location. +func DateOf(t time.Time) Date { + y, m, d := t.Date() + return Date{Year: y, Month: m, Day: d} +} + +// epochDate is the date that the zero Date stands for. +var epochDate = Date{Year: 1970, Month: time.January, Day: 1} + +// Time returns the start of the date in UTC. +func (d Date) Time() time.Time { + if d == (Date{}) { + d = epochDate + } + return time.Date(d.Year, d.Month, d.Day, 0, 0, 0, 0, time.UTC) +} + +// String returns the date in ISO 8601 format, such as 2006-01-02. +func (d Date) String() string { + return d.Time().Format("2006-01-02") +} + +const secondsPerDay = 24 * 60 * 60 + +// minDateDays and maxDateDays bound the supported days since the epoch. +var ( + minDateDays = time.Date(-999999999, time.January, 1, 0, 0, 0, 0, time.UTC).Unix() / secondsPerDay + maxDateDays = time.Date(999999999, time.December, 31, 0, 0, 0, 0, time.UTC).Unix() / secondsPerDay +) + +func dateToStorage(d Date) (int64, error) { + if d == (Date{}) { + d = epochDate + } + t := d.Time() + // Out of range fields can overflow in time.Date, so only a date that + // round trips is encoded. + if DateOf(t) != d { + return 0, errors.Errorf("%d-%02d-%02d is not a valid date", d.Year, d.Month, d.Day) + } + days := t.Unix() / secondsPerDay + if days < minDateDays || days > maxDateDays { + return 0, errors.Errorf("date %v is out of range", d) + } + return days, nil +} + +func dateFromStorage(days int64) (Date, error) { + if days < minDateDays || days > maxDateDays { + return Date{}, errors.Errorf("%v days since the epoch is out of the supported date range", days) + } + return DateOf(time.Unix(days*secondsPerDay, 0).UTC()), nil +} + +// MicrosInstant is a timestamp with microsecond precision, the +// beam:logical_type:micros_instant:v1 logical type. It is stored as a row of +// the seconds and the microseconds since the epoch. Encoding a value with +// sub microsecond precision fails; truncate such values with time.Time.Truncate +// first. +type MicrosInstant time.Time + +// Time returns the instant as a time.Time. +func (m MicrosInstant) Time() time.Time { + return time.Time(m) +} + +// String returns the instant in RFC 3339 format with microseconds, such as +// 2020-08-13T14:14:14.123456Z. +func (m MicrosInstant) String() string { + return time.Time(m).Format("2006-01-02T15:04:05.000000Z07:00") +} + +// Equals reports whether m and o are the same instant. +func (m MicrosInstant) Equals(o MicrosInstant) bool { + return time.Time(m).Equal(time.Time(o)) +} + +// microsInstantStorage is the storage type of MicrosInstant. +type microsInstantStorage struct { + Seconds int64 `beam:"seconds"` + Micros int64 `beam:"micros"` +} + +func microsInstantToStorage(m MicrosInstant) (microsInstantStorage, error) { + t := time.Time(m) + nanos := t.Nanosecond() + if nanos%1000 != 0 { + return microsInstantStorage{}, errors.Errorf("MicrosInstant %v has sub microsecond precision", t) + } + return microsInstantStorage{Seconds: t.Unix(), Micros: int64(nanos / 1000)}, nil +} + +func microsInstantFromStorage(s microsInstantStorage) (MicrosInstant, error) { + if s.Micros < 0 || s.Micros > 999999 { + return MicrosInstant{}, errors.Errorf("MicrosInstant micros %v is out of range [0, 999999]", s.Micros) + } + return MicrosInstant(time.Unix(s.Seconds, s.Micros*1000).UTC()), nil +} + +// registerStandardLogicalTypes registers the standard logical types with the +// registry. +func registerStandardLogicalTypes(r *Registry) { + registerLogicalTypeConversion(r, ToLogicalType(URNDate, typeOf[Date](), reflectx.Int64), dateToStorage, dateFromStorage) + registerLogicalTypeConversion(r, ToLogicalType(URNMicrosInstant, typeOf[MicrosInstant](), typeOf[microsInstantStorage]()), microsInstantToStorage, microsInstantFromStorage) +} + +func init() { + // Pipeline graphs serialize the standard logical types by name. + runtime.RegisterType(typeOf[Date]()) + runtime.RegisterType(typeOf[MicrosInstant]()) +} diff --git a/sdks/go/pkg/beam/core/runtime/graphx/schema/standard_logical_types_test.go b/sdks/go/pkg/beam/core/runtime/graphx/schema/standard_logical_types_test.go new file mode 100644 index 000000000000..88b86be1248e --- /dev/null +++ b/sdks/go/pkg/beam/core/runtime/graphx/schema/standard_logical_types_test.go @@ -0,0 +1,421 @@ +// 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 schema + +import ( + "bytes" + "encoding/binary" + "io" + "math" + "reflect" + "testing" + "time" + + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph/coder" + pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1" + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/encoding/prototext" + "google.golang.org/protobuf/testing/protocmp" +) + +func atomicType(at pipepb.AtomicType) *pipepb.FieldType { + return &pipepb.FieldType{TypeInfo: &pipepb.FieldType_AtomicType{AtomicType: at}} +} + +func logicalFieldType(urn string, representation *pipepb.FieldType) *pipepb.FieldType { + return &pipepb.FieldType{ + TypeInfo: &pipepb.FieldType_LogicalType{ + LogicalType: &pipepb.LogicalType{ + Urn: urn, + Representation: representation, + }, + }, + } +} + +// standardRow uses the standard logical types as fields, with the same field +// names as the micros_instant case of standard_coders.yaml. +type standardRow struct { + F_timestamp MicrosInstant `beam:"f_timestamp"` + F_string string `beam:"f_string"` + F_int int64 `beam:"f_int"` + F_date Date `beam:"f_date"` +} + +func TestStandardLogicalTypes_Schema(t *testing.T) { + rt := reflect.TypeOf(standardRow{}) + want := &pipepb.Schema{ + Fields: []*pipepb.Field{ + {Name: "f_timestamp", Type: logicalFieldType(URNMicrosInstant, &pipepb.FieldType{ + TypeInfo: &pipepb.FieldType_RowType{RowType: &pipepb.RowType{Schema: &pipepb.Schema{ + Fields: []*pipepb.Field{ + {Name: "seconds", Type: atomicType(pipepb.AtomicType_INT64)}, + {Name: "micros", Type: atomicType(pipepb.AtomicType_INT64)}, + }, + }}}, + })}, + {Name: "f_string", Type: atomicType(pipepb.AtomicType_STRING)}, + {Name: "f_int", Type: atomicType(pipepb.AtomicType_INT64)}, + {Name: "f_date", Type: logicalFieldType(URNDate, atomicType(pipepb.AtomicType_INT64))}, + }, + } + got, err := FromType(rt) + if err != nil { + t.Fatalf("FromType(%v) = %v, want nil error", rt, err) + } + if d := cmp.Diff(want, got, protocmp.Transform(), protocmp.IgnoreFields(&pipepb.Schema{}, "id")); d != "" { + t.Errorf("FromType(%v) diff (-want, +got): %v", rt, d) + } + // A schema from another SDK has a different id, and must still map to the + // standard Go types. + want.Id = "standard-logical-types-test" + gotType, err := ToType(want) + if err != nil { + t.Fatalf("ToType(%v) = %v, want nil error", prototext.Format(want), err) + } + for i, wantField := range []reflect.Type{typeOf[MicrosInstant](), typeOf[string](), typeOf[int64](), typeOf[Date]()} { + if got := gotType.Field(i).Type; got != wantField { + t.Errorf("ToType field %d type = %v, want %v", i, got, wantField) + } + } +} + +func TestStandardLogicalTypes_RowEncoding(t *testing.T) { + // The bytes are the micros_instant example of standard_coders.yaml, with a + // trailing date field appended: 2020-08-13 is 18487 days since the epoch. + want := standardRow{ + F_timestamp: MicrosInstant(time.Date(2020, 8, 13, 14, 14, 14, 123456000, time.UTC)), + F_string: "2020-08-13T14:14:14.123456Z", + F_int: 1597328054123456, + F_date: Date{Year: 2020, Month: time.August, Day: 13}, + } + wantBytes := []byte("\x04\x00\x02\x00\xb6\x95\xd5\xf9\x05\xc0\xc4\x07\x1b2020-08-13T14:14:14.123456Z\xc0\xf7\x85\xda\xae\x98\xeb\x02\xb7\x90\x01") + rt := reflect.TypeOf(want) + enc, err := coder.RowEncoderForStruct(rt) + if err != nil { + t.Fatalf("RowEncoderForStruct(%v) = %v, want nil error", rt, err) + } + var buf bytes.Buffer + if err := enc(want, &buf); err != nil { + t.Fatalf("enc(%v) = %v, want nil error", want, err) + } + if got := buf.Bytes(); !bytes.Equal(got, wantBytes) { + t.Fatalf("enc(%v) = %q, want %q", want, got, wantBytes) + } + dec, err := coder.RowDecoderForStruct(rt) + if err != nil { + t.Fatalf("RowDecoderForStruct(%v) = %v, want nil error", rt, err) + } + got, err := dec(bytes.NewBuffer(wantBytes)) + if err != nil { + t.Fatalf("dec(%q) = %v, want nil error", wantBytes, err) + } + if d := cmp.Diff(want, got, cmp.Comparer(MicrosInstant.Equals)); d != "" { + t.Errorf("dec(enc(%v)) diff (-want, +got): %v", want, d) + } +} + +func TestMicrosInstant_SubMicrosecondPrecision(t *testing.T) { + enc, err := coder.RowEncoderForStruct(reflect.TypeOf(standardRow{})) + if err != nil { + t.Fatalf("RowEncoderForStruct = %v, want nil error", err) + } + row := standardRow{F_timestamp: MicrosInstant(time.Unix(0, 1500))} + if err := enc(row, &bytes.Buffer{}); err == nil { + t.Errorf("enc(%v) = nil error, want error for sub microsecond precision", row) + } +} + +func TestDate_Storage(t *testing.T) { + tests := []struct { + date Date + days int64 + }{ + {Date{1970, time.January, 1}, 0}, + {Date{1969, time.December, 31}, -1}, + {Date{2020, time.August, 13}, 18487}, + {Date{1600, time.February, 29}, -135081}, + {Date{9999, time.December, 31}, 2932896}, + } + for _, test := range tests { + days, err := dateToStorage(test.date) + if err != nil { + t.Fatalf("dateToStorage(%v) = %v, want nil error", test.date, err) + } + if days != test.days { + t.Errorf("dateToStorage(%v) = %v, want %v", test.date, days, test.days) + } + date, err := dateFromStorage(test.days) + if err != nil { + t.Fatalf("dateFromStorage(%v) = %v, want nil error", test.days, err) + } + if date != test.date { + t.Errorf("dateFromStorage(%v) = %v, want %v", test.days, date, test.date) + } + } + if got, want := DateOf(time.Date(2020, 8, 13, 23, 59, 59, 0, time.UTC)).String(), "2020-08-13"; got != want { + t.Errorf("DateOf(...).String() = %v, want %v", got, want) + } +} + +func TestRegisterLogicalTypeConversion(t *testing.T) { + type celsius float64 + type reading struct { + Temperature celsius + } + // Registering with the default registry mirrors what users do in init(). + RegisterLogicalTypeConversion[celsius, float64]("schema.test:celsius:v1", + func(c celsius) (float64, error) { return float64(c) + 273.15, nil }, + func(k float64) (celsius, error) { return celsius(k - 273.15), nil }) + + rt := reflect.TypeOf(reading{}) + schm, err := FromType(rt) + if err != nil { + t.Fatalf("FromType(%v) = %v, want nil error", rt, err) + } + wantField := logicalFieldType("schema.test:celsius:v1", atomicType(pipepb.AtomicType_DOUBLE)) + if d := cmp.Diff(wantField, schm.GetFields()[0].GetType(), protocmp.Transform()); d != "" { + t.Errorf("FromType(%v) field diff (-want, +got): %v", rt, d) + } + + enc, err := coder.RowEncoderForStruct(rt) + if err != nil { + t.Fatalf("RowEncoderForStruct(%v) = %v, want nil error", rt, err) + } + var buf bytes.Buffer + if err := enc(reading{Temperature: 10}, &buf); err != nil { + t.Fatalf("enc = %v, want nil error", err) + } + // 283.15 as a big endian double, after the row header. + wantBytes := []byte{0x01, 0x00, 0x40, 0x71, 0xb2, 0x66, 0x66, 0x66, 0x66, 0x66} + if got := buf.Bytes(); !bytes.Equal(got, wantBytes) { + t.Fatalf("enc(reading{10}) = %v, want %v", got, wantBytes) + } + dec, err := coder.RowDecoderForStruct(rt) + if err != nil { + t.Fatalf("RowDecoderForStruct(%v) = %v, want nil error", rt, err) + } + got, err := dec(&buf) + if err != nil { + t.Fatalf("dec = %v, want nil error", err) + } + if d := cmp.Diff(reading{Temperature: 10}, got, cmp.Comparer(func(a, b celsius) bool { return a-b < 1e-9 && b-a < 1e-9 })); d != "" { + t.Errorf("dec(enc(reading{10})) diff (-want, +got): %v", d) + } +} + +// fixedCode is a user logical type with an argument, registered with the +// exported parameterized registration. +type fixedCode string + +func TestRegisterParameterizedLogicalTypeConversion(t *testing.T) { + RegisterParameterizedLogicalTypeConversion[fixedCode, string]("schema.test:fixed_code:v1", int32(4), + func(c fixedCode) (string, error) { return string(c), nil }, + func(s string) (fixedCode, error) { return fixedCode(s), nil }) + rt := reflect.TypeOf(struct{ Code fixedCode }{}) + schm, err := FromType(rt) + if err != nil { + t.Fatalf("FromType(%v) = %v, want nil error", rt, err) + } + want := logicalFieldType("schema.test:fixed_code:v1", atomicType(pipepb.AtomicType_STRING)) + want.GetLogicalType().ArgumentType = int32FieldType() + want.GetLogicalType().Argument = int32FieldValue(4) + if d := cmp.Diff(want, schm.GetFields()[0].GetType(), protocmp.Transform()); d != "" { + t.Errorf("FromType(%v) field diff (-want, +got): %v", rt, d) + } + schm.Id = "fixed-code-from-elsewhere" + got, err := ToType(schm) + if err != nil { + t.Fatalf("ToType = %v, want nil error", err) + } + if ft := got.Field(0).Type; ft != typeOf[fixedCode]() { + t.Errorf("ToType field type = %v, want %v", ft, typeOf[fixedCode]()) + } + enc, err := coder.RowEncoderForStruct(rt) + if err != nil { + t.Fatalf("RowEncoderForStruct(%v) = %v, want nil error", rt, err) + } + var buf bytes.Buffer + if err := enc(struct{ Code fixedCode }{"ABCD"}, &buf); err != nil { + t.Fatalf("enc = %v, want nil error", err) + } + if got, want := buf.Bytes(), []byte("\x01\x00\x04ABCD"); !bytes.Equal(got, want) { + t.Errorf("enc = %q, want %q", got, want) + } +} + +// money is a user logical type with its own wire encoding: the cents as a +// big endian int32 inside a BYTES representation. +type money int32 + +func TestRegisterLogicalTypeCoder(t *testing.T) { + RegisterLogicalTypeCoder[money, []byte]("schema.test:money:v1", + func(m money, w io.Writer) error { + var b [4]byte + binary.BigEndian.PutUint32(b[:], uint32(m)) + return coder.EncodeBytes(b[:], w) + }, + func(r io.Reader) (money, error) { + b, err := coder.DecodeBytes(r) + if err != nil { + return 0, err + } + return money(int32(binary.BigEndian.Uint32(b))), nil + }) + rt := reflect.TypeOf(struct{ Price money }{}) + schm, err := FromType(rt) + if err != nil { + t.Fatalf("FromType(%v) = %v, want nil error", rt, err) + } + want := logicalFieldType("schema.test:money:v1", atomicType(pipepb.AtomicType_BYTES)) + if d := cmp.Diff(want, schm.GetFields()[0].GetType(), protocmp.Transform()); d != "" { + t.Errorf("FromType(%v) field diff (-want, +got): %v", rt, d) + } + enc, err := coder.RowEncoderForStruct(rt) + if err != nil { + t.Fatalf("RowEncoderForStruct(%v) = %v, want nil error", rt, err) + } + var buf bytes.Buffer + if err := enc(struct{ Price money }{-2}, &buf); err != nil { + t.Fatalf("enc = %v, want nil error", err) + } + wantBytes := []byte{0x01, 0x00, 0x04, 0xff, 0xff, 0xff, 0xfe} + if got := buf.Bytes(); !bytes.Equal(got, wantBytes) { + t.Fatalf("enc = %v, want %v", got, wantBytes) + } + dec, err := coder.RowDecoderForStruct(rt) + if err != nil { + t.Fatalf("RowDecoderForStruct(%v) = %v, want nil error", rt, err) + } + got, err := dec(&buf) + if err != nil { + t.Fatalf("dec = %v, want nil error", err) + } + if d := cmp.Diff(struct{ Price money }{-2}, got); d != "" { + t.Errorf("dec(enc) diff (-want, +got): %v", d) + } +} + +func TestRegisterLogicalTypeConversion_PointerStorage(t *testing.T) { + type celsiusPtr float64 + defer func() { + if recover() == nil { + t.Errorf("RegisterLogicalTypeConversion with a pointer storage type did not panic") + } + }() + RegisterLogicalTypeConversion[celsiusPtr, *float64]("schema.test:celsius_ptr:v1", + func(c celsiusPtr) (*float64, error) { f := float64(c); return &f, nil }, + func(f *float64) (celsiusPtr, error) { return celsiusPtr(*f), nil }) +} + +func TestRegisterLogicalType_InterfaceGoType(t *testing.T) { + type textual interface{ Text() string } + t.Run("conversion", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Errorf("RegisterLogicalTypeConversion with an interface Go type did not panic") + } + }() + RegisterLogicalTypeConversion[textual, string]("schema.test:textual:v1", + func(v textual) (string, error) { return v.Text(), nil }, + func(s string) (textual, error) { return nil, nil }) + }) + t.Run("coder", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Errorf("RegisterLogicalTypeCoder with an interface Go type did not panic") + } + }() + RegisterLogicalTypeCoder[textual, string]("schema.test:textual_coder:v1", + func(v textual, w io.Writer) error { return nil }, + func(r io.Reader) (textual, error) { return nil, nil }) + }) +} + +func TestDate_StorageRange(t *testing.T) { + for _, days := range []int64{minDateDays, maxDateDays} { + date, err := dateFromStorage(days) + if err != nil { + t.Fatalf("dateFromStorage(%v) = %v, want nil error", days, err) + } + if got, err := dateToStorage(date); err != nil || got != days { + t.Errorf("dateToStorage(%v) = %v, %v, want %v, nil", date, got, err, days) + } + } + for _, days := range []int64{minDateDays - 1, maxDateDays + 1, 1 << 62, math.MinInt64} { + if date, err := dateFromStorage(days); err == nil { + t.Errorf("dateFromStorage(%v) = %v, want error", days, date) + } + } + for _, date := range []Date{ + {-1000000000, time.December, 31}, + {1000000000, time.January, 1}, + {2020, time.February, 30}, + {1970, time.January, math.MaxInt/4 + 2}, + {math.MaxInt / 2, time.January, 1}, + } { + if days, err := dateToStorage(date); err == nil { + t.Errorf("dateToStorage(%d-%02d-%02d) = %v, want error", date.Year, date.Month, date.Day, days) + } + } +} + +func TestMicrosInstant_StorageRange(t *testing.T) { + for _, micros := range []int64{-1, 1000000, 1 << 61} { + s := microsInstantStorage{Seconds: 0, Micros: micros} + if m, err := microsInstantFromStorage(s); err == nil { + t.Errorf("microsInstantFromStorage(%+v) = %v, want error", s, m.Time()) + } + } + s := microsInstantStorage{Seconds: -1, Micros: 999999} + m, err := microsInstantFromStorage(s) + if err != nil { + t.Fatalf("microsInstantFromStorage(%+v) = %v, want nil error", s, err) + } + if want := time.Unix(-1, 999999000); !m.Time().Equal(want) { + t.Errorf("microsInstantFromStorage(%+v) = %v, want %v", s, m.Time(), want) + } +} + +func TestDate_Zero(t *testing.T) { + var d Date + if got, want := d.Time(), time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC); !got.Equal(want) { + t.Errorf("Date{}.Time() = %v, want %v", got, want) + } + if got, want := d.String(), "1970-01-01"; got != want { + t.Errorf("Date{}.String() = %v, want %v", got, want) + } + if days, err := dateToStorage(d); err != nil || days != 0 { + t.Errorf("dateToStorage(Date{}) = %v, %v, want 0, nil", days, err) + } +} + +func TestMicrosInstant_StringAndEquals(t *testing.T) { + utc := MicrosInstant(time.Date(2020, time.August, 13, 14, 14, 14, 123456000, time.UTC)) + if got, want := utc.String(), "2020-08-13T14:14:14.123456Z"; got != want { + t.Errorf("String() = %v, want %v", got, want) + } + zoned := MicrosInstant(utc.Time().In(time.FixedZone("UTC+2", 2*60*60))) + if got, want := zoned.String(), "2020-08-13T16:14:14.123456+02:00"; got != want { + t.Errorf("String() = %v, want %v", got, want) + } + if !utc.Equals(zoned) { + t.Errorf("%v.Equals(%v) = false, want true", utc, zoned) + } + if later := MicrosInstant(utc.Time().Add(time.Microsecond)); utc.Equals(later) { + t.Errorf("%v.Equals(%v) = true, want false", utc, later) + } +} diff --git a/sdks/go/pkg/beam/core/runtime/graphx/serialize_test.go b/sdks/go/pkg/beam/core/runtime/graphx/serialize_test.go index 265d936a71ec..a6aceee4409d 100644 --- a/sdks/go/pkg/beam/core/runtime/graphx/serialize_test.go +++ b/sdks/go/pkg/beam/core/runtime/graphx/serialize_test.go @@ -23,6 +23,7 @@ import ( "testing" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/graphx/schema" v1pb "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/graphx/v1" ) @@ -88,4 +89,19 @@ func TestEncodeType(t *testing.T) { t.Errorf("expected error about wrapping in a struct, got %q", err.Error()) } }) + t.Run("StandardLogicalTypes", func(t *testing.T) { + rt := reflect.TypeOf(struct { + D schema.Date + M schema.MicrosInstant + }{}) + pbT, err := encodeType(rt) + if err != nil { + t.Fatalf("got error = %v, want nil", err) + } + for _, f := range pbT.GetFields() { + if got, want := f.GetType().GetKind(), v1pb.Type_EXTERNAL; got != want { + t.Errorf("got field %v kind == %v, want %v", f.GetName(), got, want) + } + } + }) } diff --git a/sdks/go/test/regression/coders/fromyaml/fromyaml.go b/sdks/go/test/regression/coders/fromyaml/fromyaml.go index 466f361b0138..08000db877da 100644 --- a/sdks/go/test/regression/coders/fromyaml/fromyaml.go +++ b/sdks/go/test/regression/coders/fromyaml/fromyaml.go @@ -29,16 +29,19 @@ import ( "runtime/debug" "strconv" "strings" + "time" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph/mtime" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/graph/window" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/exec" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/graphx" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/graphx/schema" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/typex" "github.com/apache/beam/sdks/v2/go/pkg/beam/core/util/reflectx" pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1" "github.com/google/go-cmp/cmp" "golang.org/x/text/encoding/charmap" + "google.golang.org/protobuf/proto" yaml "gopkg.in/yaml.v2" ) @@ -49,11 +52,11 @@ var unimplementedCoders = map[string]bool{ } var filteredCases = []struct{ filter, reason string }{ - {"logical", "BEAM-9615: Support logical types"}, {"30ea5a25-dcd8-4cdb-abeb-5332d15ab4b9", "https://github.com/apache/beam/issues/21206: Support encoding position."}, - {"80be749a-5700-4ede-89d8-dd9a4433a3f8", "https://github.com/apache/beam/issues/19817: Support millis_instant."}, - {"800c44ae-a1b7-4def-bbf6-6217cca89ec4", "https://github.com/apache/beam/issues/19817: Support decimal."}, - {"f0ffb3a4-f46f-41ca-a942-85e3e939452a", "https://github.com/apache/beam/issues/23526: Support char/varchar, binary/varbinary."}, + {"beam:logical_type:millis_instant:v1", "https://github.com/apache/beam/issues/39684: Support millis_instant."}, + {"beam:logical_type:decimal:v1", "https://github.com/apache/beam/issues/39684: Support decimal."}, + {"beam:logical_type:fixed_char:v1", "https://github.com/apache/beam/issues/39684: Support char/varchar, binary/varbinary."}, + {"beam:logical_type:timestamp:v1", "https://github.com/apache/beam/issues/39684: Support timestamp."}, } // Coder is a representation a serialized beam coder. @@ -164,6 +167,7 @@ func (s *Spec) testStandardCoder() (err error) { } if d := cmp.Diff(recoded, string(out.Bytes())); d != "" { log.Printf("Encoding error: diff(-want,+got): %v\n", d) + encFails++ } } if decFails+encFails > 0 { @@ -177,6 +181,15 @@ var cmpOpts = []cmp.Option{ cmp.Transformer("bytes2string", func(in []byte) (out string) { return string(in) }), + cmp.Comparer(func(a, b schema.MicrosInstant) bool { + return a.Time().Equal(b.Time()) + }), +} + +// latin1Bytes returns the bytes that a yaml string denotes. The yaml strings +// escape bytes above 0x7f as code points, which yaml decodes to UTF-8. +func latin1Bytes(s string) ([]byte, error) { + return charmap.ISO8859_1.NewEncoder().Bytes([]byte(s)) } func diff(c Coder, elem *exec.FullValue, eg yaml.MapItem) bool { @@ -301,29 +314,12 @@ func diff(c Coder, elem *exec.FullValue, eg yaml.MapItem) bool { } return pass case "beam:coder:row:v1": - fs := eg.Value.(yaml.MapSlice) - var rfs []reflect.StructField - // There are only 2 pointer examples, but they reuse field names, - // so we key off the proto hash to know which example we're handling. - ptrEg := strings.Contains(c.Payload, "51ace21c7393") - for _, rf := range fs { - name := rf.Key.(string) - t := nameToType[name] - if ptrEg { - t = reflect.PtrTo(t) - } - rfs = append(rfs, reflect.StructField{ - Name: strings.ToUpper(name[:1]) + name[1:], - Type: t, - Tag: reflect.StructTag(fmt.Sprintf("beam:\"%v\"", name)), - }) - } - rv := reflect.New(reflect.StructOf(rfs)).Elem() - for i, rf := range fs { - setField(rv, i, rf.Value) + row, err := rowFromYAML(c.Payload, eg.Value.(yaml.MapSlice)) + if err != nil { + log.Printf("unable to build the expected row: %v\n", err) + return false } - - got, want = elem.Elm, rv.Interface() + got, want = elem.Elm, row case "beam:coder:timer:v1": pass := true tm := elem.Elm.(exec.TimerRecv) @@ -411,73 +407,137 @@ func diffPane(eg any, got typex.PaneInfo) bool { return pass } -// standard_coders.yaml uses the name for type indication, except for nullability. -var nameToType = map[string]reflect.Type{ - "str": reflectx.String, - "i32": reflectx.Int32, - "f64": reflectx.Float64, - "arr": reflect.SliceOf(reflectx.String), - "f_bool": reflectx.Bool, - "f_bytes": reflect.PtrTo(reflectx.ByteSlice), - "f_map": reflect.MapOf(reflectx.String, reflect.PtrTo(reflectx.Int64)), - "f_float": reflectx.Float32, +// rowFromYAML builds the expected value of a beam:coder:row:v1 example from +// the schema in the coder payload and the field values of the example. +// The Go type is the one the Go SDK derives from the schema, so logical type +// fields have their registered Go types. +func rowFromYAML(payload string, fields yaml.MapSlice) (any, error) { + b, err := latin1Bytes(payload) + if err != nil { + return nil, err + } + var s pipepb.Schema + if err := proto.Unmarshal(b, &s); err != nil { + return nil, fmt.Errorf("unmarshalling the row schema: %v", err) + } + rt, err := schema.ToType(&s) + if err != nil { + return nil, fmt.Errorf("converting the row schema to a type: %v", err) + } + rv := reflect.New(rt).Elem() + if err := setStruct(rv, fields); err != nil { + return nil, err + } + return rv.Interface(), nil +} + +// setStruct sets the fields of the struct value rv from the yaml mapping. +func setStruct(rv reflect.Value, fields yaml.MapSlice) error { + for _, f := range fields { + name := f.Key.(string) + fv, ok := fieldByName(rv, name) + if !ok { + return fmt.Errorf("no field %q in %v", name, rv.Type()) + } + if err := setValue(fv, f.Value); err != nil { + return fmt.Errorf("field %q: %v", name, err) + } + } + return nil } -func setField(rv reflect.Value, i int, v any) { +// fieldByName returns the field of rv with the given schema field name, which +// is either the Go field name or the name in the beam struct tag. +func fieldByName(rv reflect.Value, name string) (reflect.Value, bool) { + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + sf := rt.Field(i) + if sf.Name == name { + return rv.Field(i), true + } + if tag, ok := sf.Tag.Lookup("beam"); ok && strings.Split(tag, ",")[0] == name { + return rv.Field(i), true + } + } + return reflect.Value{}, false +} + +// setValue sets rv from a yaml example value. A nil value leaves rv at its +// zero value, which is nil for nullable fields. +func setValue(rv reflect.Value, v any) error { if v == nil { - return + return nil } - rf := rv.Field(i) - if rf.Kind() == reflect.Ptr { - // Ensure it's initialized. - rf.Set(reflect.New(rf.Type().Elem())) - rf = rf.Elem() + if rv.Kind() == reflect.Ptr { + rv.Set(reflect.New(rv.Type().Elem())) + rv = rv.Elem() } - switch rf.Kind() { + switch rv.Type() { + case reflect.TypeOf(schema.MicrosInstant{}): + var seconds, micros int64 + for _, f := range v.(yaml.MapSlice) { + switch f.Key.(string) { + case "seconds": + seconds = int64(f.Value.(int)) + case "micros": + micros = int64(f.Value.(int)) + } + } + rv.Set(reflect.ValueOf(schema.MicrosInstant(time.Unix(seconds, micros*1000).UTC()))) + return nil + } + switch rv.Kind() { case reflect.String: - rf.SetString(v.(string)) - case reflect.Int32: - rf.SetInt(int64(v.(int))) + rv.SetString(v.(string)) + case reflect.Int16, reflect.Int32, reflect.Int64: + rv.SetInt(int64(v.(int))) case reflect.Float32: c, err := strconv.ParseFloat(v.(string), 32) if err != nil { - panic(err) + return err } - rf.SetFloat(c) + rv.SetFloat(c) case reflect.Float64: c, err := strconv.ParseFloat(v.(string), 64) if err != nil { - panic(err) + return err } - rf.SetFloat(c) + rv.SetFloat(c) + case reflect.Bool: + rv.SetBool(v.(bool)) case reflect.Slice: - if rf.Type() == reflectx.ByteSlice { - rf.Set(reflect.ValueOf([]byte(v.(string)))) - break + if rv.Type() == reflectx.ByteSlice { + rv.SetBytes([]byte(v.(string))) + return nil } - // Value is a []any with string values. - var arr []string - for _, a := range v.([]any) { - arr = append(arr, a.(string)) + items := v.([]any) + sv := reflect.MakeSlice(rv.Type(), len(items), len(items)) + for i, item := range items { + if err := setValue(sv.Index(i), item); err != nil { + return err + } } - rf.Set(reflect.ValueOf(arr)) - case reflect.Bool: - rf.SetBool(v.(bool)) + rv.Set(sv) case reflect.Map: - // only f_map presently, which is always map[string]*int64 - rm := reflect.MakeMap(rf.Type()) - for _, a := range v.(yaml.MapSlice) { - rk := reflect.ValueOf(a.Key.(string)) - rv := reflect.Zero(rf.Type().Elem()) - if a.Value != nil { - rv = reflect.New(reflectx.Int64) - rv.Elem().SetInt(int64(a.Value.(int))) + mv := reflect.MakeMap(rv.Type()) + for _, entry := range v.(yaml.MapSlice) { + key := reflect.New(rv.Type().Key()).Elem() + if err := setValue(key, entry.Key); err != nil { + return err + } + val := reflect.New(rv.Type().Elem()).Elem() + if err := setValue(val, entry.Value); err != nil { + return err } - rm.SetMapIndex(rk, rv) + mv.SetMapIndex(key, val) } - rf.Set(rm) - + rv.Set(mv) + case reflect.Struct: + return setStruct(rv, v.(yaml.MapSlice)) + default: + return fmt.Errorf("unsupported field type %v", rv.Type()) } + return nil } func (s *Spec) parseCoder(c Coder) string { @@ -486,10 +546,14 @@ func (s *Spec) parseCoder(c Coder) string { for _, comp := range c.Components { compIDs = append(compIDs, s.parseCoder(comp)) } + payload, err := latin1Bytes(c.Payload) + if err != nil { + panic(fmt.Sprintf("invalid coder payload %q: %v", c.Payload, err)) + } s.coderPBs[id] = &pipepb.Coder{ Spec: &pipepb.FunctionSpec{ Urn: c.Urn, - Payload: []byte(c.Payload), + Payload: payload, }, ComponentCoderIds: compIDs, } From f8b1e8cdd75a02e19e1026f58428c59a91ba7c70 Mon Sep 17 00:00:00 2001 From: Bruno Volpato Date: Mon, 19 Oct 2026 14:54:48 -0400 Subject: [PATCH 043/108] [Python] Add typed decision questions and model adapters (#40220) * [Python] Add decision model routing examples * [Python] Reuse decision model clients in a lightweight DoFn * [Python] Add reusable decision model primitives and adapters * [Python] Gate live Jev tests and isolate runtime value options * [Python] Validate decision responses and add dead-letter handling * [Python] Run decision adapter tests in ML CI * [Python] Move decision SDK tests into the ML extra --- CHANGES.md | 1 + .../apache_beam/examples/inference/README.md | 8 +- .../inference/decision_models/README.md | 304 ++++++++++++++++++ .../inference/decision_models/__init__.py | 18 ++ .../inference/decision_models/fraud_review.py | 126 ++++++++ .../decision_models/fraud_review_test.py | 86 +++++ .../inference/decision_models/local_model.py | 78 +++++ .../decision_models/message_router.py | 199 ++++++++++++ .../decision_models/message_router.svg | 98 ++++++ .../decision_models/message_router_test.py | 75 +++++ .../decision_models/requirements.txt | 19 ++ .../examples/snippets/snippets_test.py | 8 +- .../apache_beam/ml/inference/decision.py | 246 ++++++++++++++ .../apache_beam/ml/inference/decision_test.py | 254 +++++++++++++++ .../ml/inference/typesafe_inference.py | 164 ++++++++++ .../inference/typesafe_inference_it_test.py | 93 ++++++ .../ml/inference/typesafe_inference_test.py | 256 +++++++++++++++ .../inference/typesafe_tests_requirements.txt | 18 ++ sdks/python/setup.py | 1 + 19 files changed, 2049 insertions(+), 3 deletions(-) create mode 100644 sdks/python/apache_beam/examples/inference/decision_models/README.md create mode 100644 sdks/python/apache_beam/examples/inference/decision_models/__init__.py create mode 100644 sdks/python/apache_beam/examples/inference/decision_models/fraud_review.py create mode 100644 sdks/python/apache_beam/examples/inference/decision_models/fraud_review_test.py create mode 100644 sdks/python/apache_beam/examples/inference/decision_models/local_model.py create mode 100644 sdks/python/apache_beam/examples/inference/decision_models/message_router.py create mode 100644 sdks/python/apache_beam/examples/inference/decision_models/message_router.svg create mode 100644 sdks/python/apache_beam/examples/inference/decision_models/message_router_test.py create mode 100644 sdks/python/apache_beam/examples/inference/decision_models/requirements.txt create mode 100644 sdks/python/apache_beam/ml/inference/decision.py create mode 100644 sdks/python/apache_beam/ml/inference/decision_test.py create mode 100644 sdks/python/apache_beam/ml/inference/typesafe_inference.py create mode 100644 sdks/python/apache_beam/ml/inference/typesafe_inference_it_test.py create mode 100644 sdks/python/apache_beam/ml/inference/typesafe_inference_test.py create mode 100644 sdks/python/apache_beam/ml/inference/typesafe_tests_requirements.txt diff --git a/CHANGES.md b/CHANGES.md index d50b14cadd1a..00fb9aa825b5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -73,6 +73,7 @@ * (Java) Flink runner: sources now forward Beam metrics to Flink at most once per second instead of after every record, which was a significant share of the CPU of reading. Metrics in the pipeline result are not affected ([#40607](https://github.com/apache/beam/issues/40607)). * (Go) Added `wait.On`, which delays each input window until the corresponding windows in its signal PCollections have closed ([#39909](https://github.com/apache/beam/issues/39909)). * (Python) Expanded the SDK worker heap dump (`--experiments=enable_heap_dump`) with process RSS, CPython allocator/GC stats, and glibc `mallinfo2` native-heap/fragmentation stats to help distinguish native-heap from Python-object memory growth ([#39244](https://github.com/apache/beam/issues/39244)). +* (Python) Added typed decision questions and the `EvaluateDecisions` transform, with an optional Jev model adapter ([#40220](https://github.com/apache/beam/pull/40220)). * The `disableCounterMetrics`, `disableStringSetMetrics` and `disableBoundedTrieMetrics` experiments are now honored by the Python SDK, as they already were in Java (Python) ([#38746](https://github.com/apache/beam/issues/38746)). * (Go) Pipeline-level `--resource_hints` are now forwarded to expansion services, so they apply to cross-language transforms as they do in the Java and Python SDKs. The `max_active_bundles_per_worker` hint name is now also accepted by `--resource_hints` ([#23893](https://github.com/apache/beam/issues/23893)). * `QdrantWriteConfig` can now optionally store chunk content in the point payload via `content_payload_key`, so retrieved points include the chunk text (Python) ([#40476](https://github.com/apache/beam/issues/40476)). diff --git a/sdks/python/apache_beam/examples/inference/README.md b/sdks/python/apache_beam/examples/inference/README.md index c43ad4583479..f14b531c21a2 100644 --- a/sdks/python/apache_beam/examples/inference/README.md +++ b/sdks/python/apache_beam/examples/inference/README.md @@ -17,7 +17,13 @@ under the License. --> -# Example RunInference API pipelines +# Example inference pipelines + +For typed classification, Boolean judgments, and scoring, see the +[decision model examples](decision_models/README.md). They use +`EvaluateDecisions` with a swappable model adapter, including Jev and local rules. + +## RunInference This module contains example pipelines that use the Beam RunInference API. diff --git a/sdks/python/apache_beam/examples/inference/decision_models/README.md b/sdks/python/apache_beam/examples/inference/decision_models/README.md new file mode 100644 index 000000000000..637b8159c555 --- /dev/null +++ b/sdks/python/apache_beam/examples/inference/decision_models/README.md @@ -0,0 +1,304 @@ + + +# Beam decision models + +These examples use the reusable decision primitives in +`apache_beam.ml.inference.decision`. A model evaluates named, typed questions +for each Beam element. `EvaluateDecisions` returns the original state, +normalized answers, provider metadata, and request latency. Policy and sinks +remain ordinary Beam transforms after evaluation. + +## Public API + +The core module contains `ChoiceQuestion`, `BooleanQuestion`, and +`ScoreQuestion`, their typed answers (`ChoiceAnswer`, `BooleanAnswer`, and +`ScoreAnswer`), the `DecisionModel` ABC, and the `EvaluateDecisions` +`PTransform`. + +| Question | Normalized answer | +| --- | --- | +| `ChoiceQuestion(instructions, criteria)` | `ChoiceAnswer.choice`, with optional label probabilities and confidence | +| `BooleanQuestion(instructions)` | `BooleanAnswer.probability` in `[0, 1]` | +| `ScoreQuestion(instructions, criteria)` | `ScoreAnswer.score` and `ScoreAnswer.probabilities`, keyed by zero-based integer level, with optional confidence | + +`JevDecisionModel` maps `BooleanQuestion` to Jev's `Noul`, returning +`BooleanAnswer.probability`; a missing confidence is `None`. + +`DecisionModel` is the adapter ABC. A subclass implements +`evaluate(state, questions)` and returns one matching typed answer per named +question in a `DecisionResponse`. Override `__enter__` and `__exit__` to reuse +a client, connection pool, or model weights. The transform enters the model +during DoFn setup and closes it during teardown for each DoFn instance. + +`EvaluateDecisions(model, questions)` is a `PTransform` from input states to +`DecisionResult`. Evaluation is synchronous, timestamps and windows are +preserved, exceptions propagate to the runner, and request, failure, and +latency metrics are recorded. + +Before returning a result, `JevDecisionModel` checks that its response contains +the requested questions with matching answer types. It rejects unknown choice +labels and non-finite or out-of-range scores, probabilities, and confidence +values with `ValueError`. Custom `DecisionModel` implementations must enforce +the same output contract. These errors fail the pipeline by default. + +## Architecture + +```mermaid +flowchart LR + input["PCollection[state]"] --> evaluate["EvaluateDecisions(model, questions)
PTransform"] + model["DecisionModel adapter"] --> evaluate + evaluate --> result["PCollection[DecisionResult]"] + result --> policy["route_message
policy and sink selection"] + policy --> sink["JSONL files
or BigQuery tables"] +``` + +![Rendered Beam message router graph](message_router.svg) + +The optional Jev adapter lives in +`apache_beam.ml.inference.typesafe_inference`; the deterministic +`LocalDecisionModel` stays in +`apache_beam.examples.inference.decision_models.local_model`. Both implement +the same core contract. + +## Integration + +Questions and events are regular Python values. The model is the only +provider-specific argument to `EvaluateDecisions`: + +```python +import apache_beam as beam + +from apache_beam.ml.inference.decision import ChoiceQuestion +from apache_beam.ml.inference.decision import EvaluateDecisions +from apache_beam.examples.inference.decision_models.local_model import ( + LocalDecisionModel, +) +from apache_beam.ml.inference.typesafe_inference import JevDecisionModel + +questions = { + 'team': ChoiceQuestion('Which team should handle this message?', { + 'billing': 'Invoices', 'technical': 'Technical problems'}), +} + +def select_team(result): + return result.response.answers['team'].choice + +model = LocalDecisionModel() +# model = JevDecisionModel() + +with beam.Pipeline() as pipeline: + events = pipeline | 'Events' >> beam.Create( + [{'message': 'The API returns 503 after I rotated my key.'}]) + decisions = events | 'Evaluate decisions' >> EvaluateDecisions( + model, questions) + rows = decisions | 'Apply downstream policy' >> beam.Map(select_team) +``` + +The downstream policy stays unchanged when swapping adapters. The Jev client +is optional; install the shared SDK requirements listed below and set +`TYPESAFE_API_KEY` when running it. + +## Install + +From a Beam checkout, activate a virtual environment and install the core SDK: + +```sh +cd sdks/python +python3 -m pip install -e . +``` + +Install the optional Jev SDK used by the adapter and examples: + +```sh +cd sdks/python +python3 -m pip install -r \ + apache_beam/ml/inference/typesafe_tests_requirements.txt +``` + +Install Beam's GCP extra for Pub/Sub or BigQuery sinks: + +```sh +cd sdks/python +python3 -m pip install -e '.[gcp]' +``` + +The example requirements file delegates to that same bounded dependency file. + +## Run examples + +The main demo uses a three-element `TestStream` and writes one JSONL file per +destination: + +```sh +cd sdks/python +output_dir="$(mktemp -d /tmp/beam-decision-model-output.XXXXXX)" +python3 -m apache_beam.examples.inference.decision_models.message_router \ + --model=local \ + --output-dir="$output_dir" \ + --graph="$output_dir/message_router.svg" +find "$output_dir" -name '*.jsonl' -print -exec sed -n '1,3p' {} \; +``` + +Render the graph without running the job. The sink stays in the graph: + +```sh +cd sdks/python +python3 -m apache_beam.examples.inference.decision_models.message_router \ + --model=jev \ + --graph-only \ + --bq-dataset=PROJECT:DATASET \ + --graph=/tmp/beam-decision-model.svg +``` + +The `.dot` export works without Graphviz; SVG and PNG require `dot` on `PATH`. + +Run the Jev path against the same stream with `TYPESAFE_API_KEY` set: + +```sh +cd sdks/python +export TYPESAFE_API_KEY='replace-with-your-key' +jev_output_dir="$(mktemp -d /tmp/beam-decision-model-jev-output.XXXXXX)" +python3 -m apache_beam.examples.inference.decision_models.message_router \ + --model=jev \ + --min-confidence=0.65 \ + --output-dir="$jev_output_dir" \ + --graph="$jev_output_dir/message_router.svg" +``` + +For a streaming source, provide a Pub/Sub subscription containing JSON objects +with `event_id` and `message` fields: + +```sh +cd sdks/python +python3 -m apache_beam.examples.inference.decision_models.message_router \ + --model=jev \ + --pubsub-subscription=projects/PROJECT/subscriptions/SUBSCRIPTION \ + --bq-dataset=PROJECT:DATASET +``` + +With `--bq-dataset=PROJECT:DATASET`, rows go to `messages_billing`, +`messages_technical`, `messages_sales`, or `messages_review`; `--output-dir` +writes local JSONL files. A normal run accepts one sink option. + +The fraud example prints one JSON row per sample message. Noul supplies the +boolean probability; values at or above `0.7` are flagged: + +```sh +cd sdks/python +python3 -m apache_beam.examples.inference.decision_models.fraud_review \ + --model=local --primitive=noul +python3 -m apache_beam.examples.inference.decision_models.fraud_review \ + --model=local --primitive=both +python3 -m apache_beam.examples.inference.decision_models.fraud_review \ + --model=jev --primitive=both +``` + +Pass `--dead-letter-dir` to write per-element `ValueError` failures from +decision evaluation as JSONL records containing the original input and the +exception representation. This opt-in catches malformed-response validation +errors; without the option, they fail the pipeline. The Jev SDK retries +transient transport and service failures with exponential backoff and jitter, +and honors `Retry-After` when supplied. Those retries run independently on each +Beam worker, which does not impose a global request-rate limit. Failures that +remain after retries still fail the pipeline instead of going to this sink. +Errors while the model context is created during worker setup also fail the +pipeline and are not routed to this sink. + +For example, enable the local dead-letter sink with the Jev adapter: + +```sh +cd sdks/python +export TYPESAFE_API_KEY='replace-with-your-key' +dead_letter_dir="$(mktemp -d /tmp/beam-decision-model-dead-letter.XXXXXX)" +python3 -m apache_beam.examples.inference.decision_models.fraud_review \ + --model=jev --primitive=both --dead-letter-dir="$dead_letter_dir" +find "$dead_letter_dir" -name '*.jsonl' -print -exec sed -n '1,3p' {} \; +``` + +A final three-element DirectRunner run returned these values. Beam may print +the rows in a different order: + +| message | Noul probability | Score | +| --- | ---: | ---: | +| billing address | 0.42 | 0.80 | +| duplicate charge | 0.32 | 0.85 | +| bypass verification | 0.97 | 3.00 | + +Only the bypass verification message crossed the `0.7` review threshold. + +## Tests + +Install Beam's test dependencies in the same virtual environment: + +```sh +cd sdks/python +python3 -m pip install -e '.[test]' +``` + +The unit tests cover the shared API, routing policy, and mocked Jev responses: + +```sh +cd sdks/python +python3 -m pytest -q \ + apache_beam/ml/inference/decision_test.py \ + apache_beam/ml/inference/typesafe_inference_test.py \ + apache_beam/examples/inference/decision_models/message_router_test.py \ + apache_beam/examples/inference/decision_models/fraud_review_test.py +``` + +With the optional Jev SDK installed and `TYPESAFE_API_KEY` set, run the live +integration test: + +```sh +cd sdks/python +python3 -m pytest -q -rs -m it_postcommit \ + apache_beam/ml/inference/typesafe_inference_it_test.py +``` + +It sends one streaming event through `EvaluateDecisions`, asking Choice, +Boolean/Noul, and Score questions together. It checks the normalized answer +types and ranges, and skips when the SDK or API key is missing. The mocked +adapter tests run with the SDK installed and require no API key. + +## Jev benchmark + +These measurements were captured on September 22, 2026 with Jev 1.13.0, +TypeSafe SDK 0.7.1, and DirectRunner. The final trace used exactly one SDK +client, one TCP connection, and one TLS handshake for three events: + +| event | request latency | +| --- | ---: | +| billing | 408.27 ms | +| technical | 217.50 ms | +| sales | 190.43 ms | + +Total pipeline time was `986.68 ms`, including pipeline construction. + +### Standalone transport experiment, September 22, 2026 + +An earlier standalone experiment compared fresh clients with one pooled client: + +| client setup | observations | +| --- | --- | +| fresh client, `n=3` | 602.44 ms, 433.56 ms, 537.60 ms | +| one pooled client | first 416.77 ms; next five 170.98 ms, 211.24 ms, 222.02 ms, 240.40 ms, 226.82 ms | + +The trace showed no TCP/TLS spans on reused calls. Reuse pays connection setup +once. Warm-call delay is response wait plus network and service time. diff --git a/sdks/python/apache_beam/examples/inference/decision_models/__init__.py b/sdks/python/apache_beam/examples/inference/decision_models/__init__.py new file mode 100644 index 000000000000..fc00cf8225fd --- /dev/null +++ b/sdks/python/apache_beam/examples/inference/decision_models/__init__.py @@ -0,0 +1,18 @@ +# +# 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. +# + +"""Decision model routing examples.""" diff --git a/sdks/python/apache_beam/examples/inference/decision_models/fraud_review.py b/sdks/python/apache_beam/examples/inference/decision_models/fraud_review.py new file mode 100644 index 000000000000..5afcb6f34f2d --- /dev/null +++ b/sdks/python/apache_beam/examples/inference/decision_models/fraud_review.py @@ -0,0 +1,126 @@ +# +# 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. +# + +"""Small Beam example: flag messages for fraud review with Noul or Score.""" + +import argparse +import json +import os + +import apache_beam as beam +from apache_beam.examples.inference.decision_models.local_model import ( + LocalDecisionModel) +from apache_beam.ml.inference.decision import BooleanQuestion +from apache_beam.ml.inference.decision import EvaluateDecisions +from apache_beam.ml.inference.decision import ScoreQuestion +from apache_beam.ml.inference.typesafe_inference import JevDecisionModel +from apache_beam.options.pipeline_options import PipelineOptions + +SAMPLE_MESSAGES = [ + 'Please update my billing address before the next renewal.', + 'I was charged twice; please look into the duplicate payment.', + 'Transfer the refund to this new account immediately and ' + 'skip verification.', +] + +FRAUD_NOUL = BooleanQuestion( + instructions='Does this message contain signals that warrant fraud review?') +FRAUD_SCORE = ScoreQuestion( + instructions='How strongly does this message warrant fraud review?', + criteria=( + 'No visible fraud cue', + 'A weak cue that merits routine checking', + 'A concrete suspicious instruction', + 'An explicit attempt to bypass verification')) + + +def review_fraud_cues(result): + response = result.response + row = { + 'message': result.state['message'], + 'model': response.model, + 'provider': response.provider, + 'latency_ms': round(result.latency_ms, 2), + } + if 'fraud_cue' in response.answers: + probability = response.answers['fraud_cue'].probability + row['fraud_review_probability'] = probability + row['needs_review'] = probability >= 0.7 + if 'risk_level' in response.answers: + score = response.answers['risk_level'] + row['risk_score'] = score.score + row['risk_confidence'] = score.confidence + row['risk_probabilities'] = score.probabilities + return row + + +def dead_letter_record(dead_letter): + """Keep the input and Beam's exception representation for a failed row.""" + original_input, exception_info = dead_letter + _, error, _ = exception_info + return {'input': original_input, 'error': error} + + +def run(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--model', choices=('jev', 'local'), default='jev') + parser.add_argument( + '--primitive', choices=('noul', 'score', 'both'), default='noul') + parser.add_argument( + '--dead-letter-dir', + help=( + 'Optional local JSONL output directory for per-element ' + 'ValueError failures during decision evaluation.')) + args, pipeline_args = parser.parse_known_args(argv) + if args.model == 'jev' and not os.environ.get('TYPESAFE_API_KEY'): + parser.error('TYPESAFE_API_KEY is required for --model=jev') + model = JevDecisionModel() if args.model == 'jev' else LocalDecisionModel() + questions = {} + if args.primitive in ('noul', 'both'): + questions['fraud_cue'] = FRAUD_NOUL + if args.primitive in ('score', 'both'): + questions['risk_level'] = FRAUD_SCORE + with beam.Pipeline(options=PipelineOptions(pipeline_args)) as pipeline: + states = pipeline | 'Sample payment messages' >> beam.Create([{ + 'message': message + } for message in SAMPLE_MESSAGES]) + evaluation = EvaluateDecisions(model, questions) + if args.dead_letter_dir: + good, bad = ( + states + | 'Evaluate fraud cues' >> evaluation.with_exception_handling( + exc_class=ValueError)) + ( + bad + | 'Format malformed responses' >> beam.Map(dead_letter_record) + | 'Encode malformed responses' >> + beam.Map(lambda row: json.dumps(row, sort_keys=True)) + | 'Write malformed responses' >> beam.io.WriteToText( + os.path.join(args.dead_letter_dir, 'malformed'), + file_name_suffix='.jsonl')) + decisions = good + else: + decisions = states | 'Evaluate fraud cues' >> evaluation + ( + decisions + | 'Apply review threshold' >> beam.Map(review_fraud_cues) + | 'Show review decisions' >> + beam.Map(lambda row: print(json.dumps(row, sort_keys=True)))) + + +if __name__ == '__main__': + run() diff --git a/sdks/python/apache_beam/examples/inference/decision_models/fraud_review_test.py b/sdks/python/apache_beam/examples/inference/decision_models/fraud_review_test.py new file mode 100644 index 000000000000..ec6f8160ea6e --- /dev/null +++ b/sdks/python/apache_beam/examples/inference/decision_models/fraud_review_test.py @@ -0,0 +1,86 @@ +# +# 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. +# + +"""Tests for the fraud example's opt-in malformed-response dead letter.""" + +import glob +import json +import os +import tempfile +import unittest + +import apache_beam as beam +from apache_beam.examples.inference.decision_models.fraud_review import ( + dead_letter_record) +from apache_beam.ml.inference.decision import BooleanAnswer +from apache_beam.ml.inference.decision import BooleanQuestion +from apache_beam.ml.inference.decision import DecisionModel +from apache_beam.ml.inference.decision import DecisionResponse +from apache_beam.ml.inference.decision import EvaluateDecisions +from apache_beam.testing.test_pipeline import TestPipeline +from apache_beam.testing.util import assert_that +from apache_beam.testing.util import equal_to + +_QUESTION = {'fraud_cue': BooleanQuestion('Does this need fraud review?')} +_VALID_INPUT = {'message': 'Looks like an ordinary payment question.'} +_MALFORMED_INPUT = {'message': 'Provider returned an invalid probability.'} + + +class _MalformedResponseModel(DecisionModel): + def evaluate(self, state, questions): + if state == _MALFORMED_INPUT: + raise ValueError('Invalid response probability: 1.2') + return DecisionResponse( + answers={'fraud_cue': BooleanAnswer(0.2)}, + model='fixed', + provider='test') + + +class FraudReviewDeadLetterTest(unittest.TestCase): + def test_malformed_response_is_routed_with_original_input(self): + with tempfile.TemporaryDirectory() as output_dir: + with TestPipeline() as pipeline: + good, bad = ( + pipeline + | beam.Create([_VALID_INPUT, _MALFORMED_INPUT]) + | EvaluateDecisions(_MalformedResponseModel(), _QUESTION). + with_exception_handling(exc_class=ValueError)) + + assert_that( + good | beam.Map(lambda result: result.state), + equal_to([_VALID_INPUT])) + ( + bad + | beam.Map(dead_letter_record) + | beam.Map(lambda row: json.dumps(row, sort_keys=True)) + | beam.io.WriteToText( + os.path.join(output_dir, 'malformed'), + file_name_suffix='.jsonl', + num_shards=1)) + + output_files = glob.glob(os.path.join(output_dir, 'malformed-*.jsonl')) + self.assertEqual(1, len(output_files)) + with open(output_files[0], encoding='utf-8') as output_file: + records = [json.loads(line) for line in output_file] + self.assertEqual(1, len(records)) + self.assertEqual(_MALFORMED_INPUT, records[0]['input']) + self.assertTrue(records[0]['error'].startswith('ValueError(')) + self.assertIn('1.2', records[0]['error']) + + +if __name__ == '__main__': + unittest.main() diff --git a/sdks/python/apache_beam/examples/inference/decision_models/local_model.py b/sdks/python/apache_beam/examples/inference/decision_models/local_model.py new file mode 100644 index 000000000000..55c30216f2c2 --- /dev/null +++ b/sdks/python/apache_beam/examples/inference/decision_models/local_model.py @@ -0,0 +1,78 @@ +# +# 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. +# + +"""Deterministic rules for the decision model examples.""" + +from apache_beam.ml.inference.decision import BooleanAnswer +from apache_beam.ml.inference.decision import BooleanQuestion +from apache_beam.ml.inference.decision import ChoiceAnswer +from apache_beam.ml.inference.decision import ChoiceQuestion +from apache_beam.ml.inference.decision import DecisionModel +from apache_beam.ml.inference.decision import DecisionResponse +from apache_beam.ml.inference.decision import ScoreAnswer +from apache_beam.ml.inference.decision import ScoreQuestion + +_ROUTE_TERMS = { + 'billing': ('invoice', 'charge', 'refund', 'payment', 'subscription'), + 'technical': ('api', '503', 'error', 'integration', 'outage'), + 'sales': ('quote', 'seat', 'pricing', 'price', 'plan'), +} + + +class LocalDecisionModel(DecisionModel): + """Transparent rules for running these examples without network access.""" + def evaluate(self, state, questions): + message = state['message'].lower() + fraud_cue = ( + 'skip verification' in message or 'bypass verification' in message or + 'unauthorized' in message or 'phishing' in message) + answers = {} + for name, question in questions.items(): + if isinstance(question, ChoiceQuestion): + matches = { + label: sum(term in message for term in _ROUTE_TERMS.get(label, ())) + for label in question.criteria + } + if not matches: + raise ValueError('Choice needs at least one option') + best = max(matches.values()) + winners = [label for label, count in matches.items() if count == best] + probabilities = { + label: 1 / len(winners) if label in winners else 0.0 + for label in matches + } + answers[name] = ChoiceAnswer( + choice=winners[0], + probabilities=probabilities, + confidence=0.0 if best == 0 else 1 / len(winners)) + elif isinstance(question, BooleanQuestion): + answers[name] = BooleanAnswer(probability=float(fraud_cue)) + elif isinstance(question, ScoreQuestion): + if not question.criteria: + raise ValueError('Score needs at least one level') + score = min(3, len(question.criteria) - 1) if fraud_cue else 0 + answers[name] = ScoreAnswer( + score=float(score), + probabilities={ + level: float(level == score) + for level in range(len(question.criteria)) + }, + confidence=1.0) + else: + raise TypeError('Unsupported decision question: %r' % type(question)) + return DecisionResponse( + answers=answers, model='local-rules', provider='local') diff --git a/sdks/python/apache_beam/examples/inference/decision_models/message_router.py b/sdks/python/apache_beam/examples/inference/decision_models/message_router.py new file mode 100644 index 000000000000..8413d1e0b0ef --- /dev/null +++ b/sdks/python/apache_beam/examples/inference/decision_models/message_router.py @@ -0,0 +1,199 @@ +# +# 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. +# + +"""Route a short stream of messages to local files or BigQuery with Choice.""" + +import argparse +import json +import os + +import apache_beam as beam +from apache_beam.examples.inference.decision_models.local_model import LocalDecisionModel +from apache_beam.io import fileio +from apache_beam.ml.inference.decision import ChoiceQuestion +from apache_beam.ml.inference.decision import EvaluateDecisions +from apache_beam.ml.inference.typesafe_inference import JevDecisionModel +from apache_beam.options.pipeline_options import PipelineOptions +from apache_beam.options.pipeline_options import StandardOptions +from apache_beam.runners import render +from apache_beam.testing.test_stream import TestStream +from apache_beam.transforms.window import FixedWindows +from apache_beam.transforms.window import TimestampedValue + +DESTINATIONS = { + 'billing': 'Invoices, charges, payments, and refunds', + 'technical': 'Bugs, outages, and integration problems', + 'sales': 'Pricing, plans, and buying questions', +} + +QUESTION = ChoiceQuestion( + instructions='Which team should handle this customer message?', + criteria=DESTINATIONS) + +SAMPLE_MESSAGES = [ + { + 'event_id': 'msg-001', + 'message': 'My invoice has two charges for the same subscription. ' + 'Please refund one.' + }, + { + 'event_id': 'msg-002', + 'message': 'The API returns 503 for every request after I rotated ' + 'my key.' + }, + { + 'event_id': 'msg-003', + 'message': 'Can I get a quote for 50 seats next month?' + }, +] + +ROW_SCHEMA = ( + 'event_id:STRING,message:STRING,decision:STRING,destination:STRING,' + 'confidence:FLOAT,probabilities_json:STRING,model:STRING,' + 'provider:STRING,request_id:STRING,latency_ms:FLOAT') + + +def route_message(result, min_confidence): + event = result.state + response = result.response + answer = response.answers['destination'] + if answer.choice not in DESTINATIONS: + raise ValueError('Decision model returned an unknown destination') + destination = ( + answer.choice if answer.confidence is not None and + answer.confidence >= min_confidence else 'review') + return { + 'event_id': event['event_id'], + 'message': event['message'], + 'decision': answer.choice, + 'destination': destination, + 'confidence': answer.confidence, + 'probabilities_json': json.dumps(answer.probabilities, sort_keys=True), + 'model': response.model, + 'provider': response.provider, + 'request_id': response.request_id, + 'latency_ms': round(result.latency_ms, 2), + } + + +class JsonSink(fileio.FileSink): + def open(self, fh): + self.fh = fh + + def write(self, row): + self.fh.write(json.dumps(row, sort_keys=True).encode('utf-8') + b'\n') + + def flush(self): + self.fh.flush() + + +def decode_event(payload): + event = json.loads(payload.decode('utf-8')) + if not isinstance(event, dict) or not isinstance(event.get('message'), str): + raise ValueError('Pub/Sub event must be JSON with a message string') + if not isinstance(event.get('event_id'), str): + raise ValueError('Pub/Sub event must have an event_id string') + return event + + +def table_for(row, dataset): + return '%s.messages_%s' % (dataset, row['destination']) + + +def build_pipeline(pipeline, args): + if args.pubsub_subscription: + events = ( + pipeline + | 'Read Pub/Sub' >> + beam.io.ReadFromPubSub(subscription=args.pubsub_subscription) + | 'Decode events' >> beam.Map(decode_event)) + else: + stream = TestStream().add_elements([ + TimestampedValue(message, index) + for index, message in enumerate(SAMPLE_MESSAGES) + ]).advance_watermark_to_infinity() + events = pipeline | 'Sample stream' >> stream + + model = JevDecisionModel() if args.model == 'jev' else LocalDecisionModel() + rows = ( + events + | 'Ask Choice' >> EvaluateDecisions(model, {'destination': QUESTION}) + | 'Select destination' >> beam.Map(route_message, args.min_confidence) + | 'Window routed messages' >> beam.WindowInto(FixedWindows(60))) + + if args.bq_dataset: + rows | 'Write routed BigQuery tables' >> beam.io.WriteToBigQuery( + table=lambda row: table_for(row, args.bq_dataset), + schema=ROW_SCHEMA, + method=beam.io.WriteToBigQuery.Method.STREAMING_INSERTS, + write_disposition=beam.io.BigQueryDisposition.WRITE_APPEND, + create_disposition=beam.io.BigQueryDisposition.CREATE_IF_NEEDED) + else: + rows | 'Write routed local files' >> fileio.WriteToFiles( + path=args.output_dir, + destination=lambda row: row['destination'], + file_naming=fileio.destination_prefix_naming('.jsonl'), + sink=JsonSink) + return rows + + +def run(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--model', choices=('jev', 'local'), default='jev') + parser.add_argument('--min-confidence', type=float, default=0.5) + parser.add_argument('--pubsub-subscription') + parser.add_argument('--bq-dataset', help='BigQuery project:dataset') + parser.add_argument('--output-dir', help='Local output directory') + parser.add_argument('--graph', help='Render the Beam graph to .dot or .svg') + parser.add_argument( + '--graph-only', + action='store_true', + help='Render without running the job') + args, pipeline_args = parser.parse_known_args(argv) + if not 0 <= args.min_confidence <= 1: + parser.error('--min-confidence must be between 0 and 1') + if not args.bq_dataset and not args.output_dir: + parser.error('Set --output-dir or --bq-dataset') + if args.bq_dataset and args.output_dir: + parser.error('Use only one sink') + if (args.model == 'jev' and not args.graph_only and + not os.environ.get('TYPESAFE_API_KEY')): + parser.error('TYPESAFE_API_KEY is required for --model=jev') + if args.graph_only and not args.graph: + parser.error('--graph-only requires --graph') + + options = PipelineOptions(pipeline_args) + options.view_as(StandardOptions).streaming = True + pipeline = beam.Pipeline(options=options) + build_pipeline(pipeline, args) + if args.graph: + render_options = render.RenderOptions([ + '--render_output=' + args.graph, + '--render_leaf_composite_nodes=^Sample stream$', + '--render_leaf_composite_nodes=^Ask Choice$', + '--render_leaf_composite_nodes=^Write routed .*$', + ]) + render.RenderRunner().run_pipeline(pipeline, render_options) + if args.graph_only: + return None + result = pipeline.run() + result.wait_until_finish() + return result + + +if __name__ == '__main__': + run() diff --git a/sdks/python/apache_beam/examples/inference/decision_models/message_router.svg b/sdks/python/apache_beam/examples/inference/decision_models/message_router.svg new file mode 100644 index 000000000000..e37964497cf8 --- /dev/null +++ b/sdks/python/apache_beam/examples/inference/decision_models/message_router.svg @@ -0,0 +1,98 @@ + + + + + + + +G + + + +ref_AppliedPTransform_Sample-stream_2 + + +Sample stream + + + + + +ref_AppliedPTransform_Ask-Choice_3 + + +Ask Choice + + + + + +ref_AppliedPTransform_Sample-stream_2->ref_AppliedPTransform_Ask-Choice_3 + + + + + +ref_AppliedPTransform_Select-destination_5 + + +Select destination + + + + + +ref_AppliedPTransform_Ask-Choice_3->ref_AppliedPTransform_Select-destination_5 + + + + + +ref_AppliedPTransform_Window-routed-messages_6 + + +Window routed messages + + + + + +ref_AppliedPTransform_Select-destination_5->ref_AppliedPTransform_Window-routed-messages_6 + + + + + +ref_AppliedPTransform_Write-routed-BigQuery-tables_7 + + +Write routed BigQuery tables + + + + + +ref_AppliedPTransform_Window-routed-messages_6->ref_AppliedPTransform_Write-routed-BigQuery-tables_7 + + + + + diff --git a/sdks/python/apache_beam/examples/inference/decision_models/message_router_test.py b/sdks/python/apache_beam/examples/inference/decision_models/message_router_test.py new file mode 100644 index 000000000000..c5040599b081 --- /dev/null +++ b/sdks/python/apache_beam/examples/inference/decision_models/message_router_test.py @@ -0,0 +1,75 @@ +# +# 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. +# + +"""Tests for the policy boundary after a Choice decision.""" + +import unittest + +from apache_beam.examples.inference.decision_models.message_router import route_message +from apache_beam.ml.inference.decision import ChoiceAnswer +from apache_beam.ml.inference.decision import DecisionResponse +from apache_beam.ml.inference.decision import DecisionResult + + +def result_for(answer): + return DecisionResult( + state={ + 'event_id': '1', 'message': 'I have an account question' + }, + response=DecisionResponse( + answers={'destination': answer}, model='fixed', provider='test'), + latency_ms=1.0) + + +class RouteMessageTest(unittest.TestCase): + def test_uncertain_choice_goes_to_review(self): + result = result_for( + ChoiceAnswer( + choice='billing', + probabilities={ + 'billing': 0.6, 'sales': 0.4 + }, + confidence=0.4)) + + row = route_message(result, min_confidence=0.65) + + self.assertEqual('billing', row['decision']) + self.assertEqual('review', row['destination']) + + def test_confident_choice_selects_destination(self): + result = result_for(ChoiceAnswer(choice='sales', confidence=0.65)) + + row = route_message(result, min_confidence=0.65) + + self.assertEqual('sales', row['destination']) + + def test_unknown_choice_cannot_select_a_table(self): + result = result_for(ChoiceAnswer(choice='other', confidence=1.0)) + + with self.assertRaisesRegex(ValueError, 'unknown destination'): + route_message(result, min_confidence=0.5) + + def test_missing_confidence_goes_to_review(self): + result = result_for(ChoiceAnswer(choice='sales')) + + row = route_message(result, min_confidence=0.5) + + self.assertEqual('review', row['destination']) + + +if __name__ == '__main__': + unittest.main() diff --git a/sdks/python/apache_beam/examples/inference/decision_models/requirements.txt b/sdks/python/apache_beam/examples/inference/decision_models/requirements.txt new file mode 100644 index 000000000000..6b99d3ce92cd --- /dev/null +++ b/sdks/python/apache_beam/examples/inference/decision_models/requirements.txt @@ -0,0 +1,19 @@ +# +# 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. +# + +# Optional client for apache_beam.ml.inference.typesafe_inference. +-r ../../../ml/inference/typesafe_tests_requirements.txt diff --git a/sdks/python/apache_beam/examples/snippets/snippets_test.py b/sdks/python/apache_beam/examples/snippets/snippets_test.py index 5a47d9289012..8898cc8555f9 100644 --- a/sdks/python/apache_beam/examples/snippets/snippets_test.py +++ b/sdks/python/apache_beam/examples/snippets/snippets_test.py @@ -50,6 +50,7 @@ from apache_beam.options.pipeline_options import GoogleCloudOptions from apache_beam.options.pipeline_options import PipelineOptions from apache_beam.options.pipeline_options import StandardOptions +from apache_beam.options.value_provider import RuntimeValueProvider from apache_beam.testing.test_pipeline import TestPipeline from apache_beam.testing.test_stream import TestStream from apache_beam.testing.util import assert_that @@ -1509,8 +1510,11 @@ def check_enriched_elements(actual): class ValueProviderInfoTest(unittest.TestCase): """Tests for accessing value provider info after run.""" def test_accessing_valueprovider_info_after_run(self): - with self.assertLogs(level='INFO') as log_capture: - snippets.accessing_valueprovider_info_after_run() + # Prism's loopback worker runs in this process and does not initialize + # RuntimeValueProvider through sdk_worker_main. + with mock.patch.object(RuntimeValueProvider, 'runtime_options', {}): + with self.assertLogs(level='INFO') as log_capture: + snippets.accessing_valueprovider_info_after_run() expected_log_message = "The string value is" self.assertTrue( any(expected_log_message in log for log in log_capture.output), diff --git a/sdks/python/apache_beam/ml/inference/decision.py b/sdks/python/apache_beam/ml/inference/decision.py new file mode 100644 index 000000000000..fdfdc5801dc4 --- /dev/null +++ b/sdks/python/apache_beam/ml/inference/decision.py @@ -0,0 +1,246 @@ +# +# 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. +# + +"""Typed questions and a Beam transform for decision models. + +A decision model evaluates named questions against each input state. Choice +selects a label, Boolean estimates the probability of a true statement, and +Score evaluates an ordered rubric. Adapters translate these types to a local +model or a service's API. + +Use the same questions and transform when changing providers:: + + from apache_beam.ml.inference.decision import ChoiceQuestion + from apache_beam.ml.inference.decision import EvaluateDecisions + + questions = { + 'team': ChoiceQuestion( + 'Which team should handle this message?', + {'billing': 'Invoices and refunds', 'support': 'Technical problems'}) + } + decisions = events | EvaluateDecisions(model, questions) + +Each output is a :class:`DecisionResult` containing the original state, +normalized answers, provider metadata, and request latency. Applications apply +thresholds and route these results with ordinary Beam transforms. +""" + +import abc +import time +from collections.abc import Mapping +from collections.abc import Sequence +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import Union + +import apache_beam as beam +from apache_beam.metrics import Metrics + +__all__ = [ + 'ChoiceQuestion', + 'BooleanQuestion', + 'ScoreQuestion', + 'Question', + 'ChoiceAnswer', + 'BooleanAnswer', + 'ScoreAnswer', + 'Answer', + 'DecisionResponse', + 'DecisionResult', + 'DecisionModel', + 'EvaluateDecisions', +] + + +@dataclass(frozen=True) +class ChoiceQuestion: + """Select one label from ``criteria``, a mapping of labels to descriptions.""" + instructions: str + criteria: Mapping[str, str] + + +@dataclass(frozen=True) +class BooleanQuestion: + """Estimate the probability that the statement in ``instructions`` is true.""" + instructions: str + + +@dataclass(frozen=True) +class ScoreQuestion: + """Score an ordered rubric whose ``criteria`` levels are indexed from zero. + + Providers may interpolate between levels, returning a fractional score. + """ + instructions: str + criteria: Sequence[str] + + +Question = Union[ChoiceQuestion, BooleanQuestion, ScoreQuestion] + + +@dataclass(frozen=True) +class ChoiceAnswer: + """A selected label, with optional label probabilities and confidence. + + Probabilities and confidence use [0, 1]. Confidence is a provider's estimate + of answer certainty; ``None`` means the provider did not supply it. + """ + choice: str + probabilities: Mapping[str, float] = field(default_factory=dict) + confidence: float | None = None + + +@dataclass(frozen=True) +class BooleanAnswer: + """Probability in [0, 1] that the statement is true. + + Applications choose their own threshold for acting on this probability. + """ + probability: float + + +@dataclass(frozen=True) +class ScoreAnswer: + """Rubric score, optional probabilities by level index, and confidence. + + The score uses the question's zero-based scale. Probabilities and confidence + use [0, 1]; ``None`` confidence means the provider did not supply it. + """ + score: float + probabilities: Mapping[int, float] = field(default_factory=dict) + confidence: float | None = None + + +Answer = Union[ChoiceAnswer, BooleanAnswer, ScoreAnswer] + + +@dataclass(frozen=True) +class DecisionResponse: + """One answer per question name, plus the model and provider identifiers.""" + answers: Mapping[str, Answer] + model: str + provider: str + request_id: str | None = None + + +@dataclass(frozen=True) +class DecisionResult: + """An input state, its decision response, and evaluation time in milliseconds.""" + state: Any + response: DecisionResponse + latency_ms: float + + +class DecisionModel(abc.ABC): + """Adapter contract for hosted and local decision models. + + Implement :meth:`evaluate` to return the shared answer types. Override the + context manager methods to open and close clients or load model weights. + Constructors hold serializable configuration; Beam enters the context on + the worker and reuses it across elements and bundles on that DoFn instance. + + An adapter owns provider timeouts, retries, and conversion to the shared + types. Input states must be serializable by Beam and accepted by the adapter. + """ + def __enter__(self) -> 'DecisionModel': + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + pass + + @abc.abstractmethod + def evaluate( + self, state: Any, questions: Mapping[str, Question]) -> DecisionResponse: + """Evaluate each named question against the same state. + + Return exactly one answer of the corresponding type for each question. + Choice labels must belong to the question's criteria; score levels use + their zero-based indices. Raise an exception if evaluation fails. + """ + raise NotImplementedError + + +class EvaluateDecisions(beam.PTransform): + """Evaluate typed questions for every input element with a reusable model. + + Args: + model: A serializable :class:`DecisionModel` adapter. Its context is entered + once per DoFn instance and closed during teardown, which is best effort. + questions: A mapping of question names to Choice, Boolean, or Score + questions. All questions are evaluated against each input state. + + Returns: + A PCollection of :class:`DecisionResult` objects, preserving each element's + timestamp and window. Calls run synchronously on the processing thread. + The ``EvaluateDecisions`` metrics namespace records ``requests``, + ``failures``, and the ``request_latency_ms`` distribution for successful + evaluations. Exceptions propagate to the runner. + """ + def __init__(self, model: DecisionModel, questions: Mapping[str, Question]): + super().__init__() + self._model = model + self._questions = dict(questions) + self._exception_handling = None + + def with_exception_handling(self, **kwargs): + """Return successful decisions and failed states as tagged PCollections. + + Uses :meth:`apache_beam.ParDo.with_exception_handling` with the supplied + options. The default tags are ``good`` and ``bad``; bad records contain + the original state and exception details. Only processing failures are + caught. Model setup and teardown failures still propagate to the runner. + """ + self._exception_handling = kwargs + return self + + def expand(self, states): + evaluate = beam.ParDo(_EvaluateDecisionsDoFn(self._model, self._questions)) + if self._exception_handling is not None: + evaluate = evaluate.with_exception_handling(**self._exception_handling) + return states | evaluate + + +class _EvaluateDecisionsDoFn(beam.DoFn): + def __init__(self, model, questions): + self._model = model + self._questions = questions + self._active_model = None + self._requests = Metrics.counter('EvaluateDecisions', 'requests') + self._failures = Metrics.counter('EvaluateDecisions', 'failures') + self._latency = Metrics.distribution( + 'EvaluateDecisions', 'request_latency_ms') + + def setup(self): + self._active_model = self._model.__enter__() + + def process(self, state): + self._requests.inc() + started = time.perf_counter() + try: + response = self._active_model.evaluate(state, self._questions) + except Exception: + self._failures.inc() + raise + elapsed_ms = (time.perf_counter() - started) * 1000 + self._latency.update(int(elapsed_ms)) + yield DecisionResult(state, response, elapsed_ms) + + def teardown(self): + if self._active_model is not None: + self._model.__exit__(None, None, None) + self._active_model = None diff --git a/sdks/python/apache_beam/ml/inference/decision_test.py b/sdks/python/apache_beam/ml/inference/decision_test.py new file mode 100644 index 000000000000..f596acd6e3ec --- /dev/null +++ b/sdks/python/apache_beam/ml/inference/decision_test.py @@ -0,0 +1,254 @@ +# +# 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. +# + +import unittest +from unittest import mock + +import apache_beam as beam +from apache_beam.ml.inference.decision import BooleanAnswer +from apache_beam.ml.inference.decision import BooleanQuestion +from apache_beam.ml.inference.decision import ChoiceAnswer +from apache_beam.ml.inference.decision import ChoiceQuestion +from apache_beam.ml.inference.decision import DecisionModel +from apache_beam.ml.inference.decision import DecisionResponse +from apache_beam.ml.inference.decision import EvaluateDecisions +from apache_beam.ml.inference.decision import ScoreAnswer +from apache_beam.ml.inference.decision import ScoreQuestion +from apache_beam.ml.inference.decision import _EvaluateDecisionsDoFn +from apache_beam.testing.test_pipeline import TestPipeline +from apache_beam.testing.test_stream import TestStream +from apache_beam.testing.util import assert_that +from apache_beam.testing.util import equal_to +from apache_beam.transforms.window import FixedWindows + +_QUESTIONS = { + 'team': ChoiceQuestion( + 'Which team should handle this message?', { + 'billing': 'Invoices and refunds', + 'support': 'Technical problems', + }), + 'eligible': BooleanQuestion('Is this message eligible for automation?'), + 'severity': ScoreQuestion( + 'How severe is this message?', ('low', 'medium', 'high')), +} + + +def _response(model, provider, state, eligible, severity): + return DecisionResponse( + answers={ + 'team': ChoiceAnswer( + 'support' if eligible > .5 else 'billing', + probabilities={ + 'support': eligible, + 'billing': 1 - eligible, + }), + 'eligible': BooleanAnswer(eligible), + 'severity': ScoreAnswer(float(severity)), + }, + model=model, + provider=provider, + request_id=state['id']) + + +class _KeywordModel(DecisionModel): + def evaluate(self, state, questions): + if set(questions) != set(_QUESTIONS): + raise AssertionError('the model received a different question set') + eligible = .9 if 'bug' in state['text'] else .2 + return _response( + 'keyword-v1', 'local-keyword', state, eligible, state['priority']) + + +class _PriorityModel(DecisionModel): + def evaluate(self, state, questions): + if set(questions) != set(_QUESTIONS): + raise AssertionError('the model received a different question set') + eligible = .8 if state['priority'] >= 2 else .1 + return _response( + 'priority-v1', 'local-priority', state, eligible, state['priority']) + + +class _RejectingModel(_KeywordModel): + def evaluate(self, state, questions): + if state['id'] == 'bad': + raise ValueError('Invalid answer probability') + return super().evaluate(state, questions) + + +class _SummarizeDecision(beam.DoFn): + def process( + self, + result, + timestamp=beam.DoFn.TimestampParam, + window=beam.DoFn.WindowParam): + answers = result.response.answers + yield ( + result.state, + result.response, + answers['team'].choice, + answers['eligible'].probability >= .5, + answers['severity'].score, + timestamp.micros, (window.start.micros, window.end.micros)) + + +class _LifecycleModel(DecisionModel): + def __init__(self): + self.enter_count = 0 + self.exit_calls = [] + self.active = mock.Mock() + + def __enter__(self): + self.enter_count += 1 + return self.active + + def __exit__(self, exc_type, exc_value, traceback): + self.exit_calls.append((exc_type, exc_value, traceback)) + + def evaluate(self, state, questions): + raise AssertionError('the context returned model must evaluate') + + +def _summarize_failure( + failure, timestamp=beam.DoFn.TimestampParam, window=beam.DoFn.WindowParam): + return ( + failure[0], + failure[1][1], + timestamp.micros, (window.start.micros, window.end.micros)) + + +class DecisionTest(unittest.TestCase): + def test_dead_letter_output_preserves_failed_state_and_window(self): + good = {'id': 'good', 'text': 'bug in invoice', 'priority': 2} + bad = {'id': 'bad', 'text': 'refund request', 'priority': 0} + with TestPipeline() as pipeline: + states = ( + pipeline + | beam.Create([ + beam.window.TimestampedValue(good, 1), + beam.window.TimestampedValue(bad, 11), + ]) + | beam.WindowInto(FixedWindows(10))) + decisions, failures = states | EvaluateDecisions( + _RejectingModel(), _QUESTIONS).with_exception_handling( + exc_class=ValueError) + assert_that( + decisions | beam.Map(lambda result: result.state), + equal_to([good]), + label='successful decisions') + assert_that( + failures | beam.Map(_summarize_failure), + equal_to([( + bad, + "ValueError('Invalid answer probability')", + 11_000_000, (10_000_000, 20_000_000))]), + label='failed decisions') + + def test_models_share_questions_downstream_and_preserve_window_metadata(self): + first = {'id': 'first', 'text': 'bug in invoice', 'priority': 2} + second = {'id': 'second', 'text': 'refund request', 'priority': 0} + stream = ( + TestStream().add_elements([ + beam.window.TimestampedValue(first, 1), + beam.window.TimestampedValue(second, 11), + ]).advance_watermark_to_infinity()) + + expected = { + 'keyword': [ + ( + first, + _response('keyword-v1', 'local-keyword', first, .9, 2), + 'support', + True, + 2.0, + 1_000_000, (0, 10_000_000)), + ( + second, + _response('keyword-v1', 'local-keyword', second, .2, 0), + 'billing', + False, + 0.0, + 11_000_000, (10_000_000, 20_000_000)), + ], + 'priority': [ + ( + first, + _response('priority-v1', 'local-priority', first, .8, 2), + 'support', + True, + 2.0, + 1_000_000, (0, 10_000_000)), + ( + second, + _response('priority-v1', 'local-priority', second, .1, 0), + 'billing', + False, + 0.0, + 11_000_000, (10_000_000, 20_000_000)), + ], + } + + with TestPipeline() as pipeline: + states = pipeline | stream | beam.WindowInto(FixedWindows(10)) + for label, model in ( + ('keyword', _KeywordModel()), + ('priority', _PriorityModel()), + ): + decisions = states | f'{label} decisions' >> EvaluateDecisions( + model, _QUESTIONS) + summarized = decisions | f'{label} downstream' >> beam.ParDo( + _SummarizeDecision()) + assert_that( + summarized, equal_to(expected[label]), label=f'check {label}') + + def test_context_model_is_reused_across_bundles_and_closed(self): + model = _LifecycleModel() + model.active.evaluate.side_effect = [ + _response('test', 'mock', {'id': 'one'}, .9, 1), + _response('test', 'mock', {'id': 'two'}, .1, 0), + ] + dofn = _EvaluateDecisionsDoFn(model, _QUESTIONS) + + dofn.setup() + dofn.start_bundle() + first = list(dofn.process({'id': 'one'})) + dofn.finish_bundle() + dofn.start_bundle() + second = list(dofn.process({'id': 'two'})) + dofn.finish_bundle() + dofn.teardown() + + self.assertEqual(1, model.enter_count) + self.assertEqual(2, model.active.evaluate.call_count) + self.assertEqual(1, len(model.exit_calls)) + self.assertEqual((None, None, None), model.exit_calls[0]) + self.assertEqual('one', first[0].state['id']) + self.assertEqual('two', second[0].state['id']) + + def test_model_exception_propagates_from_process(self): + model = _LifecycleModel() + model.active.evaluate.side_effect = RuntimeError('provider failed') + dofn = _EvaluateDecisionsDoFn(model, _QUESTIONS) + + dofn.setup() + with self.assertRaisesRegex(RuntimeError, 'provider failed'): + list(dofn.process({'id': 'failed'})) + dofn.teardown() + self.assertEqual(1, len(model.exit_calls)) + + +if __name__ == '__main__': + unittest.main() diff --git a/sdks/python/apache_beam/ml/inference/typesafe_inference.py b/sdks/python/apache_beam/ml/inference/typesafe_inference.py new file mode 100644 index 000000000000..130a0cafb5e9 --- /dev/null +++ b/sdks/python/apache_beam/ml/inference/typesafe_inference.py @@ -0,0 +1,164 @@ +# +# 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. +# + +"""Optional TypeSafe adapter for Beam decision models.""" + +from collections.abc import Mapping + +from apache_beam.ml.inference.decision import BooleanAnswer +from apache_beam.ml.inference.decision import BooleanQuestion +from apache_beam.ml.inference.decision import ChoiceAnswer +from apache_beam.ml.inference.decision import ChoiceQuestion +from apache_beam.ml.inference.decision import DecisionModel +from apache_beam.ml.inference.decision import DecisionResponse +from apache_beam.ml.inference.decision import ScoreAnswer +from apache_beam.ml.inference.decision import ScoreQuestion + +__all__ = ['JevDecisionModel'] + + +def _number(name, field, value, maximum=1): + if (isinstance(value, bool) or not isinstance(value, (int, float)) or + not 0 <= value <= maximum): + raise ValueError( + f'Invalid {field} for question {name!r}: ' + f'expected a finite number in [0, {maximum}]') + return value + + +def _probabilities(name, probabilities, keys, key_type): + if not isinstance(probabilities, Mapping): + raise ValueError( + f'Invalid probabilities for question {name!r}: ' + 'expected a mapping') + for key, probability in probabilities.items(): + if (not isinstance(key, key_type) or isinstance(key, bool) or + key not in keys): + raise ValueError( + f'Invalid probability key for question {name!r}: {key!r}') + _number(name, 'probability', probability) + return dict(probabilities) + + +def _confidence(name, confidence): + return None if confidence is None else _number(name, 'confidence', confidence) + + +def _answer_type(name, answer, expected_type): + if not isinstance(answer, expected_type): + raise ValueError( + f'Invalid answer type for question {name!r}: ' + f'expected {expected_type.__name__}') + + +class JevDecisionModel(DecisionModel): + """Evaluate Beam decision questions with Jev through the TypeSafe SDK. + + Install the optional SDK from ``typesafe_tests_requirements.txt`` and set + ``TYPESAFE_API_KEY`` on the workers. The client is created when Beam + enters the adapter context; its HTTP pool is reused until teardown. + Choice and Score map directly to the SDK primitives. Boolean maps to Noul, + whose ``noul`` field becomes :attr:`BooleanAnswer.probability`. + + The SDK retries 429 and server errors with exponential backoff, jitter, and + ``Retry-After`` support. Calls and retries block the processing thread; they + do not coordinate rate limits across workers. Invalid answer keys, types, + labels, numeric ranges, or probability keys raise ``ValueError``. + + Args: + model: TypeSafe model identifier, defaulting to ``jev-latest``. + timeout: Timeout in seconds for HTTP operations. The SDK handles retries. + """ + def __init__(self, model='jev-latest', timeout=10.0): + self.model = model + self.timeout = timeout + self.client = None + + def __enter__(self): + try: + from typesafe_sdk import TypeSafeClient + except ImportError as error: + raise ImportError( + 'Install typesafe-sdk to use JevDecisionModel') from error + self.client = TypeSafeClient(timeout=self.timeout) + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.client.close() + self.client = None + + def evaluate(self, state, questions): + if self.client is None: + raise RuntimeError('Enter JevDecisionModel before evaluating questions') + import typesafe_sdk + + sdk_questions = {} + for name, question in questions.items(): + if isinstance(question, ChoiceQuestion): + sdk_questions[name] = typesafe_sdk.Choice( + instructions=question.instructions, criteria=question.criteria) + elif isinstance(question, BooleanQuestion): + sdk_questions[name] = typesafe_sdk.Noul( + instructions=question.instructions) + elif isinstance(question, ScoreQuestion): + sdk_questions[name] = typesafe_sdk.Score( + instructions=question.instructions, criteria=question.criteria) + else: + raise TypeError('Unsupported decision question: %r' % type(question)) + + try: + response = self.client.system_one( + state=state, questions=sdk_questions, model=self.model) + except typesafe_sdk.TypeSafeAPIResponseValidationError as error: + raise ValueError('Invalid Jev response') from error + if (not isinstance(response.answers, Mapping) or + response.answers.keys() != questions.keys()): + raise ValueError( + 'Jev answer question names must match the requested ' + 'question names') + answers = {} + for name, question in questions.items(): + answer = response.answers[name] + if isinstance(question, ChoiceQuestion): + _answer_type(name, answer, typesafe_sdk.ChoiceAnswer) + if (not isinstance(answer.choice, str) or + answer.choice not in question.criteria): + raise ValueError( + f'Invalid choice for question {name!r}: ' + f'{answer.choice!r}') + answers[name] = ChoiceAnswer( + choice=answer.choice, + probabilities=_probabilities( + name, answer.probabilities, question.criteria, str), + confidence=_confidence(name, answer.confidence)) + elif isinstance(question, BooleanQuestion): + _answer_type(name, answer, typesafe_sdk.NoulAnswer) + answers[name] = BooleanAnswer( + probability=_number(name, 'probability', answer.noul)) + else: + _answer_type(name, answer, typesafe_sdk.ScoreAnswer) + answers[name] = ScoreAnswer( + score=_number( + name, 'score', answer.score, len(question.criteria) - 1), + probabilities=_probabilities( + name, answer.probabilities, range(len(question.criteria)), int), + confidence=_confidence(name, answer.confidence)) + return DecisionResponse( + answers=answers, + model=response.model, + provider='typesafe', + request_id=response.request_id) diff --git a/sdks/python/apache_beam/ml/inference/typesafe_inference_it_test.py b/sdks/python/apache_beam/ml/inference/typesafe_inference_it_test.py new file mode 100644 index 000000000000..acd2234f2ddf --- /dev/null +++ b/sdks/python/apache_beam/ml/inference/typesafe_inference_it_test.py @@ -0,0 +1,93 @@ +# +# 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. +# + +"""Live Jev integration test, enabled with TYPESAFE_API_KEY.""" + +import os +import unittest + +import pytest + +try: + import typesafe_sdk +except ImportError: + typesafe_sdk = None + +from apache_beam.ml.inference.decision import BooleanAnswer +from apache_beam.ml.inference.decision import BooleanQuestion +from apache_beam.ml.inference.decision import ChoiceAnswer +from apache_beam.ml.inference.decision import ChoiceQuestion +from apache_beam.ml.inference.decision import EvaluateDecisions +from apache_beam.ml.inference.decision import ScoreAnswer +from apache_beam.ml.inference.decision import ScoreQuestion +from apache_beam.ml.inference.typesafe_inference import JevDecisionModel +from apache_beam.testing.test_pipeline import TestPipeline +from apache_beam.testing.test_stream import TestStream +from apache_beam.testing.util import assert_that + + +@pytest.mark.it_postcommit +@unittest.skipIf(typesafe_sdk is None, 'typesafe-sdk is not installed') +@unittest.skipUnless( + os.environ.get('TYPESAFE_API_KEY'), + 'TYPESAFE_API_KEY environment variable is not set') +class JevInferenceIT(unittest.TestCase): + def test_typed_decisions_in_streaming_pipeline(self): + state = { + 'message': 'My invoice was charged twice. Please refund the duplicate.' + } + questions = { + 'team': ChoiceQuestion( + 'Which team should handle this message?', + { + 'billing': 'Invoices and refunds', + 'technical': 'Bugs and outages', + }), + 'refund': BooleanQuestion('Does this message ask for a refund?'), + 'urgency': ScoreQuestion( + 'How urgent is this message?', ('Routine', 'Urgent', 'Emergency')), + } + + def check_results(results): + assert len(results) == 1, results + result = results[0] + assert result.state == state + assert result.response.provider == 'typesafe' + assert result.response.model + assert set(result.response.answers) == set(questions) + choice = result.response.answers['team'] + boolean = result.response.answers['refund'] + score = result.response.answers['urgency'] + assert isinstance(choice, ChoiceAnswer) + assert choice.choice in questions['team'].criteria + assert isinstance(boolean, BooleanAnswer) + assert 0 <= boolean.probability <= 1 + assert isinstance(score, ScoreAnswer) + assert 0 <= score.score <= 2 + assert set(score.probabilities) <= {0, 1, 2} + + stream = TestStream().add_elements([state]).advance_watermark_to_infinity() + with TestPipeline() as pipeline: + results = ( + pipeline + | stream + | EvaluateDecisions(JevDecisionModel(), questions)) + assert_that(results, check_results) + + +if __name__ == '__main__': + unittest.main() diff --git a/sdks/python/apache_beam/ml/inference/typesafe_inference_test.py b/sdks/python/apache_beam/ml/inference/typesafe_inference_test.py new file mode 100644 index 000000000000..5776fc9133e8 --- /dev/null +++ b/sdks/python/apache_beam/ml/inference/typesafe_inference_test.py @@ -0,0 +1,256 @@ +# +# 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. +# + +"""Tests for TypeSafe's translation to the shared decision types.""" + +import unittest +from types import SimpleNamespace +from unittest import mock + +from parameterized import parameterized + +try: + import typesafe_sdk +except ImportError: + raise unittest.SkipTest('typesafe-sdk is not installed') + +from apache_beam.ml.inference.decision import BooleanAnswer +from apache_beam.ml.inference.decision import BooleanQuestion +from apache_beam.ml.inference.decision import ChoiceAnswer +from apache_beam.ml.inference.decision import ChoiceQuestion +from apache_beam.ml.inference.decision import ScoreAnswer +from apache_beam.ml.inference.decision import ScoreQuestion +from apache_beam.ml.inference.typesafe_inference import JevDecisionModel + + +def _choice_answer(**fields): + return typesafe_sdk.ChoiceAnswer.model_construct( + **{ + 'choice': 'billing', + 'probabilities': { + 'billing': 1.0 + }, + 'confidence': 0.9, + **fields, + }) + + +def _score_answer(**fields): + return typesafe_sdk.ScoreAnswer.model_construct( + **{ + 'score': 0.7, + 'probabilities': { + 0: 0.3, 1: 0.7 + }, + 'confidence': 0.6, + **fields, + }) + + +class JevDecisionModelTest(unittest.TestCase): + def test_normalizes_all_primitives_and_reuses_client(self): + client = mock.Mock() + client.system_one.return_value = SimpleNamespace( + answers={ + 'team': _choice_answer(), + 'review': typesafe_sdk.NoulAnswer.model_construct(noul=0.8), + 'urgency': _score_answer(), + }, + model='jev-test', + request_id='request-1') + questions = { + 'team': ChoiceQuestion('Pick a team', {'billing': 'Invoices'}), + 'review': BooleanQuestion('Does this need review?'), + 'urgency': ScoreQuestion('How urgent is it?', ['routine', 'urgent']), + } + state = {'message': 'Review my invoice'} + with mock.patch.object(typesafe_sdk, 'TypeSafeClient', + return_value=client) as factory: + adapter = JevDecisionModel(model='jev-test', timeout=2.0) + factory.assert_not_called() + with adapter: + response = adapter.evaluate(state, questions) + adapter.evaluate(state, questions) + factory.assert_called_once_with(timeout=2.0) + client.close.assert_called_once_with() + self.assertEqual(2, client.system_one.call_count) + request = client.system_one.call_args.kwargs + self.assertEqual(state, request['state']) + self.assertEqual('jev-test', request['model']) + sent = request['questions'] + self.assertIsInstance(sent['team'], typesafe_sdk.Choice) + self.assertEqual(questions['team'].criteria, sent['team'].criteria) + self.assertIsInstance(sent['review'], typesafe_sdk.Noul) + self.assertEqual( + questions['review'].instructions, sent['review'].instructions) + self.assertIsInstance(sent['urgency'], typesafe_sdk.Score) + self.assertEqual(questions['urgency'].criteria, sent['urgency'].criteria) + self.assertEqual( + ChoiceAnswer('billing', {'billing': 1.0}, 0.9), + response.answers['team']) + self.assertEqual(BooleanAnswer(0.8), response.answers['review']) + self.assertEqual( + ScoreAnswer(0.7, { + 0: 0.3, 1: 0.7 + }, 0.6), response.answers['urgency']) + self.assertEqual('jev-test', response.model) + self.assertEqual('typesafe', response.provider) + self.assertEqual('request-1', response.request_id) + + @parameterized.expand([ + ('choice_label', 'team', _choice_answer(choice='sales')), + ('choice_type', 'team', typesafe_sdk.NoulAnswer.model_construct(noul=.5)), + ( + 'choice_probability_key', + 'team', + _choice_answer(probabilities={'sales': 1.0})), + ('choice_probabilities_type', 'team', _choice_answer(probabilities=[])), + ( + 'choice_probability_range', + 'team', + _choice_answer(probabilities={'billing': 1.2})), + ( + 'choice_probability_nan', + 'team', + _choice_answer(probabilities={'billing': float('nan')})), + ('choice_confidence', 'team', _choice_answer(confidence=1.3)), + ('choice_confidence_type', 'team', _choice_answer(confidence=True)), + ( + 'boolean_range', + 'review', + typesafe_sdk.NoulAnswer.model_construct(noul=1.4)), + ( + 'boolean_nan', + 'review', + typesafe_sdk.NoulAnswer.model_construct(noul=float('nan'))), + ( + 'boolean_type', + 'review', + typesafe_sdk.NoulAnswer.model_construct(noul='0.5')), + ('boolean_answer_type', 'review', _choice_answer()), + ('score_range', 'urgency', _score_answer(score=3.5)), + ('score_negative', 'urgency', _score_answer(score=-.1)), + ('score_infinite', 'urgency', _score_answer(score=float('inf'))), + ('score_boolean', 'urgency', _score_answer(score=True)), + ('score_answer_type', 'urgency', _choice_answer()), + ( + 'score_probability_key', + 'urgency', + _score_answer(probabilities={999: 1.0})), + ( + 'score_probability_key_type', + 'urgency', + _score_answer(probabilities={'1': 1.0})), + ( + 'score_probability_fractional_key', + 'urgency', + _score_answer(probabilities={.5: 1.0})), + ( + 'score_probability_negative', + 'urgency', + _score_answer(probabilities={0: -.1})), + ('score_confidence', 'urgency', _score_answer(confidence=-.1)), + ]) + def test_rejects_malformed_answer(self, unused_name, name, answer): + questions = { + 'team': ChoiceQuestion('Pick a team', {'billing': 'Invoices'}), + 'review': BooleanQuestion('Does this need review?'), + 'urgency': ScoreQuestion('How urgent is it?', ['routine', 'urgent']), + } + client = mock.Mock() + client.system_one.return_value = SimpleNamespace( + answers={name: answer}, model='jev-test', request_id='request-1') + with mock.patch.object(typesafe_sdk, 'TypeSafeClient', return_value=client): + with JevDecisionModel() as adapter: + with self.assertRaisesRegex(ValueError, name): + adapter.evaluate('Hello', {name: questions[name]}) + + @parameterized.expand([ + ('missing', {}), + ( + 'extra', + { + 'review': typesafe_sdk.NoulAnswer.model_construct(noul=.5), + 'unexpected': typesafe_sdk.NoulAnswer.model_construct(noul=.5), + }), + ('wrong_container', []), + ]) + def test_rejects_answer_keys(self, unused_name, answers): + client = mock.Mock() + client.system_one.return_value = SimpleNamespace( + answers=answers, model='jev-test', request_id='request-1') + with mock.patch.object(typesafe_sdk, 'TypeSafeClient', return_value=client): + with JevDecisionModel() as adapter: + with self.assertRaisesRegex(ValueError, 'question names'): + adapter.evaluate( + 'Hello', {'review': BooleanQuestion('Needs review?')}) + + def test_accepts_boundaries_and_optional_probabilities(self): + questions = { + 'team': ChoiceQuestion('Pick a team', {'billing': 'Invoices'}), + 'review': BooleanQuestion('Does this need review?'), + 'urgency': ScoreQuestion('How urgent is it?', ['routine', 'urgent']), + } + client = mock.Mock() + for probability in (0, 1): + with self.subTest(probability=probability): + client.system_one.return_value = SimpleNamespace( + answers={ + 'team': _choice_answer(probabilities={}, confidence=None), + 'review': typesafe_sdk.NoulAnswer.model_construct( + noul=probability), + 'urgency': _score_answer( + score=probability, probabilities={}, confidence=None), + }, + model='jev-test', + request_id='request-1') + with mock.patch.object(typesafe_sdk, + 'TypeSafeClient', + return_value=client): + with JevDecisionModel() as adapter: + response = adapter.evaluate('Hello', questions) + self.assertEqual(probability, response.answers['review'].probability) + self.assertEqual(probability, response.answers['urgency'].score) + self.assertIsNone(response.answers['team'].confidence) + + def test_provider_error_propagates_and_client_closes(self): + client = mock.Mock() + client.system_one.side_effect = TimeoutError('request timed out') + with mock.patch.object(typesafe_sdk, 'TypeSafeClient', return_value=client): + with self.assertRaisesRegex(TimeoutError, 'request timed out'): + with JevDecisionModel() as adapter: + adapter.evaluate( + 'Hello', {'review': BooleanQuestion('Needs review?')}) + client.close.assert_called_once_with() + + def test_sdk_response_validation_error_is_a_value_error(self): + error = typesafe_sdk.TypeSafeAPIResponseValidationError( + status=200, body={}, headers={}, field_path='answers.review.noul') + client = mock.Mock() + client.system_one.side_effect = error + with mock.patch.object(typesafe_sdk, 'TypeSafeClient', return_value=client): + with JevDecisionModel() as adapter: + with self.assertRaisesRegex(ValueError, + 'Invalid Jev response') as raised: + adapter.evaluate( + 'Hello', {'review': BooleanQuestion('Needs review?')}) + self.assertIs(error, raised.exception.__cause__) + client.close.assert_called_once_with() + + +if __name__ == '__main__': + unittest.main() diff --git a/sdks/python/apache_beam/ml/inference/typesafe_tests_requirements.txt b/sdks/python/apache_beam/ml/inference/typesafe_tests_requirements.txt new file mode 100644 index 000000000000..982081593f5e --- /dev/null +++ b/sdks/python/apache_beam/ml/inference/typesafe_tests_requirements.txt @@ -0,0 +1,18 @@ +# +# 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. +# + +typesafe-sdk>=0.7.1,<0.8 diff --git a/sdks/python/setup.py b/sdks/python/setup.py index 08c5964898de..a4888617a520 100644 --- a/sdks/python/setup.py +++ b/sdks/python/setup.py @@ -613,6 +613,7 @@ def get_portability_package_data(): ] + ml_base + qdrant_dependency, 'p312_ml_test': [ 'datatable', + 'typesafe-sdk>=0.7.1,<0.8', ] + ml_base + qdrant_dependency, # maintainer: milvus tests only run with this extension. Make sure it # is covered by docker-in-docker test when changing py version From 75d7e2c467ff8e074f30cb439502ee6cba1374bf Mon Sep 17 00:00:00 2001 From: Andrew Crites Date: Mon, 19 Oct 2026 12:42:14 -0700 Subject: [PATCH 044/108] Process deletes before adds when committing OrderedList state updates. Otherwise we delete the newly added id from the idTracker. (#40659) --- .../windmill/state/WindmillOrderedList.java | 28 ++--- .../state/WindmillStateInternalsTest.java | 105 ++++++++++++++++++ 2 files changed, 119 insertions(+), 14 deletions(-) diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillOrderedList.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillOrderedList.java index 03652471a049..0b362d40a666 100644 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillOrderedList.java +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillOrderedList.java @@ -233,6 +233,20 @@ public Windmill.WorkItemCommitRequest persistDirectly(WindmillStateCache.ForKeyA cleared = false; } + if (!pendingDeletes.isEmpty()) { + for (Range range : pendingDeletes.asRanges()) { + Windmill.TagSortedListDeleteRequest.Builder deletesBuilder = + updatesBuilder.addDeletesBuilder(); + deletesBuilder.setRange( + Windmill.SortedListRange.newBuilder() + .setStart(WindmillTimeUtils.harnessToWindmillTimestamp(range.lowerEndpoint())) + .setLimit(WindmillTimeUtils.harnessToWindmillTimestamp(range.upperEndpoint()))); + deletesBuilder.build(); + idTracker.remove(range); + } + pendingDeletes.clear(); + } + if (!pendingAdds.isEmpty()) { // TODO(reuvenlax): Once we start caching data, we should remove this line. We have it // here now @@ -261,20 +275,6 @@ public Windmill.WorkItemCommitRequest persistDirectly(WindmillStateCache.ForKeyA pendingAdds.clear(); insertBuilder.build(); } - - if (!pendingDeletes.isEmpty()) { - for (Range range : pendingDeletes.asRanges()) { - Windmill.TagSortedListDeleteRequest.Builder deletesBuilder = - updatesBuilder.addDeletesBuilder(); - deletesBuilder.setRange( - Windmill.SortedListRange.newBuilder() - .setStart(WindmillTimeUtils.harnessToWindmillTimestamp(range.lowerEndpoint())) - .setLimit(WindmillTimeUtils.harnessToWindmillTimestamp(range.upperEndpoint()))); - deletesBuilder.build(); - idTracker.remove(range); - } - pendingDeletes.clear(); - } } catch (ExecutionException | InterruptedException e) { throw new RuntimeException(e); } diff --git a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillStateInternalsTest.java b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillStateInternalsTest.java index 87b746089f11..7a68974f4128 100644 --- a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillStateInternalsTest.java +++ b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/state/WindmillStateInternalsTest.java @@ -2386,6 +2386,111 @@ public void testOrderedListDeleteRangePersist() { assertBuildable(commitBuilder); } + @Test + public void testOrderedListDeleteRangeThenAddPersist() throws Exception { + SettableFuture, RangeSet>> orderedListFuture = SettableFuture.create(); + orderedListFuture.set(null); + SettableFuture, RangeSet>> deletionsFuture = + SettableFuture.create(); + deletionsFuture.set(null); + when(mockReader.valueFuture( + systemKey(NAMESPACE, "orderedList" + IdTracker.IDS_AVAILABLE_STR), + STATE_FAMILY, + IdTracker.IDS_AVAILABLE_CODER)) + .thenReturn(orderedListFuture); + when(mockReader.valueFuture( + systemKey(NAMESPACE, "orderedList" + IdTracker.DELETIONS_STR), + STATE_FAMILY, + IdTracker.SUBRANGE_DELETIONS_CODER)) + .thenReturn(deletionsFuture); + + StateTag> addr = + StateTags.orderedList("orderedList", StringUtf8Coder.of()); + OrderedListState orderedListState = underTest.state(NAMESPACE, addr); + + // Clear a sub-range [2ms, 4ms) and then add an element at 2ms within that cleared range. + orderedListState.clearRange(Instant.ofEpochMilli(2), Instant.ofEpochMilli(4)); + orderedListState.add(TimestampedValue.of("hello", Instant.ofEpochMilli(2))); + + Windmill.WorkItemCommitRequest.Builder commitBuilder = + Windmill.WorkItemCommitRequest.newBuilder(); + underTest.persist(commitBuilder); + + assertEquals(1, commitBuilder.getSortedListUpdatesCount()); + TagSortedListUpdateRequest updates = commitBuilder.getSortedListUpdates(0); + assertEquals(1, updates.getDeletesCount()); + assertEquals(2000, updates.getDeletes(0).getRange().getStart()); + assertEquals(4000, updates.getDeletes(0).getRange().getLimit()); + assertEquals(1, updates.getInsertsCount()); + assertEquals(1, updates.getInserts(0).getEntriesCount()); + assertEquals("hello", updates.getInserts(0).getEntries(0).getValue().toStringUtf8()); + assertEquals(2000, updates.getInserts(0).getEntries(0).getSortKey()); + assertEquals(IdTracker.NEW_RANGE_MIN_ID, updates.getInserts(0).getEntries(0).getId()); + assertBuildable(commitBuilder); + + // Verify that the sub-range deletion for [2ms, 3ms) was removed when "hello" was added at 2ms, + // so clearing the rest of the 5-minute bucket [0, 2ms) and [4ms, 5m) in the next work item + // does not wipe out id tracking for the bucket. + resetUnderTest(); + orderedListState = underTest.state(NAMESPACE, addr); + orderedListState.clearRange(Instant.EPOCH, Instant.ofEpochMilli(2)); + orderedListState.clearRange(Instant.ofEpochMilli(4), Instant.ofEpochSecond(300)); + + commitBuilder = Windmill.WorkItemCommitRequest.newBuilder(); + underTest.persist(commitBuilder); + assertBuildable(commitBuilder); + + // Adding another element at 2ms in a subsequent work item should get the next id (1), not 0. + resetUnderTest(); + orderedListState = underTest.state(NAMESPACE, addr); + orderedListState.add(TimestampedValue.of("world", Instant.ofEpochMilli(2))); + + commitBuilder = Windmill.WorkItemCommitRequest.newBuilder(); + underTest.persist(commitBuilder); + + updates = commitBuilder.getSortedListUpdates(0); + assertEquals(1, updates.getInsertsCount()); + assertEquals(1, updates.getInserts(0).getEntriesCount()); + assertEquals("world", updates.getInserts(0).getEntries(0).getValue().toStringUtf8()); + assertEquals(2000, updates.getInserts(0).getEntries(0).getSortKey()); + assertEquals(IdTracker.NEW_RANGE_MIN_ID + 1, updates.getInserts(0).getEntries(0).getId()); + assertBuildable(commitBuilder); + + // Now clear the entire 5-minute bucket [0, 5m) and add a new element in the same work item. + // The delete should be processed before the add, resetting the bucket's ids to start at + // NEW_RANGE_MIN_ID (0) for the new element while preserving its id tracking for future adds. + resetUnderTest(); + orderedListState = underTest.state(NAMESPACE, addr); + orderedListState.clearRange(Instant.EPOCH, Instant.ofEpochSecond(300)); + orderedListState.add(TimestampedValue.of("afterFullClear1", Instant.ofEpochMilli(2))); + + commitBuilder = Windmill.WorkItemCommitRequest.newBuilder(); + underTest.persist(commitBuilder); + + updates = commitBuilder.getSortedListUpdates(0); + assertEquals(1, updates.getDeletesCount()); + assertEquals(1, updates.getInsertsCount()); + assertEquals(1, updates.getInserts(0).getEntriesCount()); + assertEquals("afterFullClear1", updates.getInserts(0).getEntries(0).getValue().toStringUtf8()); + assertEquals(IdTracker.NEW_RANGE_MIN_ID, updates.getInserts(0).getEntries(0).getId()); + assertBuildable(commitBuilder); + + // A subsequent add in the same bucket should receive NEW_RANGE_MIN_ID + 1. + resetUnderTest(); + orderedListState = underTest.state(NAMESPACE, addr); + orderedListState.add(TimestampedValue.of("afterFullClear2", Instant.ofEpochMilli(2))); + + commitBuilder = Windmill.WorkItemCommitRequest.newBuilder(); + underTest.persist(commitBuilder); + + updates = commitBuilder.getSortedListUpdates(0); + assertEquals(1, updates.getInsertsCount()); + assertEquals(1, updates.getInserts(0).getEntriesCount()); + assertEquals("afterFullClear2", updates.getInserts(0).getEntries(0).getValue().toStringUtf8()); + assertEquals(IdTracker.NEW_RANGE_MIN_ID + 1, updates.getInserts(0).getEntries(0).getId()); + assertBuildable(commitBuilder); + } + @Test public void testOrderedListMergePendingAdds() { SettableFuture, RangeSet>> orderedListFuture = SettableFuture.create(); From b4ecb26ada0b8355c8a32ddb0799ae447339c8cf Mon Sep 17 00:00:00 2001 From: CH-Abhinav Date: Tue, 20 Oct 2026 01:40:49 +0530 Subject: [PATCH 045/108] Modernize legacy DoFn in BatchElementsExample (#40557) (#40560) --- .../org/apache/beam/examples/BatchElementsExample.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/java/src/main/java/org/apache/beam/examples/BatchElementsExample.java b/examples/java/src/main/java/org/apache/beam/examples/BatchElementsExample.java index 79130d7ac138..42cfd4f071d0 100644 --- a/examples/java/src/main/java/org/apache/beam/examples/BatchElementsExample.java +++ b/examples/java/src/main/java/org/apache/beam/examples/BatchElementsExample.java @@ -68,13 +68,10 @@ static class LogOutput extends DoFn, String> { private static final Logger LOG = LoggerFactory.getLogger(LogOutput.class); @ProcessElement - public void processElement(ProcessContext c) throws Exception { - List batch = c.element(); - + public void processElement(@Element List batch, OutputReceiver receiver) { LOG.info("Batch Contents: {}", batch); - for (String element : batch) { - c.output(element); + receiver.output(element); } } } From 18db4896cf142b11f4f6e91d57ad4183d76c6115 Mon Sep 17 00:00:00 2001 From: Arun Pandian Date: Mon, 19 Oct 2026 13:48:38 -0700 Subject: [PATCH 046/108] [Multikey] [Dataflow Streaming] Replace KeyGroupWorkQueue with a sharded KeyGroupIndex (#40593) * [Dataflow Streaming] Replace KeyGroupWorkQueue with a sharded KeyGroupWorkIndex `KeyGroupWorkQueue` guarded a doubly-linked global queue and per-key-group queues with a single `ReentrantLock`, creating lock contention across worker and `GetWork` threads when multi-key bundles are enabled. Use `LinkedBlockingQueue` as the `BoundedQueueExecutor` work queue unconditionally, and index stealable work in `KeyGroupWorkIndex` across 64 fixed `HashMap>` shards synchronized per shard. An executor thread and `pollWork` coordinate on a `QueuedWork` via a single atomic state transition, clearing work and budget references immediately upon claim. Claimed entries at the head of a key-group queue are drained in amortized O(1) time during index mutations, and stale shells left in `LinkedBlockingQueue` by stolen work are purged in bulk once the queue size exceeds twice `maximumElementsOutstanding`. * Address comments + refactorings + change purge logic * Address comments * Clarify comments regarding maximumElementsOutstanding --- .../worker/StreamingDataflowWorker.java | 7 +- .../dataflow/worker/streaming/Work.java | 3 +- .../worker/util/BoundedQueueExecutor.java | 224 ++++++-- .../worker/util/KeyGroupWorkIndex.java | 190 +++++++ .../worker/util/KeyGroupWorkQueue.java | 472 ---------------- .../worker/StreamingDataflowWorkerTest.java | 12 +- .../worker/util/BoundedQueueExecutorTest.java | 428 +++++++++----- .../worker/util/KeyGroupWorkIndexTest.java | 319 +++++++++++ .../worker/util/KeyGroupWorkQueueTest.java | 523 ------------------ .../StreamingCommitFinalizerTest.java | 2 +- .../failures/WorkFailureProcessorTest.java | 2 +- .../work/refresh/ActiveWorkRefresherTest.java | 2 +- 12 files changed, 991 insertions(+), 1193 deletions(-) create mode 100644 runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkIndex.java delete mode 100644 runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkQueue.java create mode 100644 runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkIndexTest.java delete mode 100644 runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkQueueTest.java diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingDataflowWorker.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingDataflowWorker.java index fd00cea775f2..dd96142c41a9 100644 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingDataflowWorker.java +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingDataflowWorker.java @@ -1055,7 +1055,6 @@ private static BoundedQueueExecutor createWorkUnitExecutor( DataflowWorkerHarnessOptions options, MultiKeyBundleOptions multiKeyBundleOptions) { boolean useFairMonitor = DataflowRunner.hasExperiment(options, BOUNDED_QUEUE_EXECUTOR_USE_FAIR_MONITOR_EXPERIMENT); - boolean useKeyGroupWorkQueue = multiKeyBundleOptions.multiKeyBundleEnabled(); return new BoundedQueueExecutor( chooseMaxThreads(options), THREAD_EXPIRATION_TIME_SEC, @@ -1064,7 +1063,7 @@ private static BoundedQueueExecutor createWorkUnitExecutor( chooseMaxBytesOutstanding(options), new ThreadFactoryBuilder().setNameFormat("DataflowWorkUnits-%d").setDaemon(true).build(), useFairMonitor, - useKeyGroupWorkQueue); + multiKeyBundleOptions.multiKeyBundleEnabled()); } public static void main(String[] args) throws Exception { @@ -1134,8 +1133,8 @@ void reportPeriodicWorkerUpdatesForTest() { } @VisibleForTesting - public boolean workExecutorIsEmpty() { - return workUnitExecutor.executorQueueIsEmpty(); + public boolean workExecutorIsEmptyForTesting() { + return workUnitExecutor.executorQueueIsEmptyForTesting(); } @VisibleForTesting diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/Work.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/Work.java index 2acee9410fa3..724668b689f1 100644 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/Work.java +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/Work.java @@ -25,7 +25,6 @@ import java.util.IntSummaryStatistics; import java.util.Map; import java.util.Map.Entry; -import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -554,7 +553,7 @@ public boolean equals(@Nullable Object o) { @Override public int hashCode() { - return Objects.hash(high, low); + return 31 * Long.hashCode(high) + Long.hashCode(low); } @Override diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/BoundedQueueExecutor.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/BoundedQueueExecutor.java index 046d8cae9f9d..1d10d074b115 100644 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/BoundedQueueExecutor.java +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/BoundedQueueExecutor.java @@ -30,6 +30,8 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; import javax.annotation.concurrent.GuardedBy; import org.apache.beam.runners.dataflow.worker.streaming.BoundedQueueExecutorWorkHandle; import org.apache.beam.runners.dataflow.worker.streaming.ExecutableWork; @@ -41,13 +43,20 @@ import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.util.concurrent.Monitor.Guard; import org.checkerframework.checker.nullness.qual.Nullable; -/** An executor for executing work on windmill items. */ +/** + * An executor for executing work on windmill items. + * + *

When multi-key bundles are enabled, batchable work is also indexed in {@link + * KeyGroupWorkIndex} so running bundles can steal it via {@link #pollWork}. Stolen work leaves a + * no-op stale shell in the executor queue, which is discarded when dequeued or purged in bulk if + * the queue grows too large. + */ public class BoundedQueueExecutor { private final ThreadPoolExecutor executor; private final long maximumBytesOutstanding; - // Used to guard elementsOutstanding and bytesOutstanding. + // Used to guard writes to elementsOutstanding and bytesOutstanding. private final Monitor monitor; private static class Budget { @@ -64,11 +73,10 @@ private static class Budget { private final ConcurrentLinkedQueue decrementQueue = new ConcurrentLinkedQueue<>(); private final Object decrementQueueDrainLock = new Object(); private final AtomicBoolean isDecrementBatchPending = new AtomicBoolean(false); - private int elementsOutstanding = 0; + private final AtomicInteger elementsOutstanding = new AtomicInteger(); private long bytesOutstanding = 0; - @GuardedBy("this") - private int maximumElementsOutstanding; + private volatile int maximumElementsOutstanding; @GuardedBy("this") private int activeCount; @@ -82,8 +90,10 @@ private static class Budget { @GuardedBy("this") private long totalTimeMaxActiveThreadsUsed; - // If set the keyGroupWorkQueue is used by the underlying executor. - private final @Nullable KeyGroupWorkQueue keyGroupWorkQueue; + private final LinkedBlockingQueue workQueue = new LinkedBlockingQueue<>(); + private final @Nullable KeyGroupWorkIndex keyGroupWorkIndex; + + private final AtomicBoolean purgingStaleShells = new AtomicBoolean(false); public BoundedQueueExecutor( int initialMaximumPoolSize, @@ -93,8 +103,8 @@ public BoundedQueueExecutor( long maximumBytesOutstanding, ThreadFactory threadFactory, boolean useFairMonitor, - boolean useKeyGroupWorkQueue) { - this.keyGroupWorkQueue = useKeyGroupWorkQueue ? new KeyGroupWorkQueue(useFairMonitor) : null; + boolean multiKeyBundleEnabled) { + this.keyGroupWorkIndex = multiKeyBundleEnabled ? new KeyGroupWorkIndex() : null; this.maximumPoolSize = initialMaximumPoolSize; monitor = new Monitor(useFairMonitor); executor = @@ -103,7 +113,7 @@ public BoundedQueueExecutor( initialMaximumPoolSize, keepAliveTime, unit, - keyGroupWorkQueue != null ? keyGroupWorkQueue : new LinkedBlockingQueue<>(), + workQueue, threadFactory) { @Override protected void beforeExecute(Thread t, Runnable r) { @@ -139,9 +149,9 @@ public void execute(ExecutableWork work, long workBytes) { new Guard(monitor) { @Override public boolean isSatisfied() { - return elementsOutstanding == 0 - || (bytesAvailable() >= workBytes - && elementsOutstanding < maximumElementsOutstanding()); + int outstanding = elementsOutstanding.get(); + return outstanding == 0 + || (bytesAvailable() >= workBytes && outstanding < maximumElementsOutstanding()); } }); executeMonitorHeld(work, workBytes); @@ -181,8 +191,21 @@ public void shutdown() throws InterruptedException { } } - public boolean executorQueueIsEmpty() { - return executor.getQueue().isEmpty(); + @VisibleForTesting + public boolean executorQueueIsEmptyForTesting() { + return workQueue.stream().allMatch(BoundedQueueExecutor::isStaleShell); + } + + /** Returns the raw number of entries in the executor queue, including stale shells. */ + @VisibleForTesting + int rawQueueSizeForTesting() { + return workQueue.size(); + } + + @VisibleForTesting + boolean keyGroupWorkIndexIsEmpty() { + @Nullable KeyGroupWorkIndex index = keyGroupWorkIndex; + return index == null || index.isEmpty(); } public synchronized long allThreadsActiveTime() { @@ -203,19 +226,14 @@ public long bytesOutstanding() { } public int elementsOutstanding() { - monitor.enter(); - try { - return elementsOutstanding; - } finally { - monitor.leave(); - } + return elementsOutstanding.get(); } public long maximumBytesOutstanding() { return maximumBytesOutstanding; } - public synchronized int maximumElementsOutstanding() { + public int maximumElementsOutstanding() { return maximumElementsOutstanding; } @@ -238,7 +256,7 @@ public String summaryHtml() { builder.append("
/n"); builder.append("Work Queue Size: "); - builder.append(elementsOutstanding); + builder.append(elementsOutstanding.get()); builder.append("/"); builder.append(maximumElementsOutstanding()); builder.append("
/n"); @@ -328,49 +346,132 @@ public synchronized void close() { } } + /** + * A {@link Runnable} for an {@link ExecutableWork} that can be claimed either by an executor + * thread in {@link #run} or by a multi-key bundle in {@link #pollWork}. + * + *

Thread safety: {@link #state} is {@code volatile} and transitions at most once from {@link + * #QUEUED} to {@link #STARTED} or {@link #STOLEN} via {@link #STATE}{@code .compareAndSet}. + * Non-final fields {@link #work} and {@link #handle} are written before {@code state} is + * initialized in the constructor, and are subsequently read and cleared only by the single thread + * that wins that {@code compareAndSet}. All other accesses read only {@code final} fields or + * {@code volatile state}. + */ static final class QueuedWork implements Runnable { - private final ExecutableWork work; - private final BoundedQueueExecutorWorkHandleImpl handle; - - public QueuedWork(ExecutableWork work, BoundedQueueExecutorWorkHandleImpl handle) { + private static final int QUEUED = 0; + private static final int STARTED = 1; // Claimed by an executor thread in run(). + private static final int STOLEN = 2; // Claimed by a multi-key bundle in pollWork(). + + private static final AtomicIntegerFieldUpdater STATE = + AtomicIntegerFieldUpdater.newUpdater(QueuedWork.class, "state"); + + // Non-null iff the work is eligible to be stolen by a multi-key bundle. + private final @Nullable KeyGroupWorkIndex keyGroupWorkIndex; + private final KeyGroupWorkIndex.@Nullable QueueKey queueKey; + // Cleared upon claim so stale shells and claimed index entries do not retain work references. + private @Nullable ExecutableWork work; + private @Nullable BoundedQueueExecutorWorkHandleImpl handle; + private volatile int state = QUEUED; + + QueuedWork( + ExecutableWork work, + BoundedQueueExecutorWorkHandleImpl handle, + @Nullable KeyGroupWorkIndex keyGroupWorkIndex) { this.work = work; this.handle = handle; + Work w = work.work(); + if (keyGroupWorkIndex != null + && !w.getKeyGroup().equals(Work.KeyGroup.DEFAULT) + && !w.isMultiKeyBatchingDisabled()) { + this.keyGroupWorkIndex = keyGroupWorkIndex; + this.queueKey = new KeyGroupWorkIndex.QueueKey(w.getComputationId(), w.getKeyGroup()); + } else { + this.keyGroupWorkIndex = null; + this.queueKey = null; + } + } + + @VisibleForTesting + ExecutableWork getWork() { + return checkStateNotNull(work, "QueuedWork was already claimed."); + } + + private BoundedQueueExecutorWorkHandleImpl getHandle() { + return checkStateNotNull(handle, "QueuedWork was already claimed."); + } + + @VisibleForTesting + boolean isStealable() { + return keyGroupWorkIndex != null; + } + + KeyGroupWorkIndex.QueueKey getQueueKey() { + return checkStateNotNull(queueKey); } - public ExecutableWork getWork() { - return work; + boolean isClaimed() { + return state != QUEUED; } - public BoundedQueueExecutorWorkHandleImpl getHandle() { - return handle; + private boolean isStolen() { + return state == STOLEN; + } + + @VisibleForTesting + boolean tryClaimForExecution() { + return STATE.compareAndSet(this, QUEUED, STARTED); + } + + boolean tryClaimForSteal() { + return STATE.compareAndSet(this, QUEUED, STOLEN); + } + + private void clearClaimedWork() { + work = null; + handle = null; } @Override public void run() { - checkArgument(!handle.isClosed()); - try (handle) { - work.run(handle); + if (!tryClaimForExecution()) { + return; // Already stolen by a multi-key bundle. + } + ExecutableWork workToRun = getWork(); + BoundedQueueExecutorWorkHandleImpl workHandle = getHandle(); + clearClaimedWork(); + @Nullable KeyGroupWorkIndex index = keyGroupWorkIndex; + if (index != null) { + index.drainClaimed(this); + } + checkArgument(!workHandle.isClosed()); + try (workHandle) { + workToRun.run(workHandle); } } } private void executeMonitorHeld(ExecutableWork work, long workBytes) { - ++elementsOutstanding; + elementsOutstanding.incrementAndGet(); bytesOutstanding += workBytes; monitor.leave(); BoundedQueueExecutorWorkHandleImpl handle = new BoundedQueueExecutorWorkHandleImpl(work.work(), workBytes); + QueuedWork queuedWork = new QueuedWork(work, handle, keyGroupWorkIndex); try { - executor.execute(new QueuedWork(work, handle)); + executor.execute(queuedWork); } catch (Throwable t) { handle.close(); throw ExceptionUtils.safeWrapThrowableAsException(t); } + if (queuedWork.isStealable()) { + // Index after queuing so work rejected after shutdown cannot be stolen. + checkStateNotNull(keyGroupWorkIndex).add(queuedWork); + } } private void executeMonitorHeld(Runnable work) { - ++elementsOutstanding; + elementsOutstanding.incrementAndGet(); monitor.leave(); try { @@ -403,23 +504,56 @@ BoundedQueueExecutorWorkHandleImpl createBudgetHandle(Work work, long bytes) { checkArgument(handle instanceof BoundedQueueExecutorWorkHandleImpl); checkStateNotNull(onFailedWorkHandler); BoundedQueueExecutorWorkHandleImpl internalHandle = (BoundedQueueExecutorWorkHandleImpl) handle; - if (keyGroupWorkQueue == null) { + @Nullable KeyGroupWorkIndex index = keyGroupWorkIndex; + if (index == null) { return null; } while (true) { - @Nullable QueuedWork queuedWork = keyGroupWorkQueue.pollWork(computationId, keyGroup); - if (queuedWork == null) { + @Nullable QueuedWork stolen = index.pollAndClaim(computationId, keyGroup); + if (stolen == null) { return null; } - Work work = queuedWork.getWork().work(); + ExecutableWork stolenWork = stolen.getWork(); + BoundedQueueExecutorWorkHandleImpl stolenHandle = stolen.getHandle(); + stolen.clearClaimedWork(); + onWorkStolen(); + Work work = stolenWork.work(); if (work.isFailed()) { - queuedWork.getHandle().close(); + stolenHandle.close(); onFailedWorkHandler.onFailedWork(work); continue; } - internalHandle.merge(queuedWork.getHandle()); - return queuedWork.getWork(); + internalHandle.merge(stolenHandle); + return stolenWork; + } + } + + /** + * Purges stale shells in bulk if they accumulate while all executor threads are busy running + * multi-key bundles. + * + *

{@code forceExecute} can queue more than {@code maximumElementsOutstanding} unstarted items + * (e.g. retries or finalizations). {@code elementsOutstanding} counts non-shell work items, so + * comparing {@code workQueue.size()} against {@code elementsOutstanding + + * maximumElementsOutstanding} means there is at least {@code maximumElementsOutstanding} closed + * stale shells before running an O(n) {@code removeIf}. + */ + private void onWorkStolen() { + if (workQueue.size() <= (long) elementsOutstanding.get() + maximumElementsOutstanding + || !purgingStaleShells.compareAndSet(false, true)) { + return; } + try { + if (!executor.isShutdown()) { + workQueue.removeIf(BoundedQueueExecutor::isStaleShell); + } + } finally { + purgingStaleShells.set(false); + } + } + + private static boolean isStaleShell(Runnable runnable) { + return runnable instanceof QueuedWork && ((QueuedWork) runnable).isStolen(); } private void decrementCounters(int elements, long bytes) { @@ -457,7 +591,7 @@ private void decrementCounters(int elements, long bytes) { } monitor.enter(); - elementsOutstanding -= elementsToDecrement; + elementsOutstanding.addAndGet(-elementsToDecrement); bytesOutstanding -= bytesToDecrement; monitor.leave(); } diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkIndex.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkIndex.java new file mode 100644 index 000000000000..10cdab083b5f --- /dev/null +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkIndex.java @@ -0,0 +1,190 @@ +/* + * 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 org.apache.beam.runners.dataflow.worker.util; + +import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.concurrent.GuardedBy; +import javax.annotation.concurrent.ThreadSafe; +import org.apache.beam.runners.dataflow.worker.streaming.Work; +import org.apache.beam.runners.dataflow.worker.util.BoundedQueueExecutor.QueuedWork; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** + * Sharded FIFO index of {@link QueuedWork} by computation and {@link Work.KeyGroup} for work + * stealing by multi-key bundles. + */ +@ThreadSafe +final class KeyGroupWorkIndex { + + private static final int NUM_SHARDS = 64; + private static final int INITIAL_QUEUE_CAPACITY = 4; + + private final ImmutableList shards; + + KeyGroupWorkIndex() { + ImmutableList.Builder builder = ImmutableList.builderWithExpectedSize(NUM_SHARDS); + for (int i = 0; i < NUM_SHARDS; i++) { + builder.add(new Shard()); + } + this.shards = builder.build(); + } + + private Shard shardFor(QueueKey queueKey) { + return shards.get(Math.floorMod(queueKey.hashCode(), NUM_SHARDS)); + } + + /** Adds {@code work} to its key-group queue if it has not been claimed yet. */ + void add(QueuedWork work) { + if (work.isClaimed()) { + return; + } + QueueKey queueKey = work.getQueueKey(); + shardFor(queueKey).add(queueKey, work); + } + + /** + * Drains claimed work from the head of {@code work}'s key-group queue. If an earlier item is + * still unclaimed, {@code work} remains in the queue with its references cleared and is drained + * in amortized O(1) time once the earlier item is claimed. + */ + void drainClaimed(QueuedWork completedWork) { + checkArgument(completedWork.isClaimed()); + QueueKey queueKey = completedWork.getQueueKey(); + shardFor(queueKey).drainClaimed(queueKey); + } + + /** Claims and returns the oldest unclaimed work for the computation and key group, or null. */ + @Nullable QueuedWork pollAndClaim(String computationId, Work.KeyGroup keyGroup) { + QueueKey queueKey = new QueueKey(computationId, keyGroup); + return shardFor(queueKey).pollAndClaim(queueKey); + } + + @VisibleForTesting + boolean isEmpty() { + for (Shard shard : shards) { + if (!shard.isEmpty()) { + return false; + } + } + return true; + } + + /** A shard of key-group queues; the map and its queues are guarded by the shard's monitor. */ + private static final class Shard { + + @GuardedBy("this") + private final Map> queuesByKeyGroup = new HashMap<>(); + + private synchronized void add(QueueKey queueKey, QueuedWork work) { + if (work.isClaimed()) { + return; + } + ArrayDeque queue = + queuesByKeyGroup.computeIfAbsent( + queueKey, unused -> new ArrayDeque<>(INITIAL_QUEUE_CAPACITY)); + drainClaimedFromHead(queue); + queue.addLast(work); + } + + private synchronized void drainClaimed(QueueKey queueKey) { + @Nullable ArrayDeque queue = queuesByKeyGroup.get(queueKey); + if (queue == null) { + return; + } + drainClaimedFromHead(queue); + if (queue.isEmpty()) { + queuesByKeyGroup.remove(queueKey); + } + } + + private synchronized @Nullable QueuedWork pollAndClaim(QueueKey queueKey) { + @Nullable ArrayDeque queue = queuesByKeyGroup.get(queueKey); + if (queue == null) { + return null; + } + @Nullable QueuedWork claimed = null; + while (claimed == null && !queue.isEmpty()) { + QueuedWork candidate = queue.removeFirst(); + if (candidate.tryClaimForSteal()) { + claimed = candidate; + } + } + drainClaimedFromHead(queue); + if (queue.isEmpty()) { + queuesByKeyGroup.remove(queueKey); + } + return claimed; + } + + private synchronized boolean isEmpty() { + return queuesByKeyGroup.isEmpty(); + } + + private static void drainClaimedFromHead(ArrayDeque queue) { + while (!queue.isEmpty() && queue.peekFirst().isClaimed()) { + queue.removeFirst(); + } + } + } + + static final class QueueKey { + private final String computationId; + private final Work.KeyGroup keyGroup; + private final int hashCode; + + QueueKey(String computationId, Work.KeyGroup keyGroup) { + this.computationId = computationId; + this.keyGroup = keyGroup; + this.hashCode = 31 * computationId.hashCode() + keyGroup.hashCode(); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof QueueKey)) { + return false; + } + QueueKey other = (QueueKey) o; + return computationId.equals(other.computationId) && keyGroup.equals(other.keyGroup); + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public String toString() { + return "QueueKey{" + + "computationId='" + + computationId + + '\'' + + ", keyGroup=" + + keyGroup + + '}'; + } + } +} diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkQueue.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkQueue.java deleted file mode 100644 index dd409616ab9d..000000000000 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkQueue.java +++ /dev/null @@ -1,472 +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 org.apache.beam.runners.dataflow.worker.util; - -import static org.apache.beam.sdk.util.Preconditions.checkArgumentNotNull; -import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull; -import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument; -import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState; - -import java.util.AbstractQueue; -import java.util.Collection; -import java.util.HashMap; -import java.util.Iterator; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.Condition; -import java.util.concurrent.locks.ReentrantLock; -import javax.annotation.concurrent.GuardedBy; -import org.apache.beam.runners.dataflow.worker.streaming.Work; -import org.apache.beam.runners.dataflow.worker.streaming.Work.KeyGroup; -import org.apache.beam.runners.dataflow.worker.util.BoundedQueueExecutor.QueuedWork; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * A custom, thread-safe doubly-linked BlockingQueue. In addition to global FIFO ordering, the queue - * supports polling work by computation + key group in FIFO order - */ -class KeyGroupWorkQueue extends AbstractQueue implements BlockingQueue { - - public static final Runnable SENTINEL_RUNNABLE = - () -> { - throw new IllegalStateException("sentinel runnable called"); - }; - - static class Node { - // If keyGroup is non-null, task is an instance of QueuedWork - final Runnable task; - final @Nullable String computationId; - final Work.@Nullable KeyGroup keyGroup; - // cached keyGroupList if the Node is part of one. - @Nullable KeyGroupWorkList keyGroupList; - - // prevNode, nextNode are used for the global order across all queued Runnables - @Nullable Node prevNode; - @Nullable Node nextNode; - - // prevKeyGroupNode and nextKeyGroupNode are used for the keyGroup level lists linking - // QueuedWork with same keyGroup - @Nullable Node prevKeyGroupNode; - @Nullable Node nextKeyGroupNode; - - private static boolean isMultiKeyBatchingDisabled(Runnable task) { - return !(task instanceof QueuedWork) - || ((QueuedWork) task).getWork().isMultiKeyBatchingDisabled(); - } - - Node(Runnable task) { - this.task = task; - if (!isMultiKeyBatchingDisabled(task)) { - this.computationId = ((QueuedWork) task).getWork().getComputationId(); - this.keyGroup = ((QueuedWork) task).getWork().getKeyGroup(); - } else { - this.computationId = null; - this.keyGroup = null; - } - } - } - - /** Double linked list implementing key group level queue */ - private static class KeyGroupWorkList { - final Node head = new Node(SENTINEL_RUNNABLE); - final Node tail = new Node(SENTINEL_RUNNABLE); - - KeyGroupWorkList() { - head.nextKeyGroupNode = tail; - tail.prevKeyGroupNode = head; - } - - boolean isEmpty() { - return head.nextKeyGroupNode == tail; - } - - void append(Node node) { - Node last = checkStateNotNull(tail.prevKeyGroupNode); - node.prevKeyGroupNode = last; - node.nextKeyGroupNode = tail; - last.nextKeyGroupNode = node; - tail.prevKeyGroupNode = node; - } - - void remove(Node node) { - @Nullable Node prev = node.prevKeyGroupNode; - @Nullable Node next = node.nextKeyGroupNode; - if (prev != null && next != null) { - prev.nextKeyGroupNode = next; - next.prevKeyGroupNode = prev; - node.prevKeyGroupNode = null; - node.nextKeyGroupNode = null; - } - } - } - - private final ReentrantLock lock; - private final Condition notEmpty; - - // Sentinels for the global list - @GuardedBy("lock") - private final Node globalHead = new Node(SENTINEL_RUNNABLE); - - @GuardedBy("lock") - private final Node globalTail = new Node(SENTINEL_RUNNABLE); - - @GuardedBy("lock") - private final Map keyGroupQueueMap = new HashMap<>(); - - @GuardedBy("lock") - private int size = 0; - - public KeyGroupWorkQueue(boolean fair) { - this.lock = new ReentrantLock(fair); - this.notEmpty = lock.newCondition(); - globalHead.nextNode = globalTail; - globalTail.prevNode = globalHead; - } - - @GuardedBy("lock") - private void unlinkNode(Node node) { - // An existing node should always have previous and next since we have sentinels - // 1. Unlink from global list - Node prevG = checkArgumentNotNull(node.prevNode); - Node nextG = checkArgumentNotNull(node.nextNode); - prevG.nextNode = nextG; - nextG.prevNode = prevG; - node.prevNode = null; - node.nextNode = null; - - // 2. Unlink from key group list - KeyGroupWorkList list = node.keyGroupList; - if (list != null) { - list.remove(node); - if (list.isEmpty()) { - String compId = checkStateNotNull(node.computationId); - Work.KeyGroup keyGroup = checkStateNotNull(node.keyGroup); - QueueKey key = new QueueKey(compId, keyGroup); - keyGroupQueueMap.remove(key); - } - node.keyGroupList = null; - } - --size; - } - - @GuardedBy("lock") - private @Nullable Node removeFirstGlobal() { - Node first = checkStateNotNull(globalHead.nextNode); - if (first == globalTail) { - return null; - } - unlinkNode(first); - return first; - } - - /** - * Remove and Return QueuedWork for the computationId, keyGroup in the FIFO order. Returns null, - * if there are no matches. - * - * @param keyGroup should not be equal to KeyGroup.DEFAULT - */ - public @Nullable QueuedWork pollWork(String computationId, Work.KeyGroup keyGroup) { - checkArgument(computationId != null && keyGroup != null && !keyGroup.equals(KeyGroup.DEFAULT)); - QueueKey key = new QueueKey(computationId, keyGroup); - lock.lock(); - try { - KeyGroupWorkList keyGroupWorkList = keyGroupQueueMap.get(key); - if (keyGroupWorkList == null || keyGroupWorkList.isEmpty()) { - return null; - } - - // Retrieve the first pending task for this computation and keyGroup in O(1) - Node firstNode = checkStateNotNull(keyGroupWorkList.head.nextKeyGroupNode); - if (firstNode == keyGroupWorkList.tail) { - return null; - } - - // MultiKeyBatchingDisabled items should not be in keyGroupWorkList - checkState(!Node.isMultiKeyBatchingDisabled(firstNode.task)); - - unlinkNode(firstNode); - - return (QueuedWork) firstNode.task; - } finally { - lock.unlock(); - } - } - - @Override - public boolean offer(@NonNull Runnable runnable) { - Node node = new Node(checkStateNotNull(runnable)); - lock.lock(); - try { - // Append to global list tail - Node lastG = checkStateNotNull(globalTail.prevNode); - node.prevNode = lastG; - node.nextNode = globalTail; - lastG.nextNode = node; - globalTail.prevNode = node; - - // Append to key group list if applicable - String compId = node.computationId; - Work.KeyGroup keyGroup = node.keyGroup; - if (compId != null && keyGroup != null && !keyGroup.equals(KeyGroup.DEFAULT)) { - QueueKey key = new QueueKey(compId, keyGroup); - KeyGroupWorkList keyGroupWorkList = - keyGroupQueueMap.computeIfAbsent(key, k -> new KeyGroupWorkList()); - keyGroupWorkList.append(node); - node.keyGroupList = keyGroupWorkList; - } - - ++size; - notEmpty.signal(); - return true; - } finally { - lock.unlock(); - } - } - - @Override - public void put(Runnable e) throws InterruptedException { - offer(e); // Unbounded queue - } - - @Override - public boolean offer(Runnable e, long timeout, TimeUnit unit) throws InterruptedException { - return offer(e); // Unbounded queue - } - - @Override - public @Nullable Runnable poll() { - lock.lock(); - try { - @Nullable Node node = removeFirstGlobal(); - return (node != null) ? node.task : null; - } finally { - lock.unlock(); - } - } - - @Override - public Runnable take() throws InterruptedException { - lock.lockInterruptibly(); - try { - while (size == 0) { - notEmpty.await(); - } - @Nullable Node node = removeFirstGlobal(); - checkStateNotNull(node, "Queue is empty but size was " + size); - return node.task; - } finally { - lock.unlock(); - } - } - - @Override - public @Nullable Runnable poll(long timeout, TimeUnit unit) throws InterruptedException { - long nanos = unit.toNanos(timeout); - lock.lockInterruptibly(); - try { - while (size == 0) { - if (nanos <= 0) { - return null; - } - nanos = notEmpty.awaitNanos(nanos); - } - @Nullable Node node = removeFirstGlobal(); - return (node != null) ? node.task : null; - } finally { - lock.unlock(); - } - } - - @Override - public @Nullable Runnable peek() { - lock.lock(); - try { - Node first = checkStateNotNull(globalHead.nextNode); - if (first == globalTail) { - return null; - } - return first.task; - } finally { - lock.unlock(); - } - } - - @Override - public int size() { - lock.lock(); - try { - return size; - } finally { - lock.unlock(); - } - } - - @Override - public boolean isEmpty() { - lock.lock(); - try { - return size == 0; - } finally { - lock.unlock(); - } - } - - @Override - public boolean remove(Object o) { - if (o == null) return false; - lock.lock(); - try { - // Walk the global queue in O(N) to find and unlink the node - Node curr = checkStateNotNull(globalHead.nextNode); - while (curr != globalTail) { - if (o.equals(curr.task)) { - unlinkNode(curr); - return true; - } - curr = checkStateNotNull(curr.nextNode); - } - return false; - } finally { - lock.unlock(); - } - } - - @Override - public boolean contains(Object o) { - if (o == null) return false; - lock.lock(); - try { - Node curr = checkStateNotNull(globalHead.nextNode); - while (curr != globalTail) { - if (o.equals(curr.task)) { - return true; - } - curr = checkStateNotNull(curr.nextNode); - } - return false; - } finally { - lock.unlock(); - } - } - - @Override - public int remainingCapacity() { - return Integer.MAX_VALUE; - } - - @Override - public int drainTo(Collection c) { - return drainTo(c, Integer.MAX_VALUE); - } - - @Override - public int drainTo(Collection c, int maxElements) { - if (c == null) throw new NullPointerException(); - if (c == this) throw new IllegalArgumentException(); - if (maxElements <= 0) return 0; - lock.lock(); - try { - int added = 0; - Node curr = checkStateNotNull(globalHead.nextNode); - while (curr != globalTail && added < maxElements) { - Node next = checkStateNotNull(curr.nextNode); - unlinkNode(curr); - Runnable task = curr.task; - c.add(task); - ++added; - curr = next; - } - return added; - } finally { - lock.unlock(); - } - } - - @Override - public void clear() { - lock.lock(); - try { - Node curr = checkStateNotNull(globalHead.nextNode); - while (curr != globalTail) { - Node next = checkStateNotNull(curr.nextNode); - unlinkNode(curr); - curr = next; - } - } finally { - lock.unlock(); - } - } - - @Override - public Iterator iterator() { - lock.lock(); - try { - ImmutableList.Builder builder = ImmutableList.builderWithExpectedSize(size); - Node curr = checkStateNotNull(globalHead.nextNode); - while (curr != globalTail) { - builder.add(curr.task); - curr = checkStateNotNull(curr.nextNode); - } - return builder.build().iterator(); - } finally { - lock.unlock(); - } - } - - static final class QueueKey { - private final String computationId; - private final Work.KeyGroup keyGroup; - - QueueKey(String computationId, Work.KeyGroup keyGroup) { - this.computationId = computationId; - this.keyGroup = keyGroup; - } - - @Override - public boolean equals(@Nullable Object o) { - if (this == o) { - return true; - } - if (!(o instanceof QueueKey)) { - return false; - } - QueueKey other = (QueueKey) o; - return computationId.equals(other.computationId) && keyGroup.equals(other.keyGroup); - } - - @Override - public int hashCode() { - return Objects.hash(computationId, keyGroup); - } - - @Override - public String toString() { - return "QueueKey{" - + "computationId='" - + computationId - + '\'' - + ", keyGroup=" - + keyGroup - + '}'; - } - } -} diff --git a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/StreamingDataflowWorkerTest.java b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/StreamingDataflowWorkerTest.java index b679ca40a956..f3371b54673f 100644 --- a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/StreamingDataflowWorkerTest.java +++ b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/StreamingDataflowWorkerTest.java @@ -2405,10 +2405,10 @@ public void testExceptions() throws Exception { // Wait until the worker has given up. int maxTries = 100; - while (maxTries-- > 0 && !worker.workExecutorIsEmpty()) { + while (maxTries-- > 0 && !worker.workExecutorIsEmptyForTesting()) { Uninterruptibles.sleepUninterruptibly(100, TimeUnit.MILLISECONDS); } - assertTrue(worker.workExecutorIsEmpty()); + assertTrue(worker.workExecutorIsEmptyForTesting()); // Spam worker updates a few times. maxTries = 10; @@ -4005,7 +4005,7 @@ public void testMaxThreadMetric() throws Exception { .setDaemon(true) .build(), /* useFairMonitor= */ false, - /* useKeyGroupWorkQueue= */ false); + /* multiKeyBundleEnabled= */ false); ComputationState computationState = new ComputationState( @@ -4067,7 +4067,7 @@ public void testActiveThreadMetric() throws Exception { .setDaemon(true) .build(), /* useFairMonitor= */ false, - /* useKeyGroupWorkQueue= */ false); + /* multiKeyBundleEnabled= */ false); ComputationState computationState = new ComputationState( @@ -4138,7 +4138,7 @@ public void testOutstandingBytesMetric() throws Exception { .setDaemon(true) .build(), /* useFairMonitor= */ false, - /* useKeyGroupWorkQueue= */ false); + /* multiKeyBundleEnabled= */ false); ComputationState computationState = new ComputationState( @@ -4213,7 +4213,7 @@ public void testOutstandingBundlesMetric() throws Exception { .setDaemon(true) .build(), /* useFairMonitor= */ false, - /* useKeyGroupWorkQueue= */ false); + /* multiKeyBundleEnabled= */ false); ComputationState computationState = new ComputationState( diff --git a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/BoundedQueueExecutorTest.java b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/BoundedQueueExecutorTest.java index c1408db5b4a6..4446d1ea2cbb 100644 --- a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/BoundedQueueExecutorTest.java +++ b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/BoundedQueueExecutorTest.java @@ -23,6 +23,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -31,6 +32,9 @@ import java.util.Collection; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicIntegerArray; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -99,28 +103,10 @@ private static ExecutableWork createWorkWithCompIdAndKeyGroup( private static ExecutableWork createWorkWithCompIdAndKeyGroupAndWorkToken( String computationId, Work.KeyGroup keyGroup, long workToken, Consumer executeWorkFn) { - WorkItem workItem = - WorkItem.newBuilder() - .setKey(ByteString.EMPTY) - .setShardingKey(1) - .setWorkToken(workToken) - .setCacheToken(1) - .setKeyGroup( - Windmill.Uint128Proto.newBuilder() - .setHigh(keyGroup.high()) - .setLow(keyGroup.low()) - .build()) - .build(); - return ExecutableWork.create( - Work.create( - workItem, - workItem.getSerializedSize(), - Watermarks.builder().setInputDataWatermark(Instant.now()).build(), - Work.createProcessingContext( - computationId, new FakeGetDataClient(), ignored -> {}, mock(HeartbeatSender.class)), - false, - Instant::now, - ImmutableList.of()), + return createWork( + createProcessingContext(computationId), + keyGroup, + workToken, (work, handle) -> executeWorkFn.accept(work)); } @@ -128,11 +114,25 @@ private static ExecutableWork createWorkWithHandle( String computationId, Work.KeyGroup keyGroup, BiConsumer executeWorkFn) { + return createWork( + createProcessingContext(computationId), keyGroup, /* workToken= */ 33, executeWorkFn); + } + + private static Work.ProcessingContext createProcessingContext(String computationId) { + return Work.createProcessingContext( + computationId, new FakeGetDataClient(), ignored -> {}, mock(HeartbeatSender.class)); + } + + private static ExecutableWork createWork( + Work.ProcessingContext processingContext, + Work.KeyGroup keyGroup, + long workToken, + BiConsumer executeWorkFn) { WorkItem workItem = WorkItem.newBuilder() .setKey(ByteString.EMPTY) .setShardingKey(1) - .setWorkToken(33) + .setWorkToken(workToken) .setCacheToken(1) .setKeyGroup( Windmill.Uint128Proto.newBuilder() @@ -145,8 +145,7 @@ private static ExecutableWork createWorkWithHandle( workItem, workItem.getSerializedSize(), Watermarks.builder().setInputDataWatermark(Instant.now()).build(), - Work.createProcessingContext( - computationId, new FakeGetDataClient(), ignored -> {}, mock(HeartbeatSender.class)), + processingContext, false, Instant::now, ImmutableList.of()), @@ -179,7 +178,7 @@ public void setUp() { .setDaemon(true) .build(), useFairMonitor, - /* useKeyGroupWorkQueue= */ false); + /* multiKeyBundleEnabled= */ false); } @Test @@ -201,13 +200,13 @@ public void testScheduleWorkWhenExceedMaximumPoolSize() throws Exception { // m1 and m2 have started and all threads are occupied so m3 will be queued and not executed. executor.execute(m3, 1); assertFalse(processStart3.await(1000, TimeUnit.MILLISECONDS)); - assertFalse(executor.executorQueueIsEmpty()); + assertFalse(executor.executorQueueIsEmptyForTesting()); // Stop m1 so there is an available thread for m3 to run. processStop1.countDown(); processStart3.await(); // m3 started. - assertTrue(executor.executorQueueIsEmpty()); + assertTrue(executor.executorQueueIsEmptyForTesting()); processStop2.countDown(); processStop3.countDown(); executor.shutdown(); @@ -236,7 +235,7 @@ public void testScheduleWorkWhenExceedMaximumBytesOutstanding() throws Exception assertFalse(processStart2.await(1000, TimeUnit.MILLISECONDS)); // m2 will wait for monitor instead of being queued. assertEquals(Thread.State.WAITING, m2Runner.getState()); - assertTrue(executor.executorQueueIsEmpty()); + assertTrue(executor.executorQueueIsEmptyForTesting()); // Stop m1 so there are available bytes for m2 to run. processStop1.countDown(); @@ -487,55 +486,23 @@ public void testRenderSummaryHtml() { @Test public void testPollWork() throws Exception { // Create separate BoundedQueueExecutor with 1 thread so we can block it easily - BoundedQueueExecutor testExecutor = - new BoundedQueueExecutor( - 1, - 60, - TimeUnit.SECONDS, - 100, - 10000000, - new ThreadFactoryBuilder().setNameFormat("testStealing-%d").setDaemon(true).build(), - useFairMonitor, - /* useKeyGroupWorkQueue= */ true); + BoundedQueueExecutor testExecutor = createMultiKeyBundleExecutor(1, 100); // 1. Create blocker task to occupy the worker thread - CountDownLatch blockerStart = new CountDownLatch(1); CountDownLatch blockerStop = new CountDownLatch(1); - AtomicReference blockerHandleRef = new AtomicReference<>(); - ExecutableWork blockerWork = - createWorkWithHandle( - "blockerComp", - DEFAULT_KEY_GROUP, - (work, handle) -> { - blockerHandleRef.set(handle); - blockerStart.countDown(); - try { - blockerStop.await(); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - }); - - testExecutor.execute(blockerWork, 0); - blockerStart.await(); - BoundedQueueExecutorWorkHandleImpl stealHandle = - (BoundedQueueExecutorWorkHandleImpl) blockerHandleRef.get(); - assertNotNull(stealHandle); + BoundedQueueExecutorWorkHandleImpl stealHandle = occupyThread(testExecutor, blockerStop); // 2. Create two distinct key groups Work.KeyGroup keyGroup1 = Work.KeyGroup.create(1, 1); Work.KeyGroup keyGroup2 = Work.KeyGroup.create(1, 2); // Create executable tasks - CountDownLatch targetStart = new CountDownLatch(1); - ExecutableWork work1 = createWorkWithCompIdAndKeyGroup("compA", keyGroup1, ignored -> {}); + AtomicInteger work1Runs = new AtomicInteger(); + AtomicInteger work2Runs = new AtomicInteger(); + ExecutableWork work1 = + createWorkWithCompIdAndKeyGroup("compA", keyGroup1, ignored -> work1Runs.incrementAndGet()); ExecutableWork work2 = - createWorkWithCompIdAndKeyGroup( - "compA", - keyGroup2, - ignored -> { - targetStart.countDown(); - }); + createWorkWithCompIdAndKeyGroup("compA", keyGroup2, ignored -> work2Runs.incrementAndGet()); // Enqueue tasks (they will wait in the queue because the thread is blocked) testExecutor.execute(work1, 100); @@ -543,70 +510,66 @@ public void testPollWork() throws Exception { // Total outstanding elements must be 3 (blocker + work1 + work2) assertEquals(3, testExecutor.elementsOutstanding()); + assertFalse(testExecutor.executorQueueIsEmptyForTesting()); // Steal work2 using pollWork with compA and keyGroup2 - ExecutableWork stolen = testExecutor.pollWork("compA", keyGroup2, stealHandle, ignored -> {}); - assertNotNull(stolen); + ExecutableWork stolen = + testExecutor.pollWork("compA", keyGroup2, stealHandle, FAILING_FAILED_WORK_HANDLER); assertEquals(work2, stolen); // Run the stolen task stolen.run(stealHandle); - targetStart.await(); + assertEquals(1, work2Runs.get()); // Steal work1 using pollWork with compA and keyGroup1 ExecutableWork stolen1 = testExecutor.pollWork("compA", keyGroup1, stealHandle, FAILING_FAILED_WORK_HANDLER); - assertNotNull(stolen1); assertEquals(work1, stolen1); + stolen1.run(stealHandle); + assertEquals(1, work1Runs.get()); - // Unblock the blocker and shut down + // All queued work was stolen; only stale shells remain in the executor queue + assertTrue(testExecutor.keyGroupWorkIndexIsEmpty()); + assertTrue(testExecutor.executorQueueIsEmptyForTesting()); + assertEquals(2, testExecutor.rawQueueSizeForTesting()); + assertEquals(3, testExecutor.elementsOutstanding()); + assertEquals(250, testExecutor.bytesOutstanding()); + + // Closing the merged handle releases the budget for the blocker and both stolen tasks + stealHandle.close(); + assertEquals(0, testExecutor.elementsOutstanding()); + assertEquals(0, testExecutor.bytesOutstanding()); + + // Unblock the blocker and shut down; dequeued stale shells are no-ops blockerStop.countDown(); testExecutor.shutdown(); + assertEquals(1, work1Runs.get()); + assertEquals(1, work2Runs.get()); + assertEquals(0, testExecutor.rawQueueSizeForTesting()); + assertEquals(0, testExecutor.elementsOutstanding()); + assertEquals(0, testExecutor.bytesOutstanding()); } @Test - public void testPollWorkWithLinkedBlockingQueue() throws Exception { + public void testPollWork_multiKeyBundleDisabled() throws Exception { BoundedQueueExecutor testExecutor = new BoundedQueueExecutor( 1, - 60, + DEFAULT_THREAD_EXPIRATION_SEC, TimeUnit.SECONDS, 100, - 10000000, - new ThreadFactoryBuilder().setNameFormat("testLinkedQueue-%d").setDaemon(true).build(), + MAXIMUM_BYTES_OUTSTANDING, + new ThreadFactoryBuilder().setNameFormat("testDisabled-%d").setDaemon(true).build(), useFairMonitor, - /* useKeyGroupWorkQueue= */ false); - - CountDownLatch blockerStart = new CountDownLatch(1); + /* multiKeyBundleEnabled= */ false); CountDownLatch blockerStop = new CountDownLatch(1); - AtomicReference blockerHandleRef = new AtomicReference<>(); - ExecutableWork blockerWork = - createWorkWithHandle( - "blockerComp", - DEFAULT_KEY_GROUP, - (work, handle) -> { - blockerHandleRef.set(handle); - blockerStart.countDown(); - try { - blockerStop.await(); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - }); - - testExecutor.execute(blockerWork, 0); - blockerStart.await(); - BoundedQueueExecutorWorkHandleImpl stealHandle = - (BoundedQueueExecutorWorkHandleImpl) blockerHandleRef.get(); - assertNotNull(stealHandle); + BoundedQueueExecutorWorkHandleImpl stealHandle = occupyThread(testExecutor, blockerStop); Work.KeyGroup keyGroup = Work.KeyGroup.create(1, 1); ExecutableWork work = createWorkWithCompIdAndKeyGroup("compA", keyGroup, ignored -> {}); testExecutor.execute(work, 100); - ExecutableWork stolen = - testExecutor.pollWork("compA", keyGroup, stealHandle, FAILING_FAILED_WORK_HANDLER); - assertNull(stolen); + assertNull(testExecutor.pollWork("compA", keyGroup, stealHandle, FAILING_FAILED_WORK_HANDLER)); blockerStop.countDown(); testExecutor.shutdown(); @@ -614,39 +577,10 @@ public void testPollWorkWithLinkedBlockingQueue() throws Exception { @Test public void testPollWork_skipsFailedWorkAndCallsOnFailedWorkHandler() throws Exception { - BoundedQueueExecutor testExecutor = - new BoundedQueueExecutor( - 1, - 60, - TimeUnit.SECONDS, - 100, - 10000000, - new ThreadFactoryBuilder().setNameFormat("testPollWork-%d").setDaemon(true).build(), - useFairMonitor, - /* useKeyGroupWorkQueue= */ true); - - CountDownLatch blockerStart = new CountDownLatch(1); + BoundedQueueExecutor testExecutor = createMultiKeyBundleExecutor(1, 100); CountDownLatch blockerStop = new CountDownLatch(1); - AtomicReference blockerHandleRef = new AtomicReference<>(); - ExecutableWork blockerWork = - createWorkWithHandle( - "compA", - DEFAULT_KEY_GROUP, - (work, handle) -> { - blockerHandleRef.set(handle); - blockerStart.countDown(); - try { - blockerStop.await(); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } - }); - - testExecutor.execute(blockerWork, 10); - blockerStart.await(); BoundedQueueExecutorWorkHandleImpl stealHandle = - (BoundedQueueExecutorWorkHandleImpl) blockerHandleRef.get(); - assertNotNull(stealHandle); + occupyThread(testExecutor, blockerStop, /* bytes= */ 10); Work.KeyGroup keyGroup = Work.KeyGroup.create(1, 1); FailedWorkHandler onFailedWorkHandler = mock(FailedWorkHandler.class); @@ -658,7 +592,7 @@ public void testPollWork_skipsFailedWorkAndCallsOnFailedWorkHandler() throws Exc ExecutableWork work3 = createWorkWithCompIdAndKeyGroupAndWorkToken("compA", keyGroup, 103, ignored -> {}); - // Enqueue both tasks (they will wait in the queue because the thread is blocked). + // Enqueue tasks (they will wait in the queue because the thread is blocked). testExecutor.execute(work1, 100); testExecutor.execute(work2, 150); testExecutor.execute(work3, 200); @@ -674,9 +608,7 @@ public void testPollWork_skipsFailedWorkAndCallsOnFailedWorkHandler() throws Exc // onFailedWorkHandler callback, and return work2. ExecutableWork stolen = testExecutor.pollWork("compA", keyGroup, stealHandle, onFailedWorkHandler); - assertNotNull(stolen); assertEquals(work2, stolen); - verify(onFailedWorkHandler).onFailedWork(work1.work()); // Verify stealHandle merged (blockerWork: 10 bytes, work2: 150 bytes). @@ -693,9 +625,229 @@ public void testPollWork_skipsFailedWorkAndCallsOnFailedWorkHandler() throws Exc assertNull(testExecutor.pollWork("compA", keyGroup, stealHandle, onFailedWorkHandler)); blockerStop.countDown(); - stealHandle.close(); + testExecutor.shutdown(); + assertEquals(0, testExecutor.elementsOutstanding()); + assertEquals(0, testExecutor.bytesOutstanding()); + } + + @Test + public void testPollWork_doesNotStealWorkStartedByExecutor() throws Exception { + BoundedQueueExecutor testExecutor = createMultiKeyBundleExecutor(1, 1000); + Work.KeyGroup keyGroup = Work.KeyGroup.create(1, 1); + CountDownLatch release = new CountDownLatch(1); + BoundedQueueExecutorWorkHandleImpl handle = + occupyThread(testExecutor, "compA", keyGroup, release, /* bytes= */ 100); + + assertTrue(testExecutor.keyGroupWorkIndexIsEmpty()); + assertNull(testExecutor.pollWork("compA", keyGroup, handle, FAILING_FAILED_WORK_HANDLER)); + + release.countDown(); + testExecutor.shutdown(); + assertEquals(0, testExecutor.elementsOutstanding()); + assertEquals(0, testExecutor.bytesOutstanding()); + } + + @Test + public void testPollWork_doesNotStealWorkWithMultiKeyBatchingDisabled() throws Exception { + BoundedQueueExecutor testExecutor = createMultiKeyBundleExecutor(1, 1000); + CountDownLatch releaseBlocker = new CountDownLatch(1); + BoundedQueueExecutorWorkHandleImpl stealHandle = occupyThread(testExecutor, releaseBlocker); + + Work.KeyGroup keyGroup = Work.KeyGroup.create(1, 1); + AtomicBoolean disabledWorkRan = new AtomicBoolean(); + ExecutableWork disabledWork = + createWorkWithCompIdAndKeyGroup("compA", keyGroup, ignored -> disabledWorkRan.set(true)); + disabledWork.work().setMultiKeyBatchingDisabled(true); + ExecutableWork enabledWork = createWorkWithCompIdAndKeyGroup("compA", keyGroup, ignored -> {}); + testExecutor.execute(disabledWork, 100); + testExecutor.execute(enabledWork, 100); + + assertEquals( + enabledWork, + testExecutor.pollWork("compA", keyGroup, stealHandle, FAILING_FAILED_WORK_HANDLER)); + assertNull(testExecutor.pollWork("compA", keyGroup, stealHandle, FAILING_FAILED_WORK_HANDLER)); + + releaseBlocker.countDown(); + testExecutor.shutdown(); + assertTrue(disabledWorkRan.get()); + assertEquals(0, testExecutor.elementsOutstanding()); + } + + @Test + public void testPollWork_purgesStaleShellsToBoundExecutorQueue() throws Exception { + int maximumElementsOutstanding = 4; + BoundedQueueExecutor testExecutor = createMultiKeyBundleExecutor(2, maximumElementsOutstanding); + CountDownLatch releaseBundles = new CountDownLatch(1); + BoundedQueueExecutorWorkHandleImpl bundle1 = occupyThread(testExecutor, releaseBundles); + BoundedQueueExecutorWorkHandleImpl bundle2 = occupyThread(testExecutor, releaseBundles); + + Work.KeyGroup keyGroup = Work.KeyGroup.create(1, 1); + int numWorks = 20; + for (int i = 0; i < numWorks; i++) { + testExecutor.forceExecute( + createWorkWithCompIdAndKeyGroup("compA", keyGroup, ignored -> {}), 10); + } + + // Each steal leaves a stale shell in the executor queue. While bundle1 is open, its stolen + // work is still counted in elementsOutstanding, so there is nothing to purge. + int numStolenByBundle1 = maximumElementsOutstanding + 2; + for (int i = 0; i < numStolenByBundle1; i++) { + assertNotNull(testExecutor.pollWork("compA", keyGroup, bundle1, FAILING_FAILED_WORK_HANDLER)); + } + // 2 = bundle1 + bundle2. + assertEquals(2 + numWorks, testExecutor.elementsOutstanding()); + assertEquals(numWorks, testExecutor.rawQueueSizeForTesting()); + + // Closing bundle1 leaves its 6 stale shells uncounted. The executor queue now exceeds + // elementsOutstanding (bundle2 + unstarted work) by more than maximumElementsOutstanding, so + // the next steal purges every stale shell. + bundle1.close(); + int numStolenByBundle2 = 1; + assertNotNull(testExecutor.pollWork("compA", keyGroup, bundle2, FAILING_FAILED_WORK_HANDLER)); + // 1 = bundle2. + assertEquals(1 + numWorks - numStolenByBundle1, testExecutor.elementsOutstanding()); + assertEquals( + numWorks - numStolenByBundle1 - numStolenByBundle2, testExecutor.rawQueueSizeForTesting()); + + releaseBundles.countDown(); + testExecutor.shutdown(); + assertEquals(0, testExecutor.rawQueueSizeForTesting()); + assertEquals(0, testExecutor.elementsOutstanding()); + assertEquals(0, testExecutor.bytesOutstanding()); + } + + @Test + public void testConcurrentExecuteAndPollWork_processesEachWorkExactlyOnce() throws Exception { + int numThreads = 4; + int numWorks = 4000; + int maxStolenPerBundle = 10; + BoundedQueueExecutor testExecutor = + createMultiKeyBundleExecutor(numThreads, /* maximumElementsOutstanding= */ 8); + Work.ProcessingContext processingContext = createProcessingContext("compA"); + ImmutableList keyGroups = + ImmutableList.of( + Work.KeyGroup.create(1, 1), + Work.KeyGroup.create(1, 2), + Work.KeyGroup.create(2, 1), + Work.KeyGroup.create(2, 2)); + + AtomicIntegerArray processedCounts = new AtomicIntegerArray(numWorks); + AtomicInteger numStolen = new AtomicInteger(); + CountDownLatch allProcessed = new CountDownLatch(numWorks); + BiConsumer bundleFn = + (work, handle) -> { + processedCounts.incrementAndGet((int) work.getWorkItem().getWorkToken()); + allProcessed.countDown(); + for (int i = 0; i < maxStolenPerBundle; i++) { + ExecutableWork stolen = + testExecutor.pollWork( + work.getComputationId(), + work.getKeyGroup(), + handle, + FAILING_FAILED_WORK_HANDLER); + if (stolen == null) { + break; + } + numStolen.incrementAndGet(); + processedCounts.incrementAndGet((int) stolen.work().getWorkItem().getWorkToken()); + allProcessed.countDown(); + } + }; + + // Hold all threads until half of the work is queued so bundles steal from a backlog. + CountDownLatch releaseBlockers = new CountDownLatch(1); + for (int i = 0; i < numThreads; i++) { + occupyThread(testExecutor, releaseBlockers); + } + for (int workToken = 0; workToken < numWorks; workToken++) { + if (workToken == numWorks / 2) { + releaseBlockers.countDown(); + } + testExecutor.forceExecute( + createWork( + processingContext, keyGroups.get(workToken % keyGroups.size()), workToken, bundleFn), + 1); + } + allProcessed.await(); + testExecutor.shutdown(); + + for (int workToken = 0; workToken < numWorks; workToken++) { + assertEquals("work token " + workToken, 1, processedCounts.get(workToken)); + } + assertThat(numStolen.get(), greaterThan(0)); + assertEquals(0, testExecutor.rawQueueSizeForTesting()); + assertTrue(testExecutor.keyGroupWorkIndexIsEmpty()); assertEquals(0, testExecutor.elementsOutstanding()); assertEquals(0, testExecutor.bytesOutstanding()); + } + + @Test + public void testExecuteAfterShutdown_rejectedWorkIsNotStealable() throws Exception { + BoundedQueueExecutor testExecutor = createMultiKeyBundleExecutor(1, 1000); testExecutor.shutdown(); + + ExecutableWork work = + createWorkWithCompIdAndKeyGroup( + "compA", Work.KeyGroup.create(1, 1), ignored -> Assert.fail()); + assertThrows(RuntimeException.class, () -> testExecutor.execute(work, 100)); + + assertTrue(testExecutor.keyGroupWorkIndexIsEmpty()); + assertEquals(0, testExecutor.elementsOutstanding()); + assertEquals(0, testExecutor.bytesOutstanding()); + } + + private BoundedQueueExecutor createMultiKeyBundleExecutor( + int maxThreads, int maximumElementsOutstanding) { + return new BoundedQueueExecutor( + maxThreads, + DEFAULT_THREAD_EXPIRATION_SEC, + TimeUnit.SECONDS, + maximumElementsOutstanding, + MAXIMUM_BYTES_OUTSTANDING, + new ThreadFactoryBuilder().setNameFormat("testMultiKeyBundle-%d").setDaemon(true).build(), + useFairMonitor, + /* multiKeyBundleEnabled= */ true); + } + + private static BoundedQueueExecutorWorkHandleImpl occupyThread( + BoundedQueueExecutor testExecutor, CountDownLatch release) throws InterruptedException { + return occupyThread(testExecutor, release, /* bytes= */ 0); + } + + private static BoundedQueueExecutorWorkHandleImpl occupyThread( + BoundedQueueExecutor testExecutor, CountDownLatch release, long bytes) + throws InterruptedException { + return occupyThread(testExecutor, "blockerComp", Work.KeyGroup.DEFAULT, release, bytes); + } + + private static BoundedQueueExecutorWorkHandleImpl occupyThread( + BoundedQueueExecutor testExecutor, + String computationId, + Work.KeyGroup keyGroup, + CountDownLatch release, + long bytes) + throws InterruptedException { + CountDownLatch started = new CountDownLatch(1); + AtomicReference handle = new AtomicReference<>(); + testExecutor.execute( + createWorkWithHandle( + computationId, + keyGroup, + (work, workHandle) -> { + handle.set(workHandle); + started.countDown(); + awaitUninterruptibly(release); + }), + bytes); + started.await(); + return (BoundedQueueExecutorWorkHandleImpl) handle.get(); + } + + private static void awaitUninterruptibly(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } } } diff --git a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkIndexTest.java b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkIndexTest.java new file mode 100644 index 000000000000..f41b0da4eece --- /dev/null +++ b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkIndexTest.java @@ -0,0 +1,319 @@ +/* + * 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 org.apache.beam.runners.dataflow.worker.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicIntegerArray; +import org.apache.beam.runners.dataflow.worker.streaming.ExecutableWork; +import org.apache.beam.runners.dataflow.worker.streaming.Watermarks; +import org.apache.beam.runners.dataflow.worker.streaming.Work; +import org.apache.beam.runners.dataflow.worker.util.BoundedQueueExecutor.QueuedWork; +import org.apache.beam.runners.dataflow.worker.windmill.Windmill; +import org.apache.beam.runners.dataflow.worker.windmill.Windmill.WorkItem; +import org.apache.beam.runners.dataflow.worker.windmill.client.getdata.FakeGetDataClient; +import org.apache.beam.runners.dataflow.worker.windmill.work.refresh.HeartbeatSender; +import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.ByteString; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.util.concurrent.ThreadFactoryBuilder; +import org.joda.time.Instant; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class KeyGroupWorkIndexTest { + + private static final String COMP_A = "compA"; + private static final String COMP_B = "compB"; + private static final Work.KeyGroup KEY_GROUP_1 = Work.KeyGroup.create(1, 1); + private static final Work.KeyGroup KEY_GROUP_2 = Work.KeyGroup.create(1, 2); + + @Rule public transient Timeout globalTimeout = Timeout.seconds(300); + + // Creates the budget handles of the QueuedWork created by the tests. Its executor is never used. + private final BoundedQueueExecutor owner = + new BoundedQueueExecutor( + 1, + 60, + TimeUnit.SECONDS, + 100, + 10000000, + new ThreadFactoryBuilder() + .setNameFormat("KeyGroupWorkIndexTest-%d") + .setDaemon(true) + .build(), + /* useFairMonitor= */ false, + /* multiKeyBundleEnabled= */ true); + private final KeyGroupWorkIndex index = new KeyGroupWorkIndex(); + private long nextWorkToken = 0; + + @After + public void tearDown() throws InterruptedException { + owner.shutdown(); + } + + private QueuedWork createQueuedWork(String computationId, Work.KeyGroup keyGroup) { + long workToken = nextWorkToken++; + WorkItem workItem = + WorkItem.newBuilder() + .setKey(ByteString.EMPTY) + .setShardingKey(workToken) + .setWorkToken(workToken) + .setCacheToken(1) + .setKeyGroup( + Windmill.Uint128Proto.newBuilder() + .setHigh(keyGroup.high()) + .setLow(keyGroup.low()) + .build()) + .build(); + Work.ProcessingContext processingContext = + Work.createProcessingContext( + computationId, new FakeGetDataClient(), ignored -> {}, mock(HeartbeatSender.class)); + ExecutableWork work = + ExecutableWork.create( + Work.create( + workItem, + workItem.getSerializedSize(), + Watermarks.builder().setInputDataWatermark(Instant.now()).build(), + processingContext, + false, + Instant::now, + ImmutableList.of()), + (w, handle) -> {}); + QueuedWork queuedWork = + new QueuedWork(work, owner.createBudgetHandle(work.work(), /* bytes= */ 0), index); + assertTrue(queuedWork.isStealable()); + return queuedWork; + } + + private static int workToken(QueuedWork queuedWork) { + return (int) queuedWork.getWork().work().getWorkItem().getWorkToken(); + } + + @Test + public void testPollAndClaim_returnsWorkInFifoOrder() { + QueuedWork work1 = createQueuedWork(COMP_A, KEY_GROUP_1); + QueuedWork work2 = createQueuedWork(COMP_A, KEY_GROUP_1); + QueuedWork work3 = createQueuedWork(COMP_A, KEY_GROUP_1); + index.add(work1); + index.add(work2); + index.add(work3); + + assertSame(work1, index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertSame(work2, index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertSame(work3, index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertNull(index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertTrue(work1.isClaimed()); + assertTrue(work2.isClaimed()); + assertTrue(work3.isClaimed()); + assertTrue(index.isEmpty()); + } + + @Test + public void testPollAndClaim_isolatesComputationsAndKeyGroups() { + QueuedWork workA1 = createQueuedWork(COMP_A, KEY_GROUP_1); + QueuedWork workA2 = createQueuedWork(COMP_A, KEY_GROUP_2); + QueuedWork workB1 = createQueuedWork(COMP_B, KEY_GROUP_1); + index.add(workA1); + index.add(workA2); + index.add(workB1); + + assertNull(index.pollAndClaim(COMP_A, Work.KeyGroup.create(3, 4))); + assertNull(index.pollAndClaim(COMP_B, KEY_GROUP_2)); + assertSame(workA2, index.pollAndClaim(COMP_A, KEY_GROUP_2)); + assertNull(index.pollAndClaim(COMP_A, KEY_GROUP_2)); + assertSame(workB1, index.pollAndClaim(COMP_B, KEY_GROUP_1)); + assertNull(index.pollAndClaim(COMP_B, KEY_GROUP_1)); + assertSame(workA1, index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertNull(index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertTrue(index.isEmpty()); + } + + @Test + public void testPollAndClaim_skipsWorkClaimedByExecutor() { + QueuedWork work1 = createQueuedWork(COMP_A, KEY_GROUP_1); + QueuedWork work2 = createQueuedWork(COMP_A, KEY_GROUP_1); + index.add(work1); + index.add(work2); + + assertTrue(work1.tryClaimForExecution()); + + assertSame(work2, index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertNull(index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertTrue(index.isEmpty()); + index.drainClaimed(work1); + assertTrue(index.isEmpty()); + + QueuedWork work3 = createQueuedWork(COMP_A, KEY_GROUP_1); + QueuedWork work4 = createQueuedWork(COMP_A, KEY_GROUP_1); + index.add(work3); + index.add(work4); + + assertTrue(work4.tryClaimForExecution()); + index.drainClaimed(work4); + assertFalse(index.isEmpty()); + + assertSame(work3, index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertTrue(index.isEmpty()); + } + + @Test + public void testAdd_skipsClaimedWork() { + QueuedWork work = createQueuedWork(COMP_A, KEY_GROUP_1); + assertTrue(work.tryClaimForExecution()); + + index.add(work); + + assertTrue(index.isEmpty()); + assertNull(index.pollAndClaim(COMP_A, KEY_GROUP_1)); + } + + @Test + public void testDrainClaimed_drainsFromHead() { + QueuedWork work1 = createQueuedWork(COMP_A, KEY_GROUP_1); + QueuedWork work2 = createQueuedWork(COMP_A, KEY_GROUP_1); + index.add(work1); + index.add(work2); + + assertTrue(work2.tryClaimForExecution()); + index.drainClaimed(work2); + assertFalse(index.isEmpty()); + assertTrue(work1.tryClaimForExecution()); + index.drainClaimed(work1); + assertTrue(index.isEmpty()); + + QueuedWork work3 = createQueuedWork(COMP_A, KEY_GROUP_1); + index.add(work3); + assertSame(work3, index.pollAndClaim(COMP_A, KEY_GROUP_1)); + assertTrue(index.isEmpty()); + } + + @Test + public void testConcurrentAddDrainAndPollAndClaim_claimsEachWorkExactlyOnce() throws Exception { + int numProducers = 2; + int numExecutors = 2; + int numStealers = 2; + int numWorksPerProducer = 10000; + ImmutableList computations = ImmutableList.of(COMP_A, COMP_B); + ImmutableList keyGroups = + ImmutableList.of(KEY_GROUP_1, KEY_GROUP_2, Work.KeyGroup.create(2, 1)); + + List> worksByProducer = new ArrayList<>(); + for (int producer = 0; producer < numProducers; producer++) { + List works = new ArrayList<>(); + for (int i = 0; i < numWorksPerProducer; i++) { + works.add( + createQueuedWork( + computations.get(i % computations.size()), keyGroups.get(i % keyGroups.size()))); + } + worksByProducer.add(works); + } + int numWorks = numProducers * numWorksPerProducer; + // Simulates the executor queue: work is queued before it is indexed. + ConcurrentLinkedQueue executorQueue = new ConcurrentLinkedQueue<>(); + AtomicIntegerArray executedCounts = new AtomicIntegerArray(numWorks); + AtomicIntegerArray stolenCounts = new AtomicIntegerArray(numWorks); + AtomicBoolean producersDone = new AtomicBoolean(false); + AtomicBoolean executorsDone = new AtomicBoolean(false); + + ExecutorService threads = + Executors.newFixedThreadPool(numProducers + numExecutors + numStealers); + List> producerFutures = new ArrayList<>(); + for (List works : worksByProducer) { + producerFutures.add( + threads.submit( + () -> { + for (QueuedWork work : works) { + executorQueue.add(work); + index.add(work); + } + })); + } + List> executorFutures = new ArrayList<>(); + for (int i = 0; i < numExecutors; i++) { + executorFutures.add( + threads.submit( + () -> { + while (!producersDone.get() || !executorQueue.isEmpty()) { + QueuedWork work = executorQueue.poll(); + if (work != null && work.tryClaimForExecution()) { + index.drainClaimed(work); + executedCounts.incrementAndGet(workToken(work)); + } + } + })); + } + List> stealerFutures = new ArrayList<>(); + for (int i = 0; i < numStealers; i++) { + stealerFutures.add( + threads.submit( + () -> { + while (!executorsDone.get()) { + ThreadLocalRandom random = ThreadLocalRandom.current(); + QueuedWork work = + index.pollAndClaim( + computations.get(random.nextInt(computations.size())), + keyGroups.get(random.nextInt(keyGroups.size()))); + if (work != null) { + stolenCounts.incrementAndGet(workToken(work)); + } + } + })); + } + + for (Future future : producerFutures) { + future.get(); + } + producersDone.set(true); + for (Future future : executorFutures) { + future.get(); + } + executorsDone.set(true); + for (Future future : stealerFutures) { + future.get(); + } + threads.shutdown(); + assertTrue(threads.awaitTermination(30, TimeUnit.SECONDS)); + + for (int workToken = 0; workToken < numWorks; workToken++) { + assertEquals( + "work token " + workToken, + 1, + executedCounts.get(workToken) + stolenCounts.get(workToken)); + } + assertTrue(index.isEmpty()); + } +} diff --git a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkQueueTest.java b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkQueueTest.java deleted file mode 100644 index a58851cf3702..000000000000 --- a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/util/KeyGroupWorkQueueTest.java +++ /dev/null @@ -1,523 +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 org.apache.beam.runners.dataflow.worker.util; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -import static org.mockito.Mockito.mock; - -import java.lang.Thread.State; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import org.apache.beam.runners.dataflow.worker.streaming.ExecutableWork; -import org.apache.beam.runners.dataflow.worker.streaming.Watermarks; -import org.apache.beam.runners.dataflow.worker.streaming.Work; -import org.apache.beam.runners.dataflow.worker.util.BoundedQueueExecutor.QueuedWork; -import org.apache.beam.runners.dataflow.worker.windmill.Windmill.WorkItem; -import org.apache.beam.runners.dataflow.worker.windmill.client.getdata.FakeGetDataClient; -import org.apache.beam.runners.dataflow.worker.windmill.work.refresh.HeartbeatSender; -import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.ByteString; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.util.concurrent.ThreadFactoryBuilder; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.joda.time.Instant; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; -import org.junit.runners.Parameterized.Parameters; - -@RunWith(Parameterized.class) -public class KeyGroupWorkQueueTest { - - @Parameters(name = "fairQueue={0}") - public static Iterable data() { - return Arrays.asList(new Object[][] {{true}, {false}}); - } - - @Parameterized.Parameter public boolean fairQueue; - private BoundedQueueExecutor executor; - - @Before - public void setUp() { - executor = - new BoundedQueueExecutor( - 2, - 60, - TimeUnit.SECONDS, - 100, - 10000000, - new ThreadFactoryBuilder().setNameFormat("Test-%d").setDaemon(true).build(), - fairQueue, - /* useKeyGroupWorkQueue= */ true); - } - - private static final Work.KeyGroup TEST_KEY_GROUP = Work.KeyGroup.create(1, 2); - - private QueuedWork createQueuedWork(String computationId, long workBytes) { - return createQueuedWork(computationId, TEST_KEY_GROUP, workBytes); - } - - private QueuedWork createQueuedWork( - String computationId, Work.@Nullable KeyGroup keyGroup, long workBytes) { - WorkItem.Builder workItemBuilder = - WorkItem.newBuilder() - .setKey(ByteString.EMPTY) - .setShardingKey(1) - .setWorkToken(33) - .setCacheToken(1); - if (keyGroup != null) { - workItemBuilder.setKeyGroup( - org.apache.beam.runners.dataflow.worker.windmill.Windmill.Uint128Proto.newBuilder() - .setHigh(keyGroup.high()) - .setLow(keyGroup.low()) - .build()); - } - WorkItem workItem = workItemBuilder.build(); - ExecutableWork work = - ExecutableWork.create( - Work.create( - workItem, - workItem.getSerializedSize(), - Watermarks.builder().setInputDataWatermark(Instant.now()).build(), - Work.createProcessingContext( - computationId, - new FakeGetDataClient(), - ignored -> {}, - mock(HeartbeatSender.class)), - false, - Instant::now, - ImmutableList.of()), - (w, h) -> {}); - return new QueuedWork(work, executor.createBudgetHandle(work.work(), workBytes)); - } - - private static class NoOpRunnable implements Runnable { - final String id; - - NoOpRunnable(String id) { - this.id = id; - } - - @Override - public void run() {} - - @Override - public String toString() { - return "NoOpRunnable(" + id + ")"; - } - } - - @Test - public void testBasicOfferAndPoll() { - KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - assertTrue(queue.isEmpty()); - assertEquals(0, queue.size()); - - NoOpRunnable task1 = new NoOpRunnable("1"); - NoOpRunnable task2 = new NoOpRunnable("2"); - - assertTrue(queue.offer(task1)); - assertTrue(queue.offer(task2)); - assertEquals(2, queue.size()); - - assertEquals(task1, queue.poll()); - assertEquals(task2, queue.poll()); - assertNull(queue.poll()); - assertTrue(queue.isEmpty()); - } - - @Test - public void testRemove() { - KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - NoOpRunnable task1 = new NoOpRunnable("1"); - NoOpRunnable task2 = new NoOpRunnable("2"); - - queue.offer(task1); - queue.offer(task2); - - assertTrue(queue.remove(task1)); - assertEquals(1, queue.size()); - assertEquals(task2, queue.poll()); - assertFalse(queue.remove(task1)); // Already gone - } - - @Test - public void testDrainTo() { - KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - NoOpRunnable task1 = new NoOpRunnable("1"); - NoOpRunnable task2 = new NoOpRunnable("2"); - queue.offer(task1); - queue.offer(task2); - - List drained = new ArrayList<>(); - assertEquals(2, queue.drainTo(drained)); - assertEquals(2, drained.size()); - assertEquals(task1, drained.get(0)); - assertEquals(task2, drained.get(1)); - assertTrue(queue.isEmpty()); - } - - @Test - public void testIteratorSafeTraversalAndImmutable() { - KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - NoOpRunnable task1 = new NoOpRunnable("1"); - NoOpRunnable task2 = new NoOpRunnable("2"); - queue.offer(task1); - queue.offer(task2); - - Iterator it = queue.iterator(); - assertTrue(it.hasNext()); - assertEquals(task1, it.next()); - assertTrue(it.hasNext()); - assertEquals(task2, it.next()); - assertFalse(it.hasNext()); - - // Assert that mutating the iterator throws UnsupportedOperationException - it = queue.iterator(); - assertTrue(it.hasNext()); - it.next(); - try { - it.remove(); - fail("Iterator must be immutable"); - } catch (UnsupportedOperationException e) { - // Expected - } - } - - @Test - public void testPollWorkTargeted() { - KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - - QueuedWork workA1 = createQueuedWork("compA", 100); - QueuedWork workB1 = createQueuedWork("compB", 200); - QueuedWork workA2 = createQueuedWork("compA", 150); - - queue.offer(workA1); - queue.offer(workB1); - queue.offer(workA2); - - assertEquals(3, queue.size()); - assertFalse(queue.isEmpty()); - - // Targeted poll A - QueuedWork polledA1 = queue.pollWork("compA", TEST_KEY_GROUP); - assertNotNull(polledA1); - assertEquals("compA", polledA1.getWork().getComputationId()); - assertEquals(100, polledA1.getHandle().bytes()); - assertEquals(2, queue.size()); - assertFalse(queue.isEmpty()); - - // Poll next should be B1 (since A1 was stolen, B1 is now first global) - assertEquals(workB1, queue.poll()); - assertEquals(1, queue.size()); - assertFalse(queue.isEmpty()); - - // Last should be A2 - assertEquals(workA2, queue.poll()); - assertEquals(0, queue.size()); - assertTrue(queue.isEmpty()); - - assertEquals(null, queue.poll()); - } - - @Test - public void testConcurrentStress() throws InterruptedException, ExecutionException { - KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - int producerThreads = 4; - int consumerThreads = 4; - int tasksPerProducer = 1000; - int totalTasks = producerThreads * tasksPerProducer; - Runnable poisonPill = - new Runnable() { - @Override - public void run() {} - - @Override - public String toString() { - return "POISON_PILL"; - } - }; - - ExecutorService executorService = - Executors.newFixedThreadPool(producerThreads + consumerThreads); - CountDownLatch startLatch = new CountDownLatch(1); - CountDownLatch producersDoneLatch = new CountDownLatch(producerThreads); - CountDownLatch consumersDoneLatch = new CountDownLatch(consumerThreads); - CountDownLatch consumedLatch = new CountDownLatch(totalTasks); - List> futures = new ArrayList<>(); - - // Start consumers - for (int i = 0; i < consumerThreads; i++) { - int consumerId = i; - futures.add( - executorService.submit( - () -> { - try { - startLatch.await(); - int iteration = consumerId % 4; - while (true) { - int strategy = iteration; - iteration = (iteration + 1) % 4; - Runnable task = null; - if (strategy == 0) { - String compId = "comp-" + (consumedLatch.getCount() % 5); - task = queue.pollWork(compId, TEST_KEY_GROUP); - } else if (strategy == 1) { - task = queue.poll(); - } else if (strategy == 2) { - task = queue.poll(10, TimeUnit.MICROSECONDS); - } else if (strategy == 3) { - task = queue.take(); - } - - if (task == poisonPill) { - break; - } - if (task != null) { - consumedLatch.countDown(); - } - } - } catch (Exception e) { - throw new RuntimeException(e); - } finally { - consumersDoneLatch.countDown(); - } - })); - } - // Start producers - for (int i = 0; i < producerThreads; i++) { - futures.add( - executorService.submit( - () -> { - try { - startLatch.await(); - for (int j = 0; j < tasksPerProducer; j++) { - String compId = "comp-" + (j % 5); - queue.offer(createQueuedWork(compId, 10)); - } - } catch (Exception e) { - throw new RuntimeException(e); - } finally { - producersDoneLatch.countDown(); - } - })); - } - - // Release the start latch to start the test - startLatch.countDown(); - - // Wait for all tasks to be consumed - assertTrue(consumedLatch.await(30, TimeUnit.SECONDS)); - - // Send poison pills to stop all consumers - for (int i = 0; i < consumerThreads; i++) { - queue.offer(poisonPill); - } - - // Wait for consumers to finish - assertTrue(consumersDoneLatch.await(30, TimeUnit.SECONDS)); - // Wait for producers to finish - assertTrue(producersDoneLatch.await(30, TimeUnit.SECONDS)); - - // Check for exceptions in threads - for (Future future : futures) { - future.get(); - } - - executorService.shutdown(); - assertTrue(executorService.awaitTermination(30, TimeUnit.SECONDS)); - - assertEquals(0, queue.size()); - assertTrue(queue.isEmpty()); - } - - @Test - public void testTakeBlocksAndWakesUp() throws InterruptedException { - final KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - final NoOpRunnable task = new NoOpRunnable("take-task"); - final AtomicReference result = new AtomicReference<>(); - final CountDownLatch started = new CountDownLatch(1); - final CountDownLatch finished = new CountDownLatch(1); - - Thread t = - new Thread( - () -> { - started.countDown(); - try { - result.set(queue.take()); - } catch (InterruptedException e) { - // Ignore - } finally { - finished.countDown(); - } - }); - t.setDaemon(true); - t.start(); - - assertTrue(started.await(30, TimeUnit.SECONDS)); - waitForThreadState(t, State.WAITING); - - queue.offer(task); - - assertTrue(finished.await(30, TimeUnit.SECONDS)); - assertEquals(task, result.get()); - } - - @Test - public void testPollWithTimeout() throws InterruptedException { - final KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - final NoOpRunnable task = new NoOpRunnable("poll-task"); - final AtomicReference result = new AtomicReference<>(); - final CountDownLatch started = new CountDownLatch(1); - final CountDownLatch finished = new CountDownLatch(1); - - // 1. Verify timeout returns null - Thread t1 = - new Thread( - () -> { - started.countDown(); - try { - result.set(queue.poll(500, TimeUnit.MILLISECONDS)); - } catch (InterruptedException e) { - // Ignore - } finally { - finished.countDown(); - } - }); - t1.setDaemon(true); - t1.start(); - - assertTrue(started.await(30, TimeUnit.SECONDS)); - waitForThreadState(t1, State.TIMED_WAITING); - - assertTrue(finished.await(30, TimeUnit.SECONDS)); - assertNull(result.get()); - - // 2. Verify timed poll receives task offered concurrently - final CountDownLatch started2 = new CountDownLatch(1); - final CountDownLatch finished2 = new CountDownLatch(1); - final AtomicReference result2 = new AtomicReference<>(); - - Thread t2 = - new Thread( - () -> { - started2.countDown(); - try { - result2.set(queue.poll(2, TimeUnit.SECONDS)); - } catch (InterruptedException e) { - // Ignore - } finally { - finished2.countDown(); - } - }); - t2.setDaemon(true); - t2.start(); - - assertTrue(started2.await(30, TimeUnit.SECONDS)); - waitForThreadState(t2, State.TIMED_WAITING); - - queue.offer(task); - - assertTrue(finished2.await(30, TimeUnit.SECONDS)); - assertEquals(task, result2.get()); - } - - @Test - public void testPollWorkWithKeyGroup() { - KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - - Work.KeyGroup keyGroup1 = Work.KeyGroup.create(1, 1); - Work.KeyGroup keyGroup2 = Work.KeyGroup.create(1, 2); - Work.KeyGroup keyGroupNotExist = Work.KeyGroup.create(3, 4); - - QueuedWork workA1 = createQueuedWork("compA", keyGroup1, 100); - QueuedWork workA2 = createQueuedWork("compA", keyGroup2, 150); - - queue.offer(workA1); - queue.offer(workA2); - - assertEquals(2, queue.size()); - - QueuedWork polledNotExist = queue.pollWork("compA", keyGroupNotExist); - assertNull(polledNotExist); - assertEquals(2, queue.size()); - // Poll with keyGroup2 first - should return workA2 - QueuedWork polledA2 = queue.pollWork("compA", keyGroup2); - assertNotNull(polledA2); - assertEquals(workA2, polledA2); - assertEquals(1, queue.size()); - - // Poll with keyGroup2 again - should return null - assertNull(queue.pollWork("compA", keyGroup2)); - - // Poll with keyGroup1 - should return workA1 - QueuedWork polledA1 = queue.pollWork("compA", keyGroup1); - assertNotNull(polledA1); - assertEquals(workA1, polledA1); - assertTrue(queue.isEmpty()); - polledNotExist = queue.pollWork("compA", keyGroupNotExist); - assertNull(polledNotExist); - assertTrue(queue.isEmpty()); - } - - @Test - public void testOffer_multiKeyBatchingDisabled_notInsertedInKeyGroupQueue() { - KeyGroupWorkQueue queue = new KeyGroupWorkQueue(fairQueue); - QueuedWork workDisabled = createQueuedWork("compA", 100); - workDisabled.getWork().work().setMultiKeyBatchingDisabled(true); - QueuedWork workEnabled = createQueuedWork("compA", 200); - - queue.offer(workDisabled); - queue.offer(workEnabled); - assertEquals(2, queue.size()); - - QueuedWork polledWork = queue.pollWork("compA", TEST_KEY_GROUP); - assertNotNull(polledWork); - assertEquals(workEnabled, polledWork); - assertEquals(1, queue.size()); - - assertNull(queue.pollWork("compA", TEST_KEY_GROUP)); - assertEquals(workDisabled, queue.poll()); - assertTrue(queue.isEmpty()); - } - - private void waitForThreadState(Thread t, State state) throws InterruptedException { - long timeoutMs = 30000; - long start = System.currentTimeMillis(); - while (t.getState() != state) { - if (System.currentTimeMillis() - start > timeoutMs) { - fail("Thread did not reach " + state + " state within " + timeoutMs + "ms"); - } - Thread.sleep(1); - } - } -} diff --git a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/StreamingCommitFinalizerTest.java b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/StreamingCommitFinalizerTest.java index a11fb1f3ad33..688bad933e0f 100644 --- a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/StreamingCommitFinalizerTest.java +++ b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/StreamingCommitFinalizerTest.java @@ -63,7 +63,7 @@ public void setUp() { .setDaemon(true) .build(), /* useFairMonitor= */ false, - /* useKeyGroupWorkQueue= */ false); + /* multiKeyBundleEnabled= */ false); cleanupExecutor = Executors.newScheduledThreadPool( diff --git a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/failures/WorkFailureProcessorTest.java b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/failures/WorkFailureProcessorTest.java index 6f440c9cf30b..f11add07f6f1 100644 --- a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/failures/WorkFailureProcessorTest.java +++ b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/failures/WorkFailureProcessorTest.java @@ -68,7 +68,7 @@ private static WorkFailureProcessor createWorkFailureProcessor( .setDaemon(true) .build(), /* useFairMonitor= */ false, - /* useKeyGroupWorkQueue= */ false); + /* multiKeyBundleEnabled= */ false); return WorkFailureProcessor.forTesting(workExecutor, failureTracker, Optional::empty, clock, 0); } diff --git a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/refresh/ActiveWorkRefresherTest.java b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/refresh/ActiveWorkRefresherTest.java index 017a22ca6842..8ad5896dfabb 100644 --- a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/refresh/ActiveWorkRefresherTest.java +++ b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/windmill/work/refresh/ActiveWorkRefresherTest.java @@ -75,7 +75,7 @@ private static BoundedQueueExecutor workExecutor() { 10000000, new ThreadFactoryBuilder().setNameFormat("DataflowWorkUnits-%d").setDaemon(true).build(), /* useFairMonitor= */ false, - /* useKeyGroupWorkQueue= */ false); + /* multiKeyBundleEnabled= */ false); } private static ComputationState createComputationState(int computationIdSuffix) { From 68e762ecadbc5e5e351f6881baab4fa11a71ff0c Mon Sep 17 00:00:00 2001 From: claudevdm <33973061+claudevdm@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:54:40 -0400 Subject: [PATCH 047/108] Default to parquet in AddFiles for extension less files. (#40614) * Assume files are parquet by default if no extension * fix messge --- .../IO_Iceberg_Integration_Tests.json | 2 +- .../apache/beam/sdk/io/iceberg/AddFiles.java | 35 +++- .../beam/sdk/io/iceberg/ReadFooterSchema.java | 61 +++++-- .../beam/sdk/io/iceberg/AddFilesIT.java | 80 +++++++++ .../beam/sdk/io/iceberg/AddFilesTest.java | 157 ++++++++++++++++++ .../sdk/io/iceberg/ReadFooterSchemaTest.java | 52 ++++++ 6 files changed, 364 insertions(+), 23 deletions(-) diff --git a/.github/trigger_files/IO_Iceberg_Integration_Tests.json b/.github/trigger_files/IO_Iceberg_Integration_Tests.json index e1290fa50c32..b73af5e61a43 100644 --- a/.github/trigger_files/IO_Iceberg_Integration_Tests.json +++ b/.github/trigger_files/IO_Iceberg_Integration_Tests.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run.", - "modification": 11 + "modification": 1 } diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java index cb54183e0a07..c72479aa39b3 100644 --- a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java +++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java @@ -468,6 +468,8 @@ public ConvertToDataFile( static final String PREFIX_ERROR = "File path did not start with the specified prefix"; private static final String UNKNOWN_FORMAT_ERROR = "Could not determine the file's format"; + static final String NOT_PARQUET_ERROR = + UNKNOWN_FORMAT_ERROR + ": the file name has no extension and the file is not Parquet: "; static final String UNKNOWN_PARTITION_ERROR = "Could not determine the file's partition: "; static final String UNREADABLE_SCHEMA_ERROR = "Could not read the file's schema: "; static final String UNCOVERED_ERROR = "Table schema does not cover the file after refresh: "; @@ -626,10 +628,21 @@ private Callable createProcessTask( return () -> { FileFormat format; - try { - format = inferFormat(filePath); - } catch (UnknownFormatException e) { - return errorResult(filePath, UNKNOWN_FORMAT_ERROR, timestamp, window, paneInfo); + @Nullable ParquetMetadata parquetFooter = null; + if (hasNoExtension(filePath)) { + try { + parquetFooter = ParquetFooters.read(filePath); + } catch (Exception e) { + return errorResult( + filePath, NOT_PARQUET_ERROR + errorMessage(e), timestamp, window, paneInfo); + } + format = FileFormat.PARQUET; + } else { + try { + format = inferFormat(filePath); + } catch (UnknownFormatException e) { + return errorResult(filePath, UNKNOWN_FORMAT_ERROR, timestamp, window, paneInfo); + } } // ---- Infrastructure phase. Failures propagate so the runner retries the bundle; @@ -670,8 +683,7 @@ private Callable createProcessTask( } // ---- Per-file phase: every failure below is one error row, never a failed bundle. - @Nullable ParquetMetadata parquetFooter = null; - if (format.equals(FileFormat.PARQUET)) { + if (format.equals(FileFormat.PARQUET) && parquetFooter == null) { try { parquetFooter = ParquetFooters.read(filePath); } catch (Exception e) { @@ -1369,7 +1381,7 @@ static String errorMessage(Throwable e) { return e.getMessage() != null ? e.getMessage() : e.toString(); } - /** Tries to infer other file formats. Defaults to Parquet. */ + /** Attempt to infer file format from extension. */ public static FileFormat inferFormat(String path) { String lowerPath = path.toLowerCase(); @@ -1384,6 +1396,15 @@ public static FileFormat inferFormat(String path) { } } + /** + * A name with no extension, as Hive and other engines write by default, says nothing about the + * format, so the file is read to see whether it is Parquet. A name with a dot names some format. + */ + static boolean hasNoExtension(String path) { + String name = path.substring(path.lastIndexOf('/') + 1); + return !name.isEmpty() && name.indexOf('.') < 0; + } + static class UnknownFormatException extends IllegalArgumentException {} static class UnknownPartitionException extends IllegalStateException { diff --git a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java index 389f03490fe4..f111ed4b8554 100644 --- a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java +++ b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchema.java @@ -161,29 +161,60 @@ private static void count(ReadResult result) { private Callable createReadTask( String filePath, Instant timestamp, BoundedWindow window, PaneInfo paneInfo) { return () -> { - FileFormat format; - try { - format = AddFiles.inferFormat(filePath); - } catch (AddFiles.UnknownFormatException e) { - return new ReadResult(dryRunMarker(UNREADABLE_KEY), false, timestamp, window, paneInfo); - } - if (!format.equals(FileFormat.PARQUET)) { - return new ReadResult( - dryRunMarker(UNCHECKED_FORMAT_KEY), false, timestamp, window, paneInfo); + ParquetMetadata footer; + if (AddFiles.hasNoExtension(filePath)) { + try { + footer = ParquetFooters.read(filePath); + } catch (Exception e) { + return unknownFormat(timestamp, window, paneInfo); + } + } else { + FileFormat format; + try { + format = AddFiles.inferFormat(filePath); + } catch (AddFiles.UnknownFormatException e) { + return unknownFormat(timestamp, window, paneInfo); + } + if (!format.equals(FileFormat.PARQUET)) { + return new ReadResult( + dryRunMarker(UNCHECKED_FORMAT_KEY), false, timestamp, window, paneInfo); + } + try { + footer = ParquetFooters.read(filePath); + } catch (Exception e) { + return unreadableFooter(filePath, e, timestamp, window, paneInfo); + } } try { - ParquetMetadata footer = ParquetFooters.read(filePath); return new ReadResult(FileSchemas.schemaGroup(footer), false, timestamp, window, paneInfo); } catch (Exception e) { - LOG.warn( - "Could not read the footer of {}; the file will not contribute to schema inference: {}", - filePath, - AddFiles.errorMessage(e)); - return new ReadResult(dryRunMarker(UNREADABLE_KEY), true, timestamp, window, paneInfo); + return unconvertibleSchema(filePath, e, timestamp, window, paneInfo); } }; } + private ReadResult unknownFormat(Instant timestamp, BoundedWindow window, PaneInfo paneInfo) { + return new ReadResult(dryRunMarker(UNREADABLE_KEY), false, timestamp, window, paneInfo); + } + + private ReadResult unreadableFooter( + String filePath, Exception e, Instant timestamp, BoundedWindow window, PaneInfo paneInfo) { + LOG.warn( + "Could not read the footer of {}; the file will not contribute to schema inference: {}", + filePath, + AddFiles.errorMessage(e)); + return new ReadResult(dryRunMarker(UNREADABLE_KEY), true, timestamp, window, paneInfo); + } + + private ReadResult unconvertibleSchema( + String filePath, Exception e, Instant timestamp, BoundedWindow window, PaneInfo paneInfo) { + LOG.warn( + "Could not convert the schema of {}; the file will not contribute to schema inference: {}", + filePath, + AddFiles.errorMessage(e)); + return new ReadResult(dryRunMarker(UNREADABLE_KEY), false, timestamp, window, paneInfo); + } + /** A dry run counts files that contribute no schema; a real run handles them at registration. */ private CollectDistinctSchemas.@Nullable SchemaGroup dryRunMarker(String key) { if (!config.getDryRun()) { diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java index a1bb79431753..276ea4b0ee90 100644 --- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java +++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesIT.java @@ -33,6 +33,8 @@ import com.google.api.client.http.HttpResponseException; import com.google.api.services.storage.model.StorageObject; import com.google.cloud.storage.Blob; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.BlobInfo; import com.google.cloud.storage.Notification; import com.google.cloud.storage.NotificationInfo; import com.google.cloud.storage.Storage; @@ -698,6 +700,84 @@ public void testBatchParquetImportWithSchemaEvolution() throws IOException { s.run().waitUntilFinish(); } + /** + * Hive names Parquet files without an extension (000000_0), and Spark and Hadoop leave a _SUCCESS + * marker beside them. The files register and read back, BigQuery included; the marker is one + * error row and must not seed the table. + */ + @Test + public void testBatchImportOfParquetFilesWithoutExtension() throws IOException { + String parquetDir = format("gs://%s/%s/", DATA_BUCKET, dirName); + String tempDir = format("gs://%s/%s-tmp/", DATA_BUCKET, dirName); + Pipeline q = Pipeline.create(); + org.apache.avro.Schema avroSchema = AvroUtils.toAvroSchema(ROW_SCHEMA); + q.apply(Create.of(TEST_ROWS)) + .setRowSchema(ROW_SCHEMA) + .apply( + MapElements.into(TypeDescriptor.of(GenericRecord.class)) + .via(AvroUtils.getRowToGenericRecordFunction(avroSchema))) + .setCoder(AvroCoder.of(avroSchema)) + .apply( + FileIO.writeDynamic() + .by(record -> String.valueOf(record.get("id"))) + .via(ParquetIO.sink(avroSchema)) + .withNaming( + id -> + (window, pane, numShards, shardIndex, compression) -> + format("%06d_%d", Long.parseLong(id), shardIndex)) + .withTempDirectory(tempDir) + .to(parquetDir) + .withDestinationCoder(StringUtf8Coder.of())); + q.run().waitUntilFinish(); + String marker = parquetDir + "_SUCCESS"; + storage.create( + BlobInfo.newBuilder(BlobId.of(DATA_BUCKET, dirName + "/_SUCCESS")).build(), new byte[0]); + + GcsUtil gcsUtil = TestPipeline.testingPipelineOptions().as(GcsOptions.class).getGcsUtil(); + List dataFiles = new ArrayList<>(); + for (StorageObject object : gcsUtil.listObjects(DATA_BUCKET, dirName + "/", null).getItems()) { + String path = format("gs://%s/%s", object.getBucket(), object.getName()); + if (!path.equals(marker)) { + dataFiles.add(path); + } + } + LOG.info("Extensionless files: {}", dataFiles); + assertEquals(TEST_ROWS.size(), dataFiles.size()); + for (String path : dataFiles) { + assertFalse(path, path.substring(path.lastIndexOf('/') + 1).contains(".")); + } + List inputs = new ArrayList<>(dataFiles); + inputs.add(marker); + + Pipeline p = Pipeline.create(); + PCollectionRowTuple tuple = + p.apply(Create.of(inputs)) + .apply( + new AddFiles( + IcebergCatalogConfig.builder().setCatalogProperties(LAKEHOUSE_PROPS).build(), + namespace + "." + destTableName, + null, + null, + null, + TABLE_PROPS, + null, + null)); + PAssert.that(tuple.get("errors")) + .satisfies( + rows -> { + Row row = Iterables.getOnlyElement(rows); + String error = String.valueOf(row.getString("error")); + LOG.info("Marker error row: {}", row); + assertEquals(marker, row.getString("file")); + assertTrue(error, error.startsWith(AddFiles.ConvertToDataFile.NOT_PARQUET_ERROR)); + return null; + }); + p.run().waitUntilFinish(); + + assertTrue(checkTableHasRegisteredParquetFiles(dataFiles)); + checkRecordsInDestinationTable(/* alsoCheckWithBigQueryIO= */ true); + } + private static void writeParquet(List rows, Schema schema, String dir, String tempDir) { Pipeline q = Pipeline.create(); org.apache.avro.Schema avroSchema = AvroUtils.toAvroSchema(schema); diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java index 0d5a41e0cc4b..578d18c539d2 100644 --- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java +++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java @@ -18,6 +18,7 @@ package org.apache.beam.sdk.io.iceberg; import static org.apache.beam.sdk.io.iceberg.AddFiles.ConvertToDataFile.FIELD_ID_ERROR; +import static org.apache.beam.sdk.io.iceberg.AddFiles.ConvertToDataFile.NOT_PARQUET_ERROR; import static org.apache.beam.sdk.io.iceberg.AddFiles.ConvertToDataFile.PREFIX_ERROR; import static org.apache.beam.sdk.io.iceberg.AddFiles.ConvertToDataFile.UNKNOWN_PARTITION_ERROR; import static org.apache.beam.sdk.io.iceberg.AddFiles.ConvertToDataFile.getPartitionFromMetrics; @@ -450,6 +451,105 @@ public void testUnknownFormatErrors() throws Exception { pipeline.run().waitUntilFinish(); } + /** Hive names its output files like {@code 000000_0}, with no extension. */ + @Test + public void testExtensionlessParquetFileRegisters() throws Exception { + catalog.createTable(tableId, icebergSchema); + String file = root + "000000_0"; + DataWriter writer = createWriter(file); + writer.write(record(1, "Mark", 20)); + writer.close(); + + PCollectionRowTuple output = + pipeline + .apply("Create Input", Create.of(file)) + .apply( + new AddFiles( + catalogConfig, tableId.toString(), null, null, null, null, null, null)); + PAssert.that(output.get("errors")).empty(); + + pipeline.run().waitUntilFinish(); + + List registered = new ArrayList<>(); + for (FileScanTask task : catalog.loadTable(tableId).newScan().planFiles()) { + registered.add(task.file()); + } + assertEquals(1, registered.size()); + assertEquals(file, registered.get(0).location()); + assertEquals(FileFormat.PARQUET, registered.get(0).format()); + assertEquals(1L, registered.get(0).recordCount()); + } + + /** + * A _SUCCESS marker has no extension either. It must become an error row rather than seed the + * missing table, where a failed footer read would fail the job. + */ + @Test + public void testExtensionlessFileThatIsNotParquetIsAnErrorRow() throws Exception { + File marker = temp.newFile("_SUCCESS"); + + PCollectionRowTuple output = + pipeline + .apply("Create Input", Create.of(marker.getAbsolutePath())) + .apply( + new AddFiles( + catalogConfig, tableId.toString(), null, null, null, null, null, null)); + assertNotParquetErrorFor(output, marker.getAbsolutePath()); + + pipeline.run().waitUntilFinish(); + + assertFalse("the marker must not create the table", catalog.tableExists(tableId)); + } + + @Test + public void testMissingTableIsCreatedFromAnExtensionlessFile() throws Exception { + String file = writeWithoutFieldIds("000000_0", icebergSchema, record(1, "a", 1)); + File marker = temp.newFile("_SUCCESS"); + + PCollectionRowTuple output = + pipeline + .apply("Create Input", Create.of(file, marker.getAbsolutePath())) + .apply(addFiles(ADDITIONS)); + assertNotParquetErrorFor(output, marker.getAbsolutePath()); + + PipelineResult result = pipeline.run(); + result.waitUntilFinish(); + + assertEquals(Arrays.asList("id=1 name=a age=1"), readRows("id", "name", "age")); + assertEquals( + "the marker is an unknown format, not a footer that failed to read", + 0, + counted(result, ReadFooterSchema.class, ReadFooterSchema.FOOTER_READ_ERRORS_COUNTER)); + } + + /** Only a name with no extension is left to its contents; inferFormat stays strict. */ + @Test + public void testOnlyAnExtensionlessNameIsReadForItsFormat() { + assertTrue(AddFiles.hasNoExtension("gs://b/wh/sales.db/orders/000000_0")); + assertTrue(AddFiles.hasNoExtension("gs://b/t/part-00000-1a2b3c-c000")); + assertFalse(AddFiles.hasNoExtension("gs://b/t/data.txt")); + assertFalse(AddFiles.hasNoExtension("gs://b/t/.hidden")); + assertFalse(AddFiles.hasNoExtension("gs://b/t/")); + assertEquals(FileFormat.PARQUET, AddFiles.inferFormat("gs://b/t/data.PARQUET")); + assertEquals(FileFormat.ORC, AddFiles.inferFormat("gs://b/t/data.orc")); + assertThrows( + AddFiles.UnknownFormatException.class, () -> AddFiles.inferFormat("gs://b/t/000000_0")); + assertThrows( + AddFiles.UnknownFormatException.class, () -> AddFiles.inferFormat("gs://b/t/data.txt")); + } + + private static void assertNotParquetErrorFor(PCollectionRowTuple output, String file) { + PAssert.that(output.get("errors")) + .satisfies( + rows -> { + Row row = Iterables.getOnlyElement(rows); + String error = String.valueOf(row.getString("error")); + assertEquals(file, row.getString("file")); + assertTrue(error, error.startsWith(NOT_PARQUET_ERROR)); + return null; + }); + } + @Test public void testPartitionPrefixErrors() throws Exception { // Drop unpartitioned table and create a partitioned one @@ -1814,6 +1914,63 @@ public void testExtraColumnsRegisterWhenEvolutionDisabled() throws Exception { pipeline.run().waitUntilFinish(); } + /** + * Through the whole transform, so the pre-pass sees the file too: its footer reads, so it is not + * a footer read error, and it costs one error row, not the job. + */ + @Test + public void testUnconvertibleSchemaIsOneErrorRowWithEvolution() throws Exception { + catalog.createTable(tableId, icebergSchema); + File legacy = new File(temp.getRoot(), "legacy.parquet"); + writeLegacyRepeated(legacy); + String good = writeWithoutFieldIds("good.parquet", icebergSchema, record(1, "a", 1)); + + PCollectionRowTuple output = + pipeline + .apply("Create Input", Create.of(legacy.getAbsolutePath(), good)) + .apply(addFiles(ADDITIONS)); + PAssert.that(output.get("errors")) + .satisfies( + rows -> { + Row row = Iterables.getOnlyElement(rows); + assertEquals(legacy.getAbsolutePath(), row.getString("file")); + assertTrue( + String.valueOf(row.getString("error")), + String.valueOf(row.getString("error")) + .startsWith(AddFiles.ConvertToDataFile.UNREADABLE_SCHEMA_ERROR)); + return null; + }); + + PipelineResult result = pipeline.run(); + result.waitUntilFinish(); + + assertEquals(1, Iterables.size(catalog.loadTable(tableId).newScan().planFiles())); + assertEquals( + 0, counted(result, ReadFooterSchema.class, ReadFooterSchema.FOOTER_READ_ERRORS_COUNTER)); + } + + /** Readable Parquet whose unannotated repeated field Iceberg's converter rejects. */ + private static void writeLegacyRepeated(File file) throws IOException { + org.apache.parquet.schema.MessageType legacy = + org.apache.parquet.schema.Types.buildMessage() + .required(org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32) + .named("id") + .repeated(org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32) + .named("vals") + .named("root"); + try (org.apache.parquet.hadoop.ParquetWriter writer = + org.apache.parquet.hadoop.example.ExampleParquetWriter.builder( + new org.apache.hadoop.fs.Path(file.getAbsolutePath())) + .withType(legacy) + .build()) { + org.apache.parquet.example.data.Group group = + new org.apache.parquet.example.data.simple.SimpleGroupFactory(legacy).newGroup(); + group.add("id", 1); + group.add("vals", 2); + writer.write(group); + } + } + @Test public void testUnreadableSchemaRoutesToErrorsWithConverterMessage() throws Exception { catalog.createTable(tableId, icebergSchema); diff --git a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java index 4f17bc738400..a6c178a520f2 100644 --- a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java +++ b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ReadFooterSchemaTest.java @@ -164,6 +164,25 @@ public void testNonParquetEmitsNothing() throws IOException { pipeline.run(); } + /** Hive names its output files like this, with no extension. */ + @Test + public void testExtensionlessParquetEmitsSchema() throws IOException { + String file = writeParquet("000000_0", FLAT_SCHEMA, record(FLAT_SCHEMA, "id", 1)); + + assertSchemas(run(file), FLAT_SCHEMA); + + pipeline.run(); + } + + @Test + public void testExtensionlessNonParquetEmitsNothing() throws IOException { + String marker = writeBytes("_SUCCESS", new byte[0]); + + PAssert.that(run(marker)).empty(); + + pipeline.run(); + } + /** * The schema is emitted exactly as declared; which columns proved null-free travels beside it. */ @@ -250,6 +269,39 @@ private static long counter(PipelineResult result, String name) { return total; } + /** The footer reads, but its schema does not convert: the file contributes no schema. */ + @Test + public void testUnconvertibleSchemaEmitsNothing() throws IOException { + File file = new File(temporaryFolder.getRoot(), "legacy.parquet"); + writeLegacyRepeated(file); + + PAssert.that(run(file.getAbsolutePath())).empty(); + + pipeline.run(); + } + + /** Readable Parquet whose unannotated repeated field Iceberg's converter rejects. */ + private static void writeLegacyRepeated(File file) throws IOException { + org.apache.parquet.schema.MessageType legacy = + org.apache.parquet.schema.Types.buildMessage() + .required(org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32) + .named("id") + .repeated(org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.INT32) + .named("vals") + .named("root"); + try (org.apache.parquet.hadoop.ParquetWriter writer = + org.apache.parquet.hadoop.example.ExampleParquetWriter.builder( + new org.apache.hadoop.fs.Path(file.getAbsolutePath())) + .withType(legacy) + .build()) { + org.apache.parquet.example.data.Group group = + new org.apache.parquet.example.data.simple.SimpleGroupFactory(legacy).newGroup(); + group.add("id", 1); + group.add("vals", 2); + writer.write(group); + } + } + private PCollection run(String... paths) { return pipeline .apply(Create.of(Arrays.asList(paths))) From 0c370589685d55c637e57fe854d9b0135358eebd Mon Sep 17 00:00:00 2001 From: Abdelrahman Ibrahim Date: Tue, 20 Oct 2026 02:36:14 +0300 Subject: [PATCH 048/108] Remove Beam SQL Data Catalog provider (#40663) --- CHANGES.md | 1 + build.gradle.kts | 2 - .../beam/gradle/BeamModulePlugin.groovy | 2 - .../assets/symbols/java.g.yaml | 19 -- .../beam/checkstyle/suppressions.xml | 1 - .../extensions/sql/datacatalog/build.gradle | 107 -------- .../example/BeamSqlDataCatalogExample.java | 107 -------- .../extensions/sql/example/package-info.java | 20 -- .../datacatalog/BigQueryTableFactory.java | 72 ------ .../datacatalog/ChainedTableFactory.java | 50 ---- .../DataCatalogPipelineOptions.java | 43 ---- .../DataCatalogPipelineOptionsRegistrar.java | 31 --- .../datacatalog/DataCatalogTableProvider.java | 235 ------------------ .../provider/datacatalog/GcsTableFactory.java | 61 ----- .../datacatalog/PubsubTableFactory.java | 62 ----- .../provider/datacatalog/SchemaUtils.java | 190 -------------- .../provider/datacatalog/TableFactory.java | 37 --- .../provider/datacatalog/ZetaSqlIdUtils.java | 83 ------- .../provider/datacatalog/package-info.java | 20 -- .../datacatalog/DataCatalogBigQueryIT.java | 115 --------- .../datacatalog/DataCatalogGCSIT.java | 83 ------- .../provider/datacatalog/SchemaUtilsTest.java | 157 ------------ .../datacatalog/ZetaSqlIdUtilsTest.java | 64 ----- settings.gradle.kts | 1 - 24 files changed, 1 insertion(+), 1562 deletions(-) delete mode 100644 sdks/java/extensions/sql/datacatalog/build.gradle delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/example/BeamSqlDataCatalogExample.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/example/package-info.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/BigQueryTableFactory.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ChainedTableFactory.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogPipelineOptions.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogPipelineOptionsRegistrar.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogTableProvider.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/GcsTableFactory.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/PubsubTableFactory.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/SchemaUtils.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/TableFactory.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ZetaSqlIdUtils.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/package-info.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogBigQueryIT.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogGCSIT.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/SchemaUtilsTest.java delete mode 100644 sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ZetaSqlIdUtilsTest.java diff --git a/CHANGES.md b/CHANGES.md index 00fb9aa825b5..d06940d12cf4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -85,6 +85,7 @@ ## Breaking Changes +* (Java) Removed Beam SQL Google Cloud Data Catalog table provider (`beam-sdks-java-extensions-sql-datacatalog`) because GCP Data Catalog has been decommissioned ([#40663](https://github.com/apache/beam/issues/40663)). * (Go) The row coder now encodes `int16` and `uint16` struct fields as 2 byte big endian INT16 values, matching the Java and Python SDKs. This is an update incompatible change for streaming pipelines that use rows with `int16` or `uint16` fields ([#40151](https://github.com/apache/beam/issues/40151)). * (Java) JmsIO migrated to `jakarta.jms` (JMS 3.1) and ActiveMQ 6.2.5. User code implementing `JmsIO.MessageMapper`, `valueMapper`, `topicNameMapper`, or providing a `ConnectionFactory` must update imports from `javax.jms.*` to diff --git a/build.gradle.kts b/build.gradle.kts index 8b68b2737961..1cefe1ac5b93 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -393,7 +393,6 @@ tasks.register("flinkPreCommit") { tasks.register("sqlPreCommit") { dependsOn(":sdks:java:extensions:sql:preCommit") dependsOn(":sdks:java:extensions:sql:buildDependents") - dependsOn(":sdks:java:extensions:sql:datacatalog:build") dependsOn(":sdks:java:extensions:sql:expansion-service:build") dependsOn(":sdks:java:extensions:sql:hcatalog:build") dependsOn(":sdks:java:extensions:sql:iceberg:build") @@ -442,7 +441,6 @@ tasks.register("javaAvroVersionsTest") { tasks.register("sqlPostCommit") { dependsOn(":sdks:java:extensions:sql:postCommit") dependsOn(":sdks:java:extensions:sql:jdbc:postCommit") - dependsOn(":sdks:java:extensions:sql:datacatalog:postCommit") dependsOn(":sdks:java:extensions:sql:iceberg:integrationTest") dependsOn(":sdks:java:extensions:sql:hadoopVersionsTest") } diff --git a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy index cf5979dd176b..17fc16e9967f 100644 --- a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy +++ b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy @@ -770,7 +770,6 @@ class BeamModulePlugin implements Plugin { google_cloud_core : "com.google.cloud:google-cloud-core", // google_cloud_platform_libraries_bom sets version google_cloud_core_grpc : "com.google.cloud:google-cloud-core-grpc", // google_cloud_platform_libraries_bom sets version google_cloud_core_http : "com.google.cloud:google-cloud-core-http", // google_cloud_platform_libraries_bom sets version - google_cloud_datacatalog_v1beta1 : "com.google.cloud:google-cloud-datacatalog", // google_cloud_platform_libraries_bom sets version google_cloud_dataflow_java_proto_library_all: "com.google.cloud.dataflow:google-cloud-dataflow-java-proto-library-all:0.5.160304", google_cloud_datastore_v1_proto_client : "com.google.cloud.datastore:datastore-v1-proto-client:3.4.0", // [bomupgrader] sets version google_cloud_firestore : "com.google.cloud:google-cloud-firestore", // google_cloud_platform_libraries_bom sets version @@ -893,7 +892,6 @@ class BeamModulePlugin implements Plugin { proto_google_cloud_bigquery_storage_v1 : "com.google.api.grpc:proto-google-cloud-bigquerystorage-v1", // google_cloud_platform_libraries_bom sets version proto_google_cloud_bigtable_admin_v2 : "com.google.api.grpc:proto-google-cloud-bigtable-admin-v2", // google_cloud_platform_libraries_bom sets version proto_google_cloud_bigtable_v2 : "com.google.api.grpc:proto-google-cloud-bigtable-v2", // google_cloud_platform_libraries_bom sets version - proto_google_cloud_datacatalog_v1beta1 : "com.google.api.grpc:proto-google-cloud-datacatalog-v1beta1", // google_cloud_platform_libraries_bom sets version proto_google_cloud_datastore_v1 : "com.google.api.grpc:proto-google-cloud-datastore-v1", // google_cloud_platform_libraries_bom sets version proto_google_cloud_firestore_v1 : "com.google.api.grpc:proto-google-cloud-firestore-v1", // google_cloud_platform_libraries_bom sets version proto_google_cloud_kms_v1 : "com.google.api.grpc:proto-google-cloud-kms-v1", // google_cloud_platform_libraries_bom sets version diff --git a/playground/frontend/playground_components/assets/symbols/java.g.yaml b/playground/frontend/playground_components/assets/symbols/java.g.yaml index e0f2b1d15d14..2619d8702f10 100644 --- a/playground/frontend/playground_components/assets/symbols/java.g.yaml +++ b/playground/frontend/playground_components/assets/symbols/java.g.yaml @@ -1292,9 +1292,6 @@ BeamSqlCli: - explainQuery - getMetaStore - metaStore -BeamSqlDataCatalogExample: - methods: - - main BeamSqlEnv: methods: - addSchema @@ -3424,22 +3421,6 @@ DaoFactory: - getChangeStreamDao - getPartitionMetadataAdminDao - getPartitionMetadataDao -DataCatalogPipelineOptionsRegistrar: - methods: - - getPipelineOptions -DataCatalogTableProvider: - methods: - - buildBeamSqlTable - - close - - create - - createDataCatalogClient - - createTable - - dropTable - - getTable - - getTableByFullName - - getTableType - - getTables - - setSchemaIfNotPresent DataChangeRecord: methods: - equals diff --git a/sdks/java/build-tools/src/main/resources/beam/checkstyle/suppressions.xml b/sdks/java/build-tools/src/main/resources/beam/checkstyle/suppressions.xml index 833fd30cf218..d3ec39dc6ac4 100644 --- a/sdks/java/build-tools/src/main/resources/beam/checkstyle/suppressions.xml +++ b/sdks/java/build-tools/src/main/resources/beam/checkstyle/suppressions.xml @@ -70,7 +70,6 @@ - diff --git a/sdks/java/extensions/sql/datacatalog/build.gradle b/sdks/java/extensions/sql/datacatalog/build.gradle deleted file mode 100644 index db4a92001501..000000000000 --- a/sdks/java/extensions/sql/datacatalog/build.gradle +++ /dev/null @@ -1,107 +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. - */ - -import groovy.json.JsonOutput - -plugins { id 'org.apache.beam.module' } - -applyJavaNature( - automaticModuleName: 'org.apache.beam.sdk.extensions.sql.datacatalog') - -dependencies { - implementation enforcedPlatform(library.java.google_cloud_platform_libraries_bom) - implementation(library.java.google_cloud_datacatalog_v1beta1) { - exclude group: 'io.grpc', module: 'grpc-core' // Use Beam's version - } - implementation library.java.gax - implementation library.java.google_auth_library_credentials - implementation library.java.proto_google_cloud_datacatalog_v1beta1 - implementation library.java.protobuf_java - implementation library.java.slf4j_api - implementation library.java.vendored_guava_32_1_2_jre - implementation library.java.jackson_databind - implementation project(path: ":sdks:java:core", configuration: "shadow") - implementation "org.threeten:threetenbp:1.4.5" - provided project(":sdks:java:extensions:sql") - - // Dependencies for the example - implementation project(":sdks:java:extensions:google-cloud-platform-core") - provided project(":sdks:java:io:google-cloud-platform") - permitUnusedDeclared project(":sdks:java:io:google-cloud-platform") - implementation library.java.slf4j_api - - testImplementation project(":sdks:java:extensions:sql") - testImplementation project(":runners:direct-java") - testImplementation project(":sdks:java:io:google-cloud-platform") - testImplementation library.java.google_api_services_bigquery - testImplementation library.java.junit - testRuntimeOnly library.java.slf4j_simple -} - -task runDataCatalogExample(type: JavaExec) { - description = "Run SQL example of how to use Data Catalog table provider" - mainClass = "org.apache.beam.sdk.extensions.sql.example.BeamSqlDataCatalogExample" - classpath = sourceSets.main.runtimeClasspath - - def runner = project.findProperty('runner') ?: 'DirectRunner' - def queryString = project.findProperty('queryString') ?: '' - def outputFilePrefix = project.findProperty('outputFilePrefix') ?: '' - def gcpProject = project.findProperty('gcpProject') ?: 'apache-beam-testing' - def gcpRegion = project.findProperty('gcpRegion') ?: 'us-central1' - def gcsTempRoot = project.findProperty('gcsTempRoot') ?: 'gs://temp-storage-for-end-to-end-tests/' - - args = [ - "--runner=${runner}", - "--queryString=${queryString}", - "--outputFilePrefix=${outputFilePrefix}", - "--project=${gcpProject}", - "--region=${gcpRegion}", - "--tempLocation=${gcsTempRoot}", - ] -} - -task integrationTest(type: Test) { - group = "Verification" - def gcpProject = project.findProperty('gcpProject') ?: 'apache-beam-testing' - def gcsTempRoot = project.findProperty('gcsTempRoot') ?: 'gs://temp-storage-for-end-to-end-tests/' - - // Disable Gradle cache (it should not be used because the IT's won't run). - outputs.upToDateWhen { false } - - def pipelineOptions = [ - "--project=${gcpProject}", - "--tempLocation=${gcsTempRoot}", - "--blockOnRun=false"] - - systemProperty "beamTestPipelineOptions", JsonOutput.toJson(pipelineOptions) - - include '**/*IT.class' - maxParallelForks 4 - classpath = project(":sdks:java:extensions:sql:datacatalog") - .sourceSets - .test - .runtimeClasspath - testClassesDirs = files(project(":sdks:java:extensions:sql:datacatalog").sourceSets.test.output.classesDirs) - useJUnit {} -} - -task postCommit { - group = "Verification" - description = "Various integration tests" - dependsOn integrationTest -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/example/BeamSqlDataCatalogExample.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/example/BeamSqlDataCatalogExample.java deleted file mode 100644 index 0b2f1b04cbba..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/example/BeamSqlDataCatalogExample.java +++ /dev/null @@ -1,107 +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 org.apache.beam.sdk.extensions.sql.example; - -import java.util.Arrays; -import java.util.stream.Collectors; -import org.apache.beam.sdk.Pipeline; -import org.apache.beam.sdk.extensions.sql.SqlTransform; -import org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog.DataCatalogPipelineOptions; -import org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog.DataCatalogTableProvider; -import org.apache.beam.sdk.io.TextIO; -import org.apache.beam.sdk.options.Description; -import org.apache.beam.sdk.options.PipelineOptions; -import org.apache.beam.sdk.options.PipelineOptionsFactory; -import org.apache.beam.sdk.options.Validation; -import org.apache.beam.sdk.transforms.MapElements; -import org.apache.beam.sdk.values.Row; -import org.apache.beam.sdk.values.TypeDescriptor; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Example pipeline that uses Google Cloud Data Catalog to retrieve the table metadata. */ -public class BeamSqlDataCatalogExample { - - private static final Logger LOG = LoggerFactory.getLogger(BeamSqlDataCatalogExample.class); - - /** Pipeline options to specify the query and the output for the example. */ - public interface DCExamplePipelineOptions extends PipelineOptions { - - /** SQL Query. */ - @Description("Required. SQL Query containing the pipeline logic.") - @Validation.Required - String getQueryString(); - - void setQueryString(String queryString); - - /** Output file prefix. */ - @Description("Required. Output file prefix.") - @Validation.Required - String getOutputFilePrefix(); - - void setOutputFilePrefix(String outputPathPrefix); - } - - public static void main(String[] args) throws Exception { - LOG.info("Args: {}", Arrays.asList(args)); - DCExamplePipelineOptions options = - PipelineOptionsFactory.fromArgs(args).as(DCExamplePipelineOptions.class); - LOG.info("Query: {}\nOutput: {}", options.getQueryString(), options.getOutputFilePrefix()); - - Pipeline pipeline = Pipeline.create(options); - - validateArgs(options); - - try (DataCatalogTableProvider tableProvider = - DataCatalogTableProvider.create(options.as(DataCatalogPipelineOptions.class))) { - pipeline - .apply( - "SQL Query", - SqlTransform.query(options.getQueryString()) - .withDefaultTableProvider("datacatalog", tableProvider)) - .apply("Convert to Strings", rowsToStrings()) - .apply("Write output", TextIO.write().to(options.getOutputFilePrefix())); - - pipeline.run().waitUntilFinish(); - } - } - - private static MapElements rowsToStrings() { - return MapElements.into(TypeDescriptor.of(String.class)) - .via( - row -> row.getValues().stream().map(String::valueOf).collect(Collectors.joining(", "))); - } - - private static void validateArgs(DCExamplePipelineOptions options) { - if (Strings.isNullOrEmpty(options.getQueryString()) - || Strings.isNullOrEmpty(options.getOutputFilePrefix())) { - String usage = - "ERROR: SQL query or output file is not specified." - + "To run this example:\n" - + "./gradlew " - + ":sdks:java:extensions:sql:datacatalog:runDataCatalogExample " - + "-PgcpProject= " - + "-PgcsTempRoot= " - + "-PqueryString= " - + "-PoutputFilePrefix= " - + "-PtempLocation=\n\n"; - throw new IllegalArgumentException(usage); - } - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/example/package-info.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/example/package-info.java deleted file mode 100644 index 7d6ff31c68a5..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/example/package-info.java +++ /dev/null @@ -1,20 +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. - */ - -/** Example how to use Data Catalog table provider. */ -package org.apache.beam.sdk.extensions.sql.example; diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/BigQueryTableFactory.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/BigQueryTableFactory.java deleted file mode 100644 index 172dad752d93..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/BigQueryTableFactory.java +++ /dev/null @@ -1,72 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import com.google.cloud.datacatalog.v1beta1.Entry; -import java.net.URI; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import org.apache.beam.sdk.extensions.sql.TableUtils; -import org.apache.beam.sdk.extensions.sql.meta.Table; - -/** {@link TableFactory} that understands Data Catalog BigQuery entries. */ -class BigQueryTableFactory implements TableFactory { - private static final String BIGQUERY_API = "bigquery.googleapis.com"; - - private static final Pattern BQ_PATH_PATTERN = - Pattern.compile( - "/projects/(?[^/]+)/datasets/(?[^/]+)/tables/(?[^/]+)"); - - private final boolean truncateTimestamps; - - public BigQueryTableFactory(boolean truncateTimestamps) { - this.truncateTimestamps = truncateTimestamps; - } - - @Override - public Optional tableBuilder(Entry entry) { - if (!URI.create(entry.getLinkedResource()).getAuthority().equalsIgnoreCase(BIGQUERY_API)) { - return Optional.empty(); - } - - return Optional.of( - Table.builder() - .location(getLocation(entry)) - .properties(TableUtils.emptyProperties().put("truncateTimestamps", truncateTimestamps)) - .type("bigquery") - .comment("")); - } - - private static String getLocation(Entry entry) { - URI entryName = URI.create(entry.getLinkedResource()); - String bqPath = entryName.getPath(); - - Matcher bqPathMatcher = BQ_PATH_PATTERN.matcher(bqPath); - if (!bqPathMatcher.matches()) { - throw new IllegalArgumentException( - "Unsupported format for BigQuery table path: '" + entry.getLinkedResource() + "'"); - } - - String project = bqPathMatcher.group("PROJECT"); - String dataset = bqPathMatcher.group("DATASET"); - String table = bqPathMatcher.group("TABLE"); - - return String.format("%s:%s.%s", project, dataset, table); - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ChainedTableFactory.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ChainedTableFactory.java deleted file mode 100644 index a68dafe63f49..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ChainedTableFactory.java +++ /dev/null @@ -1,50 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import com.google.cloud.datacatalog.v1beta1.Entry; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import org.apache.beam.sdk.extensions.sql.meta.Table; - -/** {@link TableFactory} that uses the first applicable sub-{@link TableFactory}. */ -class ChainedTableFactory implements TableFactory { - - private final List subTableFactories; - - public static ChainedTableFactory of(TableFactory... subTableFactories) { - return new ChainedTableFactory(Arrays.asList(subTableFactories)); - } - - private ChainedTableFactory(List subTableFactories) { - this.subTableFactories = subTableFactories; - } - - /** Creates a Beam SQL table description from a GCS fileset entry. */ - @Override - public Optional tableBuilder(Entry entry) { - for (TableFactory tableFactory : subTableFactories) { - Optional tableBuilder = tableFactory.tableBuilder(entry); - if (tableBuilder.isPresent()) { - return tableBuilder; - } - } - return Optional.empty(); - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogPipelineOptions.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogPipelineOptions.java deleted file mode 100644 index bb4afee3a210..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogPipelineOptions.java +++ /dev/null @@ -1,43 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import org.apache.beam.sdk.options.Default; -import org.apache.beam.sdk.options.Description; -import org.apache.beam.sdk.options.PipelineOptions; -import org.apache.beam.sdk.options.Validation; - -/** Pipeline options for Data Catalog table provider. */ -public interface DataCatalogPipelineOptions extends PipelineOptions { - - /** DataCatalog endpoint. */ - @Description("Data catalog endpoint.") - @Validation.Required - @Default.String("datacatalog.googleapis.com:443") - String getDataCatalogEndpoint(); - - void setDataCatalogEndpoint(String dataCatalogEndpoint); - - /** Whether to truncate timestamps in tables described by Data Catalog. */ - @Description("Truncate sub-millisecond precision timestamps in tables described by Data Catalog") - @Validation.Required - @Default.Boolean(false) - boolean getTruncateTimestamps(); - - void setTruncateTimestamps(boolean newValue); -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogPipelineOptionsRegistrar.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogPipelineOptionsRegistrar.java deleted file mode 100644 index 7f055dd245af..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogPipelineOptionsRegistrar.java +++ /dev/null @@ -1,31 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import com.google.auto.service.AutoService; -import org.apache.beam.sdk.options.PipelineOptions; -import org.apache.beam.sdk.options.PipelineOptionsRegistrar; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; - -@AutoService(PipelineOptionsRegistrar.class) -public class DataCatalogPipelineOptionsRegistrar implements PipelineOptionsRegistrar { - @Override - public Iterable> getPipelineOptions() { - return ImmutableList.of(DataCatalogPipelineOptions.class); - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogTableProvider.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogTableProvider.java deleted file mode 100644 index 19cc37b3f0dd..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogTableProvider.java +++ /dev/null @@ -1,235 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import static java.util.stream.Collectors.toMap; - -import com.google.api.gax.rpc.InvalidArgumentException; -import com.google.api.gax.rpc.NotFoundException; -import com.google.api.gax.rpc.PermissionDeniedException; -import com.google.api.gax.rpc.StatusCode.Code; -import com.google.cloud.datacatalog.v1beta1.DataCatalogClient; -import com.google.cloud.datacatalog.v1beta1.DataCatalogSettings; -import com.google.cloud.datacatalog.v1beta1.Entry; -import com.google.cloud.datacatalog.v1beta1.LookupEntryRequest; -import com.google.cloud.datacatalog.v1beta1.UpdateEntryRequest; -import com.google.protobuf.FieldMask; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Stream; -import org.apache.beam.sdk.annotations.Internal; -import org.apache.beam.sdk.extensions.gcp.options.GcpOptions; -import org.apache.beam.sdk.extensions.sql.impl.TableName; -import org.apache.beam.sdk.extensions.sql.meta.BeamSqlTable; -import org.apache.beam.sdk.extensions.sql.meta.Table; -import org.apache.beam.sdk.extensions.sql.meta.provider.FullNameTableProvider; -import org.apache.beam.sdk.extensions.sql.meta.provider.InvalidTableException; -import org.apache.beam.sdk.extensions.sql.meta.provider.TableProvider; -import org.apache.beam.sdk.extensions.sql.meta.provider.bigquery.BigQueryTableProvider; -import org.apache.beam.sdk.extensions.sql.meta.provider.pubsub.PubsubTableProvider; -import org.apache.beam.sdk.extensions.sql.meta.provider.text.TextTableProvider; -import org.apache.beam.sdk.schemas.Schema; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.MoreObjects; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.threeten.bp.Duration; - -/** Uses DataCatalog to get the source type and schema for a table. */ -public class DataCatalogTableProvider extends FullNameTableProvider implements AutoCloseable { - - private static final Logger LOG = LoggerFactory.getLogger(DataCatalogTableProvider.class); - - private static final TableFactory PUBSUB_TABLE_FACTORY = new PubsubTableFactory(); - private static final TableFactory GCS_TABLE_FACTORY = new GcsTableFactory(); - - private static final Map DELEGATE_PROVIDERS = - Stream.of(new PubsubTableProvider(), new BigQueryTableProvider(), new TextTableProvider()) - .collect(toMap(TableProvider::getTableType, p -> p)); - - private final DataCatalogClient dataCatalog; - private final Map tableCache; - private final TableFactory tableFactory; - - private DataCatalogTableProvider(DataCatalogClient dataCatalog, boolean truncateTimestamps) { - this.tableCache = new HashMap<>(); - this.dataCatalog = dataCatalog; - this.tableFactory = - ChainedTableFactory.of( - PUBSUB_TABLE_FACTORY, GCS_TABLE_FACTORY, new BigQueryTableFactory(truncateTimestamps)); - } - - public static DataCatalogTableProvider create(DataCatalogPipelineOptions options) { - return new DataCatalogTableProvider( - createDataCatalogClient(options), options.getTruncateTimestamps()); - } - - @Override - public String getTableType() { - return "google.cloud.datacatalog"; - } - - @Override - public void createTable(Table table) { - throw new UnsupportedOperationException( - "Creating tables is not supported with DataCatalog table provider."); - } - - @Override - public void dropTable(String tableName) { - throw new UnsupportedOperationException( - "Dropping tables is not supported with DataCatalog table provider"); - } - - @Override - public Map getTables() { - throw new UnsupportedOperationException("Loading all tables from DataCatalog is not supported"); - } - - @Override - public @Nullable Table getTable(String tableName) { - return loadTable(tableName); - } - - @Override - public Table getTableByFullName(TableName fullTableName) { - - ImmutableList allNameParts = - ImmutableList.builder() - .addAll(fullTableName.getPath()) - .add(fullTableName.getTableName()) - .build(); - - String fullEscapedTableName = ZetaSqlIdUtils.escapeAndJoin(allNameParts); - - return loadTable(fullEscapedTableName); - } - - @Override - public BeamSqlTable buildBeamSqlTable(Table table) { - TableProvider tableProvider = DELEGATE_PROVIDERS.get(table.getType()); - if (tableProvider == null) { - throw new RuntimeException("TableProvider is null"); - } - return tableProvider.buildBeamSqlTable(table); - } - - private Table loadTable(String tableName) { - if (!tableCache.containsKey(tableName)) { - tableCache.put(tableName, loadTableFromDC(tableName)); - } - - return tableCache.get(tableName); - } - - private Table loadTableFromDC(String tableName) { - try { - return toCalciteTable( - tableName, - dataCatalog.lookupEntry( - LookupEntryRequest.newBuilder().setSqlResource(tableName).build())); - } catch (InvalidArgumentException | PermissionDeniedException | NotFoundException e) { - throw new InvalidTableException("Could not resolve table in Data Catalog: " + tableName, e); - } - } - - @Internal - public static DataCatalogClient createDataCatalogClient(DataCatalogPipelineOptions options) { - try { - DataCatalogSettings.Builder builder = - DataCatalogSettings.newBuilder() - .setCredentialsProvider(() -> options.as(GcpOptions.class).getGcpCredential()) - .setEndpoint(options.getDataCatalogEndpoint()); - - // Retry permission denied errors, they are likely due to sync delay. - // Limit max retry delay to 1 minute, at that point its probably a legitimate permission error - // and we should get back to the user. - builder - .lookupEntrySettings() - .setRetryableCodes( - ImmutableSet.of(Code.PERMISSION_DENIED, Code.DEADLINE_EXCEEDED, Code.UNAVAILABLE)) - .setRetrySettings( - builder.lookupEntrySettings().getRetrySettings().toBuilder() - .setMaxRetryDelay(Duration.ofMinutes(1L)) - .build()); - builder - .updateEntrySettings() - .setRetryableCodes( - ImmutableSet.of(Code.PERMISSION_DENIED, Code.DEADLINE_EXCEEDED, Code.UNAVAILABLE)) - .setRetrySettings( - builder.updateEntrySettings().getRetrySettings().toBuilder() - .setMaxRetryDelay(Duration.ofMinutes(1L)) - .build()); - - return DataCatalogClient.create(builder.build()); - } catch (IOException e) { - throw new RuntimeException("Error creating Data Catalog client", e); - } - } - - private Table toCalciteTable(String tableName, Entry entry) { - if (entry.getSchema().getColumnsCount() == 0) { - throw new UnsupportedOperationException( - "Entry doesn't have a schema. Please attach a schema to '" - + tableName - + "' in Data Catalog: " - + entry.toString()); - } - Schema schema = SchemaUtils.fromDataCatalog(entry.getSchema()); - - Optional tableBuilder = tableFactory.tableBuilder(entry); - if (!tableBuilder.isPresent()) { - throw new UnsupportedOperationException( - String.format( - "Unsupported Data Catalog entry: %s", - MoreObjects.toStringHelper(entry) - .add("linkedResource", entry.getLinkedResource()) - .add("hasGcsFilesetSpec", entry.hasGcsFilesetSpec()) - .toString())); - } - - return tableBuilder.get().schema(schema).name(tableName).build(); - } - - @Internal - public boolean setSchemaIfNotPresent(String resource, Schema schema) { - com.google.cloud.datacatalog.v1beta1.Schema dcSchema = SchemaUtils.toDataCatalog(schema); - Entry entry = - dataCatalog.lookupEntry(LookupEntryRequest.newBuilder().setSqlResource(resource).build()); - if (entry.getSchema().getColumnsCount() == 0) { - dataCatalog.updateEntry( - UpdateEntryRequest.newBuilder() - .setEntry(entry.toBuilder().setSchema(dcSchema).build()) - .setUpdateMask(FieldMask.newBuilder().addPaths("schema").build()) - .build()); - return true; - } else { - LOG.info("Not updating schema for '{}' since it already has one.", resource); - return false; - } - } - - @Override - public void close() { - dataCatalog.close(); - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/GcsTableFactory.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/GcsTableFactory.java deleted file mode 100644 index 3a85362bbeab..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/GcsTableFactory.java +++ /dev/null @@ -1,61 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import com.google.cloud.datacatalog.v1beta1.Entry; -import com.google.cloud.datacatalog.v1beta1.GcsFilesetSpec; -import java.util.List; -import java.util.Optional; -import org.apache.beam.sdk.extensions.sql.TableUtils; -import org.apache.beam.sdk.extensions.sql.meta.Table; - -/** {@link TableFactory} that understands Data Catalog GCS entries. */ -class GcsTableFactory implements TableFactory { - - /** Creates a Beam SQL table description from a GCS fileset entry. */ - @Override - public Optional tableBuilder(Entry entry) { - if (!entry.hasGcsFilesetSpec()) { - return Optional.empty(); - } - - GcsFilesetSpec gcsFilesetSpec = entry.getGcsFilesetSpec(); - List filePatterns = gcsFilesetSpec.getFilePatternsList(); - - // We support exactly one 'file_patterns' field and nothing else at the moment - if (filePatterns.size() != 1) { - throw new UnsupportedOperationException( - "Unable to parse GCS entry '" + entry.getName() + "'"); - } - - String filePattern = filePatterns.get(0); - - if (!filePattern.startsWith("gs://")) { - throw new UnsupportedOperationException( - "Unsupported file pattern. " - + "Only file patterns with 'gs://' schema are supported at the moment."); - } - - return Optional.of( - Table.builder() - .type("text") - .location(filePattern) - .properties(TableUtils.emptyProperties()) - .comment("")); - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/PubsubTableFactory.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/PubsubTableFactory.java deleted file mode 100644 index 5688713f7ee8..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/PubsubTableFactory.java +++ /dev/null @@ -1,62 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import com.google.cloud.datacatalog.v1beta1.Entry; -import java.net.URI; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import org.apache.beam.sdk.extensions.sql.TableUtils; -import org.apache.beam.sdk.extensions.sql.meta.Table; - -/** {@link TableFactory} that understands Data Catalog Pubsub entries. */ -class PubsubTableFactory implements TableFactory { - - private static final String PUBSUB_API = "pubsub.googleapis.com"; - - private static final Pattern PS_PATH_PATTERN = - Pattern.compile("/projects/(?[^/]+)/topics/(?[^/]+)"); - - @Override - public Optional tableBuilder(Entry entry) { - if (!URI.create(entry.getLinkedResource()).getAuthority().equalsIgnoreCase(PUBSUB_API)) { - return Optional.empty(); - } - - return Optional.of( - Table.builder() - .location(getLocation(entry)) - .properties(TableUtils.emptyProperties()) - .type("pubsub") - .comment("")); - } - - private static String getLocation(Entry entry) { - URI entryName = URI.create(entry.getLinkedResource()); - String psPath = entryName.getPath(); - - Matcher bqPathMatcher = PS_PATH_PATTERN.matcher(psPath); - if (!bqPathMatcher.matches()) { - throw new IllegalArgumentException( - "Unsupported format for Pubsub topic: '" + entry.getLinkedResource() + "'"); - } - - return psPath.substring(1); - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/SchemaUtils.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/SchemaUtils.java deleted file mode 100644 index ef66d0e656cb..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/SchemaUtils.java +++ /dev/null @@ -1,190 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import static org.apache.beam.sdk.schemas.Schema.toSchema; - -import com.google.cloud.datacatalog.v1beta1.ColumnSchema; -import java.util.List; -import java.util.Map; -import org.apache.beam.sdk.schemas.Schema; -import org.apache.beam.sdk.schemas.Schema.Field; -import org.apache.beam.sdk.schemas.Schema.FieldType; -import org.apache.beam.sdk.schemas.logicaltypes.SqlTypes; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap; - -@SuppressWarnings({ - "nullness" // TODO(https://github.com/apache/beam/issues/20497) -}) -class SchemaUtils { - - private static final Map FIELD_TYPES = - ImmutableMap.builder() - .put("BOOL", FieldType.BOOLEAN) - .put("BYTES", FieldType.BYTES) - .put("DATE", FieldType.logicalType(SqlTypes.DATE)) - .put("DATETIME", FieldType.logicalType(SqlTypes.DATETIME)) - .put("DOUBLE", FieldType.DOUBLE) - .put("FLOAT", FieldType.DOUBLE) - .put("FLOAT64", FieldType.DOUBLE) - .put("INT32", FieldType.INT32) - .put("INT64", FieldType.INT64) - .put("STRING", FieldType.STRING) - .put("TIME", FieldType.logicalType(SqlTypes.TIME)) - .put("TIMESTAMP", FieldType.DATETIME) - .put("NUMERIC", FieldType.DECIMAL) - .put("MAP", FieldType.map(FieldType.STRING, FieldType.STRING)) - .build(); - - /** Convert DataCatalog schema to Beam schema. */ - static Schema fromDataCatalog(com.google.cloud.datacatalog.v1beta1.Schema dcSchema) { - return fromColumnsList(dcSchema.getColumnsList()); - } - - private static Schema fromColumnsList(List columnsMap) { - return columnsMap.stream().map(SchemaUtils::toBeamField).collect(toSchema()); - } - - private static Field toBeamField(ColumnSchema column) { - String name = column.getColumn(); - - // basic field type - FieldType fieldType = getBeamFieldType(column); - Field field = Field.of(name, fieldType); - - // set the nullable flag, or convert to a list if repeated - if (Strings.isNullOrEmpty(column.getMode()) || "NULLABLE".equals(column.getMode())) { - field = field.withNullable(true); - } else if ("REQUIRED".equals(column.getMode())) { - field = field.withNullable(false); - } else if ("REPEATED".equals(column.getMode())) { - field = Field.of(name, FieldType.array(fieldType)); - } else { - throw new UnsupportedOperationException( - "Field mode '" + column.getMode() + "' is not supported (field '" + name + "')"); - } - - return field; - } - - private static FieldType getBeamFieldType(ColumnSchema column) { - String dcFieldType = column.getType(); - - if (FIELD_TYPES.containsKey(dcFieldType)) { - return FIELD_TYPES.get(dcFieldType); - } - - if ("STRUCT".equals(dcFieldType)) { - Schema structSchema = fromColumnsList(column.getSubcolumnsList()); - return FieldType.row(structSchema); - } - - throw new UnsupportedOperationException( - "Field type '" + dcFieldType + "' is not supported (field '" + column.getColumn() + "')"); - } - - /** Convert Beam schema to DataCatalog schema. */ - static com.google.cloud.datacatalog.v1beta1.Schema toDataCatalog(Schema schema) { - com.google.cloud.datacatalog.v1beta1.Schema.Builder schemaBuilder = - com.google.cloud.datacatalog.v1beta1.Schema.newBuilder(); - for (Schema.Field field : schema.getFields()) { - schemaBuilder.addColumns(fromBeamField(field)); - } - return schemaBuilder.build(); - } - - private static ColumnSchema fromBeamField(Schema.Field field) { - Schema.FieldType fieldType = field.getType(); - if (fieldType.getTypeName().equals(Schema.TypeName.ARRAY)) { - if (fieldType.getNullable()) { - throw new UnsupportedOperationException( - "Nullable array type is not supported in DataCatalog schemas: " + fieldType); - } else if (fieldType.getCollectionElementType().getNullable()) { - throw new UnsupportedOperationException( - "Nullable array element type is not supported in DataCatalog schemas: " + fieldType); - } else if (fieldType.getCollectionElementType().getTypeName().equals(Schema.TypeName.ARRAY)) { - throw new UnsupportedOperationException( - "Array of arrays not supported in DataCatalog schemas: " + fieldType); - } - ColumnSchema column = - fromBeamField(Field.of(field.getName(), fieldType.getCollectionElementType())); - if (!column.getMode().equals("REQUIRED")) { - // We should have bailed out earlier for any cases that would result in mode being set. - throw new AssertionError( - "ColumnSchema for collection element type has non-empty mode: " + fieldType); - } - return column.toBuilder().setMode("REPEATED").build(); - } else { // struct or primitive type - ColumnSchema.Builder colBuilder = - ColumnSchema.newBuilder().setType(getDataCatalogType(fieldType)); - - if (fieldType.getNullable()) { - colBuilder.setMode("NULLABLE"); - } else { - colBuilder.setMode("REQUIRED"); - } - - // if this is a struct, add the child columns - if (fieldType.getTypeName().equals(Schema.TypeName.ROW)) { - for (Schema.Field subField : fieldType.getRowSchema().getFields()) { - colBuilder.addSubcolumns(fromBeamField(subField)); - } - } - - return colBuilder.setColumn(field.getName()).build(); - } - } - - private static String getDataCatalogType(FieldType fieldType) { - switch (fieldType.getTypeName()) { - case INT32: - case INT64: - case BYTES: - case DOUBLE: - case STRING: - return fieldType.getTypeName().name(); - case BOOLEAN: - return "BOOL"; - case DATETIME: - return "TIMESTAMP"; - case DECIMAL: - return "NUMERIC"; - case LOGICAL_TYPE: - Schema.LogicalType logical = fieldType.getLogicalType(); - if (SqlTypes.TIME.getIdentifier().equals(logical.getIdentifier())) { - return "TIME"; - } else if (SqlTypes.DATE.getIdentifier().equals(logical.getIdentifier())) { - return "DATE"; - } else if (SqlTypes.DATETIME.getIdentifier().equals(logical.getIdentifier())) { - return "DATETIME"; - } else { - throw new UnsupportedOperationException("Unsupported logical type: " + logical); - } - case ROW: - return "STRUCT"; - case MAP: - return String.format( - "MAP<%s,%s>", - getDataCatalogType(fieldType.getMapKeyType()), - getDataCatalogType(fieldType.getMapValueType())); - default: - throw new UnsupportedOperationException("Unsupported type: " + fieldType); - } - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/TableFactory.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/TableFactory.java deleted file mode 100644 index 5a1f7182f786..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/TableFactory.java +++ /dev/null @@ -1,37 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import com.google.cloud.datacatalog.v1beta1.Entry; -import java.util.Optional; -import org.apache.beam.sdk.extensions.sql.meta.Table; - -/** - * A {@link TableFactory} may be able to interpret a given Data Catalog {@link Entry} into - * Beam SQL {@link Table}. - */ -interface TableFactory { - - /** - * If this {@link TableFactory} instance can interpret the given {@link Entry}, then a Beam SQL - * {@link Table} is constructed, else returns {@link Optional#empty}. - * - *

The {@link Table} is returned as a builder for further customization by the caller. - */ - Optional tableBuilder(Entry entry); -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ZetaSqlIdUtils.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ZetaSqlIdUtils.java deleted file mode 100644 index e00fbb3148a7..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ZetaSqlIdUtils.java +++ /dev/null @@ -1,83 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import static java.util.stream.Collectors.joining; -import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull; - -import java.util.List; -import java.util.regex.Pattern; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap; - -/** Utils to work with ZetaSQL-compatible IDs. */ -class ZetaSqlIdUtils { - - /** - * Some special characters we explicitly handle. - * - *

Everything else is ignored, e.g. tabs, newlines, etc. - */ - private static final Pattern SPECIAL_CHARS_ESCAPE = - Pattern.compile( - "(?[" - + "\\\\" // slash - + "`" // backtick - + "'" // single quote - + "\"" // double quote - + "?" // question mark - + "])"); - - private static final ImmutableMap WHITESPACES = - ImmutableMap.of( - "\n", "\\\\n", - "\t", "\\\\t", - "\r", "\\\\r", - "\f", "\\\\f"); - - private static final Pattern SIMPLE_ID = Pattern.compile("[A-Za-z_][A-Za-z_0-9]*"); - - /** - * Joins parts into a single compound ZetaSQL identifier. - * - *

Escapes backticks, slashes, double and single quotes, doesn't handle other special - * characters for now. - */ - static String escapeAndJoin(List parts) { - return parts.stream() - .map(ZetaSqlIdUtils::escapeSpecialChars) - .map(ZetaSqlIdUtils::replaceWhitespaces) - .map(ZetaSqlIdUtils::backtickIfNeeded) - .collect(joining(".")); - } - - private static String escapeSpecialChars(String str) { - return SPECIAL_CHARS_ESCAPE.matcher(str).replaceAll("\\\\${SpecialChar}"); - } - - private static String replaceWhitespaces(String s) { - return WHITESPACES.keySet().stream() - .reduce( - s, - (str, whitespace) -> - str.replaceAll(whitespace, checkNotNull(WHITESPACES.get(whitespace)))); - } - - private static String backtickIfNeeded(String s) { - return SIMPLE_ID.matcher(s).matches() ? s : ("`" + s + "`"); - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/package-info.java b/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/package-info.java deleted file mode 100644 index 029f3f800103..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/main/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/package-info.java +++ /dev/null @@ -1,20 +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. - */ - -/** Table schema for Google Cloud Data Catalog. */ -package org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; diff --git a/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogBigQueryIT.java b/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogBigQueryIT.java deleted file mode 100644 index cdfc8ce16316..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogBigQueryIT.java +++ /dev/null @@ -1,115 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import static org.apache.beam.sdk.schemas.Schema.FieldType.INT64; -import static org.apache.beam.sdk.schemas.Schema.FieldType.STRING; - -import com.google.api.services.bigquery.model.TableReference; -import java.util.Arrays; -import org.apache.beam.sdk.extensions.sql.SqlTransform; -import org.apache.beam.sdk.extensions.sql.impl.BeamSqlPipelineOptions; -import org.apache.beam.sdk.extensions.sql.impl.CalciteQueryPlanner; -import org.apache.beam.sdk.extensions.sql.impl.QueryPlanner; -import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO; -import org.apache.beam.sdk.io.gcp.bigquery.BigQueryIO.Write.Method; -import org.apache.beam.sdk.io.gcp.bigquery.BigQueryUtils; -import org.apache.beam.sdk.io.gcp.bigquery.TestBigQuery; -import org.apache.beam.sdk.schemas.Schema; -import org.apache.beam.sdk.testing.PAssert; -import org.apache.beam.sdk.testing.TestPipeline; -import org.apache.beam.sdk.transforms.Create; -import org.apache.beam.sdk.values.PCollection; -import org.apache.beam.sdk.values.Row; -import org.joda.time.Duration; -import org.junit.Rule; -import org.junit.Test; -import org.junit.experimental.runners.Enclosed; -import org.junit.runner.RunWith; -import org.junit.runners.Parameterized; - -/** Integration tests for DataCatalog+BigQuery. */ -@RunWith(Enclosed.class) -public class DataCatalogBigQueryIT { - - @RunWith(Parameterized.class) - public static class DialectSensitiveTests { - private static final Schema ID_NAME_SCHEMA = - Schema.builder().addNullableField("id", INT64).addNullableField("name", STRING).build(); - @Rule public transient TestPipeline writePipeline = TestPipeline.create(); - @Rule public transient TestPipeline readPipeline = TestPipeline.create(); - @Rule public transient TestBigQuery bigQuery = TestBigQuery.create(ID_NAME_SCHEMA); - - /** Parameterized by which SQL dialect, since the syntax here is the same. */ - @Parameterized.Parameters(name = "{0}") - public static Iterable dialects() { - return Arrays.asList(new Object[][] {{"CalciteSQL", CalciteQueryPlanner.class}}); - } - - @SuppressWarnings("initialization.fields.uninitialized") - @Parameterized.Parameter(0) - public String dialectName; - - @SuppressWarnings("initialization.fields.uninitialized") - @Parameterized.Parameter(1) - public Class queryPlanner; - - @Test - public void testRead() throws Exception { - TableReference bqTable = bigQuery.tableReference(); - - // Streaming inserts do not work with DIRECT_READ mode, there is a several hour lag. - PCollection data = - writePipeline.apply(Create.of(row(1, "name1"), row(2, "name2"), row(3, "name3"))); - data.apply( - BigQueryIO.write() - .withSchema(BigQueryUtils.toTableSchema(ID_NAME_SCHEMA)) - .withFormatFunction(BigQueryUtils.toTableRow()) - .withMethod(Method.FILE_LOADS) - .to(bqTable)); - writePipeline.run().waitUntilFinish(Duration.standardMinutes(2)); - - String tableId = - String.format( - "bigquery.`table`.`%s`.`%s`.`%s`", - bqTable.getProjectId(), bqTable.getDatasetId(), bqTable.getTableId()); - - readPipeline - .getOptions() - .as(BeamSqlPipelineOptions.class) - .setPlannerName(queryPlanner.getName()); - - try (DataCatalogTableProvider tableProvider = - DataCatalogTableProvider.create( - readPipeline.getOptions().as(DataCatalogPipelineOptions.class))) { - PCollection result = - readPipeline.apply( - "query", - SqlTransform.query("SELECT id, name FROM " + tableId) - .withDefaultTableProvider("datacatalog", tableProvider)); - - PAssert.that(result).containsInAnyOrder(row(1, "name1"), row(2, "name2"), row(3, "name3")); - readPipeline.run().waitUntilFinish(Duration.standardMinutes(2)); - } - } - - private static Row row(long id, String name) { - return Row.withSchema(ID_NAME_SCHEMA).addValues(id, name).build(); - } - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogGCSIT.java b/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogGCSIT.java deleted file mode 100644 index b780f5b87085..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/DataCatalogGCSIT.java +++ /dev/null @@ -1,83 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import static org.apache.beam.sdk.schemas.Schema.FieldType.INT32; -import static org.apache.beam.sdk.schemas.Schema.FieldType.STRING; - -import java.io.Serializable; -import org.apache.beam.runners.direct.DirectOptions; -import org.apache.beam.sdk.extensions.sql.SqlTransform; -import org.apache.beam.sdk.schemas.Schema; -import org.apache.beam.sdk.testing.PAssert; -import org.apache.beam.sdk.testing.TestPipeline; -import org.apache.beam.sdk.values.PCollection; -import org.apache.beam.sdk.values.Row; -import org.joda.time.Duration; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -/** Integration tests for DataCatalog+GCS. */ -@RunWith(JUnit4.class) -public class DataCatalogGCSIT implements Serializable { - - private static final Schema ID_NAME_TYPE_SCHEMA = - Schema.builder() - .addNullableField("id", INT32) - .addNullableField("name", STRING) - .addNullableField("type", STRING) - .build(); - - @Rule public transient TestPipeline pipeline = TestPipeline.create(); - - @Test - public void testReadFromGCS() throws Exception { - String gcsEntryId = - "`datacatalog`" // this is part of the resource name in DataCatalog, so it has to be - + ".`entry`" // different from the table provider name ("dc" in this test) - + ".`apache-beam-testing`" - + ".`us-central1`" - + ".`samples`" - + ".`integ_test_small_csv_test_1`"; - - try (DataCatalogTableProvider tableProvider = - DataCatalogTableProvider.create( - pipeline.getOptions().as(DataCatalogPipelineOptions.class))) { - PCollection result = - pipeline.apply( - "query", - SqlTransform.query("SELECT id, name, type FROM " + gcsEntryId) - .withDefaultTableProvider("dc", tableProvider)); - - pipeline.getOptions().as(DirectOptions.class).setBlockOnRun(true); - PAssert.that(result) - .containsInAnyOrder( - row(1, "customer1", "test"), - row(2, "customer2", "test"), - row(3, "customer1", "test"), - row(4, "customer2", "test")); - pipeline.run().waitUntilFinish(Duration.standardMinutes(2)); - } - } - - private Row row(int id, String name, String type) { - return Row.withSchema(ID_NAME_TYPE_SCHEMA).addValues(id, name, type).build(); - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/SchemaUtilsTest.java b/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/SchemaUtilsTest.java deleted file mode 100644 index 6afc9466f60e..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/SchemaUtilsTest.java +++ /dev/null @@ -1,157 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import static org.junit.Assert.assertEquals; - -import com.google.cloud.datacatalog.v1beta1.ColumnSchema; -import org.apache.beam.sdk.schemas.Schema; -import org.apache.beam.sdk.schemas.Schema.FieldType; -import org.apache.beam.sdk.schemas.logicaltypes.SqlTypes; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -/** Unit tests for {@link SchemaUtils}. */ -@RunWith(JUnit4.class) -public class SchemaUtilsTest { - - private static final Schema TEST_INNER_SCHEMA = - Schema.builder().addField("i1", FieldType.INT64).addField("i2", FieldType.STRING).build(); - - private static final Schema TEST_SCHEMA = - Schema.builder() - .addNullableField("f_int32", FieldType.INT32) - .addNullableField("f_int64", FieldType.INT64) - .addNullableField("f_bytes", FieldType.BYTES) - .addNullableField("f_double", FieldType.DOUBLE) - .addNullableField("f_string", FieldType.STRING) - .addNullableField("f_bool", FieldType.BOOLEAN) - .addNullableField("f_ts", FieldType.DATETIME) - .addNullableField("f_numeric", FieldType.DECIMAL) - .addLogicalTypeField("f_time", SqlTypes.TIME) - .addLogicalTypeField("f_date", SqlTypes.DATE) - .addLogicalTypeField("f_datetime", SqlTypes.DATETIME) - .addArrayField("f_array", FieldType.INT64) - .addRowField("f_struct", TEST_INNER_SCHEMA) - .build(); - - private static final com.google.cloud.datacatalog.v1beta1.Schema TEST_DC_SCHEMA = - com.google.cloud.datacatalog.v1beta1.Schema.newBuilder() - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_int32") - .setType("INT32") - .setMode("NULLABLE") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_int64") - .setType("INT64") - .setMode("NULLABLE") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_bytes") - .setType("BYTES") - .setMode("NULLABLE") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_double") - .setType("DOUBLE") - .setMode("NULLABLE") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_string") - .setType("STRING") - .setMode("NULLABLE") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_bool") - .setType("BOOL") - .setMode("NULLABLE") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_ts") - .setType("TIMESTAMP") - .setMode("NULLABLE") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_numeric") - .setType("NUMERIC") - .setMode("NULLABLE") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_time") - .setType("TIME") - .setMode("REQUIRED") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_date") - .setType("DATE") - .setMode("REQUIRED") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_datetime") - .setType("DATETIME") - .setMode("REQUIRED") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_array") - .setType("INT64") - .setMode("REPEATED") - .build()) - .addColumns( - ColumnSchema.newBuilder() - .setColumn("f_struct") - .setType("STRUCT") - .addSubcolumns( - ColumnSchema.newBuilder() - .setColumn("i1") - .setType("INT64") - .setMode("REQUIRED") - .build()) - .addSubcolumns( - ColumnSchema.newBuilder() - .setColumn("i2") - .setType("STRING") - .setMode("REQUIRED") - .build()) - .setMode("REQUIRED") - .build()) - .build(); - - @Test - public void testFromDataCatalog() { - assertEquals(TEST_SCHEMA, SchemaUtils.fromDataCatalog(TEST_DC_SCHEMA)); - } - - @Test - public void testToDataCatalog() { - assertEquals(TEST_DC_SCHEMA, SchemaUtils.toDataCatalog(TEST_SCHEMA)); - } -} diff --git a/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ZetaSqlIdUtilsTest.java b/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ZetaSqlIdUtilsTest.java deleted file mode 100644 index ff34ef836661..000000000000 --- a/sdks/java/extensions/sql/datacatalog/src/test/java/org/apache/beam/sdk/extensions/sql/meta/provider/datacatalog/ZetaSqlIdUtilsTest.java +++ /dev/null @@ -1,64 +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 org.apache.beam.sdk.extensions.sql.meta.provider.datacatalog; - -import static org.junit.Assert.assertEquals; - -import java.util.Arrays; -import java.util.List; -import org.junit.Test; - -/** Unit tests for {@link ZetaSqlIdUtils}. */ -public class ZetaSqlIdUtilsTest { - - @Test - public void testHandlesSimpleIds() { - List id = Arrays.asList("aaa", "BbB", "zAzzz00"); - assertEquals("aaa.BbB.zAzzz00", ZetaSqlIdUtils.escapeAndJoin(id)); - } - - @Test - public void testHandlesMixedIds() { - List id = Arrays.asList("aaa", "Bb---B", "zAzzz00"); - assertEquals("aaa.`Bb---B`.zAzzz00", ZetaSqlIdUtils.escapeAndJoin(id)); - } - - @Test - public void testHandlesSpecialChars() { - List id = Arrays.asList("a\\a", "b`b", "c'c", "d\"d", "e?e"); - assertEquals("`a\\\\a`.`b\\`b`.`c\\'c`.`d\\\"d`.`e\\?e`", ZetaSqlIdUtils.escapeAndJoin(id)); - } - - @Test - public void testHandlesSpecialCharsInOnePart() { - List id = Arrays.asList("a\\ab`bc'cd\"de?e"); - assertEquals("`a\\\\ab\\`bc\\'cd\\\"de\\?e`", ZetaSqlIdUtils.escapeAndJoin(id)); - } - - @Test - public void testHandlesWhiteSpaces() { - List id = Arrays.asList("a\na", "b\tb", "c\rc", "d\fd"); - assertEquals("`a\\na`.`b\\tb`.`c\\rc`.`d\\fd`", ZetaSqlIdUtils.escapeAndJoin(id)); - } - - @Test - public void testHandlesWhiteSpacesInOnePart() { - List id = Arrays.asList("a\nab\tbc\rcd\fd"); - assertEquals("`a\\nab\\tbc\\rcd\\fd`", ZetaSqlIdUtils.escapeAndJoin(id)); - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index e1ed639b0d24..b8e7b855b999 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -219,7 +219,6 @@ include(":sdks:java:extensions:sql:payloads") include(":sdks:java:extensions:sql:perf-tests") include(":sdks:java:extensions:sql:jdbc") include(":sdks:java:extensions:sql:hcatalog") -include(":sdks:java:extensions:sql:datacatalog") include(":sdks:java:extensions:sql:expansion-service") include(":sdks:java:extensions:sql:udf") include(":sdks:java:extensions:sql:udf-test-provider") From d37ad248e7698c7ac9dd8650d90d25c3c67bf124 Mon Sep 17 00:00:00 2001 From: Valentyn Tymofieiev Date: Wed, 14 Oct 2026 19:37:15 -0700 Subject: [PATCH 049/108] [Python] Support max_batch_weight in BatchElements and honor element_size_fn in stateful batching * Add max_batch_weight to BatchElements. When set, the batch size limits count elements and element_size_fn gives the weight of an element. This fixes RunInference failing with a TypeError when a ModelHandler sets max_batch_weight. * Honor element_size_fn when max_batch_duration_secs is set. This uses a separate stateful DoFn that only adds a 'weight' state and keeps the transform label, so pipelines that don't set element_size_fn are unaffected. * Never emit an empty batch when an element exceeds the limits on its own. * Warn when length_fn is set without max_batch_duration_secs, since it is ignored. --- CHANGES.md | 2 + sdks/python/apache_beam/transforms/util.py | 272 ++++++++++++++++-- .../apache_beam/transforms/util_test.py | 174 ++++++++++- 3 files changed, 429 insertions(+), 19 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d06940d12cf4..142284de1350 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -121,6 +121,8 @@ * (Python) DataFrame API: `agg` with a list that includes `'count'` or `'size'` now labels those rows `count`/`size` instead of `sum` ([#40504](https://github.com/apache/beam/issues/40504)). * (Python) `ConcatSource` no longer raises `ZeroDivisionError` when every source bundle reports a weight of 0, e.g. bundles smaller than 1 MB ([#21316](https://github.com/apache/beam/issues/21316)). * (Python) DataFrame API: `Series.value_counts(bins=...)` no longer always fails with a `TypeError` ([#40503](https://github.com/apache/beam/issues/40503)). +* (Python) Setting `max_batch_weight` on a RunInference `ModelHandler` no longer fails with a `TypeError`: `BatchElements` now supports `max_batch_weight`, which limits the total `element_size_fn` of a batch in addition to its number of elements ([#ISSUE](https://github.com/apache/beam/issues/ISSUE)). +* (Python) `BatchElements` now honors `element_size_fn` when `max_batch_duration_secs` is set, and no longer emits an empty batch before an element that exceeds the batch size on its own ([#ISSUE](https://github.com/apache/beam/issues/ISSUE)). * Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). ## Security Fixes diff --git a/sdks/python/apache_beam/transforms/util.py b/sdks/python/apache_beam/transforms/util.py index d9dece91e944..df9607977950 100644 --- a/sdks/python/apache_beam/transforms/util.py +++ b/sdks/python/apache_beam/transforms/util.py @@ -761,28 +761,57 @@ def stats(self): self._data) +def _unit_element_size(unused_element): + return 1 + + +def _element_size_and_weight(element, element_size_fn, max_batch_weight): + """Returns the contribution of an element to the size and weight of a batch. + + Without max_batch_weight, element_size_fn determines the size of a batch. + With max_batch_weight, the size of a batch is its number of elements, and + element_size_fn determines its weight. + """ + if max_batch_weight is None: + return element_size_fn(element), 0 + return 1, element_size_fn(element) + + class _GlobalWindowsBatchingDoFn(DoFn): - def __init__(self, batch_size_estimator, element_size_fn): + def __init__( + self, batch_size_estimator, element_size_fn, max_batch_weight=None): self._batch_size_estimator = batch_size_estimator self._element_size_fn = element_size_fn + self._max_batch_weight = max_batch_weight def start_bundle(self): self._batch = [] self._running_batch_size = 0 + self._running_batch_weight = 0 self._target_batch_size = self._batch_size_estimator.next_batch_size() # The first emit often involves non-trivial setup. self._batch_size_estimator.ignore_next_timing() def process(self, element): - element_size = self._element_size_fn(element) - if self._running_batch_size + element_size > self._target_batch_size: + element_size, element_weight = _element_size_and_weight( + element, self._element_size_fn, self._max_batch_weight) + would_exceed_size = ( + self._running_batch_size + element_size > self._target_batch_size) + would_exceed_weight = ( + self._max_batch_weight is not None and + self._running_batch_weight + element_weight > self._max_batch_weight) + # Never emit an empty batch: an element that exceeds the limits on its own + # is emitted in a batch by itself. + if self._batch and (would_exceed_size or would_exceed_weight): with self._batch_size_estimator.record_time(self._running_batch_size): yield window.GlobalWindows.windowed_value_at_end_of_window(self._batch) self._batch = [] self._running_batch_size = 0 + self._running_batch_weight = 0 self._target_batch_size = self._batch_size_estimator.next_batch_size() self._batch.append(element) self._running_batch_size += element_size + self._running_batch_weight += element_weight def finish_bundle(self): if self._batch: @@ -799,15 +828,18 @@ class _SizedBatch(): def __init__(self): self.elements = [] self.size = 0 + self.weight = 0 class _WindowAwareBatchingDoFn(DoFn): _MAX_LIVE_WINDOWS = 10 - def __init__(self, batch_size_estimator, element_size_fn): + def __init__( + self, batch_size_estimator, element_size_fn, max_batch_weight=None): self._batch_size_estimator = batch_size_estimator self._element_size_fn = element_size_fn + self._max_batch_weight = max_batch_weight def start_bundle(self): self._batches = collections.defaultdict(_SizedBatch) @@ -817,8 +849,15 @@ def start_bundle(self): def process(self, element, window=DoFn.WindowParam): batch = self._batches[window] - element_size = self._element_size_fn(element) - if batch.size + element_size > self._target_batch_size: + element_size, element_weight = _element_size_and_weight( + element, self._element_size_fn, self._max_batch_weight) + would_exceed_size = batch.size + element_size > self._target_batch_size + would_exceed_weight = ( + self._max_batch_weight is not None and + batch.weight + element_weight > self._max_batch_weight) + # Never emit an empty batch: an element that exceeds the limits on its own + # is emitted in a batch by itself. + if batch.elements and (would_exceed_size or would_exceed_weight): with self._batch_size_estimator.record_time(batch.size): yield windowed_value.WindowedValue( batch.elements, window.max_timestamp(), (window, )) @@ -827,6 +866,7 @@ def process(self, element, window=DoFn.WindowParam): self._batches[window].elements.append(element) self._batches[window].size += element_size + self._batches[window].weight += element_weight if len(self._batches) > self._MAX_LIVE_WINDOWS: window, batch = max( @@ -949,6 +989,173 @@ def flush_batch( return _StatefulBatchElementsDoFn() +def _pardo_stateful_weighted_batch_elements( + input_coder: coders.Coder, + batch_size_estimator: _BatchSizeEstimator, + max_buffering_duration_secs: int, + element_size_fn: Callable[[Any], Any], + max_batch_weight: Optional[int] = None, + clock=time.time): + """Like _pardo_stateful_batch_elements, but honors element_size_fn. + + Without max_batch_weight, element_size_fn determines the contribution of each + element to the batch size, as in the non-stateful implementation. With + max_batch_weight, the batch size is the number of elements, and + max_batch_weight limits the total element_size_fn of a batch. + + This DoFn only adds a 'weight' state to the state and timers of the DoFn of + _pardo_stateful_batch_elements, and keeps its transform label, to minimize the + impact on the update compatibility of streaming pipelines. Pipelines that do + not set element_size_fn keep using _pardo_stateful_batch_elements. + """ + ELEMENT_STATE = BagStateSpec('values', input_coder) + COUNT_STATE = CombiningValueStateSpec('count', input_coder, CountCombineFn()) + # The sum of element_size_fn over the buffered elements. + WEIGHT_STATE = ReadModifyWriteStateSpec( + 'weight', coders.FastPrimitivesCoder()) + BATCH_SIZE_STATE = ReadModifyWriteStateSpec('batch_size', input_coder) + WINDOW_TIMER = TimerSpec('window_end', TimeDomain.WATERMARK) + BUFFERING_TIMER = TimerSpec('buffering_end', TimeDomain.REAL_TIME) + BATCH_ESTIMATOR_STATE = ReadModifyWriteStateSpec( + 'batch_estimator', coders.PickleCoder()) + + def batch_size(count, weight): + # The size of a batch, in the unit of the batch sizes of the estimator. + return weight if max_batch_weight is None else count + + def exceeds_limits(count, weight, target_size): + return batch_size(count, weight) > target_size or ( + max_batch_weight is not None and weight > max_batch_weight) + + def reaches_limits(count, weight, target_size): + return batch_size(count, weight) >= target_size or ( + max_batch_weight is not None and weight >= max_batch_weight) + + class _StatefulWeightedBatchElementsDoFn(DoFn): + def default_label(self): + # Matches the label of the DoFn of _pardo_stateful_batch_elements, so that + # setting element_size_fn doesn't change the transform names of a + # pipeline, which streaming pipeline updates rely on. + return '_StatefulBatchElementsDoFn' + + def process( + self, + element, + window=DoFn.WindowParam, + element_state=DoFn.StateParam(ELEMENT_STATE), + count_state=DoFn.StateParam(COUNT_STATE), + weight_state=DoFn.StateParam(WEIGHT_STATE), + batch_size_state=DoFn.StateParam(BATCH_SIZE_STATE), + batch_estimator_state=DoFn.StateParam(BATCH_ESTIMATOR_STATE), + window_timer=DoFn.TimerParam(WINDOW_TIMER), + buffering_timer=DoFn.TimerParam(BUFFERING_TIMER)): + window_timer.set(window.end) + # Drop the fixed key since we don't care about it + value = element[1] + element_weight = element_size_fn(value) + target_size = batch_size_state.read() + # Should only happen on the first element + if target_size is None: + batch_estimator = batch_size_estimator + target_size = batch_estimator.next_batch_size() + batch_size_state.write(target_size) + batch_estimator_state.write(batch_estimator) + + count = count_state.read() + weight = weight_state.read() or 0 + if count and exceeds_limits( + count + 1, weight + element_weight, target_size): + # Emit the buffered elements first, so that adding this element + # doesn't make the batch exceed the limits. An element that exceeds + # the limits on its own is emitted in a batch by itself below. + yield from self.flush_batch( + element_state, + count_state, + weight_state, + batch_size_state, + batch_estimator_state, + buffering_timer) + count, weight = 0, 0 + target_size = batch_size_state.read() + + element_state.add(value) + count_state.add(1) + count += 1 + weight += element_weight + weight_state.write(weight) + + if count == 1 and max_buffering_duration_secs > 0: + # First element in batch, start buffering timer + buffering_timer.set(clock() + max_buffering_duration_secs) + + if reaches_limits(count, weight, target_size): + yield from self.flush_batch( + element_state, + count_state, + weight_state, + batch_size_state, + batch_estimator_state, + buffering_timer) + + @on_timer(WINDOW_TIMER) + def on_window_timer( + self, + element_state=DoFn.StateParam(ELEMENT_STATE), + count_state=DoFn.StateParam(COUNT_STATE), + weight_state=DoFn.StateParam(WEIGHT_STATE), + batch_size_state=DoFn.StateParam(BATCH_SIZE_STATE), + batch_estimator_state=DoFn.StateParam(BATCH_ESTIMATOR_STATE), + buffering_timer=DoFn.TimerParam(BUFFERING_TIMER)): + return self.flush_batch( + element_state, + count_state, + weight_state, + batch_size_state, + batch_estimator_state, + buffering_timer) + + @on_timer(BUFFERING_TIMER) + def on_buffering_timer( + self, + element_state=DoFn.StateParam(ELEMENT_STATE), + count_state=DoFn.StateParam(COUNT_STATE), + weight_state=DoFn.StateParam(WEIGHT_STATE), + batch_size_state=DoFn.StateParam(BATCH_SIZE_STATE), + batch_estimator_state=DoFn.StateParam(BATCH_ESTIMATOR_STATE), + buffering_timer=DoFn.TimerParam(BUFFERING_TIMER)): + return self.flush_batch( + element_state, + count_state, + weight_state, + batch_size_state, + batch_estimator_state, + buffering_timer) + + def flush_batch( + self, + element_state, + count_state, + weight_state, + batch_size_state, + batch_estimator_state, + buffering_timer): + batch = [element for element in element_state.read()] + if not batch: + return + size = batch_size(len(batch), weight_state.read() or 0) + element_state.clear() + count_state.clear() + weight_state.clear() + batch_estimator = batch_estimator_state.read() + with batch_estimator.record_time(size): + yield batch + batch_size_state.write(batch_estimator.next_batch_size()) + batch_estimator_state.write(batch_estimator) + buffering_timer.clear() + + return _StatefulWeightedBatchElementsDoFn() + + class SharedKey(): """A class that holds a per-process UUID used to key elements for streaming BatchElements. @@ -1050,7 +1257,15 @@ class BatchElements(PTransform): element_size_fn: (optional) A mapping of an element to its contribution to batch size, defaulting to every element having size 1. When provided, attempts to provide batches of optimal total size which may consist of - a varying number of elements. + a varying number of elements. If max_batch_weight is set, the size of a + batch is its number of elements instead, and element_size_fn gives the + weight of an element. + max_batch_weight: (optional) the largest total weight of a batch, where the + weight of an element is given by element_size_fn, which must be set. + This bounds batches by both their number of elements, between + min_batch_size and max_batch_size, and their total weight, e.g. their + number of tokens. An element that exceeds max_batch_weight on its own + is emitted in a batch by itself. variance: (optional) the permitted (relative) amount of deviation from the (estimated) ideal batch size used to produce a wider base for linear interpolation @@ -1061,7 +1276,8 @@ class BatchElements(PTransform): length_fn: (optional) a callable mapping an element to its length (int). When set together with bucket_boundaries, enables length-aware bucketed keying on the stateful path so that elements of similar length are - routed to the same batch, reducing padding waste. + routed to the same batch, reducing padding waste. Ignored, with a + warning, if max_batch_duration_secs is not set. bucket_boundaries: (optional) a sorted list of positive boundary values for length bucketing. Boundaries are lower-inclusive (bisect_right semantics): e.g., for boundaries [10, 50], buckets are (-inf, 10), @@ -1079,12 +1295,19 @@ def __init__( target_batch_duration_secs_including_fixed_cost=None, max_batch_duration_secs=None, *, - element_size_fn=lambda x: 1, + element_size_fn=None, + max_batch_weight=None, variance=0.25, clock=time.time, record_metrics=True, length_fn=None, bucket_boundaries=None): + if max_batch_weight is not None: + if element_size_fn is None: + raise ValueError('max_batch_weight requires element_size_fn to be set.') + if max_batch_weight < 1: + raise ValueError( + f'max_batch_weight must be >= 1, got {max_batch_weight}') if bucket_boundaries is not None and length_fn is None: raise ValueError('bucket_boundaries requires length_fn to be set.') if bucket_boundaries is not None: @@ -1093,6 +1316,10 @@ def __init__( raise ValueError( 'bucket_boundaries must be a non-empty sorted list of ' 'positive values.') + if length_fn is not None and max_batch_duration_secs is None: + _LOGGER.warning( + 'BatchElements ignores length_fn and bucket_boundaries unless ' + 'max_batch_duration_secs is set.') self._batch_size_estimator = _BatchSizeEstimator( min_batch_size=min_batch_size, max_batch_size=max_batch_size, @@ -1104,6 +1331,7 @@ def __init__( clock=clock, record_metrics=record_metrics) self._element_size_fn = element_size_fn + self._max_batch_weight = max_batch_weight self._max_batch_dur = max_batch_duration_secs self._clock = clock self._length_fn = length_fn @@ -1122,22 +1350,32 @@ def expand(self, pcoll): self._length_fn, self._bucket_boundaries) else: keying_dofn = WithSharedKey() - return pcoll | ParDo(keying_dofn) | ParDo( - _pardo_stateful_batch_elements( - coder, - self._batch_size_estimator, - self._max_batch_dur, - self._clock)) + if self._element_size_fn is None: + batching_dofn = _pardo_stateful_batch_elements( + coder, self._batch_size_estimator, self._max_batch_dur, self._clock) + else: + batching_dofn = _pardo_stateful_weighted_batch_elements( + coder, + self._batch_size_estimator, + self._max_batch_dur, + self._element_size_fn, + self._max_batch_weight, + self._clock) + return pcoll | ParDo(keying_dofn) | ParDo(batching_dofn) elif pcoll.windowing.is_default(): # This is the same logic as _GlobalWindowsBatchingDoFn, but optimized # for that simpler case. return pcoll | ParDo( _GlobalWindowsBatchingDoFn( - self._batch_size_estimator, self._element_size_fn)) + self._batch_size_estimator, + self._element_size_fn or _unit_element_size, + self._max_batch_weight)) else: return pcoll | ParDo( _WindowAwareBatchingDoFn( - self._batch_size_estimator, self._element_size_fn)) + self._batch_size_estimator, + self._element_size_fn or _unit_element_size, + self._max_batch_weight)) class _SortAndBatchElementsDoFn(DoFn): diff --git a/sdks/python/apache_beam/transforms/util_test.py b/sdks/python/apache_beam/transforms/util_test.py index 1e3db695e074..4456fea18536 100644 --- a/sdks/python/apache_beam/transforms/util_test.py +++ b/sdks/python/apache_beam/transforms/util_test.py @@ -567,6 +567,86 @@ def test_sized_windowed_batches(self): 'a' * 7, ])) + def test_sized_batches_oversized_element(self): + # An element that exceeds the batch size on its own is emitted in a batch + # by itself, instead of after an empty batch. + with TestPipeline('FnApiRunner') as p: + res = ( + p + | beam.Create(['a' * 20, 'a', 'a'], reshuffle=False) + | util.BatchElements( + min_batch_size=10, max_batch_size=10, element_size_fn=len) + | beam.Map(lambda batch: tuple(len(e) for e in batch))) + assert_that(res, equal_to([(20, ), (1, 1)])) + + def test_sized_windowed_batches_oversized_element(self): + with TestPipeline('FnApiRunner') as p: + res = ( + p + | beam.Create(['a' * 20, 'a', 'a'], reshuffle=False) + | beam.Map(lambda e: window.TimestampedValue(e, 1)) + | beam.WindowInto(window.FixedWindows(10)) + | util.BatchElements( + min_batch_size=10, + max_batch_size=10, + element_size_fn=len, + clock=FakeClock()) + | beam.Map(lambda batch: tuple(len(e) for e in batch))) + assert_that(res, equal_to([(20, ), (1, 1)])) + + def test_max_batch_weight(self): + # Batches have at most 3 elements, with a total length of at most 10. + # The first batch of a bundle is limited by its number of elements, as the + # estimator replays its size as the next target. + with TestPipeline('FnApiRunner') as p: + res = ( + p + | beam.Create([1, 1, 1, 4, 4, 4, 1, 20, 1], reshuffle=False) + | beam.Map(lambda length: 'a' * length) + | util.BatchElements( + min_batch_size=3, + max_batch_size=3, + max_batch_weight=10, + element_size_fn=len) + | beam.Map(lambda batch: tuple(len(e) for e in batch))) + assert_that(res, equal_to([(1, 1, 1), (4, 4), (4, 1), (20, ), (1, )])) + + def test_max_batch_weight_windowed(self): + # As above, the first batch of the bundle is limited by its number of + # elements. + with TestPipeline('FnApiRunner') as p: + res = ( + p + | beam.Create([(1, 11), (1, 11), (1, 11), (1, 11), (4, 1), (4, 1), + (4, 1)], + reshuffle=False) + | beam.MapTuple( + lambda length, ts: window.TimestampedValue('a' * length, ts)) + | beam.WindowInto(window.FixedWindows(10)) + | util.BatchElements( + min_batch_size=3, + max_batch_size=3, + max_batch_weight=10, + element_size_fn=len, + clock=FakeClock()) + | beam.Map(lambda batch: tuple(len(e) for e in batch))) + assert_that( + res, + equal_to([ + (4, 4), + (4, ), # Elements in [0, 10) + (1, 1, 1), + (1, ), # Elements in [10, 20) + ])) + + def test_max_batch_weight_requires_element_size_fn(self): + with self.assertRaisesRegex(ValueError, 'requires element_size_fn'): + util.BatchElements(max_batch_weight=10) + + def test_max_batch_weight_must_be_positive(self): + with self.assertRaisesRegex(ValueError, 'must be >= 1'): + util.BatchElements(max_batch_weight=0, element_size_fn=len) + def test_target_duration(self): clock = FakeClock() batch_estimator = util._BatchSizeEstimator( @@ -879,6 +959,93 @@ def test_stateful_grows_to_max_batch(self): | beam.Map(len)) assert_that(res, equal_to([1, 1, 2, 4, 8, 16, 32, 50, 50])) + def test_stateful_sized_batches(self): + # The stateful implementation used to ignore element_size_fn. + with TestPipeline('FnApiRunner') as p: + res = ( + p + | beam.Create(['aaaaa'] * 6) + | util.BatchElements( + min_batch_size=10, + max_batch_size=10, + max_batch_duration_secs=100, + element_size_fn=len) + | beam.Map(len)) + assert_that(res, equal_to([2, 2, 2])) + + def test_stateful_max_batch_weight(self): + def batch_lengths(label, elements): + return ( + p + | f'Create{label}' >> beam.Create(elements) + | f'Batch{label}' >> util.BatchElements( + min_batch_size=3, + max_batch_size=3, + max_batch_duration_secs=100, + max_batch_weight=10, + element_size_fn=len) + | f'Len{label}' >> beam.Map(len)) + + with TestPipeline('FnApiRunner') as p: + # At most 2 elements of length 4 fit within max_batch_weight. + assert_that( + batch_lengths('Heavy', ['aaaa'] * 6), + equal_to([2, 2, 2]), + label='CheckHeavy') + # At most 3 elements fit within max_batch_size. + assert_that( + batch_lengths('Light', ['a'] * 7), + equal_to([3, 3, 1]), + label='CheckLight') + + def test_stateful_max_batch_weight_oversized_element(self): + with TestPipeline('FnApiRunner') as p: + res = ( + p + | beam.Create(['a' * 20, 'a', 'a']) + | util.BatchElements( + min_batch_size=3, + max_batch_size=3, + max_batch_duration_secs=100, + max_batch_weight=10, + element_size_fn=len) + | beam.Map(lambda batch: sorted(len(e) for e in batch))) + + def check(batches): + # Whatever the order of the elements, the oversized element is emitted + # in a batch by itself, and no batch is empty. + assert [20] in batches, batches + assert all(batches), batches + assert sorted(sum(batches, [])) == [1, 1, 20], batches + + assert_that(res, check) + + def test_stateful_element_size_fn_keeps_state_and_label(self): + # Setting element_size_fn only adds a 'weight' state, and keeps the + # transform names, to minimize the impact on the update compatibility of + # streaming pipelines. + def stateful_transforms(**kwargs): + p = beam.Pipeline() + batch_elements = util.BatchElements(max_batch_duration_secs=100, **kwargs) + _ = p | beam.Create(['a']) | 'Batch' >> batch_elements + transforms = {} + for transform in p.to_runner_api().components.transforms.values(): + if transform.spec.urn == common_urns.primitives.PAR_DO.urn: + payload = proto_utils.parse_Bytes( + transform.spec.payload, beam_runner_api_pb2.ParDoPayload) + if payload.state_specs: + transforms[transform.unique_name] = set(payload.state_specs) + return transforms + + state = {'values', 'count', 'batch_size', 'batch_estimator'} + label = 'Batch/ParDo(_StatefulBatchElementsDoFn)' + self.assertEqual(stateful_transforms(), {label: state}) + self.assertEqual( + stateful_transforms(element_size_fn=len), {label: state | {'weight'}}) + self.assertEqual( + stateful_transforms(element_size_fn=len, max_batch_weight=10), + {label: state | {'weight'}}) + def test_length_bucket_assignment(self): """WithLengthBucketKey assigns correct bucket indices.""" boundaries = [10, 50, 100] @@ -956,12 +1123,15 @@ def test_bucket_boundaries_must_be_positive(self): def test_length_fn_without_stateful_is_ignored(self): """length_fn without max_batch_duration_secs uses non-stateful path.""" + with self.assertLogs(util.__name__, level='WARNING') as logs: + batch_elements = util.BatchElements( + min_batch_size=3, max_batch_size=3, length_fn=len) + self.assertIn('ignores length_fn', logs.output[0]) with TestPipeline() as p: res = ( p | beam.Create(['a', 'bb', 'ccc']) - | util.BatchElements( - min_batch_size=3, max_batch_size=3, length_fn=len) + | batch_elements | beam.Map(len)) assert_that(res, equal_to([3])) From 86d036c2b0a0790be25343c81d08c09ab845525d Mon Sep 17 00:00:00 2001 From: Valentyn Tymofieiev Date: Wed, 14 Oct 2026 19:48:44 -0700 Subject: [PATCH 050/108] Apply element_size_fn and batch_length_fn to examples in keyed RunInference handlers. --- CHANGES.md | 1 + sdks/python/apache_beam/ml/inference/base.py | 41 +++++++- .../apache_beam/ml/inference/base_test.py | 99 +++++++++++++++++++ 3 files changed, 138 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 142284de1350..f1bae6e714fa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -123,6 +123,7 @@ * (Python) DataFrame API: `Series.value_counts(bins=...)` no longer always fails with a `TypeError` ([#40503](https://github.com/apache/beam/issues/40503)). * (Python) Setting `max_batch_weight` on a RunInference `ModelHandler` no longer fails with a `TypeError`: `BatchElements` now supports `max_batch_weight`, which limits the total `element_size_fn` of a batch in addition to its number of elements ([#ISSUE](https://github.com/apache/beam/issues/ISSUE)). * (Python) `BatchElements` now honors `element_size_fn` when `max_batch_duration_secs` is set, and no longer emits an empty batch before an element that exceeds the batch size on its own ([#ISSUE](https://github.com/apache/beam/issues/ISSUE)). +* (Python) `KeyedModelHandler` and `MaybeKeyedModelHandler` now apply the `element_size_fn` and `batch_length_fn` of the wrapped `ModelHandler` to examples, instead of to `(key, example)` pairs ([#ISSUE](https://github.com/apache/beam/issues/ISSUE)). * Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). ## Security Fixes diff --git a/sdks/python/apache_beam/ml/inference/base.py b/sdks/python/apache_beam/ml/inference/base.py index ce014e4b04f9..69cb166daf6f 100644 --- a/sdks/python/apache_beam/ml/inference/base.py +++ b/sdks/python/apache_beam/ml/inference/base.py @@ -201,8 +201,13 @@ def __init__( max_batch_size: the maximum batch size to use when batching inputs. max_batch_duration_secs: the maximum amount of time to buffer a batch before emitting; used in streaming contexts. - max_batch_weight: the maximum weight of a batch. Requires element_size_fn. + max_batch_weight: the maximum total weight of a batch, where the weight + of an element is given by element_size_fn, which must be set. The + batch size limits (min_batch_size and max_batch_size) then count + elements. element_size_fn: a function that returns the size (weight) of an element. + Without max_batch_weight, the batch size limits are expressed in this + size instead of in number of elements. batch_length_fn: a callable mapping an element to its length (int). When set together with max_batch_duration_secs, enables length-aware bucketed keying so that elements of similar length are batched together, reducing @@ -699,6 +704,35 @@ def __init__( self.mh = mh +class _ApplyToExample: + """Applies a function of an example to a (key, example) pair. + + The element_size_fn and batch_length_fn of a ModelHandler take examples, but + BatchElements sees (key, example) pairs when the ModelHandler is wrapped in a + KeyedModelHandler or a MaybeKeyedModelHandler. + """ + def __init__(self, fn: Callable[[Any], Any], maybe_keyed: bool = False): + self._fn = fn + self._maybe_keyed = maybe_keyed + + def __call__(self, element): + # Like MaybeKeyedModelHandler.run_inference, consider tuples to be keyed. + if self._maybe_keyed and not isinstance(element, tuple): + return self._fn(element) + return self._fn(element[1]) + + +def _keyed_batch_elements_kwargs( + batch_elements_kwargs: Mapping[str, Any], + maybe_keyed: bool = False) -> dict[str, Any]: + """Adapts the BatchElements kwargs of a ModelHandler to keyed examples.""" + kwargs = dict(batch_elements_kwargs) + for name in ('element_size_fn', 'length_fn'): + if kwargs.get(name) is not None: + kwargs[name] = _ApplyToExample(kwargs[name], maybe_keyed) + return kwargs + + class KeyedModelHandler(Generic[KeyT, ExampleT, PredictionT, ModelT], ModelHandler[tuple[KeyT, ExampleT], tuple[KeyT, PredictionT], @@ -963,7 +997,7 @@ def get_resource_hints(self): def batch_elements_kwargs(self): if self._single_model: - return self._unkeyed.batch_elements_kwargs() + return _keyed_batch_elements_kwargs(self._unkeyed.batch_elements_kwargs()) return {} def validate_inference_args(self, inference_args: Optional[dict[str, Any]]): @@ -1173,7 +1207,8 @@ def get_resource_hints(self): return self._unkeyed.get_resource_hints() def batch_elements_kwargs(self): - return self._unkeyed.batch_elements_kwargs() + return _keyed_batch_elements_kwargs( + self._unkeyed.batch_elements_kwargs(), maybe_keyed=True) def validate_inference_args(self, inference_args: Optional[dict[str, Any]]): return self._unkeyed.validate_inference_args(inference_args) diff --git a/sdks/python/apache_beam/ml/inference/base_test.py b/sdks/python/apache_beam/ml/inference/base_test.py index 64f5c3b1525d..2ca60061eb24 100644 --- a/sdks/python/apache_beam/ml/inference/base_test.py +++ b/sdks/python/apache_beam/ml/inference/base_test.py @@ -2650,6 +2650,105 @@ def test_run_inference_with_length_aware_batch_elements(self): assert_that(results, equal_to(['a:2', 'bb:2', 'cccccc:7', 'ddddddd:7'])) +class BatchLengthsModelHandler(base.ModelHandler[str, tuple, FakeModel]): + """Predicts the lengths of the examples in the batch of each example.""" + def load_model(self): + return FakeModel() + + def run_inference(self, batch, model, inference_args=None): + lengths = tuple(len(example) for example in batch) + return [lengths] * len(batch) + + +class RunInferenceWeightedBatchingTest(unittest.TestCase): + """End-to-end tests for max_batch_weight and element_size_fn.""" + def _handler(self, **kwargs): + # At most 2 examples of length 4 fit within max_batch_weight. + return BatchLengthsModelHandler( + min_batch_size=3, + max_batch_size=3, + max_batch_weight=10, + element_size_fn=len, + **kwargs) + + def test_max_batch_weight(self): + with TestPipeline('FnApiRunner') as p: + results = ( + p + | beam.Create(['aaaa'] * 6, reshuffle=False) + | base.RunInference(self._handler())) + assert_that(results, equal_to([(4, 4)] * 6)) + + def test_max_batch_weight_with_max_batch_duration_secs(self): + with TestPipeline('FnApiRunner') as p: + results = ( + p + | beam.Create(['aaaa'] * 6) + | base.RunInference(self._handler(max_batch_duration_secs=60))) + assert_that(results, equal_to([(4, 4)] * 6)) + + def test_keyed_model_handler_sizes_examples(self): + # element_size_fn applies to examples, not to (key, example) pairs. + handler = base.KeyedModelHandler(self._handler()) + with TestPipeline('FnApiRunner') as p: + results = ( + p + | beam.Create([(i, 'aaaa') for i in range(6)], reshuffle=False) + | base.RunInference(handler) + | beam.Values()) + assert_that(results, equal_to([(4, 4)] * 6)) + + def test_maybe_keyed_model_handler_sizes_examples(self): + handler = base.MaybeKeyedModelHandler(self._handler()) + with TestPipeline('FnApiRunner') as p: + keyed = ( + p + | 'CreateKeyed' >> beam.Create([(i, 'aaaa') for i in range(6)], + reshuffle=False) + | 'RunInferenceKeyed' >> base.RunInference(handler) + | beam.Values()) + assert_that(keyed, equal_to([(4, 4)] * 6), label='CheckKeyed') + unkeyed = ( + p + | 'CreateUnkeyed' >> beam.Create(['aaaa'] * 6, reshuffle=False) + | 'RunInferenceUnkeyed' >> base.RunInference(handler)) + assert_that(unkeyed, equal_to([(4, 4)] * 6), label='CheckUnkeyed') + + def test_keyed_model_handler_buckets_examples(self): + # batch_length_fn applies to examples, not to (key, example) pairs. + handler = base.KeyedModelHandler( + PaddingReportingStringModelHandler( + min_batch_size=2, + max_batch_size=2, + max_batch_duration_secs=60, + batch_length_fn=len, + batch_bucket_boundaries=[5])) + examples = [('k1', 'a'), ('k2', 'cccccc'), ('k3', 'bb'), ('k4', 'ddddddd')] + with TestPipeline('FnApiRunner') as p: + results = ( + p + | beam.Create(examples, reshuffle=False) + | base.RunInference(handler)) + assert_that( + results, + equal_to([('k1', 'a:2'), ('k2', 'cccccc:7'), ('k3', 'bb:2'), + ('k4', 'ddddddd:7')])) + + def test_keyed_batch_elements_kwargs(self): + unkeyed = FakeModelHandlerForBatching( + min_batch_size=2, element_size_fn=len, batch_length_fn=len) + keyed_kwargs = base.KeyedModelHandler(unkeyed).batch_elements_kwargs() + self.assertEqual(keyed_kwargs['min_batch_size'], 2) + self.assertEqual(keyed_kwargs['element_size_fn'](('key', 'abc')), 3) + self.assertEqual(keyed_kwargs['length_fn'](('key', 'abc')), 3) + maybe_keyed_kwargs = base.MaybeKeyedModelHandler( + unkeyed).batch_elements_kwargs() + self.assertEqual(maybe_keyed_kwargs['element_size_fn'](('key', 'abc')), 3) + self.assertEqual(maybe_keyed_kwargs['element_size_fn']('abc'), 3) + # The kwargs of the unkeyed ModelHandler are unchanged. + self.assertIs(unkeyed.batch_elements_kwargs()['element_size_fn'], len) + + class SimpleFakeModelHandler(base.ModelHandler[int, int, FakeModel]): def load_model(self): return FakeModel() From a8d6afdf5cbc8c4635732ad6dd8b702d16d869ec Mon Sep 17 00:00:00 2001 From: Elia LIU Date: Tue, 20 Oct 2026 20:26:05 +1100 Subject: [PATCH 051/108] Finish max_batch_weight support for RunInference: issue number in CHANGES.md and pipeline-level regression tests Builds on the commits from #40603. Adds the #40644 issue number to the CHANGES.md entries and two RunInference-level regression tests: the pipeline construction repro from the issue (plain, keyed, maybe-keyed and stateful handlers) and an oversized example that reaches run_inference in a batch by itself without any empty batch. --- CHANGES.md | 6 +-- .../apache_beam/ml/inference/base_test.py | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f1bae6e714fa..6c635bf200fa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -121,9 +121,9 @@ * (Python) DataFrame API: `agg` with a list that includes `'count'` or `'size'` now labels those rows `count`/`size` instead of `sum` ([#40504](https://github.com/apache/beam/issues/40504)). * (Python) `ConcatSource` no longer raises `ZeroDivisionError` when every source bundle reports a weight of 0, e.g. bundles smaller than 1 MB ([#21316](https://github.com/apache/beam/issues/21316)). * (Python) DataFrame API: `Series.value_counts(bins=...)` no longer always fails with a `TypeError` ([#40503](https://github.com/apache/beam/issues/40503)). -* (Python) Setting `max_batch_weight` on a RunInference `ModelHandler` no longer fails with a `TypeError`: `BatchElements` now supports `max_batch_weight`, which limits the total `element_size_fn` of a batch in addition to its number of elements ([#ISSUE](https://github.com/apache/beam/issues/ISSUE)). -* (Python) `BatchElements` now honors `element_size_fn` when `max_batch_duration_secs` is set, and no longer emits an empty batch before an element that exceeds the batch size on its own ([#ISSUE](https://github.com/apache/beam/issues/ISSUE)). -* (Python) `KeyedModelHandler` and `MaybeKeyedModelHandler` now apply the `element_size_fn` and `batch_length_fn` of the wrapped `ModelHandler` to examples, instead of to `(key, example)` pairs ([#ISSUE](https://github.com/apache/beam/issues/ISSUE)). +* (Python) Setting `max_batch_weight` on a RunInference `ModelHandler` no longer fails with a `TypeError`: `BatchElements` now supports `max_batch_weight`, which limits the total `element_size_fn` of a batch in addition to its number of elements ([#40644](https://github.com/apache/beam/issues/40644)). +* (Python) `BatchElements` now honors `element_size_fn` when `max_batch_duration_secs` is set, and no longer emits an empty batch before an element that exceeds the batch size on its own ([#40644](https://github.com/apache/beam/issues/40644)). +* (Python) `KeyedModelHandler` and `MaybeKeyedModelHandler` now apply the `element_size_fn` and `batch_length_fn` of the wrapped `ModelHandler` to examples, instead of to `(key, example)` pairs ([#40644](https://github.com/apache/beam/issues/40644)). * Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). ## Security Fixes diff --git a/sdks/python/apache_beam/ml/inference/base_test.py b/sdks/python/apache_beam/ml/inference/base_test.py index 2ca60061eb24..9915a759d04f 100644 --- a/sdks/python/apache_beam/ml/inference/base_test.py +++ b/sdks/python/apache_beam/ml/inference/base_test.py @@ -2660,6 +2660,14 @@ def run_inference(self, batch, model, inference_args=None): return [lengths] * len(batch) +class NonEmptyBatchLengthsModelHandler(BatchLengthsModelHandler): + """Fails on an empty batch, which RunInference should never produce.""" + def run_inference(self, batch, model, inference_args=None): + if not batch: + raise ValueError('run_inference received an empty batch') + return super().run_inference(batch, model, inference_args) + + class RunInferenceWeightedBatchingTest(unittest.TestCase): """End-to-end tests for max_batch_weight and element_size_fn.""" def _handler(self, **kwargs): @@ -2748,6 +2756,37 @@ def test_keyed_batch_elements_kwargs(self): # The kwargs of the unkeyed ModelHandler are unchanged. self.assertIs(unkeyed.batch_elements_kwargs()['element_size_fn'], len) + def test_oversized_example_is_batched_alone(self): + # An example heavier than max_batch_weight reaches run_inference in a + # batch by itself, and the model never receives an empty batch. The + # oversized example is not first, so that the estimator does not replay a + # batch size of 1 for the batch after it. + handler = NonEmptyBatchLengthsModelHandler( + min_batch_size=3, + max_batch_size=3, + max_batch_weight=10, + element_size_fn=len) + with TestPipeline('FnApiRunner') as p: + results = ( + p + | beam.Create(['a', 'a', 'a' * 20, 'a', 'a'], reshuffle=False) + | base.RunInference(handler)) + assert_that(results, equal_to([(1, 1)] * 4 + [(20, )])) + + def test_pipeline_construction_with_max_batch_weight(self): + # Regression test for https://github.com/apache/beam/issues/40644: + # RunInference.expand raised a TypeError because BatchElements did not + # accept max_batch_weight, before any model was loaded. + unkeyed_examples = ['a', 'bb'] + keyed_examples = [('k1', 'a'), ('k2', 'bb')] + for handler, examples in ( + (self._handler(), unkeyed_examples), + (self._handler(max_batch_duration_secs=60), unkeyed_examples), + (base.KeyedModelHandler(self._handler()), keyed_examples), + (base.MaybeKeyedModelHandler(self._handler()), unkeyed_examples)): + p = beam.Pipeline() + _ = p | beam.Create(examples) | base.RunInference(handler) + class SimpleFakeModelHandler(base.ModelHandler[int, int, FakeModel]): def load_model(self): From ba412b0ad7d64bc0e83b8eb10ca92e2e0e716c4d Mon Sep 17 00:00:00 2001 From: Vitaly Terentyev Date: Tue, 20 Oct 2026 14:03:35 +0400 Subject: [PATCH 052/108] Use with_output_types --- .../io/gcp/experimental/spannerio_read_perf_test.py | 4 +++- .../io/gcp/experimental/spannerio_write_perf_test.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/sdks/python/apache_beam/io/gcp/experimental/spannerio_read_perf_test.py b/sdks/python/apache_beam/io/gcp/experimental/spannerio_read_perf_test.py index 0fb97594d824..89502eadd3a2 100644 --- a/sdks/python/apache_beam/io/gcp/experimental/spannerio_read_perf_test.py +++ b/sdks/python/apache_beam/io/gcp/experimental/spannerio_read_perf_test.py @@ -57,6 +57,7 @@ from apache_beam import Map from apache_beam import ParDo from apache_beam.io import Read +from apache_beam.io.gcp.experimental.spannerio import _Mutator from apache_beam.io.gcp.experimental.spannerio import ReadFromSpanner from apache_beam.io.gcp.experimental.spannerio import WriteToSpanner from apache_beam.testing.load_tests.load_test import LoadTest @@ -133,7 +134,8 @@ def make_insert_mutations(element): | 'Produce rows' >> Read( SyntheticSource(self.parse_synthetic_source_options())) | 'Format' >> Map(format_record) - | 'Make mutations' >> FlatMap(make_insert_mutations) + | 'Make mutations' >> FlatMap( + make_insert_mutations).with_output_types(_Mutator) | 'Write to Spanner' >> WriteToSpanner( project_id=self.project, instance_id=self.spanner_instance, diff --git a/sdks/python/apache_beam/io/gcp/experimental/spannerio_write_perf_test.py b/sdks/python/apache_beam/io/gcp/experimental/spannerio_write_perf_test.py index 06be99fc6335..6ec1a60e999b 100644 --- a/sdks/python/apache_beam/io/gcp/experimental/spannerio_write_perf_test.py +++ b/sdks/python/apache_beam/io/gcp/experimental/spannerio_write_perf_test.py @@ -58,6 +58,7 @@ from apache_beam import Map from apache_beam import ParDo from apache_beam.io import Read +from apache_beam.io.gcp.experimental.spannerio import _Mutator from apache_beam.io.gcp.experimental.spannerio import WriteToSpanner from apache_beam.testing.load_tests.load_test import LoadTest from apache_beam.testing.load_tests.load_test_metrics_utils import CountMessages @@ -127,7 +128,8 @@ def make_insert_mutations(element): SyntheticSource(self.parse_synthetic_source_options())) | 'Count messages' >> ParDo(CountMessages(self.metrics_namespace)) | 'Format' >> Map(format_record) - | 'Make mutations' >> FlatMap(make_insert_mutations) + | 'Make mutations' >> FlatMap( + make_insert_mutations).with_output_types(_Mutator) | 'Measure time' >> ParDo(MeasureTime(self.metrics_namespace)) | 'Write to Spanner' >> WriteToSpanner( project_id=self.project, From f40b7169d963f9b8c17ede9cbaf5063a4ccadf7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:35 -0400 Subject: [PATCH 053/108] Bump distlib from 0.4.3 to 0.4.4 in /sdks/python (#40679) Bumps [distlib](https://github.com/pypa/distlib) from 0.4.3 to 0.4.4. - [Release notes](https://github.com/pypa/distlib/releases) - [Changelog](https://github.com/pypa/distlib/blob/master/CHANGES.rst) - [Commits](https://github.com/pypa/distlib/compare/0.4.3...0.4.4) --- updated-dependencies: - dependency-name: distlib dependency-version: 0.4.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- sdks/python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index f2abee2e9cbb..703047cb6ef1 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -29,7 +29,7 @@ requires = [ "grpcio-tools==1.78.0; python_version >= '3.14'", "mypy-protobuf==3.5.0", # Avoid https://github.com/pypa/virtualenv/issues/2006 - "distlib==0.4.3", + "distlib==0.4.4", # Numpy headers "numpy>=1.14.3,<2.5.0", # Update setup.py as well. # having cython here will create wheels that are platform dependent. From 41df604063fb5abdf6e25882e71d1ee3fe130e90 Mon Sep 17 00:00:00 2001 From: Vitaly Terentyev Date: Tue, 20 Oct 2026 16:53:47 +0400 Subject: [PATCH 054/108] Add missing properties (#40684) --- sdks/java/io/iceberg/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdks/java/io/iceberg/build.gradle b/sdks/java/io/iceberg/build.gradle index 86964472cd81..9c9e58edaac0 100644 --- a/sdks/java/io/iceberg/build.gradle +++ b/sdks/java/io/iceberg/build.gradle @@ -212,6 +212,10 @@ task integrationTest(type: Test) { task dataflowIntegrationTest(type: Test) { group = "Verification" + systemProperty "beam.iceberg.lakehouse.warehouse", + project.findProperty('lakehouseWarehouse') ?: 'bl://projects/apache-beam-testing/catalogs/beam-lakehouse-it' + systemProperty "beam.iceberg.lakehouse.locations", + project.findProperty('lakehouseLocations') ?: 'gs://beam-lakehouse-it,gs://beam-lakehouse-it-added-path' evaluationDependsOn(":runners:google-cloud-dataflow-java") dependsOn ":runners:google-cloud-dataflow-java:worker:shadowJar" dependsOn ":runners:google-cloud-dataflow-java:buildAndPushDockerJavaContainer" From 4dbf0d7895244981a552dbfa70e54e1123fd8b77 Mon Sep 17 00:00:00 2001 From: Yi Hu Date: Tue, 20 Oct 2026 09:18:10 -0400 Subject: [PATCH 055/108] Fix Release NightlySnapshot after enabling errorprone for Java17+ build (#40668) --- .../java/org/apache/beam/sdk/io/delta/DeltaWriteTestUtils.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaWriteTestUtils.java b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaWriteTestUtils.java index 90f987859d79..5ba483d56ce0 100644 --- a/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaWriteTestUtils.java +++ b/sdks/java/io/delta/src/test/java/org/apache/beam/sdk/io/delta/DeltaWriteTestUtils.java @@ -145,6 +145,7 @@ public int getInt(int rowId) { } @Override + @SuppressWarnings("PatternMatchingInstanceof") // Not available as spotless is on still Java11 public long getLong(int rowId) { if (dataType instanceof TimestampType) { Object val = rows.get(rowId).getValue(fieldIndex); From d746d747ef3fe88c1591d74bc8e0659fdc107368 Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Tue, 20 Oct 2026 09:36:24 -0400 Subject: [PATCH 056/108] [Python] Fix GCS client creation in non-default universes (#40674) --- sdks/python/apache_beam/io/gcp/gcsio.py | 21 +++++++- sdks/python/apache_beam/io/gcp/gcsio_test.py | 46 ++++++++++++++++ .../apache_beam/options/pipeline_options.py | 8 +-- .../options/pipeline_options_test.py | 52 +++++++++++++++++++ 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/sdks/python/apache_beam/io/gcp/gcsio.py b/sdks/python/apache_beam/io/gcp/gcsio.py index a673845811da..2bdc4ca7d7e4 100644 --- a/sdks/python/apache_beam/io/gcp/gcsio.py +++ b/sdks/python/apache_beam/io/gcp/gcsio.py @@ -30,6 +30,7 @@ # pytype: skip-file import logging +import os import re import time from typing import Optional @@ -69,6 +70,8 @@ DEFAULT_READ_BUFFER_SIZE = 16 * 1024 * 1024 DEFAULT_WRITE_BUFFER_SIZE = 16 * 1024 * 1024 +_DEFAULT_UNIVERSE_DOMAIN = 'googleapis.com' + # Writes are performed as resumable uploads, which require the chunk size to be # a multiple of 256 KiB. WRITE_BUFFER_SIZE_MULTIPLE = 256 * 1024 @@ -205,10 +208,26 @@ def create_storage_client(pipeline_options, use_credentials=True): if google_cloud_options.gcs_custom_audit_entries is not None: extra_headers.update(google_cloud_options.gcs_custom_audit_entries) + google_auth_credentials = credentials.get_google_auth_credentials() + + # storage.Client only reads the universe domain from client_options; it + # ignores GOOGLE_CLOUD_UNIVERSE_DOMAIN and the credentials' universe + # domain, and fails if the latter differs from its default + # (googleapis.com). Set it explicitly to support non-default universes + # such as Trusted Partner Cloud (TPC). + universe_domain = ( + os.environ.get('GOOGLE_CLOUD_UNIVERSE_DOMAIN') or + getattr(google_auth_credentials, 'universe_domain', None)) + client_options = None + if (isinstance(universe_domain, str) and + universe_domain != _DEFAULT_UNIVERSE_DOMAIN): + client_options = {'universe_domain': universe_domain} + return storage.Client( - credentials=credentials.get_google_auth_credentials(), + credentials=google_auth_credentials, project=google_cloud_options.project, client_info=beam_client_info, + client_options=client_options, extra_headers=extra_headers) else: return storage.Client.create_anonymous_client() diff --git a/sdks/python/apache_beam/io/gcp/gcsio_test.py b/sdks/python/apache_beam/io/gcp/gcsio_test.py index ddb94f9c17fa..30e330e3afba 100644 --- a/sdks/python/apache_beam/io/gcp/gcsio_test.py +++ b/sdks/python/apache_beam/io/gcp/gcsio_test.py @@ -959,6 +959,52 @@ def test_is_soft_delete_enabled(self, mock_get_bucket): self.gcs.is_soft_delete_enabled("gs://beam_without_soft_delete/tmp")) +_TPC_UNIVERSE_DOMAIN = "example-tpc.goog" + + +@unittest.skipIf(NotFound is None, 'GCP dependencies are not installed') +# Disable mTLS auto-detection, which is incompatible with non-default +# universe domains and may be enabled on developer machines. +@mock.patch.dict(os.environ, {"GOOGLE_API_USE_CLIENT_CERTIFICATE": "false"}) +class TestCreateStorageClientUniverseDomain(unittest.TestCase): + """Tests for Trusted Partner Cloud (TPC) universe domain support.""" + def _create_client(self, mock_get_service_credentials, universe_domain): + from apache_beam.internal.gcp.auth import _ApitoolsCredentialsAdapter + mock_get_service_credentials.return_value = _ApitoolsCredentialsAdapter( + _make_credentials("test-project", universe_domain=universe_domain)) + return gcsio.create_storage_client( + PipelineOptions(["--project=test-project"])) + + @mock.patch('apache_beam.internal.gcp.auth.get_service_credentials') + def test_default_universe_domain(self, mock_get_service_credentials): + os.environ.pop("GOOGLE_CLOUD_UNIVERSE_DOMAIN", None) + client = self._create_client( + mock_get_service_credentials, _DEFAULT_UNIVERSE_DOMAIN) + self.assertEqual(client.universe_domain, _DEFAULT_UNIVERSE_DOMAIN) + self.assertEqual( + client._connection.API_BASE_URL, "https://storage.googleapis.com") + + @mock.patch('apache_beam.internal.gcp.auth.get_service_credentials') + def test_universe_domain_from_env_var(self, mock_get_service_credentials): + os.environ["GOOGLE_CLOUD_UNIVERSE_DOMAIN"] = _TPC_UNIVERSE_DOMAIN + client = self._create_client( + mock_get_service_credentials, _TPC_UNIVERSE_DOMAIN) + self.assertEqual(client.universe_domain, _TPC_UNIVERSE_DOMAIN) + self.assertEqual( + client._connection.API_BASE_URL, + "https://storage.%s" % _TPC_UNIVERSE_DOMAIN) + + @mock.patch('apache_beam.internal.gcp.auth.get_service_credentials') + def test_universe_domain_from_credentials(self, mock_get_service_credentials): + os.environ.pop("GOOGLE_CLOUD_UNIVERSE_DOMAIN", None) + client = self._create_client( + mock_get_service_credentials, _TPC_UNIVERSE_DOMAIN) + self.assertEqual(client.universe_domain, _TPC_UNIVERSE_DOMAIN) + self.assertEqual( + client._connection.API_BASE_URL, + "https://storage.%s" % _TPC_UNIVERSE_DOMAIN) + + if __name__ == '__main__': logging.getLogger().setLevel(logging.INFO) unittest.main() diff --git a/sdks/python/apache_beam/options/pipeline_options.py b/sdks/python/apache_beam/options/pipeline_options.py index f0406e2505bc..dca07da5146e 100644 --- a/sdks/python/apache_beam/options/pipeline_options.py +++ b/sdks/python/apache_beam/options/pipeline_options.py @@ -1275,7 +1275,7 @@ def _warn_if_soft_delete_policy_enabled(self, arg_name): 'Unable to check soft delete policy because GCP dependencies are ' 'not installed.') return - if gcsio.GcsIO().is_soft_delete_enabled(gcs_path): + if gcsio.GcsIO(pipeline_options=self).is_soft_delete_enabled(gcs_path): logger.log_first_n( logging.WARN, "Bucket %s used as %s has soft-delete policy enabled." @@ -1289,8 +1289,10 @@ def _warn_if_soft_delete_policy_enabled(self, arg_name): arg_name, n=1, key="message") - except ImportError: - _LOGGER.warning('Unable to check soft delete policy due to import error.') + except Exception as e: # pylint: disable=broad-except + # This check only emits a warning, so it must not block pipeline + # submission. + _LOGGER.warning('Unable to check soft delete policy: %s', e) # If either temp or staging location has an issue, we use the valid one for # both locations. If both are bad we return an error. diff --git a/sdks/python/apache_beam/options/pipeline_options_test.py b/sdks/python/apache_beam/options/pipeline_options_test.py index 493e42d53010..2814995b067b 100644 --- a/sdks/python/apache_beam/options/pipeline_options_test.py +++ b/sdks/python/apache_beam/options/pipeline_options_test.py @@ -930,6 +930,58 @@ def _check_errors(self, options, validator, expected): errors = options._handle_temp_and_staging_locations(validator) self.assertEqual(errors, expected) + @unittest.skipIf(not has_gcsio, 'GCP dependencies are not installed') + @mock.patch.dict( + os.environ, + { + "GOOGLE_CLOUD_UNIVERSE_DOMAIN": "example-tpc.goog", + # mTLS auto-detection is incompatible with non-default universes. + "GOOGLE_API_USE_CLIENT_CERTIFICATE": "false", + }) + @mock.patch('apache_beam.io.gcp.gcsio.GcsIO.get_bucket') + @mock.patch('apache_beam.internal.gcp.auth.get_service_credentials') + def test_soft_delete_check_with_tpc_universe_domain( + self, mock_get_service_credentials, mock_get_bucket): + import google.auth.credentials + + from apache_beam.internal.gcp.auth import _ApitoolsCredentialsAdapter + mock_get_service_credentials.return_value = _ApitoolsCredentialsAdapter( + mock.Mock( + spec=google.auth.credentials.Credentials, + universe_domain="example-tpc.goog")) + soft_delete_policy = mock_get_bucket.return_value.soft_delete_policy + soft_delete_policy.retention_duration_seconds = 0 + + options = GoogleCloudOptions([ + '--project=myproject', + '--impersonate_service_account=sa@myproject.iam.gserviceaccount.com', + '--temp_location=gs://beam/tmp' + ]) + # Must not raise a universe domain mismatch ValueError. + options._warn_if_soft_delete_policy_enabled('temp_location') + + # The GCS client must be built from the user's pipeline options so that + # project and impersonation settings are honored. + used_options = mock_get_service_credentials.call_args[0][0].view_as( + GoogleCloudOptions) + self.assertEqual(used_options.project, 'myproject') + self.assertEqual( + used_options.impersonate_service_account, + 'sa@myproject.iam.gserviceaccount.com') + + @unittest.skipIf(not has_gcsio, 'GCP dependencies are not installed') + @mock.patch( + 'apache_beam.io.gcp.gcsio.GcsIO.__init__', + side_effect=ValueError('universe domain mismatch')) + def test_soft_delete_check_does_not_raise(self, unused_mock_init): + options = GoogleCloudOptions( + ['--project=myproject', '--temp_location=gs://beam/tmp']) + # The soft delete check only emits a warning; failures must not block + # pipeline submission. + with self.assertLogs('apache_beam.options.pipeline_options', + level='WARNING'): + options._warn_if_soft_delete_policy_enabled('temp_location') + def test_validation_good_stg_good_temp(self): runner = MockRunners.DataflowRunner() options = GoogleCloudOptions([ From 62f901f6bfd0ebf79430ef001cea8b9a6ba5cbf0 Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Tue, 20 Oct 2026 09:36:41 -0400 Subject: [PATCH 057/108] Upgrade google-api-client to 2.7.2 and use bomupgrade to manage (#40670) --- .../beam/gradle/BeamModulePlugin.groovy | 20 ++++++++++++------- .../worker/build.gradle | 2 +- scripts/tools/bomupgrader.py | 1 + 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy index 17fc16e9967f..c0681f63d02c 100644 --- a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy +++ b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy @@ -623,7 +623,11 @@ class BeamModulePlugin implements Plugin { // [bomupgrader] determined by: com.google.api:gax, consistent with: google_cloud_platform_libraries_bom def gax_version = "2.84.0" def google_ads_version = "33.0.0" - def google_clients_version = "2.0.0" + // [bomupgrader] determined by: com.google.api-client:google-api-client, consistent with: google_cloud_platform_libraries_bom + def google_api_client_version = "2.7.2" + // Suffix of the generated com.google.apis:google-api-services-* artifact versions + // (e.g. v1b3-rev20260503-2.0.0). This is NOT the google-api-client version. + def google_api_services_version_suffix = "2.0.0" def google_cloud_bigdataoss_version = "3.1.16" def google_code_gson_version = "2.10.1" def google_oauth_clients_version = "1.34.1" @@ -750,15 +754,17 @@ class BeamModulePlugin implements Plugin { gax_grpc : "com.google.api:gax-grpc", // google_cloud_platform_libraries_bom sets version gax_grpc_test : "com.google.api:gax-grpc:$gax_version:testlib", // google_cloud_platform_libraries_bom sets version gax_httpjson : "com.google.api:gax-httpjson", // google_cloud_platform_libraries_bom sets version - google_api_client : "com.google.api-client:google-api-client:$google_clients_version", // for the libraries using $google_clients_version below. - google_api_client_gson : "com.google.api-client:google-api-client-gson:$google_clients_version", - google_api_client_java6 : "com.google.api-client:google-api-client-java6:$google_clients_version", + google_api_client : "com.google.api-client:google-api-client:$google_api_client_version", + google_api_client_gson : "com.google.api-client:google-api-client-gson:$google_api_client_version", + google_api_client_java6 : "com.google.api-client:google-api-client-java6:$google_api_client_version", google_api_common : "com.google.api:api-common", // google_cloud_platform_libraries_bom sets version google_api_services_bigquery : "com.google.apis:google-api-services-bigquery:v2-rev20260731-2.0.0", // [bomupgrader] sets version google_api_services_cloudresourcemanager : "com.google.apis:google-api-services-cloudresourcemanager:v1-rev20250606-2.0.0", // [bomupgrader] sets version - google_api_services_dataflow : "com.google.apis:google-api-services-dataflow:v1b3-rev20260503-$google_clients_version", - google_api_services_healthcare : "com.google.apis:google-api-services-healthcare:v1-rev20240130-$google_clients_version", - google_api_services_pubsub : "com.google.apis:google-api-services-pubsub:v1-rev20220904-$google_clients_version", + // libraries-bom does not pin the following THREE google-api-services-* artifacts (no library + // in the BOM depends on them), so bomupgrader cannot upgrade them. Upgrade them manually. + google_api_services_dataflow : "com.google.apis:google-api-services-dataflow:v1b3-rev20260503-$google_api_services_version_suffix", + google_api_services_healthcare : "com.google.apis:google-api-services-healthcare:v1-rev20240130-$google_api_services_version_suffix", + google_api_services_pubsub : "com.google.apis:google-api-services-pubsub:v1-rev20220904-$google_api_services_version_suffix", google_api_services_storage : "com.google.apis:google-api-services-storage:v1-rev20260524-2.0.0", // [bomupgrader] sets version google_auth_library_credentials : "com.google.auth:google-auth-library-credentials", // google_cloud_platform_libraries_bom sets version google_auth_library_oauth2_http : "com.google.auth:google-auth-library-oauth2-http", // google_cloud_platform_libraries_bom sets version diff --git a/runners/google-cloud-dataflow-java/worker/build.gradle b/runners/google-cloud-dataflow-java/worker/build.gradle index a343cac28177..232fb683866b 100644 --- a/runners/google-cloud-dataflow-java/worker/build.gradle +++ b/runners/google-cloud-dataflow-java/worker/build.gradle @@ -20,7 +20,7 @@ plugins { id 'org.apache.beam.module' } // Set a specific version of 'com.google.apis:google-api-services-dataflow' // by adding -Pdataflow.version= in Gradle command. Otherwise, -// 'google_clients_version' defined in BeamModulePlugin will be used as default. +// 'library.java.google_api_services_dataflow' defined in BeamModulePlugin will be used as default. def DATAFLOW_VERSION = "dataflow.version" // Get full dependency of 'com.google.apis:google-api-services-dataflow' diff --git a/scripts/tools/bomupgrader.py b/scripts/tools/bomupgrader.py index 715c19a899d1..b0ad507dc4db 100644 --- a/scripts/tools/bomupgrader.py +++ b/scripts/tools/bomupgrader.py @@ -103,6 +103,7 @@ class BeamModulePluginProcessor: KNOWN_DEPS = { "arrow": "org.apache.arrow:arrow-memory-core", "gax": "com.google.api:gax", + "google_api_client": "com.google.api-client:google-api-client", "grpc": "io.grpc:grpc-netty", # use "grpc-netty" to pick up proper netty version "netty": "io.netty:netty-transport", From cdf9c3ada89b168db81429dba4fc73ebd5db5924 Mon Sep 17 00:00:00 2001 From: jrmccluskey Date: Tue, 20 Oct 2026 15:47:12 +0000 Subject: [PATCH 058/108] Update google-adk dependency --- .github/trigger_files/beam_PreCommit_Python_ML.json | 2 +- sdks/python/setup.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/trigger_files/beam_PreCommit_Python_ML.json b/.github/trigger_files/beam_PreCommit_Python_ML.json index 86bf1193abd9..273008f1b3ee 100644 --- a/.github/trigger_files/beam_PreCommit_Python_ML.json +++ b/.github/trigger_files/beam_PreCommit_Python_ML.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "revision": 7 + "revision": 8 } diff --git a/sdks/python/setup.py b/sdks/python/setup.py index a4888617a520..a983db5395ed 100644 --- a/sdks/python/setup.py +++ b/sdks/python/setup.py @@ -207,13 +207,13 @@ def cythonize(*args, **kwargs): ] ml_adk_dependency = [ - 'google-adk==1.28.1', + 'google-adk>=2.0.0,<3', # proto-plus<1.24 caps protobuf<5; opentelemetry-proto (via ADK) needs # protobuf>=5. Scoped here so the main dependency list stays broader. 'proto-plus>=1.26.1,<2', - 'opentelemetry-api==1.37.0', - 'opentelemetry-sdk==1.37.0', - 'opentelemetry-exporter-otlp-proto-http==1.37.0', + 'opentelemetry-api>=1.36.0,<1.43', + 'opentelemetry-sdk>=1.36.0,<1.43', + 'opentelemetry-exporter-otlp-proto-http>=1.36.0,<1.43', # protobuf>=5 (ADK/OTel); tf2onnx 1.16.x pins protobuf~=3.20 only. 'tf2onnx>=1.17.0,<1.18', ] From bf57625b7abb82d5a6396d4e73e50d10ad88937e Mon Sep 17 00:00:00 2001 From: Jack McCluskey <34928439+jrmccluskey@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:53:44 -0400 Subject: [PATCH 059/108] Revert errant google-adk bump on master (#40687) --- sdks/python/setup.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sdks/python/setup.py b/sdks/python/setup.py index a983db5395ed..a4888617a520 100644 --- a/sdks/python/setup.py +++ b/sdks/python/setup.py @@ -207,13 +207,13 @@ def cythonize(*args, **kwargs): ] ml_adk_dependency = [ - 'google-adk>=2.0.0,<3', + 'google-adk==1.28.1', # proto-plus<1.24 caps protobuf<5; opentelemetry-proto (via ADK) needs # protobuf>=5. Scoped here so the main dependency list stays broader. 'proto-plus>=1.26.1,<2', - 'opentelemetry-api>=1.36.0,<1.43', - 'opentelemetry-sdk>=1.36.0,<1.43', - 'opentelemetry-exporter-otlp-proto-http>=1.36.0,<1.43', + 'opentelemetry-api==1.37.0', + 'opentelemetry-sdk==1.37.0', + 'opentelemetry-exporter-otlp-proto-http==1.37.0', # protobuf>=5 (ADK/OTel); tf2onnx 1.16.x pins protobuf~=3.20 only. 'tf2onnx>=1.17.0,<1.18', ] From d8ef6a9dc3e561a4b628905f143beb424b173e7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:21:00 -0400 Subject: [PATCH 060/108] Bump github.com/klauspost/compress in /playground/backend (#40688) Bumps [github.com/klauspost/compress](https://github.com/klauspost/compress) from 1.15.9 to 1.18.7. - [Release notes](https://github.com/klauspost/compress/releases) - [Commits](https://github.com/klauspost/compress/compare/v1.15.9...v1.18.7) --- updated-dependencies: - dependency-name: github.com/klauspost/compress dependency-version: 1.18.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- playground/backend/go.mod | 2 +- playground/backend/go.sum | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/playground/backend/go.mod b/playground/backend/go.mod index dde6db29fdc3..f3c3ecb347b8 100644 --- a/playground/backend/go.mod +++ b/playground/backend/go.mod @@ -61,7 +61,7 @@ require ( github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.15.9 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/magiconair/properties v1.8.6 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/playground/backend/go.sum b/playground/backend/go.sum index a4fa7a6e6fc7..d1ce6b8b4095 100644 --- a/playground/backend/go.sum +++ b/playground/backend/go.sum @@ -1323,8 +1323,9 @@ github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYs github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= From 45e6dd25d0e42af162527d3f3507059142f98df4 Mon Sep 17 00:00:00 2001 From: Vitaly Terentyev Date: Tue, 20 Oct 2026 21:27:43 +0400 Subject: [PATCH 061/108] Fix chown --- website/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/build.gradle b/website/build.gradle index 5b49779fa44b..c4feed0045de 100644 --- a/website/build.gradle +++ b/website/build.gradle @@ -177,7 +177,7 @@ task downloadPerformanceLooks(type: Exec) { mkdir -p "\$OUT_DIR" # mkdir above runs as root. The leaf directory must be writable by the host uid. - chown "\$HOST_UID:\$HOST_GID" "\$OUT_DIR" + chown "\$HOST_UID:\$HOST_GID" "\$(dirname "\$OUT_DIR")" echo "[performance_looks] Syncing looks..." From a412e69657e6528ef893003345dfba37909535fd Mon Sep 17 00:00:00 2001 From: Ahmed Abualsaud <65791736+ahmedabu98@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:59:59 -0700 Subject: [PATCH 062/108] enable canceling SQL streaming pipeline (#40666) --- .../sql/impl/rel/BeamEnumerableConverter.java | 123 ++++++++++++++---- .../extensions/sql/impl/JdbcDriverTest.java | 51 ++++++++ .../impl/rel/BeamEnumerableConverterTest.java | 13 +- 3 files changed, 159 insertions(+), 28 deletions(-) diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamEnumerableConverter.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamEnumerableConverter.java index 69eb0a0a8d5c..12a939754602 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamEnumerableConverter.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamEnumerableConverter.java @@ -28,11 +28,14 @@ import java.util.List; import java.util.Map; import java.util.Queue; +import java.util.concurrent.CancellationException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import java.util.stream.StreamSupport; import org.apache.beam.runners.direct.DirectOptions; +import org.apache.beam.runners.direct.DirectRunner; import org.apache.beam.sdk.Pipeline; import org.apache.beam.sdk.Pipeline.PipelineVisitor; import org.apache.beam.sdk.PipelineResult; @@ -58,6 +61,7 @@ import org.apache.beam.sdk.values.PCollection.IsBounded; import org.apache.beam.sdk.values.PValue; import org.apache.beam.sdk.values.Row; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.DataContext; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.adapter.enumerable.EnumerableRel; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.adapter.enumerable.EnumerableRelImplementor; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.adapter.enumerable.PhysType; @@ -111,16 +115,18 @@ public Result implement(EnumerableRelImplementor implementor, Prefer prefer) { final PhysType physType = PhysTypeImpl.of(implementor.getTypeFactory(), rowType, prefer.preferArray()); final Expression node = implementor.stash((BeamRelNode) getInput(), BeamRelNode.class); - list.add(Expressions.call(BeamEnumerableConverter.class, "toEnumerable", node)); + list.add( + Expressions.call( + BeamEnumerableConverter.class, "toEnumerable", node, implementor.getRootExpression())); return implementor.result(physType, list.toBlock()); } - public static Enumerable toEnumerable(BeamRelNode node) { + public static Enumerable toEnumerable(BeamRelNode node, DataContext root) { final ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); try { Thread.currentThread().setContextClassLoader(BeamEnumerableConverter.class.getClassLoader()); final PipelineOptions options = createPipelineOptions(node.getPipelineOptions()); - return toEnumerable(options, node); + return toEnumerable(options, node, DataContext.Variable.CANCEL_FLAG.get(root)); } finally { Thread.currentThread().setContextClassLoader(originalClassLoader); } @@ -161,16 +167,21 @@ static List toRowList(PipelineOptions options, BeamRelNode node) { } else if (isLimitQuery(node)) { throw new UnsupportedOperationException("Does not support queries with LIMIT in toRowList."); } - return collectRows(options, node).stream().collect(Collectors.toList()); + return collectRows(options, node, new AtomicBoolean()).stream().collect(Collectors.toList()); } - static Enumerable toEnumerable(PipelineOptions options, BeamRelNode node) { + /** + * Runs the pipeline for {@code node}. Calcite sets {@code cancelFlag} when the JDBC statement is + * cancelled (e.g. with Ctrl+C in the SQL shell), which cancels the pipeline. + */ + static Enumerable toEnumerable( + PipelineOptions options, BeamRelNode node, AtomicBoolean cancelFlag) { if (node instanceof BeamIOSinkRel) { - return count(options, node); + return count(options, node, cancelFlag); } else if (isLimitQuery(node)) { - return limitCollect(options, node); + return limitCollect(options, node, cancelFlag); } - return Linq4j.asEnumerable(rowToAvaticaAndUnboxValues(collectRows(options, node))); + return Linq4j.asEnumerable(rowToAvaticaAndUnboxValues(collectRows(options, node, cancelFlag))); } private static PipelineResult limitRun( @@ -178,7 +189,8 @@ private static PipelineResult limitRun( BeamRelNode node, DoFn doFn, Queue values, - int limitCount) { + int limitCount, + AtomicBoolean cancelFlag) { options.as(DirectOptions.class).setBlockOnRun(false); Pipeline pipeline = Pipeline.create(options); PCollection resultCollection = BeamSqlRelUtils.toPCollection(pipeline, node); @@ -198,7 +210,7 @@ private static PipelineResult limitRun( } try { - if (values.size() >= limitCount) { + if (values.size() >= limitCount || cancelFlag.get()) { result.cancel(); break; } @@ -207,21 +219,78 @@ private static PipelineResult limitRun( break; } } + checkNotCancelled(cancelFlag); return result; } - private static void runCollector(PipelineOptions options, BeamRelNode node) { + private static void runCollector( + PipelineOptions options, BeamRelNode node, AtomicBoolean cancelFlag) { Pipeline pipeline = Pipeline.create(options); PCollection resultCollection = BeamSqlRelUtils.toPCollection(pipeline, node); resultCollection.apply(ParDo.of(new Collector())); - PipelineResult result = pipeline.run(); - if (PipelineResult.State.FAILED.equals(result.waitUntilFinish())) { + PipelineResult result = run(pipeline, cancelFlag); + if (PipelineResult.State.FAILED.equals(waitUntilFinish(result, cancelFlag))) { throw new RuntimeException("Pipeline failed for unknown reason"); } } - private static Queue collectRows(PipelineOptions options, BeamRelNode node) { + /** + * Runs the pipeline. If the DirectRunner would block in {@code run()} until the pipeline + * finishes, waits here instead, where the wait can be cancelled. + */ + private static PipelineResult run(Pipeline pipeline, AtomicBoolean cancelFlag) { + DirectOptions options = pipeline.getOptions().as(DirectOptions.class); + boolean blockOnRun = + options.isBlockOnRun() + && options.getRunner().getCanonicalName().equals(DirectRunner.class.getName()); + options.setBlockOnRun(false); + PipelineResult result = pipeline.run(); + if (blockOnRun) { + waitUntilFinish(result, cancelFlag); + } + return result; + } + + /** + * Waits for the pipeline to finish. If {@code cancelFlag} is set while waiting, cancels the + * pipeline so that the wait returns, and throws. + */ + private static State waitUntilFinish(PipelineResult result, AtomicBoolean cancelFlag) { + Thread canceller = + new Thread( + () -> { + try { + while (!cancelFlag.get()) { + Thread.sleep(100); + } + result.cancel(); + } catch (InterruptedException e) { + // The pipeline finished. + } catch (IOException | RuntimeException e) { + LOG.warn("Failed to cancel the pipeline", e); + } + }, + "BeamSqlPipelineCanceller"); + canceller.setDaemon(true); + canceller.start(); + try { + State state = result.waitUntilFinish(); + checkNotCancelled(cancelFlag); + return state; + } finally { + canceller.interrupt(); + } + } + + private static void checkNotCancelled(AtomicBoolean cancelFlag) { + if (cancelFlag.get()) { + throw new CancellationException("Statement canceled"); + } + } + + private static Queue collectRows( + PipelineOptions options, BeamRelNode node, AtomicBoolean cancelFlag) { long id = options.getOptionsId(); Queue values = new ConcurrentLinkedQueue<>(); @@ -233,14 +302,16 @@ private static Queue collectRows(PipelineOptions options, BeamRelNode node) "collectRowList is only available in direct runner."); Collector.globalValues.put(id, values); - - runCollector(options, node); - - Collector.globalValues.remove(id); + try { + runCollector(options, node, cancelFlag); + } finally { + Collector.globalValues.remove(id); + } return values; } - private static Enumerable limitCollect(PipelineOptions options, BeamRelNode node) { + private static Enumerable limitCollect( + PipelineOptions options, BeamRelNode node, AtomicBoolean cancelFlag) { long id = options.getOptionsId(); ConcurrentLinkedQueue values = new ConcurrentLinkedQueue<>(); @@ -254,8 +325,11 @@ private static Enumerable limitCollect(PipelineOptions options, BeamRelN int limitCount = getLimitCount(node); Collector.globalValues.put(id, values); - limitRun(options, node, new Collector(), values, limitCount); - Collector.globalValues.remove(id); + try { + limitRun(options, node, new Collector(), values, limitCount, cancelFlag); + } finally { + Collector.globalValues.remove(id); + } // remove extra retrieved values while (values.size() > limitCount) { @@ -378,14 +452,15 @@ private static Object fieldToAvatica(Schema.FieldType type, Object beamValue) { } } - private static Enumerable count(PipelineOptions options, BeamRelNode node) { + private static Enumerable count( + PipelineOptions options, BeamRelNode node, AtomicBoolean cancelFlag) { Pipeline pipeline = Pipeline.create(options); BeamSqlRelUtils.toPCollection(pipeline, node).apply(ParDo.of(new RowCounter())); - PipelineResult result = pipeline.run(); + PipelineResult result = run(pipeline, cancelFlag); long count = 0; if (!containsUnboundedPCollection(pipeline)) { - if (PipelineResult.State.FAILED.equals(result.waitUntilFinish())) { + if (PipelineResult.State.FAILED.equals(waitUntilFinish(result, cancelFlag))) { throw new RuntimeException("Pipeline failed for unknown reason"); } MetricQueryResults metrics = diff --git a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/JdbcDriverTest.java b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/JdbcDriverTest.java index 4133fb8b0700..b022d94777ba 100644 --- a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/JdbcDriverTest.java +++ b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/JdbcDriverTest.java @@ -22,9 +22,11 @@ import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import java.sql.Connection; @@ -36,13 +38,21 @@ import java.sql.Statement; import java.sql.Timestamp; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.TimeZone; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import org.apache.beam.sdk.extensions.sql.meta.catalog.InMemoryCatalogManager; import org.apache.beam.sdk.extensions.sql.meta.provider.ReadOnlyTableProvider; +import org.apache.beam.sdk.extensions.sql.meta.provider.seqgen.GenerateSequenceTableProvider; import org.apache.beam.sdk.extensions.sql.meta.provider.test.TestBoundedTable; import org.apache.beam.sdk.extensions.sql.meta.provider.test.TestTableProvider; import org.apache.beam.sdk.extensions.sql.meta.provider.test.TestUnboundedTable; @@ -449,6 +459,47 @@ public void testInternalConnect_unbounded_limit() throws Exception { assertFalse(resultSet2.next()); } + /** Statement.cancel() (e.g. Ctrl+C in the SQL shell) must stop a never-ending pipeline. */ + @Test + public void testCancelUnboundedQuery() throws Exception { + TestTableProvider testTables = new TestTableProvider(); + InMemoryCatalogManager catalogManager = new InMemoryCatalogManager(); + catalogManager.registerTableProvider(testTables); + catalogManager.registerTableProvider(new GenerateSequenceTableProvider()); + Connection connection = JdbcDriver.connect(catalogManager, PipelineOptionsFactory.create()); + Statement ddl = connection.createStatement(); + ddl.execute( + "CREATE EXTERNAL TABLE seq (sequence BIGINT, event_time TIMESTAMP) TYPE 'sequence'"); + ddl.execute("CREATE EXTERNAL TABLE sink (sequence BIGINT) TYPE 'test'"); + + ExecutorService executor = Executors.newCachedThreadPool(); + try { + Statement insert = connection.createStatement(); + Future insertRun = + executor.submit(() -> insert.execute("INSERT INTO sink SELECT sequence FROM seq")); + while (testTables.tableRows("sink").isEmpty() && !insertRun.isDone()) { + Thread.sleep(100); + } + assertCancels(insert, insertRun); + + for (String select : + Arrays.asList("SELECT sequence FROM seq", "SELECT sequence FROM seq LIMIT 1000000")) { + Statement statement = connection.createStatement(); + assertCancels(statement, executor.submit(() -> statement.executeQuery(select))); + } + } finally { + executor.shutdownNow(); + } + } + + private static void assertCancels(Statement statement, Future run) throws Exception { + statement.cancel(); + ExecutionException e = + assertThrows(ExecutionException.class, () -> run.get(30, TimeUnit.SECONDS)); + assertThat(e.getCause(), instanceOf(SQLException.class)); + assertThat(e.getCause().getMessage(), containsString("Statement canceled")); + } + private List> readResultSet(ResultSet result) throws Exception { List> results = new ArrayList<>(); diff --git a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamEnumerableConverterTest.java b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamEnumerableConverterTest.java index 697643d4715c..42d138e2f382 100644 --- a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamEnumerableConverterTest.java +++ b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamEnumerableConverterTest.java @@ -23,6 +23,7 @@ import java.math.BigDecimal; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.beam.sdk.extensions.sql.impl.utils.CalciteUtils; import org.apache.beam.sdk.extensions.sql.meta.SchemaBaseBeamTable; import org.apache.beam.sdk.options.PipelineOptions; @@ -77,7 +78,8 @@ public void testToEnumerable_collectSingle() { ImmutableList.of(ImmutableList.of(rexBuilder.makeBigintLiteral(BigDecimal.ZERO))); BeamRelNode node = new BeamValuesRel(cluster, type, tuples, RelTraitSet.createEmpty()); - Enumerable enumerable = BeamEnumerableConverter.toEnumerable(options, node); + Enumerable enumerable = + BeamEnumerableConverter.toEnumerable(options, node, new AtomicBoolean()); Enumerator enumerator = enumerable.enumerator(); assertTrue(enumerator.moveNext()); @@ -97,7 +99,8 @@ public void testToEnumerable_collectMultiple() { rexBuilder.makeBigintLiteral(BigDecimal.ONE))); BeamRelNode node = new BeamValuesRel(cluster, type, tuples, RelTraitSet.createEmpty()); - Enumerable enumerable = BeamEnumerableConverter.toEnumerable(options, node); + Enumerable enumerable = + BeamEnumerableConverter.toEnumerable(options, node, new AtomicBoolean()); Enumerator enumerator = enumerable.enumerator(); assertTrue(enumerator.moveNext()); @@ -173,7 +176,8 @@ public void testToEnumerable_count() { new FakeTable(), null); - Enumerable enumerable = BeamEnumerableConverter.toEnumerable(options, node); + Enumerable enumerable = + BeamEnumerableConverter.toEnumerable(options, node, new AtomicBoolean()); Enumerator enumerator = enumerable.enumerator(); assertTrue(enumerator.moveNext()); @@ -225,7 +229,8 @@ public void testToEnumerable_collectNullValue() { rexBuilder.makeNullLiteral(CalciteUtils.toRelDataType(TYPE_FACTORY, fieldType)))); BeamRelNode node = new BeamValuesRel(cluster, type, tuples, RelTraitSet.createEmpty()); - Enumerable enumerable = BeamEnumerableConverter.toEnumerable(options, node); + Enumerable enumerable = + BeamEnumerableConverter.toEnumerable(options, node, new AtomicBoolean()); Enumerator enumerator = enumerable.enumerator(); assertTrue(enumerator.moveNext()); From 2fdcfb830db716bce9a6af5dd694ef57681e6ac7 Mon Sep 17 00:00:00 2001 From: ZIHAN DAI <99155080+PDGGK@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:01:22 +1100 Subject: [PATCH 063/108] Render ARRAY and ITERABLE fields that are not backed by a List (#39750) * Render ARRAY and ITERABLE fields that are not backed by a List Row#toString throws for an ITERABLE field holding a plain Iterable: Schema s = Schema.builder().addStringField("k") .addIterableField("vals", FieldType.STRING).build(); Row.withSchema(s).attachValues("k1", () -> list.iterator()).toString(); IllegalArgumentException: value type is '...' for field type 'ITERABLE' toPrettyFieldValueString demanded a List before iterating. An ITERABLE field declares an Iterable, so the guard is stricter than the type it guards, and the branch only ever iterates and counts -- both of which Iterable supports once materialised. Losing a whole Row's rendering is a poor trade for a stricter check here, since this is what Row#toString calls: one unusual field takes out logging and debugger output for every field beside it. The guard now requires Iterable and materialises once for the size count. A value that is neither still throws the same exception. Two tests: a bare Iterable on an ITERABLE field, which fails on master, and an ordinary List array as a control that passes either way. The first also asserts the value really is not a List before rendering, so it cannot go green for the wrong reason if the value ever starts arriving materialised. * Render the collection in one pass instead of copying it Review feedback from @ahmedabu98: keep the original iterable rather than recreating it as a list. The copy was load-bearing as the block stood, because it asked the collection for isEmpty(), then size(), then iterated it -- three traversals. A List absorbs that for free; an Iterable that can only be read once does not, and that is exactly the kind of value this PR widens support to. Doing it in a single pass gets both: no copy, and no assumption that the collection can be traversed more than once. Output is unchanged -- elements are separated by ",\n" as before, and an empty collection still renders as "[]". Two tests added. The one-shot case hands out its iterator exactly once and throws on a second attempt; with Iterables.isEmpty plus Iterables.size over the raw iterable it fails with "iterated more than once", and it is the only one of the thirteen that does. * Remove comment --- .../apache/beam/sdk/schemas/SchemaUtils.java | 32 ++++----- .../beam/sdk/schemas/SchemaUtilsTest.java | 67 +++++++++++++++++++ 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/sdks/java/core/src/main/java/org/apache/beam/sdk/schemas/SchemaUtils.java b/sdks/java/core/src/main/java/org/apache/beam/sdk/schemas/SchemaUtils.java index 88809182efcd..d45454be90fa 100644 --- a/sdks/java/core/src/main/java/org/apache/beam/sdk/schemas/SchemaUtils.java +++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/schemas/SchemaUtils.java @@ -18,7 +18,6 @@ package org.apache.beam.sdk.schemas; import java.util.Arrays; -import java.util.List; import java.util.Map; import java.util.Objects; import org.apache.beam.sdk.schemas.Schema.FieldType; @@ -270,7 +269,11 @@ static String toPrettyFieldValueString(Schema.FieldType fieldType, Object value, case ARRAY: case ITERABLE: { - if (!(value instanceof List)) { + // An ITERABLE field declares an Iterable, and an ARRAY value need not be a List + // either, so require only what is actually iterated below. This is reached from + // Row#toString, where refusing to render one field takes out logging and debugger + // output for the entire row. + if (!(value instanceof Iterable)) { throw new IllegalArgumentException( String.format( "value type is '%s' for field type '%s'", @@ -279,24 +282,23 @@ static String toPrettyFieldValueString(Schema.FieldType fieldType, Object value, FieldType elementType = Objects.requireNonNull(fieldType.getCollectionElementType()); @SuppressWarnings("unchecked") - List list = (List) value; - if (list.isEmpty()) { - return "[]"; - } + Iterable elements = (Iterable) value; + StringBuilder sb = new StringBuilder(); sb.append("[\n"); - int size = list.size(); - int index = 0; - for (Object element : list) { - sb.append(nextPrefix) - .append(toPrettyFieldValueString(elementType, element, nextPrefix)); - if (index++ < size - 1) { + boolean empty = true; + for (Object element : elements) { + if (!empty) { sb.append(",\n"); - } else { - sb.append("\n"); } + sb.append(nextPrefix) + .append(toPrettyFieldValueString(elementType, element, nextPrefix)); + empty = false; + } + if (empty) { + return "[]"; } - sb.append(prefix).append("]"); + sb.append("\n").append(prefix).append("]"); return sb.toString(); } case MAP: diff --git a/sdks/java/core/src/test/java/org/apache/beam/sdk/schemas/SchemaUtilsTest.java b/sdks/java/core/src/test/java/org/apache/beam/sdk/schemas/SchemaUtilsTest.java index c34832f58fb8..6a5312fcc48d 100644 --- a/sdks/java/core/src/test/java/org/apache/beam/sdk/schemas/SchemaUtilsTest.java +++ b/sdks/java/core/src/test/java/org/apache/beam/sdk/schemas/SchemaUtilsTest.java @@ -21,7 +21,9 @@ import static org.junit.Assert.assertTrue; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; import org.apache.beam.sdk.schemas.Schema.FieldType; import org.apache.beam.sdk.values.Row; @@ -147,4 +149,69 @@ public void testToPrettyStringRendersNullInsideArrayOfInts() { assertTrue(rendered, rendered.contains("1")); assertTrue(rendered, rendered.contains("null")); } + + @Test + public void testToPrettyStringRendersIterableThatIsNotAList() { + Schema schema = + Schema.builder().addStringField("k").addIterableField("vals", FieldType.STRING).build(); + // A bare Iterable, which is exactly what an ITERABLE field declares. + Iterable bare = () -> Arrays.asList("p", "q").iterator(); + Row row = Row.withSchema(schema).attachValues("k1", bare); + + // The precondition that makes this test meaningful: if the value ever starts arriving as a + // List, the assertion below would pass for the wrong reason. + assertTrue(!(row.getValue("vals") instanceof java.util.List)); + + String rendered = row.toString(); + assertTrue(rendered, rendered.contains("p")); + assertTrue(rendered, rendered.contains("q")); + } + + @Test + public void testToPrettyStringStillRendersAListArray() { + // Control: the ordinary List case must be unchanged. + Schema schema = Schema.builder().addArrayField("vals", FieldType.STRING).build(); + Row row = Row.withSchema(schema).attachValues((Object) Arrays.asList("p", "q")); + + String rendered = row.toString(); + assertTrue(rendered, rendered.contains("p")); + assertTrue(rendered, rendered.contains("q")); + } + + @Test + public void testToPrettyStringRendersAnIterableThatCanOnlyBeReadOnce() { + Schema schema = + Schema.builder().addStringField("k").addIterableField("vals", FieldType.STRING).build(); + // Hands out its iterator exactly once and fails loudly on a second attempt. Rendering asks + // the collection for one traversal now; asking for isEmpty(), then size(), then iterating + // would trip the guard below. + Iterable onceOnly = + new Iterable() { + private boolean taken = false; + + @Override + public Iterator iterator() { + if (taken) { + throw new IllegalStateException("iterated more than once"); + } + taken = true; + return Arrays.asList("p", "q").iterator(); + } + }; + Row row = Row.withSchema(schema).attachValues("k1", onceOnly); + + String rendered = row.toString(); + assertTrue(rendered, rendered.contains("p")); + assertTrue(rendered, rendered.contains("q")); + } + + @Test + public void testToPrettyStringRendersAnEmptyIterableAsEmptyBrackets() { + Schema schema = Schema.builder().addIterableField("vals", FieldType.STRING).build(); + Iterable empty = Collections::emptyIterator; + Row row = Row.withSchema(schema).attachValues((Object) empty); + + String rendered = row.toString(); + assertTrue(rendered, rendered.contains("[]")); + } } From f014f5aa0539175141902a9216d4285c120e689c Mon Sep 17 00:00:00 2001 From: Jack McCluskey <34928439+jrmccluskey@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:04:23 -0400 Subject: [PATCH 064/108] Update test case for numpy 2.4.6 (#40692) --- sdks/python/apache_beam/ml/transforms/base_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdks/python/apache_beam/ml/transforms/base_test.py b/sdks/python/apache_beam/ml/transforms/base_test.py index 64d0afe955d0..c7b904c09444 100644 --- a/sdks/python/apache_beam/ml/transforms/base_test.py +++ b/sdks/python/apache_beam/ml/transforms/base_test.py @@ -150,8 +150,8 @@ def test_ml_transform_on_list_dict(self): ), param( input_data=[{ - 'x': np.array([1], dtype=np.int64), - 'y': np.array([2.0], dtype=np.float32), + 'x': np.int64(1), + 'y': np.float32(2.0), }], input_types={ 'x': np.int32, 'y': np.float32 From 9ea82538701c82293088f78d2dacce1f843f301d Mon Sep 17 00:00:00 2001 From: Vitaly Terentyev Date: Tue, 20 Oct 2026 22:12:31 +0400 Subject: [PATCH 065/108] Add -R flag --- website/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/build.gradle b/website/build.gradle index c4feed0045de..998976151c08 100644 --- a/website/build.gradle +++ b/website/build.gradle @@ -177,7 +177,7 @@ task downloadPerformanceLooks(type: Exec) { mkdir -p "\$OUT_DIR" # mkdir above runs as root. The leaf directory must be writable by the host uid. - chown "\$HOST_UID:\$HOST_GID" "\$(dirname "\$OUT_DIR")" + chown -R "\$HOST_UID:\$HOST_GID" "\$(dirname "\$OUT_DIR")" echo "[performance_looks] Syncing looks..." From 7bdc6fff5c94e83538e76f41c419825fadede039 Mon Sep 17 00:00:00 2001 From: Vitaly Terentyev Date: Tue, 20 Oct 2026 22:38:38 +0400 Subject: [PATCH 066/108] Fix Release Nightly Snapshot job (#40685) * Remove redundant finalize method * Fix instanceof * Suppress warning * Suppress warning * Add exclusion * Fix typo * Remove suppress --- .../groovy/org/apache/beam/gradle/BeamModulePlugin.groovy | 1 + .../jms/src/main/java/org/apache/beam/sdk/io/jms/JmsIO.java | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy index c0681f63d02c..1e1c9108de76 100644 --- a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy +++ b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy @@ -1647,6 +1647,7 @@ class BeamModulePlugin implements Plugin { "StringCaseLocaleUsage", // DoFn methods are executed reflectively at pipeline runtime "UnusedMethod", + "PatternMatchingInstanceof" ] disabledChecks.each { options.errorprone.errorproneArgs.add("-Xep:${it}:OFF") diff --git a/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsIO.java b/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsIO.java index 1b9d0ce5acd0..c007439c88e8 100644 --- a/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsIO.java +++ b/sdks/java/io/jms/src/main/java/org/apache/beam/sdk/io/jms/JmsIO.java @@ -1001,12 +1001,6 @@ private void closeAutoscaler() { LOG.error("Error closing autoscaler", e); } } - - @Override - @SuppressWarnings({"Finalize", "removal"}) - protected void finalize() { - doClose(); - } } /** From 14be1c060976f27dd3df4f94bd5afc97e04852bd Mon Sep 17 00:00:00 2001 From: Jack McCluskey <34928439+jrmccluskey@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:44:20 -0400 Subject: [PATCH 067/108] Update google-adk dependency range (#40689) --- .github/trigger_files/beam_PreCommit_Python_ML.json | 2 +- sdks/python/setup.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/trigger_files/beam_PreCommit_Python_ML.json b/.github/trigger_files/beam_PreCommit_Python_ML.json index 273008f1b3ee..7df0105a8112 100644 --- a/.github/trigger_files/beam_PreCommit_Python_ML.json +++ b/.github/trigger_files/beam_PreCommit_Python_ML.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "revision": 8 + "revision": 22 } diff --git a/sdks/python/setup.py b/sdks/python/setup.py index a4888617a520..a983db5395ed 100644 --- a/sdks/python/setup.py +++ b/sdks/python/setup.py @@ -207,13 +207,13 @@ def cythonize(*args, **kwargs): ] ml_adk_dependency = [ - 'google-adk==1.28.1', + 'google-adk>=2.0.0,<3', # proto-plus<1.24 caps protobuf<5; opentelemetry-proto (via ADK) needs # protobuf>=5. Scoped here so the main dependency list stays broader. 'proto-plus>=1.26.1,<2', - 'opentelemetry-api==1.37.0', - 'opentelemetry-sdk==1.37.0', - 'opentelemetry-exporter-otlp-proto-http==1.37.0', + 'opentelemetry-api>=1.36.0,<1.43', + 'opentelemetry-sdk>=1.36.0,<1.43', + 'opentelemetry-exporter-otlp-proto-http>=1.36.0,<1.43', # protobuf>=5 (ADK/OTel); tf2onnx 1.16.x pins protobuf~=3.20 only. 'tf2onnx>=1.17.0,<1.18', ] From 8a9afdccb5d6bccd724638d09d5d8ba5db84e53a Mon Sep 17 00:00:00 2001 From: Chamikara Jayalath Date: Tue, 20 Oct 2026 12:37:05 -0700 Subject: [PATCH 068/108] Fix bigquery_tools_test to mock gcp_bigquery library instead... (#40676) --- sdks/python/apache_beam/io/gcp/bigquery_tools_test.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py index de4f3e4066af..6df9f6c5c143 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py @@ -1697,15 +1697,17 @@ def request(self, uri, method='GET', body=None, headers=None, **kwargs): for headers in token_request_headers: self.assertNotIn('x-goog-user-project', headers) - @mock.patch('apache_beam.io.gcp.bigquery_tools.gcp_bigquery.Client') - def test_gcp_bigquery_client_passes_quota_project(self, mock_client): + @mock.patch('apache_beam.io.gcp.bigquery_tools.gcp_bigquery') + def test_gcp_bigquery_client_passes_quota_project(self, mock_gcp_bq): + mock_client = mock_gcp_bq.Client BigQueryWrapper._gcp_bigquery_client(quota_project_id='my-billing-project') self.assertEqual( mock_client.call_args.kwargs['client_options'], {'quota_project_id': 'my-billing-project'}) - @mock.patch('apache_beam.io.gcp.bigquery_tools.gcp_bigquery.Client') - def test_gcp_bigquery_client_no_quota_project_by_default(self, mock_client): + @mock.patch('apache_beam.io.gcp.bigquery_tools.gcp_bigquery') + def test_gcp_bigquery_client_no_quota_project_by_default(self, mock_gcp_bq): + mock_client = mock_gcp_bq.Client BigQueryWrapper._gcp_bigquery_client() self.assertIsNone(mock_client.call_args.kwargs['client_options']) From d50c0a8235c138f9653405e06efebf5fca0cc545 Mon Sep 17 00:00:00 2001 From: Divyansh Yadav Date: Wed, 21 Oct 2026 01:42:43 +0530 Subject: [PATCH 069/108] [RunInference] Reject use_model_manager with a multi-model KeyedModelHandler (#40574) --- sdks/python/apache_beam/ml/inference/base.py | 33 +++++++++++++++++++ .../apache_beam/ml/inference/base_test.py | 23 +++++++++++++ 2 files changed, 56 insertions(+) diff --git a/sdks/python/apache_beam/ml/inference/base.py b/sdks/python/apache_beam/ml/inference/base.py index ce014e4b04f9..c4c3f146ed4e 100644 --- a/sdks/python/apache_beam/ml/inference/base.py +++ b/sdks/python/apache_beam/ml/inference/base.py @@ -767,6 +767,10 @@ def __init__( at the same time. For more information about memory management, see `Use a keyed `ModelHandler _`. # pylint: disable=line-too-long + A KeyedModelHandler with many models defined per key cannot be used with + `RunInference(..., use_model_manager=True)`, since it manages its per-key + models itself. + Args: unkeyed: Either (a) an implementation of ModelHandler that does not @@ -1459,6 +1463,19 @@ def __call__(self, *args, **kwargs): raise e +def _is_multi_model_keyed_handler(model_handler: ModelHandler) -> bool: + """Returns whether model_handler is a KeyedModelHandler with many models, + looking through any batching or pre/postprocessing wrappers.""" + while isinstance(model_handler, + (_PrebatchedModelHandler, + _PreProcessingModelHandler, + _PostProcessingModelHandler)): + model_handler = model_handler._base + return ( + isinstance(model_handler, KeyedModelHandler) and + not model_handler._single_model) + + class RunInference(beam.PTransform[beam.PCollection[Union[ExampleT, Iterable[ExampleT]]], beam.PCollection[PredictionT]]): @@ -1505,10 +1522,26 @@ def __init__( the same tag for different models will lead to non-deterministic results, so exercise caution when using this parameter. This only impacts models which are already being shared across processes. + use_model_manager: Whether to load and manage models with a + ModelManager, which tracks memory usage and evicts models as needed. + This is not supported with a KeyedModelHandler that maps keys to + multiple model handlers, since that handler manages its own per-key + models (see `max_models_per_worker_hint`); a ValueError is raised + in that case. + model_manager_args: Keyword arguments used to construct the + ModelManager when use_model_manager is True. monitoring_transform: A PTransform that receives a copy of the un-postprocessed PCollection of PredictionResult objects produced directly by inference. """ + if use_model_manager and _is_multi_model_keyed_handler(model_handler): + raise ValueError( + 'use_model_manager=True is not supported with a KeyedModelHandler ' + 'that maps keys to multiple model handlers. That handler loads and ' + 'caches its per-key models itself, so they would not be tracked by ' + 'the ModelManager. Use max_models_per_worker_hint on the ' + 'KeyedModelHandler to limit how many models are loaded at once, or ' + 'set use_model_manager=False.') self._model_handler = model_handler self._inference_args = inference_args self._clock = clock diff --git a/sdks/python/apache_beam/ml/inference/base_test.py b/sdks/python/apache_beam/ml/inference/base_test.py index 64f5c3b1525d..5890dd087d9a 100644 --- a/sdks/python/apache_beam/ml/inference/base_test.py +++ b/sdks/python/apache_beam/ml/inference/base_test.py @@ -791,6 +791,29 @@ def test_keyed_many_mhs_ignores_inner_no_batching_with_warning(self): self.assertFalse(mh.should_skip_batching()) self.assertTrue(any('skips batching' in line for line in logs.output)) + def test_keyed_many_model_handlers_with_model_manager_raises(self): + mhs = [ + base.KeyModelMapping([0], + FakeModelHandler( + state=200, multi_process_shared=True)), + base.KeyModelMapping([1, 2, 3], + FakeModelHandler(multi_process_shared=True)) + ] + keyed_mh = base.KeyedModelHandler(mhs) + with self.assertRaisesRegex(ValueError, r'use_model_manager=True'): + base.RunInference(keyed_mh, use_model_manager=True) + + # Wrapped handlers should be rejected as well. + with self.assertRaisesRegex(ValueError, r'use_model_manager=True'): + base.RunInference( + keyed_mh.with_preprocess_fn(lambda x: x).with_no_batching(), + use_model_manager=True) + + # Without the model manager, or with a single keyed model, it is allowed. + base.RunInference(keyed_mh) + base.RunInference( + base.KeyedModelHandler(FakeModelHandler()), use_model_manager=True) + def test_keyed_model_handler_get_num_bytes(self): mh = base.KeyedModelHandler(FakeModelHandler(num_bytes_per_element=10)) batch = [('key1', 1), ('key2', 2), ('key1', 3)] From 0724ef9ce58b86edb4b83a89dc4825f319edd7b7 Mon Sep 17 00:00:00 2001 From: Divyansh Yadav Date: Wed, 21 Oct 2026 04:20:37 +0530 Subject: [PATCH 070/108] [Spark 4] Clear the watermark of keys without timers or state in the streaming stateful ParDo (#40550) --- .../state/BeamStatefulProcessor.java | 13 ++++- .../streaming/StatefulParDoStreamingTest.java | 49 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java index 2649f883af3f..7d794ef04506 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java @@ -120,13 +120,22 @@ public void init(OutputMode outputMode, TimeMode timeMode) { } private void storeWatermark(SparkTimerInternals timerInternals) { - checkStateNotNull(beamWatermark).update(timerInternals.currentInputWatermarkTime().getMillis()); + ValueState watermarkState = checkStateNotNull(beamWatermark); + // A key without timers and state keeps no row, its next visit is a first visit. + if (timerInternals.getTimers().isEmpty() && !checkStateNotNull(beamState).exists()) { + if (watermarkState.exists()) { + watermarkState.clear(); + } + } else { + watermarkState.update(timerInternals.currentInputWatermarkTime().getMillis()); + } } private SparkTimerInternals timerInternals(TimerValues timerValues) { ValueState watermarkState = checkStateNotNull(beamWatermark); // Late data is filtered against the watermark of the key's previous visit. - // A key seen for the first time or idle for several batches filters against an older watermark. + // A key seen for the first time, cleared after its last window expired or idle for several + // batches filters against an older watermark. Instant low = watermarkState.exists() ? new Instant(watermarkState.get()) diff --git a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java index 2d3c127d72bc..18143ce4d924 100644 --- a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java +++ b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java @@ -21,6 +21,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import java.io.File; import java.io.Serializable; import java.util.Arrays; import java.util.Set; @@ -72,6 +73,7 @@ public class StatefulParDoStreamingTest implements Serializable { private static final String TAG_RESTART = "stateful-restart"; private static final String TAG_CHAINED = "stateful-chained"; private static final String TAG_NULL_KEY = "stateful-null-key"; + private static final String TAG_EXPIRY = "stateful-expiry"; @After public void tearDown() { @@ -79,6 +81,7 @@ public void tearDown() { TestUnboundedSource.forget(TAG_RESTART); TestUnboundedSource.forget(TAG_CHAINED); TestUnboundedSource.forget(TAG_NULL_KEY); + TestUnboundedSource.forget(TAG_EXPIRY); } private static Pipeline pipeline( @@ -190,6 +193,42 @@ public void testNullKey() throws Exception { assertTrue(collected.contains(5)); } + @Test + public void testExpiredKeyLeavesNoState() throws Exception { + String checkpoint = tempFolder.newFolder("cp").getAbsolutePath(); + String collectorId = StreamingTestUtils.newCollectorId(TAG_EXPIRY); + SparkStructuredStreamingPipelineOptions options = + StreamingTestUtils.streamingOptions(checkpoint); + options.setMaxRecordsPerBatch(1L); + Pipeline pipeline = Pipeline.create(options); + pipeline + .apply("Read", Read.from(new TestUnboundedSource(TAG_EXPIRY, 1, 8))) + .apply("KeySelector", ParDo.of(new ExpiryKeySelectorFn())) + .setCoder(KvCoder.of(StringUtf8Coder.of(), StringUtf8Coder.of())) + .apply("Window", Window.into(FixedWindows.of(Duration.millis(1000)))) + .apply("StatefulParDo", ParDo.of(new DownstreamFn())) + .apply("Collect", ParDo.of(new StreamingTestUtils.CollectDoFn<>(collectorId))); + + assertEquals(PipelineResult.State.DONE, StreamingTestUtils.run(pipeline).getState()); + Set collected = StreamingTestUtils.collected(collectorId); + assertTrue(collected.contains(TestUnboundedSource.element(TAG_EXPIRY, 0, 0))); + for (String stateVarName : Arrays.asList("beamState", "beamTimers", "beamWatermark")) { + assertEquals(stateVarName, 0L, stateRows(checkpoint, stateVarName)); + } + } + + /** Rows of a state variable of the query of the only leaf, checkpointed under 0. */ + private static long stateRows(String checkpoint, String stateVarName) { + return SESSION + .getSession() + .read() + .format("statestore") + .option("path", new File(checkpoint, "0").getAbsolutePath()) + .option("stateVarName", stateVarName) + .load() + .count(); + } + private static final class KeySelectorFn extends DoFn> { @ProcessElement public void process(@Element String element, OutputReceiver> out) { @@ -311,6 +350,16 @@ public void onTimer(@Timestamp Instant ts, OutputReceiver> ou } } + private static final class ExpiryKeySelectorFn extends DoFn> { + @ProcessElement + public void process(@Element String element, OutputReceiver> out) { + int i = TestUnboundedSource.indexOf(element); + if (i == 0) { + out.output(KV.of("k1", element)); + } + } + } + private static final class DownstreamFn extends DoFn, String> { @StateId("state") private final StateSpec> stateSpec = StateSpecs.value(); From 11e1ce2c1d3894f9479799ce8c646fce835c6c9f Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Tue, 20 Oct 2026 19:46:53 -0400 Subject: [PATCH 071/108] Add an option to set the Dataflow container base repository (Python, Java, Go) (#40690) * [Python] Add --dataflow_container_base_repository option Let users point the Dataflow runner's default SDK container images, and the rewritten cross-language apache/beam images, at a base repository other than gcr.io/cloud-dataflow/v1beta3. This lets jobs run in Trusted Partner Cloud (TPC) universes, which have their own registries. If the option is unset, behavior is unchanged. * [Java] Add --dataflowContainerBaseRepository option * [Go] Add --dataflow_container_base_repository flag * [Python] Use dataflow_container_base_repository for prebuilt SDK base image * [Java] Mark dataflowContainerBaseRepository as @Nullable --- CHANGES.md | 1 + .../beam/runners/dataflow/DataflowRunner.java | 25 +++++-- .../DataflowPipelineWorkerPoolOptions.java | 17 +++++ .../runners/dataflow/DataflowRunnerTest.java | 53 +++++++++++++ sdks/go/pkg/beam/runners/dataflow/dataflow.go | 74 +++++++++++++++++- .../beam/runners/dataflow/dataflow_test.go | 65 ++++++++++++++++ .../apache_beam/options/pipeline_options.py | 13 ++++ .../runners/dataflow/dataflow_runner.py | 7 ++ .../runners/dataflow/dataflow_runner_test.py | 50 +++++++++++++ .../runners/dataflow/internal/apiclient.py | 31 +++++++- .../dataflow/internal/apiclient_test.py | 75 +++++++++++++++++++ 11 files changed, 401 insertions(+), 10 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d06940d12cf4..d044e08105bf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -82,6 +82,7 @@ * (Python) Added `--aws_credentials_secret_manager` so AWS credentials (`--aws_access_key_id`, `--aws_secret_access_key`, `--aws_session_token` and their `--s3_*` overrides) can be secret specs resolved through a secret manager such as `GoogleCloudSecretManager`, instead of plain-text values ([#40528](https://github.com/apache/beam/issues/40528)). * (Python) Added `AwsSecret` (`AwsSecretsManager` / `type:awssecret`) to retrieve secrets from AWS Secrets Manager, configured from `AwsOptions` (`--aws_*`) with optional per-secret connection overrides ([#40528](https://github.com/apache/beam/issues/40528)). * (Go) Added `schema.RegisterLogicalTypeConversion` and `schema.RegisterLogicalTypeCoder` to register portable logical types in one call, and Go types for the standard `beam:logical_type:date:v1` and `beam:logical_type:micros_instant:v1` logical types, so schemas from other SDKs with these types can be used in cross-language pipelines ([#39684](https://github.com/apache/beam/issues/39684)). +* (Python, Java, Go) Added `--dataflow_container_base_repository` (Python, Go) and `--dataflowContainerBaseRepository` (Java) to set the repository (e.g. `docker.example.com/my-prefix/cloud-dataflow/v1beta3`) from which the Dataflow runner pulls its default SDK container images, including the default images of cross-language Apache Beam environments. Defaults to `gcr.io/cloud-dataflow/v1beta3` ([#40569](https://github.com/apache/beam/issues/40569)). ## Breaking Changes diff --git a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowRunner.java b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowRunner.java index f1b0c89090d3..796aaa960a4c 100644 --- a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowRunner.java +++ b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowRunner.java @@ -169,6 +169,7 @@ import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.InvalidProtocolBufferException; import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.TextFormat; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.CharMatcher; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Joiner; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings; @@ -1041,9 +1042,7 @@ protected RunnerApi.Pipeline applySdkEnvironmentOverrides( String imageAndTag = normalizeDataflowImageAndTag( containerImage.substring(containerImage.lastIndexOf("/"))); - containerImage = - DataflowRunnerInfo.getDataflowRunnerInfo().getContainerImageBaseRepository() - + imageAndTag; + containerImage = getContainerImageBaseRepository(options) + imageAndTag; } environmentBuilder.setPayload( RunnerApi.DockerPayload.newBuilder() @@ -2722,14 +2721,30 @@ static String getContainerImageForJob(DataflowPipelineOptions options) { /** Construct the default Dataflow container full image URL. */ static String getDefaultContainerImageUrl(DataflowPipelineOptions options) { - DataflowRunnerInfo dataflowRunnerInfo = DataflowRunnerInfo.getDataflowRunnerInfo(); return String.format( "%s/%s:%s", - dataflowRunnerInfo.getContainerImageBaseRepository(), + getContainerImageBaseRepository(options), getDefaultContainerImageNameForJob(options), getDefaultContainerVersion(options)); } + /** + * Returns the base repository of the default Dataflow container images, without a trailing slash. + * Uses {@code --dataflowContainerBaseRepository} if set, and the repository configured in {@link + * DataflowRunnerInfo} otherwise. + */ + @VisibleForTesting + static String getContainerImageBaseRepository(DataflowPipelineOptions options) { + String baseRepository = options.getDataflowContainerBaseRepository(); + if (baseRepository != null) { + String trimmed = CharMatcher.is('/').trimTrailingFrom(baseRepository.trim()); + if (!trimmed.isEmpty()) { + return trimmed; + } + } + return DataflowRunnerInfo.getDataflowRunnerInfo().getContainerImageBaseRepository(); + } + /** * Construct the default Dataflow container image name based on pipeline type and Java version. */ diff --git a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineWorkerPoolOptions.java b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineWorkerPoolOptions.java index bd2b968ff893..e8cc7ddc8e91 100644 --- a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineWorkerPoolOptions.java +++ b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineWorkerPoolOptions.java @@ -136,6 +136,23 @@ public String getAlgorithm() { void setSdkContainerImage(String value); + /** + * Base repository (registry host and path prefix) of the default Dataflow container images, e.g. + * {@code docker.example.com/my-prefix/cloud-dataflow/v1beta3}. If unset, the default repository + * of the Dataflow runner is used. + */ + @Description( + "Base repository (registry host and path prefix) from which the Dataflow runner pulls its " + + "default SDK container images, e.g. " + + "docker.example.com/my-prefix/cloud-dataflow/v1beta3. The runner appends the image " + + "name and tag, e.g. /beam_java17_sdk:. This is also used for the " + + "default images of cross-language Apache Beam environments. Ignored for the Java SDK " + + "image if sdkContainerImage is set. If not set, the Google Cloud Dataflow default " + + "(gcr.io/cloud-dataflow/v1beta3) is used.") + @Nullable String getDataflowContainerBaseRepository(); + + void setDataflowContainerBaseRepository(@Nullable String value); + /** * GCE network for launching * workers. diff --git a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java index b196aa1714c6..02e701332939 100644 --- a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java +++ b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java @@ -34,6 +34,7 @@ import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.hamcrest.Matchers.matchesRegex; import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -1311,6 +1312,20 @@ public void testApplySdkEnvironmentOverridesRcByDefault() throws IOException { assertThat(runner.applySdkEnvironmentOverrides(pipeline, options), equalTo(expectedPipeline)); } + @Test + public void testApplySdkEnvironmentOverridesWithCustomBaseRepository() throws IOException { + DataflowPipelineOptions options = buildPipelineOptions(); + options.setDataflowContainerBaseRepository( + "docker.example.com/my-prefix/cloud-dataflow/v1beta3/"); + String dockerHubPythonContainerUrl = "apache/beam_python3.11_sdk:2.68.0rc2"; + String customPythonContainerUrl = + "docker.example.com/my-prefix/cloud-dataflow/v1beta3/beam_python3.11_sdk:2.68.0"; + DataflowRunner runner = DataflowRunner.fromOptions(options); + RunnerApi.Pipeline pipeline = containerUrlToPipeline(dockerHubPythonContainerUrl); + RunnerApi.Pipeline expectedPipeline = containerUrlToPipeline(customPythonContainerUrl); + assertThat(runner.applySdkEnvironmentOverrides(pipeline, options), equalTo(expectedPipeline)); + } + @Test public void testStageArtifactWithoutStagedName() throws IOException { DataflowPipelineOptions options = buildPipelineOptions(); @@ -2106,6 +2121,44 @@ public void testGetContainerImageForJobFromOptionWithPlaceholder() { } } + @Test + public void testGetContainerImageBaseRepositoryDefault() { + DataflowPipelineOptions options = PipelineOptionsFactory.as(DataflowPipelineOptions.class); + String defaultBaseRepository = + DataflowRunnerInfo.getDataflowRunnerInfo().getContainerImageBaseRepository(); + assertThat( + DataflowRunner.getContainerImageBaseRepository(options), equalTo(defaultBaseRepository)); + options.setDataflowContainerBaseRepository(""); + assertThat( + DataflowRunner.getContainerImageBaseRepository(options), equalTo(defaultBaseRepository)); + assertThat(getContainerImageForJob(options), startsWith(defaultBaseRepository + "/")); + } + + @Test + public void testGetContainerImageForJobWithCustomBaseRepository() { + String baseRepository = "docker.example.com/my-prefix/cloud-dataflow/v1beta3"; + DataflowPipelineOptions options = PipelineOptionsFactory.as(DataflowPipelineOptions.class); + String defaultImage = getContainerImageForJob(options); + String defaultBaseRepository = + DataflowRunnerInfo.getDataflowRunnerInfo().getContainerImageBaseRepository(); + + for (String value : new String[] {baseRepository, baseRepository + "/"}) { + options.setDataflowContainerBaseRepository(value); + assertThat(DataflowRunner.getContainerImageBaseRepository(options), equalTo(baseRepository)); + assertThat( + getContainerImageForJob(options), + equalTo(baseRepository + defaultImage.substring(defaultBaseRepository.length()))); + } + } + + @Test + public void testSdkContainerImageTakesPrecedenceOverBaseRepository() { + DataflowPipelineOptions options = PipelineOptionsFactory.as(DataflowPipelineOptions.class); + options.setDataflowContainerBaseRepository("docker.example.com/prefix"); + options.setSdkContainerImage("some-container"); + assertThat(getContainerImageForJob(options), equalTo("some-container")); + } + @Test public void testStreamingWriteWithNoShardingReturnsNewTransform() { PipelineOptions options = TestPipeline.testingPipelineOptions(); diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflow.go b/sdks/go/pkg/beam/runners/dataflow/dataflow.go index ce5e99b0a8de..87226404fcd7 100644 --- a/sdks/go/pkg/beam/runners/dataflow/dataflow.go +++ b/sdks/go/pkg/beam/runners/dataflow/dataflow.go @@ -45,11 +45,13 @@ import ( "github.com/apache/beam/sdks/v2/go/pkg/beam/core/util/protox" "github.com/apache/beam/sdks/v2/go/pkg/beam/internal/errors" "github.com/apache/beam/sdks/v2/go/pkg/beam/log" + pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1" "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts" "github.com/apache/beam/sdks/v2/go/pkg/beam/options/jobopts" "github.com/apache/beam/sdks/v2/go/pkg/beam/runners/dataflow/dataflowlib" "github.com/apache/beam/sdks/v2/go/pkg/beam/util/gcsx" "github.com/apache/beam/sdks/v2/go/pkg/beam/x/hooks/perf" + "google.golang.org/protobuf/proto" ) // TODO(herohde) 5/16/2017: the Dataflow flags should match the other SDKs. @@ -59,6 +61,7 @@ var ( stagingLocation = flag.String("staging_location", "", "GCS staging location (required).") workerHarnessImage = flag.String("worker_harness_container_image", "", "Worker harness container image (optional). Deprecated in favor of the sdk_container_image flag.") image = flag.String("sdk_container_image", "", "Worker harness container image (optional).") + containerBaseRepository = flag.String("dataflow_container_base_repository", "", "Base repository (registry host and path prefix) from which Dataflow pulls the default SDK container images, e.g. docker.example.com/my-prefix/cloud-dataflow/v1beta3 (optional). If set, it is also used for the default images of cross-language Apache Beam environments. Defaults to "+defaultContainerBaseRepository+".") labels = flag.String("labels", "", "JSON-formatted map[string]string of job labels (optional).") serviceAccountEmail = flag.String("service_account_email", "", "Service account email (optional).") numWorkers = flag.Int64("num_workers", 0, "Number of workers (optional).") @@ -237,6 +240,16 @@ func Execute(ctx context.Context, p *beam.Pipeline) (beam.PipelineResult, error) if err != nil { return nil, errors.WithContext(err, "applying container image overrides") } + // Only rewrite cross-language Apache Beam images when a base repository is + // set explicitly, to keep the default behavior of pulling them from Docker + // Hub unchanged. Images already rewritten by the overrides above no longer + // match apache/beam* and are left as is. + if strings.TrimSpace(*containerBaseRepository) != "" { + err = updateBeamContainerImagesForDataflow(model, getContainerBaseRepository(), opts.ContainerImage) + if err != nil { + return nil, errors.WithContext(err, "updating cross-language container images") + } + } if *dryRun { log.Info(ctx, "Dry-run: not submitting job!") @@ -468,13 +481,72 @@ func getContainerImage(ctx context.Context) string { // to have a dev image. // However, RC versions should automatically be picked up, since // they are never tagged the RC number, just the main version. - return "gcr.io/cloud-dataflow/v1beta3/beam_go_sdk:" + core.SdkVersion + return getContainerBaseRepository() + "/beam_go_sdk:" + core.SdkVersion } return envConfig } panic(fmt.Sprintf("Unsupported environment %v", urn)) } +// defaultContainerBaseRepository is the repository of the default Dataflow +// container images, used when --dataflow_container_base_repository is unset. +const defaultContainerBaseRepository = "gcr.io/cloud-dataflow/v1beta3" + +// getContainerBaseRepository returns the base repository of the default +// Dataflow container images, without a trailing slash. +func getContainerBaseRepository() string { + if repo := strings.TrimRight(strings.TrimSpace(*containerBaseRepository), "/"); repo != "" { + return repo + } + return defaultContainerBaseRepository +} + +// updateBeamContainerImagesForDataflow rewrites the images of Docker +// environments that use Apache Beam images from Docker Hub (apache/beam_*), +// typically from cross-language transforms, to the same image in +// baseRepository. Release candidate suffixes are trimmed from the tag, since +// Dataflow images are only tagged with the release version. The image of the +// pipeline's own SDK environment (sdkImage) is left unchanged. +func updateBeamContainerImagesForDataflow(p *pipepb.Pipeline, baseRepository, sdkImage string) error { + for _, env := range p.GetComponents().GetEnvironments() { + if env.GetUrn() != graphx.URNEnvDocker { + continue + } + var payload pipepb.DockerPayload + if err := proto.Unmarshal(env.GetPayload(), &payload); err != nil { + return err + } + img := payload.GetContainerImage() + if !strings.HasPrefix(img, beamContainerImagePrefix) || img == sdkImage { + continue + } + payload.ContainerImage = baseRepository + "/" + trimRCTag(img[strings.LastIndex(img, "/")+1:]) + pl, err := proto.Marshal(&payload) + if err != nil { + return err + } + env.Payload = pl + } + return nil +} + +// beamContainerImagePrefix is the prefix of Apache Beam images on Docker Hub. +const beamContainerImagePrefix = "apache/beam" + +// trimRCTag removes a release candidate suffix (e.g. "rc1") from the tag of +// an image name such as "beam_java11_sdk:2.60.0rc1". +func trimRCTag(imageAndTag string) string { + tagIdx := strings.LastIndex(imageAndTag, ":") + if tagIdx < 0 { + return imageAndTag + } + tag := imageAndTag[tagIdx:] + if rcIdx := strings.LastIndex(strings.ToLower(tag), "rc"); rcIdx > 0 { + return imageAndTag[:tagIdx] + tag[:rcIdx] + } + return imageAndTag +} + func checkSoftDeletePolicyEnabled(ctx context.Context, bucketName string, locationName string) { bucket, _, err := gcsx.ParseObject(bucketName) if err != nil { diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflow_test.go b/sdks/go/pkg/beam/runners/dataflow/dataflow_test.go index 83d3c0108439..5348eef6e1ac 100644 --- a/sdks/go/pkg/beam/runners/dataflow/dataflow_test.go +++ b/sdks/go/pkg/beam/runners/dataflow/dataflow_test.go @@ -24,9 +24,12 @@ import ( "testing" "github.com/apache/beam/sdks/v2/go/pkg/beam/core" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime/graphx" + pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1" "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts" "github.com/apache/beam/sdks/v2/go/pkg/beam/options/jobopts" "github.com/apache/beam/sdks/v2/go/pkg/beam/runners/dataflow/dataflowlib" + "google.golang.org/protobuf/proto" ) func TestDontUseFlagAsPipelineOption(t *testing.T) { @@ -364,6 +367,67 @@ func TestGetJobOptions_DockerGCROverride(t *testing.T) { } } +func TestGetContainerImage_CustomBaseRepository(t *testing.T) { + const base = "docker.example.com/my-prefix/cloud-dataflow/v1beta3" + for _, value := range []string{base, base + "/", " " + base + " "} { + resetGlobals() + *jobopts.EnvironmentType = "docker" + *containerBaseRepository = value + + if got, want := getContainerImage(context.Background()), base+"/beam_go_sdk:"+core.SdkVersion; got != want { + t.Errorf("getContainerImage() with base repository %q = %q, want %q", value, got, want) + } + } +} + +func TestGetContainerImage_SdkContainerImageTakesPrecedenceOverBaseRepository(t *testing.T) { + resetGlobals() + *jobopts.EnvironmentType = "docker" + *containerBaseRepository = "docker.example.com/prefix" + *image = "testContainerImageOverride" + + if got, want := getContainerImage(context.Background()), "testContainerImageOverride"; got != want { + t.Fatalf("getContainerImage() = %q, want %q", got, want) + } +} + +func TestUpdateBeamContainerImagesForDataflow(t *testing.T) { + const base = "docker.example.com/my-prefix/cloud-dataflow/v1beta3" + const sdkImage = "apache/beam_go_sdk:2.60.0" + tests := map[string]struct { + urn, image, want string + }{ + "xlang": {graphx.URNEnvDocker, "apache/beam_java11_sdk:2.60.0", base + "/beam_java11_sdk:2.60.0"}, + "xlangRC": {graphx.URNEnvDocker, "apache/beam_python3.11_sdk:2.60.0rc1", base + "/beam_python3.11_sdk:2.60.0"}, + "sdkImage": {graphx.URNEnvDocker, sdkImage, sdkImage}, + "nonBeam": {graphx.URNEnvDocker, "other_org/beam_java11_sdk:2.60.0", "other_org/beam_java11_sdk:2.60.0"}, + "overridden": {graphx.URNEnvDocker, "gcr.io/my-project/beam_java11_sdk:2.60.0", "gcr.io/my-project/beam_java11_sdk:2.60.0"}, + "notDocker": {"beam:env:process:v1", "apache/beam_java11_sdk:2.60.0", "apache/beam_java11_sdk:2.60.0"}, + } + envs := map[string]*pipepb.Environment{} + for name, test := range tests { + pl, err := proto.Marshal(&pipepb.DockerPayload{ContainerImage: test.image}) + if err != nil { + t.Fatal(err) + } + envs[name] = &pipepb.Environment{Urn: test.urn, Payload: pl} + } + p := &pipepb.Pipeline{Components: &pipepb.Components{Environments: envs}} + + if err := updateBeamContainerImagesForDataflow(p, base, sdkImage); err != nil { + t.Fatalf("updateBeamContainerImagesForDataflow() returned error %v", err) + } + for name, test := range tests { + var payload pipepb.DockerPayload + if err := proto.Unmarshal(p.GetComponents().GetEnvironments()[name].GetPayload(), &payload); err != nil { + t.Fatal(err) + } + if got := payload.GetContainerImage(); got != test.want { + t.Errorf("%s: container image = %q, want %q", name, got, test.want) + } + } +} + func TestGetJobOptions_TransformMapping(t *testing.T) { resetGlobals() *stagingLocation = "gs://testStagingLocation" @@ -584,6 +648,7 @@ func resetGlobals() { *gcpopts.Project = "" *gcpopts.Region = "" *image = "" + *containerBaseRepository = "" *jobopts.EnvironmentType = "" *jobopts.EnvironmentConfig = "" *jobopts.Experiments = "" diff --git a/sdks/python/apache_beam/options/pipeline_options.py b/sdks/python/apache_beam/options/pipeline_options.py index dca07da5146e..da3aae4e6a17 100644 --- a/sdks/python/apache_beam/options/pipeline_options.py +++ b/sdks/python/apache_beam/options/pipeline_options.py @@ -1583,6 +1583,19 @@ def _add_argparse_args(cls, parser): 'separated by a comma where first value gives a regex to ' 'identify the container image to override and the second value ' 'gives the replacement container image.')) + parser.add_argument( + '--dataflow_container_base_repository', + default=None, + help=( + 'Base repository (registry host and path prefix) from which the ' + 'Dataflow runner pulls its default SDK container images, e.g. ' + 'docker.example.com/my-project/cloud-dataflow/v1beta3. The ' + 'runner appends the image name and tag, e.g. ' + '/beam_python3.11_sdk:. This is also used for the ' + 'default images of cross-language Apache Beam environments. ' + 'Ignored for the local SDK image if --sdk_container_image is set. ' + 'If not set, the Google Cloud Dataflow default ' + '(gcr.io/cloud-dataflow/v1beta3) is used.')) parser.add_argument( '--default_sdk_harness_log_level', default=None, diff --git a/sdks/python/apache_beam/runners/dataflow/dataflow_runner.py b/sdks/python/apache_beam/runners/dataflow/dataflow_runner.py index 389e51b5728a..58bcbb5c334e 100644 --- a/sdks/python/apache_beam/runners/dataflow/dataflow_runner.py +++ b/sdks/python/apache_beam/runners/dataflow/dataflow_runner.py @@ -410,6 +410,13 @@ def run_pipeline(self, pipeline, options, pipeline_proto=None): # if prebuild_sdk_container_engine is specified we will build a new sdk # container image with dependencies pre-installed and use that image, # instead of using the inferred default container image. + worker_options = options.view_as(WorkerOptions) + if (worker_options.dataflow_container_base_repository and + not worker_options.sdk_container_image): + # Build on top of the Dataflow image in the configured repository + # instead of the Apache Beam image on Docker Hub. + worker_options.sdk_container_image = ( + apiclient.get_container_image_from_options(options)) self._default_environment = ( environments.DockerEnvironment.from_options(options)) options.view_as(WorkerOptions).sdk_container_image = ( diff --git a/sdks/python/apache_beam/runners/dataflow/dataflow_runner_test.py b/sdks/python/apache_beam/runners/dataflow/dataflow_runner_test.py index c10902cf1fc6..c68f053c7eb0 100644 --- a/sdks/python/apache_beam/runners/dataflow/dataflow_runner_test.py +++ b/sdks/python/apache_beam/runners/dataflow/dataflow_runner_test.py @@ -27,6 +27,7 @@ from apache_beam.options.pipeline_options import DebugOptions from apache_beam.options.pipeline_options import GoogleCloudOptions from apache_beam.options.pipeline_options import PipelineOptions +from apache_beam.options.pipeline_options import WorkerOptions from apache_beam.pipeline import AppliedPTransform from apache_beam.pipeline import Pipeline from apache_beam.portability import common_urns @@ -361,6 +362,55 @@ def test_environment_override_translation_sdk_container_image(self): dependencies=environments.python_sdk_dependencies(options=options) + main_session_dep)) + def _run_prebuild_and_get_base_image(self, extra_properties): + self.default_properties.extend( + ['--prebuild_sdk_container_engine=local_docker'] + extra_properties) + options = PipelineOptions(self.default_properties) + options.view_as(DebugOptions).add_experiment( + 'disable_logging_submission_environment') + base_images = [] + + def fake_build(pipeline_options): + # The builder uses sdk_container_image as the base image if set. + base_images.append( + pipeline_options.view_as(WorkerOptions).sdk_container_image) + return 'prebuilt/beam_python_prebuilt_sdk:tag' + + remote_runner = DataflowRunner() + with mock.patch('apache_beam.runners.portability.sdk_container_builder.' + 'SdkContainerImageBuilder.build_container_image', + side_effect=fake_build): + with Pipeline(remote_runner, options=options) as p: + p | ptransform.Create([1, 2, 3]) # pylint: disable=expression-not-assigned + + from apache_beam.utils import proto_utils + images = [ + proto_utils.parse_Bytes( + env.payload, beam_runner_api_pb2.DockerPayload).container_image for + env in remote_runner.proto_pipeline.components.environments.values() + ] + self.assertEqual(images, ['prebuilt/beam_python_prebuilt_sdk:tag']) + self.assertEqual(len(base_images), 1) + return base_images[0] + + def test_prebuild_uses_dataflow_container_base_repository(self): + base = 'docker.example.com/my-prefix/cloud-dataflow/v1beta3' + base_image = self._run_prebuild_and_get_base_image( + ['--dataflow_container_base_repository=' + base]) + self.assertTrue( + base_image.startswith(base + '/beam_python'), + 'unexpected base image %s' % base_image) + + def test_prebuild_without_base_repository_keeps_default_base_image(self): + self.assertIsNone(self._run_prebuild_and_get_base_image([])) + + def test_prebuild_sdk_container_image_takes_precedence(self): + base_image = self._run_prebuild_and_get_base_image([ + '--dataflow_container_base_repository=docker.example.com/prefix', + '--sdk_container_image=FOO' + ]) + self.assertEqual(base_image, 'FOO') + def test_remote_runner_translation(self): remote_runner = DataflowRunner() with Pipeline(remote_runner, diff --git a/sdks/python/apache_beam/runners/dataflow/internal/apiclient.py b/sdks/python/apache_beam/runners/dataflow/internal/apiclient.py index 8a478f3fb633..7cb4e3fff932 100644 --- a/sdks/python/apache_beam/runners/dataflow/internal/apiclient.py +++ b/sdks/python/apache_beam/runners/dataflow/internal/apiclient.py @@ -789,7 +789,8 @@ def create_job(self, job: Job): return None @staticmethod - def _update_container_image_for_dataflow(beam_container_image_url): + def _update_container_image_for_dataflow( + beam_container_image_url, pipeline_options=None): # By default Dataflow pipelines use containers hosted in Dataflow GCR # instead of Docker Hub. image_suffix = beam_container_image_url.rsplit('/', 1)[1] @@ -799,7 +800,9 @@ def _update_container_image_for_dataflow(beam_container_image_url): if len(check_rc) == 2: image_suffix = image_suffix[:-2 - len(check_rc[1])] - return names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY + '/' + image_suffix + return ( + get_container_base_repository_from_options(pipeline_options) + '/' + + image_suffix) @staticmethod def _apply_sdk_environment_overrides( @@ -831,7 +834,7 @@ def _apply_sdk_environment_overrides( new_container_image != current_sdk_container_image): new_container_image = ( DataflowApplicationClient._update_container_image_for_dataflow( - docker_payload.container_image)) + docker_payload.container_image, pipeline_options)) if not new_container_image: raise ValueError( @@ -1200,6 +1203,26 @@ def _get_container_image_tag(): return base_version +def get_container_base_repository_from_options(pipeline_options): + """For internal use only; no backwards-compatibility guarantees. + + Args: + pipeline_options (PipelineOptions): A container for pipeline options. + May be None, in which case the default repository is returned. + + Returns: + str: Base repository of the default Dataflow container images, without + a trailing slash. + """ + base_repository = None + if pipeline_options is not None: + base_repository = pipeline_options.view_as( + WorkerOptions).dataflow_container_base_repository + if base_repository: + base_repository = base_repository.strip().rstrip('/') + return base_repository or names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY + + def get_container_image_from_options(pipeline_options): """For internal use only; no backwards-compatibility guarantees. @@ -1215,7 +1238,7 @@ def get_container_image_from_options(pipeline_options): # Dataflow Legacy and Portable Runner exist in different repositories. # Set to legacy format, override if Dataflow Portable Runner - container_repo = names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY + container_repo = get_container_base_repository_from_options(pipeline_options) image_name = '{repository}/beam_python{major}.{minor}_sdk'.format( repository=container_repo, major=sys.version_info[0], diff --git a/sdks/python/apache_beam/runners/dataflow/internal/apiclient_test.py b/sdks/python/apache_beam/runners/dataflow/internal/apiclient_test.py index b82c7f550673..368ac5dbe381 100644 --- a/sdks/python/apache_beam/runners/dataflow/internal/apiclient_test.py +++ b/sdks/python/apache_beam/runners/dataflow/internal/apiclient_test.py @@ -520,6 +520,81 @@ def test_pipeline_sdk_not_overridden_prime(self): self._verify_pipeline_sdk_not_overridden(pipeline_options) + def test_default_container_base_repository(self): + pipeline_options = PipelineOptions(['--temp_location', 'gs://any/temp']) + self.assertEqual( + apiclient.get_container_base_repository_from_options(pipeline_options), + names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY) + self.assertEqual( + apiclient.get_container_base_repository_from_options(None), + names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY) + self.assertTrue( + apiclient.get_container_image_from_options(pipeline_options).startswith( + names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY + '/beam_python')) + + def test_custom_container_base_repository(self): + base = 'docker.example.com/my-prefix/cloud-dataflow/v1beta3' + for value in [base, base + '/']: + pipeline_options = PipelineOptions([ + '--temp_location', + 'gs://any/temp', + '--dataflow_container_base_repository=' + value + ]) + self.assertEqual( + apiclient.get_container_base_repository_from_options( + pipeline_options), + base) + self.assertEqual( + apiclient.get_container_image_from_options(pipeline_options), + '%s/beam_python%d.%d_sdk:%s' % ( + base, + sys.version_info[0], + sys.version_info[1], + apiclient._get_required_container_version())) + + def test_sdk_container_image_takes_precedence_over_base_repository(self): + pipeline_options = PipelineOptions([ + '--temp_location', + 'gs://any/temp', + '--dataflow_container_base_repository=docker.example.com/prefix', + '--sdk_container_image=dummy_prefix/dummy_name:dummy_tag' + ]) + self.assertEqual( + apiclient.get_container_image_from_options(pipeline_options), + 'dummy_prefix/dummy_name:dummy_tag') + + def test_custom_container_base_repository_for_xlang_environments(self): + base = 'docker.example.com/my-prefix/cloud-dataflow/v1beta3' + pipeline_options = PipelineOptions([ + '--experiments=use_runner_v2', + '--temp_location', + 'gs://any-location/temp', + '--dataflow_container_base_repository=' + base + ]) + pipeline = Pipeline(options=pipeline_options) + pipeline | Create([1, 2, 3]) | ParDo(DoFn()) # pylint:disable=expression-not-assigned + + dummy_env = DockerEnvironment( + container_image='apache/beam_java11_sdk:2.00.0rc1') + proto_pipeline, _ = pipeline.to_runner_api( + return_context=True, default_environment=dummy_env) + + # Accessing non-public method for testing. + apiclient.DataflowApplicationClient._apply_sdk_environment_overrides( + proto_pipeline, {}, pipeline_options) + + from apache_beam.utils import proto_utils + images = [ + proto_utils.parse_Bytes( + env.payload, beam_runner_api_pb2.DockerPayload).container_image + for env in proto_pipeline.components.environments.values() + ] + self.assertIn(base + '/beam_java11_sdk:2.00.0', images) + self.assertFalse( + any( + image.startswith(names.DATAFLOW_CONTAINER_IMAGE_REPOSITORY) + for image in images)) + def test_invalid_default_job_name(self): # Regexp for job names in dataflow. regexp = '^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$' From f1ec3ac0fac702d0f96e730193b756ccdbe6f8ee Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Tue, 20 Oct 2026 22:46:48 -0400 Subject: [PATCH 072/108] [Go] Support non-default universes for GCS and Dataflow clients (#40686) --- CHANGES.md | 1 + sdks/go/pkg/beam/options/gcpopts/options.go | 67 ++++++++++ .../pkg/beam/options/gcpopts/universe_test.go | 120 ++++++++++++++++++ sdks/go/pkg/beam/runners/dataflow/dataflow.go | 2 +- .../dataflow/dataflowlib/client_test.go | 91 +++++++++++++ .../beam/runners/dataflow/dataflowlib/job.go | 25 ++-- sdks/go/pkg/beam/util/gcsx/client_test.go | 53 ++++++++ sdks/go/pkg/beam/util/gcsx/gcs.go | 27 +++- 8 files changed, 372 insertions(+), 14 deletions(-) create mode 100644 sdks/go/pkg/beam/options/gcpopts/universe_test.go create mode 100644 sdks/go/pkg/beam/runners/dataflow/dataflowlib/client_test.go create mode 100644 sdks/go/pkg/beam/util/gcsx/client_test.go diff --git a/CHANGES.md b/CHANGES.md index 2940a8c6e708..b9accf9cf88f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -83,6 +83,7 @@ * (Python) Added `AwsSecret` (`AwsSecretsManager` / `type:awssecret`) to retrieve secrets from AWS Secrets Manager, configured from `AwsOptions` (`--aws_*`) with optional per-secret connection overrides ([#40528](https://github.com/apache/beam/issues/40528)). * (Go) Added `schema.RegisterLogicalTypeConversion` and `schema.RegisterLogicalTypeCoder` to register portable logical types in one call, and Go types for the standard `beam:logical_type:date:v1` and `beam:logical_type:micros_instant:v1` logical types, so schemas from other SDKs with these types can be used in cross-language pipelines ([#39684](https://github.com/apache/beam/issues/39684)). * (Python, Java, Go) Added `--dataflow_container_base_repository` (Python, Go) and `--dataflowContainerBaseRepository` (Java) to set the repository (e.g. `docker.example.com/my-prefix/cloud-dataflow/v1beta3`) from which the Dataflow runner pulls its default SDK container images, including the default images of cross-language Apache Beam environments. Defaults to `gcr.io/cloud-dataflow/v1beta3` ([#40569](https://github.com/apache/beam/issues/40569)). +* (Go) GCS and Dataflow clients now support non-default universes such as Trusted Partner Cloud (TPC), using the universe domain from `GOOGLE_CLOUD_UNIVERSE_DOMAIN` or the default credentials. Added a `--gcs_endpoint` pipeline option to override the GCS endpoint ([#40569](https://github.com/apache/beam/issues/40569)). ## Breaking Changes diff --git a/sdks/go/pkg/beam/options/gcpopts/options.go b/sdks/go/pkg/beam/options/gcpopts/options.go index 2acb73dbd5e4..b5df80e2de06 100644 --- a/sdks/go/pkg/beam/options/gcpopts/options.go +++ b/sdks/go/pkg/beam/options/gcpopts/options.go @@ -22,8 +22,10 @@ import ( "os" "os/exec" "strings" + "sync" "cloud.google.com/go/storage" + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime" "github.com/apache/beam/sdks/v2/go/pkg/beam/log" "golang.org/x/oauth2/google" ) @@ -34,8 +36,73 @@ var ( // Region is the GCP region where the job should be run. Region = flag.String("region", "", "GCP Region (required)") + + // GcsEndpoint overrides the Google Cloud Storage API endpoint. + GcsEndpoint = flag.String("gcs_endpoint", "", "The URL for the GCS API, "+ + "e.g. https://storage.googleapis.com/storage/v1/ (optional). If not set, the "+ + "endpoint is derived from the universe domain, which is read from "+ + "GOOGLE_CLOUD_UNIVERSE_DOMAIN or the default credentials.") ) +// GetGcsEndpoint returns the GCS endpoint override from the --gcs_endpoint +// flag. If the flag is unset, it falls back to the "gcs_endpoint" pipeline +// option, which is how the value reaches remote workers, where flags are not +// parsed. Returns an empty string if no override is configured. +func GetGcsEndpoint() string { + if *GcsEndpoint != "" { + return *GcsEndpoint + } + return runtime.GlobalOptions.Get("gcs_endpoint") +} + +const universeDomainEnvVar = "GOOGLE_CLOUD_UNIVERSE_DOMAIN" + +// findDefaultCredentials is a variable to allow overriding in tests. +var findDefaultCredentials = google.FindDefaultCredentials + +// cachedUniverseDomain caches the universe domain resolved from the default +// credentials, since resolving it may require a metadata server round trip +// and clients are created frequently. +var cachedUniverseDomain struct { + mu sync.Mutex + resolved bool + value string +} + +// GetUniverseDomain returns the Google Cloud universe domain, first from the +// GOOGLE_CLOUD_UNIVERSE_DOMAIN environment variable, then falling back to the +// universe domain of the default credentials. Returns an empty string if it +// cannot be determined. +// +// Google API clients don't infer the universe domain from the credentials, +// and requests fail if the two differ, so clients should pass this value via +// option.WithUniverseDomain to support non-default universes such as Trusted +// Partner Cloud (TPC). +func GetUniverseDomain(ctx context.Context) string { + if ud := os.Getenv(universeDomainEnvVar); ud != "" { + return ud + } + + cachedUniverseDomain.mu.Lock() + defer cachedUniverseDomain.mu.Unlock() + if cachedUniverseDomain.resolved { + return cachedUniverseDomain.value + } + creds, err := findDefaultCredentials(ctx) + if err != nil { + // Don't cache: credentials may become available later, and client + // construction surfaces the credential error itself. + return "" + } + ud, err := creds.GetUniverseDomain() + if err != nil { + return "" + } + cachedUniverseDomain.resolved = true + cachedUniverseDomain.value = ud + return ud +} + // GetProject returns the project, if non empty and exits otherwise. // Convenience function. func GetProject(ctx context.Context) string { diff --git a/sdks/go/pkg/beam/options/gcpopts/universe_test.go b/sdks/go/pkg/beam/options/gcpopts/universe_test.go new file mode 100644 index 000000000000..35050d1010e6 --- /dev/null +++ b/sdks/go/pkg/beam/options/gcpopts/universe_test.go @@ -0,0 +1,120 @@ +// 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 gcpopts + +import ( + "context" + "errors" + "testing" + + "github.com/apache/beam/sdks/v2/go/pkg/beam/core/runtime" + "golang.org/x/oauth2/google" +) + +func TestGetUniverseDomain(t *testing.T) { + tests := []struct { + name string + env string + credsUD string + credsErr error + want string + wantCalls int // credential lookups across two calls + }{ + { + name: "env var takes precedence", + env: "env.example.com", + credsUD: "creds.example.com", + want: "env.example.com", + }, + { + name: "falls back to credentials and caches", + credsUD: "creds.example.com", + want: "creds.example.com", + wantCalls: 1, + }, + { + name: "credential errors are not cached", + credsErr: errors.New("no credentials"), + want: "", + wantCalls: 2, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resetUniverseDomainCache(t) + t.Setenv(universeDomainEnvVar, test.env) + calls := 0 + orig := findDefaultCredentials + findDefaultCredentials = func(context.Context, ...string) (*google.Credentials, error) { + calls++ + if test.credsErr != nil { + return nil, test.credsErr + } + return &google.Credentials{ + UniverseDomainProvider: func() (string, error) { return test.credsUD, nil }, + }, nil + } + t.Cleanup(func() { findDefaultCredentials = orig }) + + // Call twice so wantCalls can verify caching: a successful lookup + // is reused by the second call, while a failed one is retried. + for i := 0; i < 2; i++ { + if got := GetUniverseDomain(t.Context()); got != test.want { + t.Errorf("GetUniverseDomain() = %q, want %q", got, test.want) + } + } + if calls != test.wantCalls { + t.Errorf("credentials looked up %d times, want %d", calls, test.wantCalls) + } + }) + } +} + +// resetUniverseDomainCache clears the cached universe domain and restores it +// when the test finishes. +func resetUniverseDomainCache(t *testing.T) { + t.Helper() + reset := func() { + cachedUniverseDomain.mu.Lock() + cachedUniverseDomain.resolved = false + cachedUniverseDomain.value = "" + cachedUniverseDomain.mu.Unlock() + } + reset() + t.Cleanup(reset) +} + +func TestGetGcsEndpoint(t *testing.T) { + const flagValue = "https://flag.example.com/storage/v1/" + const optionValue = "https://option.example.com/storage/v1/" + + origFlag := *GcsEndpoint + t.Cleanup(func() { + *GcsEndpoint = origFlag + runtime.GlobalOptions.Set("gcs_endpoint", "") + }) + // On workers, the value arrives as a pipeline option rather than a flag. + runtime.GlobalOptions.Set("gcs_endpoint", optionValue) + + *GcsEndpoint = "" + if got := GetGcsEndpoint(); got != optionValue { + t.Errorf("GetGcsEndpoint() with flag unset = %q, want pipeline option %q", got, optionValue) + } + *GcsEndpoint = flagValue + if got := GetGcsEndpoint(); got != flagValue { + t.Errorf("GetGcsEndpoint() with flag set = %q, want flag %q", got, flagValue) + } +} diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflow.go b/sdks/go/pkg/beam/runners/dataflow/dataflow.go index 87226404fcd7..20fb4fae0053 100644 --- a/sdks/go/pkg/beam/runners/dataflow/dataflow.go +++ b/sdks/go/pkg/beam/runners/dataflow/dataflow.go @@ -553,7 +553,7 @@ func checkSoftDeletePolicyEnabled(ctx context.Context, bucketName string, locati log.Warnf(ctx, "Error parsing bucket name: %v", err) return } - client, err := storage.NewClient(ctx) + client, err := gcsx.NewClient(ctx, storage.ScopeReadOnly) if err != nil { log.Warnf(ctx, "Error creating GCS client: %v", err) return diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflowlib/client_test.go b/sdks/go/pkg/beam/runners/dataflow/dataflowlib/client_test.go new file mode 100644 index 000000000000..63e3791f9bf2 --- /dev/null +++ b/sdks/go/pkg/beam/runners/dataflow/dataflowlib/client_test.go @@ -0,0 +1,91 @@ +// 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 dataflowlib + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "os" + "path/filepath" + "testing" +) + +func TestNewClient_BasePath(t *testing.T) { + // Fake service account credentials in a non-default universe. The auth + // library parses the key when the client is created (non-default universes + // use self-signed JWTs), so it must be a valid key. No token is fetched + // until a request is made. + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed generating key: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("Failed marshaling key: %v", err) + } + content, err := json.Marshal(map[string]string{ + "type": "service_account", + "project_id": "test-project", + "client_email": "test@test-project.iam.gserviceaccount.com", + "private_key_id": "test-key-id", + "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), + "universe_domain": "creds.example.com", + }) + if err != nil { + t.Fatalf("Failed marshaling credentials: %v", err) + } + credFile := filepath.Join(t.TempDir(), "creds.json") + if err := os.WriteFile(credFile, content, 0600); err != nil { + t.Fatalf("Failed writing fake credential file: %v", err) + } + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", credFile) + + tests := []struct { + name string + endpoint string + env string + want string + }{ + { + // The client library doesn't read the credentials' universe domain, + // so this verifies that NewClient passes it through. + name: "universe domain from credentials", + want: "https://dataflow.creds.example.com/", + }, + { + name: "endpoint takes precedence over universe domain", + endpoint: "http://localhost:8080/", + env: "creds.example.com", + want: "http://localhost:8080/", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("GOOGLE_CLOUD_UNIVERSE_DOMAIN", test.env) + + client, err := NewClient(t.Context(), test.endpoint) + if err != nil { + t.Fatalf("NewClient(%q) failed: %v", test.endpoint, err) + } + if got := client.BasePath; got != test.want { + t.Errorf("NewClient(%q).BasePath = %q, want %q", test.endpoint, got, test.want) + } + }) + } +} diff --git a/sdks/go/pkg/beam/runners/dataflow/dataflowlib/job.go b/sdks/go/pkg/beam/runners/dataflow/dataflowlib/job.go index 27a0a0d0f224..841e17498769 100644 --- a/sdks/go/pkg/beam/runners/dataflow/dataflowlib/job.go +++ b/sdks/go/pkg/beam/runners/dataflow/dataflowlib/job.go @@ -27,8 +27,9 @@ import ( "github.com/apache/beam/sdks/v2/go/pkg/beam/internal/errors" "github.com/apache/beam/sdks/v2/go/pkg/beam/log" pipepb "github.com/apache/beam/sdks/v2/go/pkg/beam/model/pipeline_v1" - "golang.org/x/oauth2/google" + "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts" df "google.golang.org/api/dataflow/v1b3" + "google.golang.org/api/option" "google.golang.org/protobuf/proto" // Importing to get the side effect of the remote execution hook. See init(). @@ -312,20 +313,22 @@ func currentStateMessage(currentState, jobID string) (bool, string, error) { // NewClient creates a new dataflow client with default application credentials // and CloudPlatformScope. The Dataflow endpoint is optionally overridden. +// +// In non-default universes (e.g. Trusted Partner Cloud), the universe domain +// is taken from GOOGLE_CLOUD_UNIVERSE_DOMAIN or, if unset, from the default +// credentials. func NewClient(ctx context.Context, endpoint string) (*df.Service, error) { - cl, err := google.DefaultClient(ctx, df.CloudPlatformScope) - if err != nil { - return nil, err - } - client, err := df.New(cl) - if err != nil { - return nil, err - } + opts := []option.ClientOption{option.WithScopes(df.CloudPlatformScope)} if endpoint != "" { log.Infof(ctx, "Dataflow endpoint override: %s", endpoint) - client.BasePath = endpoint + opts = append(opts, option.WithEndpoint(endpoint)) + } + // The client ignores the credentials' universe domain, and requests fail + // if it differs from the configured one (googleapis.com by default). + if ud := gcpopts.GetUniverseDomain(ctx); ud != "" { + opts = append(opts, option.WithUniverseDomain(ud)) } - return client, nil + return df.NewService(ctx, opts...) } // GetRunningJobByName gets a Dataflow job running by its name and returns an diff --git a/sdks/go/pkg/beam/util/gcsx/client_test.go b/sdks/go/pkg/beam/util/gcsx/client_test.go new file mode 100644 index 000000000000..6b31646d6601 --- /dev/null +++ b/sdks/go/pkg/beam/util/gcsx/client_test.go @@ -0,0 +1,53 @@ +// 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 gcsx + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts" +) + +func TestNewUnauthenticatedClient_UsesGcsEndpoint(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"name": "some-bucket"}`)) + })) + defer srv.Close() + + orig := *gcpopts.GcsEndpoint + *gcpopts.GcsEndpoint = srv.URL + "/storage/v1/" + t.Cleanup(func() { *gcpopts.GcsEndpoint = orig }) + + ctx := t.Context() + client, err := NewUnauthenticatedClient(ctx) + if err != nil { + t.Fatalf("NewUnauthenticatedClient() failed: %v", err) + } + defer client.Close() + + if _, err := client.Bucket("some-bucket").Attrs(ctx); err != nil { + t.Fatalf("Attrs() failed: %v", err) + } + if want := "/storage/v1/b/some-bucket"; !strings.HasPrefix(gotPath, want) { + t.Errorf("request path = %q, want prefix %q", gotPath, want) + } +} diff --git a/sdks/go/pkg/beam/util/gcsx/gcs.go b/sdks/go/pkg/beam/util/gcsx/gcs.go index 0986851c16d0..73300fcc6dc1 100644 --- a/sdks/go/pkg/beam/util/gcsx/gcs.go +++ b/sdks/go/pkg/beam/util/gcsx/gcs.go @@ -25,6 +25,7 @@ import ( "cloud.google.com/go/storage" "github.com/apache/beam/sdks/v2/go/pkg/beam/internal/errors" + "github.com/apache/beam/sdks/v2/go/pkg/beam/options/gcpopts" "google.golang.org/api/option" ) @@ -33,14 +34,36 @@ var userAgent = option.WithUserAgent("(GPN:Beam)") // NewClient creates a new GCS client with default application credentials, and supplied // OAuth scope. The OAuth scopes are defined in https://pkg.go.dev/cloud.google.com/go/storage#pkg-constants. // Sets the user agent to Beam. +// +// The endpoint can be overridden with the --gcs_endpoint flag. In non-default +// universes (e.g. Trusted Partner Cloud), the universe domain is taken from +// GOOGLE_CLOUD_UNIVERSE_DOMAIN or, if unset, from the default credentials. func NewClient(ctx context.Context, scope string) (*storage.Client, error) { - return storage.NewClient(ctx, option.WithScopes(scope), userAgent) + opts := []option.ClientOption{option.WithScopes(scope), userAgent} + opts = append(opts, endpointOptions()...) + // storage.NewClient ignores the credentials' universe domain, and requests + // fail if it differs from the configured one (googleapis.com by default). + if ud := gcpopts.GetUniverseDomain(ctx); ud != "" { + opts = append(opts, option.WithUniverseDomain(ud)) + } + return storage.NewClient(ctx, opts...) } // NewUnauthenticatedClient creates a new GCS client without authentication. // Sets the user agent to Beam. func NewUnauthenticatedClient(ctx context.Context) (*storage.Client, error) { - return storage.NewClient(ctx, option.WithoutAuthentication(), userAgent) + opts := []option.ClientOption{option.WithoutAuthentication(), userAgent} + opts = append(opts, endpointOptions()...) + return storage.NewClient(ctx, opts...) +} + +// endpointOptions returns the client options for the --gcs_endpoint override, +// if any. +func endpointOptions() []option.ClientOption { + if ep := gcpopts.GetGcsEndpoint(); ep != "" { + return []option.ClientOption{option.WithEndpoint(ep)} + } + return nil } // Upload writes the given content to GCS. If the specified bucket does not From e18c21762a52b8b1aa839c72ddb10ff084d62015 Mon Sep 17 00:00:00 2001 From: raams95 Date: Wed, 21 Oct 2026 09:27:47 -0500 Subject: [PATCH 073/108] [Python] Infer the right type for slices of fixed-size tuples (#40680) Type inference gave a slice of a fixed-size Tuple the type of the whole tuple, so for example row[1:] on Tuple[str, str, int] was inferred as Tuple[str, str, int]. A following CombinePerKey then failed the type check, and encoding the slice used the coder for the whole tuple. Slices with constant bounds now select the element types, and other slices of a fixed-size tuple give Tuple[Union[...], ...]. BUILD_SLICE keeps constant bounds as a Const slice, so Python 3.10 to 3.14 infer the same types. Fixes #20899 Co-authored-by: JanakiRamChittineni <341781521+raams95@users.noreply.github.com> --- CHANGES.md | 1 + sdks/python/apache_beam/typehints/opcodes.py | 33 ++++++++++++++++--- .../typehints/trivial_inference_test.py | 17 ++++++++++ .../typehints/typed_pipeline_test.py | 11 +++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index b9accf9cf88f..8cc8686e6bfe 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -106,6 +106,7 @@ * (Python) DaskRunner now preserves outputs emitted by `DoFn.finish_bundle()` instead of silently dropping them ([#40519](https://github.com/apache/beam/issues/40519)). * (Python) BigQuery streaming inserts now reset the retry budget and backoff for each batch instead of sharing them across a bundle ([#39995](https://github.com/apache/beam/pull/39995)). * (Python) State write failures reported by portable runners now fail the bundle instead of silently losing state updates ([#39992](https://github.com/apache/beam/pull/39992)). +* (Python) Type inference now gives the right type for a slice of a fixed-size tuple, such as `row[1:]` in `beam.Map(lambda row: row[1:])`. The slice used to keep the type of the whole tuple, which could make a following `CombinePerKey` fail with a `TypeCheckError` or encode elements with the wrong coder ([#20899](https://github.com/apache/beam/issues/20899)). * (Go) Fixed a data race on the Prism runner's artifact cache map in JobServices ([#32656](https://github.com/apache/beam/issues/32656)). * (Go) Fixed the harness leaking Data/State gRPC streams after the worker stops, and a deadlock when Send returns EOF ([#40260](https://github.com/apache/beam/issues/40260)). * (Go) Wrap last dataWriter/timerWriter send errors on Close ([#40405](https://github.com/apache/beam/issues/40405)). diff --git a/sdks/python/apache_beam/typehints/opcodes.py b/sdks/python/apache_beam/typehints/opcodes.py index c5dda817d0d2..a4dbb869b113 100644 --- a/sdks/python/apache_beam/typehints/opcodes.py +++ b/sdks/python/apache_beam/typehints/opcodes.py @@ -222,6 +222,22 @@ def binary_true_divide(state, arg): binary_subtract = inplace_subtract = symmetric_binary_op +def _slice_type(base, index): + """Returns the type of base[index] for an indexable base and a slice index. + + The index is a Const if the bounds of the slice are known. + """ + if isinstance(base, typehints.TupleHint.TupleConstraint): + # Slicing changes the length of a fixed-size tuple. + if isinstance(index, Const): + try: + return Tuple[base.tuple_types[index.value]] + except (TypeError, ValueError): + pass + return Tuple[element_type(base), ...] + return base + + def binary_subscr(state, unused_arg): index = state.stack.pop() base = Const.unwrap(state.stack.pop()) @@ -237,7 +253,7 @@ def binary_subscr(state, unused_arg): base, typehints.IndexableTypeConstraint): # The slice is treated as a const in 3.14, using this instead of # BINARY_SLICE - out = base + out = _slice_type(base, index) else: out = element_type(base) state.stack.append(out) @@ -259,13 +275,16 @@ def store_subscr(unused_state, unused_args): def binary_slice(state, args): - _ = state.stack.pop() - _ = state.stack.pop() + stop = state.stack.pop() + start = state.stack.pop() base = Const.unwrap(state.stack.pop()) if base is str: out = base elif isinstance(base, typehints.IndexableTypeConstraint): - out = base + if isinstance(start, Const) and isinstance(stop, Const): + out = _slice_type(base, Const(slice(start.value, stop.value))) + else: + out = _slice_type(base, slice) else: out = element_type(base) state.stack.append(out) @@ -667,7 +686,11 @@ def make_closure(state, arg): def build_slice(state, arg): - state.stack[-arg:] = [slice] # a slice object + bounds = state.stack[-arg:] + if all(isinstance(b, Const) for b in bounds): + state.stack[-arg:] = [Const(slice(*(b.value for b in bounds)))] + else: + state.stack[-arg:] = [slice] # a slice object def to_bool(state, arg): diff --git a/sdks/python/apache_beam/typehints/trivial_inference_test.py b/sdks/python/apache_beam/typehints/trivial_inference_test.py index dcb0bac97e80..f704fb2db26f 100644 --- a/sdks/python/apache_beam/typehints/trivial_inference_test.py +++ b/sdks/python/apache_beam/typehints/trivial_inference_test.py @@ -112,6 +112,23 @@ def testGetItemSlice(self): typehints.List[int], lambda v: v[::-1], [typehints.List[int]]) self.assertReturnType( typehints.Tuple[int], lambda v: v[::-1], [typehints.Tuple[int]]) + self.assertReturnType( + typehints.Tuple[str, int], lambda v: v[1:], + [typehints.Tuple[int, str, int]]) + self.assertReturnType( + typehints.Tuple[int, str], lambda v: v[:-1], + [typehints.Tuple[int, str, float]]) + self.assertReturnType( + typehints.Tuple[float, str, int], lambda v: v[::-1], + [typehints.Tuple[int, str, float]]) + self.assertReturnType( + typehints.Tuple[int, float], lambda v: v[0:3:2], + [typehints.Tuple[int, str, float]]) + self.assertReturnType( + typehints.Tuple[()], lambda v: v[5:], [typehints.Tuple[int, str]]) + self.assertReturnType( + typehints.Tuple[typehints.Union[int, str], ...], lambda v, i: v[i:], + [typehints.Tuple[int, str, int], int]) self.assertReturnType(str, lambda v: v[::-1], [str]) self.assertReturnType(typehints.Any, lambda v: v[::-1], [typehints.Any]) self.assertReturnType(typehints.Any, lambda v: v[::-1], [object]) diff --git a/sdks/python/apache_beam/typehints/typed_pipeline_test.py b/sdks/python/apache_beam/typehints/typed_pipeline_test.py index c97a1977e9c3..29cb97dd7872 100644 --- a/sdks/python/apache_beam/typehints/typed_pipeline_test.py +++ b/sdks/python/apache_beam/typehints/typed_pipeline_test.py @@ -461,6 +461,17 @@ def fn(element: int) -> typehints.Optional[int]: result = [1, 2, 3] | beam.Map(fn) self.assertCountEqual([None, 2, 3], result) + def test_inferred_type_of_tuple_slice(self): + # https://github.com/apache/beam/issues/20899 + rows = [('day1', 'a', 1), ('day2', 'a', 2), ('day1', 'b', 3)] + with TestPipeline() as p: + result = ( + p + | beam.Create(rows).with_output_types(typing.Tuple[str, str, int]) + | beam.Map(lambda row: row[1:]) + | beam.CombinePerKey(sum)) + assert_that(result, equal_to([('a', 3), ('b', 3)])) + def test_typed_flatmap(self): def fn(element: int) -> typehints.Iterable[int]: yield element * 2 From bbd5e287800913f37ec543db8727e5a938c79113 Mon Sep 17 00:00:00 2001 From: tvalentyn Date: Wed, 21 Oct 2026 07:48:52 -0700 Subject: [PATCH 074/108] [RunInference] Honor model updates in also in the first batch. (#40667) * Honor model updates in first batch. Don't reload more than necessary. * Drive-by changes.md updates. * Also reload the model previously supplied via side input if it was invalidated. * review feedback: Also reload models if they were invalidated in the remaining branch * Address comments, add a helper. --------- Co-authored-by: Jack McCluskey <34928439+jrmccluskey@users.noreply.github.com> --- CHANGES.md | 4 +- sdks/python/apache_beam/ml/inference/base.py | 56 +++++--- .../apache_beam/ml/inference/base_test.py | 122 ++++++++++++++++++ 3 files changed, 161 insertions(+), 21 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8cc8686e6bfe..74dcb41af94b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -99,7 +99,6 @@ ## Deprecations * Support for Python 3.10 was removed following its end-of-life ([#40250](https://github.com/apache/beam/issues/40250)). -* X behavior is deprecated and will be removed in X versions ([#X](https://github.com/apache/beam/issues/X)). ## Bugfixes @@ -112,6 +111,9 @@ * (Go) Wrap last dataWriter/timerWriter send errors on Close ([#40405](https://github.com/apache/beam/issues/40405)). * (Go) Fixed a deadlock recreating a data channel while holding the channel lock ([#40414](https://github.com/apache/beam/issues/40414)). * (Python) Fixed `KeyedModelHandler` not enforcing `max_models_per_worker_hint` when several copies of the model handler run in the same SDK process ([#40468](https://github.com/apache/beam/issues/40468)). +* (Python) Fixed a bug where `RunInference` was serving the first batch on each worker with the original models instead of the paths from a `KeyModelPathMapping` side input ([#40665](https://github.com/apache/beam/issues/40665)). +* (Python) Fixed a bug in `RunInference` where adaptive throttling in remote inference codepath was accidentally disabled ([#40598](https://github.com/apache/beam/pull/40598)). +* (Python) Fixed an inefficiency in `RunInference` that caused unnecessary model reloading in some cases when `large_model=True` was not set ([#40441](https://github.com/apache/beam/pull/40441)). * (Java) Fixed the declared schema of the error output of the Kafka write SchemaTransform, which wrapped the error schema a second time and did not match the rows it emits ([#39760](https://github.com/apache/beam/issues/39760)). * (Go) Fixed pubsubio importing a `google.golang.org/genproto` package removed in recent releases, which broke builds of Go modules depending on a current `genproto` version ([#40018](https://github.com/apache/beam/issues/40018)). * (Python) `VertexAIFeatureStoreEnrichmentHandler` now logs a warning instead of failing when the caller lacks permission to read the feature store metadata ([#40442](https://github.com/apache/beam/issues/40442)). diff --git a/sdks/python/apache_beam/ml/inference/base.py b/sdks/python/apache_beam/ml/inference/base.py index 3d2f2657c0a8..4c2d9cb4c80e 100644 --- a/sdks/python/apache_beam/ml/inference/base.py +++ b/sdks/python/apache_beam/ml/inference/base.py @@ -2146,7 +2146,9 @@ def __init__( self._model = None self._metrics_namespace = metrics_namespace self._load_model_at_runtime = load_model_at_runtime - self._side_input_path = None + # Most recently applied side input model path(s), if any. + self._side_input_path: Optional[Union[str, + list[KeyModelPathMapping]]] = None # _model_tag is the original tag passed in. # _cur_tag is the tag of the actually loaded model self._model_tag = model_tag @@ -2217,11 +2219,11 @@ def load(): # of calling load(). For sanity check, call update_model_path again. if isinstance(side_input_model_path, str): self._model_handler.update_model_path(side_input_model_path) - else: - if self._model is not None: - models = self._model.all_models() - for m in models: - self._model_handler.update_model_paths(m, side_input_model_path) + elif side_input_model_path: + # For per-key models, side_input_model_path is a list of mappings. + for m in model_wrapper.all_models(): + self._model_handler.update_model_paths( + m, model_paths=side_input_model_path) return model_wrapper def get_metrics_collector(self, prefix: str = ''): @@ -2251,6 +2253,22 @@ def update_model( list[KeyModelPathMapping]]] = None): self._model = self._load_model(side_input_model_path=side_input_model_path) + def _should_update_model( + self, + new_side_input_path: Optional[Union[str, + list[KeyModelPathMapping]]]) -> bool: + """Returns whether the model needs to be (re)loaded before inference. + + Args: + new_side_input_path: Model path(s) from the side input, or None if there + is no side input value. + """ + return ( + self._model is None or ( + new_side_input_path is not None and + self._side_input_path != new_side_input_path) or + not self._model_metadata.is_valid_tag(self._cur_tag)) + def _run_inference(self, batch, inference_args): start_time = _to_microseconds(self._clock.time_ns()) try: @@ -2305,26 +2323,24 @@ def process( side input is empty or the model has not been updated, the method simply runs inference on the batch of data. """ - if not si_model_metadata: - if (not self._model_metadata.is_valid_tag(self._cur_tag) or - self._model is None): + if (not si_model_metadata or + isinstance(si_model_metadata, beam.pvalue.EmptySideInput)): + # No side input value: keep the loaded model, if any. + if self._should_update_model(new_side_input_path=None): self.update_model(side_input_model_path=None) return self._run_inference(batch, inference_args) - if isinstance(si_model_metadata, beam.pvalue.EmptySideInput): - self.update_model(side_input_model_path=None) - elif isinstance(si_model_metadata, list) and hasattr(si_model_metadata[0], - 'keys'): + if isinstance(si_model_metadata, list) and hasattr(si_model_metadata[0], + 'keys'): # TODO(https://github.com/apache/beam/issues/27628): Update metrics here - self.update_model(si_model_metadata) - elif self._side_input_path != si_model_metadata.model_id: - self._side_input_path = si_model_metadata.model_id + if self._should_update_model(si_model_metadata): + self.update_model(si_model_metadata) + self._side_input_path = list(si_model_metadata) + elif self._should_update_model(si_model_metadata.model_id): self._metrics_collector = self.get_metrics_collector( prefix=si_model_metadata.model_name) - lock = threading.Lock() - with lock: - self.update_model(si_model_metadata.model_id) - return self._run_inference(batch, inference_args) + self.update_model(si_model_metadata.model_id) + self._side_input_path = si_model_metadata.model_id return self._run_inference(batch, inference_args) diff --git a/sdks/python/apache_beam/ml/inference/base_test.py b/sdks/python/apache_beam/ml/inference/base_test.py index d1c37811d9d3..5d6c5bd517c7 100644 --- a/sdks/python/apache_beam/ml/inference/base_test.py +++ b/sdks/python/apache_beam/ml/inference/base_test.py @@ -274,6 +274,41 @@ def share_model_across_processes(self): return self._multi_process_shared +class FakeModelWithPath: + """A model that reports the path it was loaded from.""" + def __init__(self, path: str): + self._path = path + + def predict(self, example: int) -> str: + return self._path + + +class FakeModelHandlerWithPath(base.ModelHandler[int, str, FakeModelWithPath]): + """Predicts the path of the model that actually served each example.""" + def __init__(self, path: str): + self._path = path + + def load_model(self): + return FakeModelWithPath(self._path) + + def run_inference( + self, + batch: Sequence[int], + model: FakeModelWithPath, + inference_args=None) -> Iterable[str]: + return [model.predict(example) for example in batch] + + def update_model_path(self, model_path: Optional[str] = None): + if model_path: + self._path = model_path + + +class SingleElementBatchesKeyedModelHandler(base.KeyedModelHandler): + """Runs inference on one element at a time, so pipelines have many batches.""" + def batch_elements_kwargs(self): + return {'min_batch_size': 1, 'max_batch_size': 1} + + class FakeModelHandlerNoEnvVars(base.ModelHandler[int, int, FakeModel]): def __init__( self, clock=None, min_batch_size=1, max_batch_size=9999, **kwargs): @@ -1463,6 +1498,41 @@ def test_run_inference_empty_side_input(self): assert_that(result_pcoll, equal_to(expected)) + def _count_update_model_calls(self): + """Returns per-DoFn-instance update_model() call counts and a patcher. + + The counts are only collected when the pipeline runs in this process, + e.g. on FnApiRunner. + """ + calls: dict[str, int] = {} + original_update_model = base._RunInferenceDoFn.update_model + + def counting_update_model(dofn, *args, **kwargs): + instance_id = dofn.__dict__.setdefault( + '_test_instance_id', uuid.uuid4().hex) + calls[instance_id] = calls.get(instance_id, 0) + 1 + return original_update_model(dofn, *args, **kwargs) + + return calls, unittest.mock.patch.object( + base._RunInferenceDoFn, 'update_model', counting_update_model) + + def test_run_inference_empty_side_input_loads_model_once(self): + examples = list(range(10)) + update_model_calls, count_calls = self._count_update_model_calls() + with count_calls, TestPipeline('FnApiRunner') as pipeline: + side_pcoll = pipeline | 'side' >> beam.Create([]) + result_pcoll = ( + pipeline + | beam.Create(examples) + | base.RunInference( + FakeModelHandler(min_batch_size=1, max_batch_size=1), + model_metadata_pcoll=side_pcoll)) + assert_that(result_pcoll, equal_to([e + 1 for e in examples])) + + # The model is loaded once per DoFn instance, not once per batch. + self.assertTrue(update_model_calls) + self.assertEqual({1}, set(update_model_calls.values())) + def test_run_inference_side_input_in_batch(self): first_ts = math.floor(time.time()) - 30 interval = 7 @@ -1745,6 +1815,58 @@ def process(self, element): assert_that(result_pcoll, equal_to(expected_result)) + def test_run_inference_side_input_per_key_models_used_from_first_batch(self): + model_handler = SingleElementBatchesKeyedModelHandler([ + base.KeyModelMapping(['key1'], + FakeModelHandlerWithPath('original/key1')), + base.KeyModelMapping(['key2'], + FakeModelHandlerWithPath('original/key2')), + ]) + key_model_paths = [ + base.KeyModelPathMapping(keys=['key1'], update_path='updated/key1'), + base.KeyModelPathMapping(keys=['key2'], update_path='updated/key2'), + ] + examples = [('key1', 1), ('key2', 2), ('key1', 3), ('key2', 4)] + update_model_calls, count_calls = self._count_update_model_calls() + with count_calls, TestPipeline('FnApiRunner') as pipeline: + side_input = pipeline | 'SideInput' >> beam.Create([key_model_paths]) + result_pcoll = ( + pipeline + | 'Examples' >> beam.Create(examples) + | base.RunInference(model_handler, model_metadata_pcoll=side_input)) + # Every example, including the ones in the first batch, is served by + # the model loaded from the path given in the side input. + assert_that( + result_pcoll, + equal_to([(key, 'updated/' + key) for key, _ in examples])) + + # The unchanged mapping is applied once per DoFn instance, not per batch. + self.assertTrue(update_model_calls) + self.assertEqual({1}, set(update_model_calls.values())) + + def test_side_input_model_reloaded_after_invalidation(self): + dofn = base._RunInferenceDoFn( + FakeModelHandler(multi_process_shared=True), + FakeClock(), + metrics_namespace=None, + load_model_at_runtime=True, + model_tag='test_model_' + uuid.uuid4().hex) + dofn.setup() + model_id = 'side_input_model_' + uuid.uuid4().hex + side_input = base.ModelMetadata(model_id=model_id, model_name='model') + self.assertEqual([2], dofn.process([1], None, side_input)) + self.assertEqual(model_id, dofn._cur_tag) + + # Simulate an inference timeout: the model is marked invalid and, since + # its grace period is 0s here, deleted in finish_bundle(). + dofn._model_metadata.try_mark_current_model_invalid(0) + dofn.finish_bundle() + + # The same side input value reloads the model under a new tag instead of + # serving the deleted one. + self.assertEqual([3], dofn.process([2], None, side_input)) + self.assertEqual(f'{model_id}_reload_1', dofn._cur_tag) + def test_run_inference_side_input_in_batch_multi_process_shared(self): first_ts = math.floor(time.time()) - 30 interval = 7 From 3988ad7a6a31506deb76bbeb7f95380c36daf2e5 Mon Sep 17 00:00:00 2001 From: Vitaly Terentyev Date: Wed, 21 Oct 2026 19:21:32 +0400 Subject: [PATCH 075/108] Apply before set image --- .test-infra/metrics/build.gradle | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.test-infra/metrics/build.gradle b/.test-infra/metrics/build.gradle index 01d8eefa91cc..384a7b465102 100644 --- a/.test-infra/metrics/build.gradle +++ b/.test-infra/metrics/build.gradle @@ -121,7 +121,10 @@ task deploy { exec { commandLine './build_and_publish_containers.sh', 'true', "${project.lastCommitId}" } - + exec { + executable 'sh' + args '-c', 'kubectl --namespace default apply -f kubernetes/beamgrafana-deploy.yaml' + } exec { executable 'sh' args '-c', "kubectl --namespace default set image deployment/beamgrafana \ @@ -129,11 +132,6 @@ task deploy { beammetricssyncjenkins=gcr.io/apache-beam-testing/beammetricssyncjenkins:${project.lastCommitId} \ beammetricssyncgithub=gcr.io/apache-beam-testing/beammetricssyncgithub:${project.lastCommitId}" } - - exec { - executable 'sh' - args '-c', 'kubectl --namespace default apply -f kubernetes/beamgrafana-deploy.yaml' - } } else { println 'Nothing to do, exiting.' } From 8cae4991fca407818454ae5bc5ac1e5f70c03411 Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Wed, 21 Oct 2026 11:43:35 -0400 Subject: [PATCH 076/108] [Java] Close GcsUtilV2's small-file performance gaps with GcsUtilV1 (#40677) * Use the shared HTTP transport in GcsUtilV2 * Start GcsUtilV2 upload sessions in the background * Raise the default GcsUtilV2 rewrite concurrency to 128 * Handle closed and failed upload sessions in GcsWritableByteChannel --- .../extensions/gcp/options/GcsOptions.java | 4 +- .../sdk/extensions/gcp/util/GcsUtilV2.java | 125 +++++++++-- .../extensions/gcp/util/GcsUtilV2Test.java | 198 +++++++++++++++++- 3 files changed, 300 insertions(+), 27 deletions(-) diff --git a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java index 16acb90d11a0..b5246a87d2ba 100644 --- a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java +++ b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java @@ -193,13 +193,13 @@ public GoogleCloudStorageReadOptions create(PipelineOptions options) { /** * Maximum number of files rewritten concurrently when copying or moving files. If unset, a - * default of 32 is used. + * default of 128 is used. * *

Only applicable to GcsUtil V2, which is enabled with the {@code use_gcsutil_v2} experiment. */ @Description( "Maximum number of files rewritten concurrently when copying or moving files in GCS." - + " If unset, defaults to 32. Only applicable to GcsUtil V2 (enabled with the" + + " If unset, defaults to 128. Only applicable to GcsUtil V2 (enabled with the" + " use_gcsutil_v2 experiment).") @Nullable Integer getGcsMaxConcurrentRewrites(); diff --git a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2.java b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2.java index 863bc9030e94..c0f40a9aec4b 100644 --- a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2.java +++ b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2.java @@ -56,9 +56,11 @@ import java.io.Closeable; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InterruptedIOException; import java.net.MalformedURLException; import java.net.URL; import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; import java.nio.channels.SeekableByteChannel; import java.nio.channels.WritableByteChannel; import java.nio.file.AccessDeniedException; @@ -159,8 +161,12 @@ Storage storage() { /** * Default maximum number of files rewritten concurrently by {@link #copy} and {@link #move}, used * when {@link GcsOptions#getGcsMaxConcurrentRewrites} is unset. + * + *

A copy or move never uses more threads than it has files, so this only caps large ones, such + * as the temp-to-final renames that finish a write of thousands of shards. There 128 brought the + * rename time close to {@code GcsUtilV1}'s batched rewrites without any request being throttled. */ - private static final int DEFAULT_MAX_CONCURRENT_REWRITES = 32; + private static final int DEFAULT_MAX_CONCURRENT_REWRITES = 128; /** * How long {@link #copy} and {@link #move} wait for in-flight rewrites to finish after a failure @@ -168,6 +174,18 @@ Storage storage() { */ private static final long REWRITE_TERMINATION_TIMEOUT_MINUTES = 5; + /** + * Starts the resumable upload sessions of {@link #create} in the background. Unbounded, so that a + * burst of creates is never queued behind one another, and daemon, so that an abandoned upload + * cannot keep the JVM alive. + */ + private static final ExecutorService UPLOAD_SESSION_EXECUTOR = + Executors.newCachedThreadPool( + new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat("gcsutil-v2-upload-session-%d") + .build()); + /** Maximum number of files rewritten concurrently by {@link #copy} and {@link #move}. */ private final int maxConcurrentRewrites; @@ -192,6 +210,13 @@ Storage storage() { storageOptionsBuilder.setHost(rootUrlOf(endpoint)); } + // Use the process-wide shared HTTP transport rather than letting each client create its own, so + // a new client reuses established connections instead of opening new ones on its first request. + storageOptionsBuilder.setTransportOptions( + StorageOptions.getDefaultHttpTransportOptions().toBuilder() + .setHttpTransportFactory(Transport::getTransport) + .build()); + storage = storageOptionsBuilder.build().getService(); uploadBufferSizeBytes = gcsOptions.getGcsUploadBufferSizeBytes(); this.gcsCountersOptions = @@ -994,20 +1019,67 @@ public SeekableByteChannel open(GcsPath path, BlobSourceOption... sourceOptions) } } - /** A bridge that allows a GCS WriteChannel to behave as a WritableByteChannel. */ + /** + * A bridge that allows a GCS WriteChannel to behave as a WritableByteChannel. + * + *

The WriteChannel is opened in the background, because java-storage starts the resumable + * upload session with a blocking request when it opens one. Waiting for it on the first write, or + * on close, rather than in {@link #create} lets that request overlap with the caller's own work, + * as gcsio does for {@code GcsUtilV1}. A failure to start the session surfaces there instead. + */ private static class GcsWritableByteChannel implements WritableByteChannel { - private final WriteChannel writer; + private final Future pendingWriter; private final GcsPath gcsPath; + private boolean closed = false; + // Set when the upload session could not be started (it failed, or waiting for it was + // interrupted). There is then nothing to finalize, and the failure has already been reported. + private boolean sessionFailed = false; - GcsWritableByteChannel(WriteChannel writer, GcsPath gcsPath) { - this.writer = writer; + GcsWritableByteChannel(Future pendingWriter, GcsPath gcsPath) { + this.pendingWriter = pendingWriter; this.gcsPath = gcsPath; } + /** Waits for the upload session to start and returns its channel. */ + private WriteChannel writer() throws IOException { + try { + return pendingWriter.get(); + } catch (InterruptedException e) { + sessionFailed = true; + Thread.currentThread().interrupt(); + pendingWriter.cancel(true); + throw new InterruptedIOException("Interrupted while starting the upload to " + gcsPath); + } catch (CancellationException e) { + // Only cancelled above, by an earlier interrupted wait, which abandoned the upload. + sessionFailed = true; + throw new InterruptedIOException( + "The upload to " + gcsPath + " was abandoned after an earlier interrupt"); + } catch (ExecutionException e) { + sessionFailed = true; + Throwable cause = e.getCause(); + if (cause instanceof StorageException) { + throw translateStorageException(gcsPath, (StorageException) cause); + } + if (cause instanceof IOException) { + throw (IOException) cause; + } + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new IOException("Failed to start the upload to " + gcsPath, cause); + } + } + @Override public int write(ByteBuffer src) throws IOException { + if (closed) { + throw new ClosedChannelException(); + } try { - return writer.write(src); + return writer().write(src); } catch (StorageException e) { throw translateStorageException(gcsPath, e); } @@ -1015,14 +1087,23 @@ public int write(ByteBuffer src) throws IOException { @Override public boolean isOpen() { - return writer.isOpen(); + return !closed; } @Override public void close() throws IOException { + if (closed) { + return; + } + closed = true; + if (sessionFailed) { + // An earlier write already reported that the session could not be started, and there is + // nothing to finalize. + return; + } // The upload is finalized here, so this is where a failed precondition surfaces. try { - writer.close(); + writer().close(); } catch (StorageException e) { throw translateStorageException(gcsPath, e); } @@ -1058,19 +1139,33 @@ public WritableByteChannel create( writeOptionList.add(BlobWriteOption.generationMatch(blob.getGeneration())); } } - // Open a WriteChannel from the storage service - WriteChannel writer = - client.writer(blobInfo, writeOptionList.toArray(new BlobWriteOption[0])); + // Open a WriteChannel from the storage service. Opening one starts the resumable upload + // session with a blocking request, so it is done in the background and the returned channel + // waits for it on its first write or on close (see GcsWritableByteChannel). + BlobWriteOption[] writeOptionArray = writeOptionList.toArray(new BlobWriteOption[0]); Integer uploadBufferSizeBytes = options.getUploadBufferSizeBytes() != null ? options.getUploadBufferSizeBytes() : this.uploadBufferSizeBytes; - writer.setChunkSize( - uploadBufferSizeBytes != null ? uploadBufferSizeBytes : DEFAULT_UPLOAD_CHUNK_SIZE_BYTES); + int chunkSize = + uploadBufferSizeBytes != null ? uploadBufferSizeBytes : DEFAULT_UPLOAD_CHUNK_SIZE_BYTES; + Future pendingWriter = + UPLOAD_SESSION_EXECUTOR.submit( + () -> { + try { + WriteChannel writer = client.writer(blobInfo, writeOptionArray); + writer.setChunkSize(chunkSize); + serviceCallMetric.call("ok"); + return writer; + } catch (StorageException e) { + serviceCallMetric.call(e.getCode()); + throw e; + } + }); - serviceCallMetric.call("ok"); // Return the bridge wrapper - return wrapInCounting(new GcsWritableByteChannel(writer, path), path.getBucket(), container); + return wrapInCounting( + new GcsWritableByteChannel(pendingWriter, path), path.getBucket(), container); } catch (StorageException e) { serviceCallMetric.call(e.getCode()); diff --git a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2Test.java b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2Test.java index 0654f9744440..9335ab6f56e0 100644 --- a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2Test.java +++ b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV2Test.java @@ -58,9 +58,11 @@ import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InterruptedIOException; import java.math.BigInteger; import java.nio.ByteBuffer; import java.nio.channels.Channels; +import java.nio.channels.ClosedChannelException; import java.nio.channels.SeekableByteChannel; import java.nio.channels.WritableByteChannel; import java.nio.file.AccessDeniedException; @@ -71,6 +73,7 @@ import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.apache.beam.repackaged.core.org.apache.commons.compress.utils.SeekableInMemoryByteChannel; import org.apache.beam.runners.core.metrics.CounterCell; import org.apache.beam.runners.core.metrics.GcpResourceIdentifiers; @@ -560,6 +563,175 @@ public void testV2WriteChannelCloseTranslatesStorageException() throws IOExcepti assertSame(preconditionFailed, thrown.getCause()); } + /** + * java-storage starts the resumable upload session with a blocking request when it opens a + * WriteChannel. create() must leave that to a background thread, as gcsio does for V1, so that + * the caller does not wait for it. + */ + @Test + public void testV2CreateStartsUploadSessionInBackground() throws Exception { + WriteChannel writer = Mockito.mock(WriteChannel.class); + CountDownLatch release = new CountDownLatch(1); + AtomicReference openingThread = new AtomicReference<>(); + Storage storage = Mockito.mock(Storage.class); + when(storage.writer(any(BlobInfo.class), any())) + .thenAnswer( + invocation -> { + openingThread.set(Thread.currentThread()); + if (!release.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("the upload session was never released"); + } + return writer; + }); + GcsUtil gcsUtil = gcsUtilWithV2Storage(storage); + + WritableByteChannel channel = + gcsUtil.create( + GcsPath.fromComponents("testbucket", "testobject"), + CreateOptions.builder().setExpectFileToNotExist(true).build()); + assertTrue(channel.isOpen()); + + release.countDown(); + channel.write(ByteBuffer.wrap(new byte[] {1, 2, 3})); + channel.close(); + + assertNotSame(Thread.currentThread(), openingThread.get()); + verify(writer).write(any(ByteBuffer.class)); + verify(writer).close(); + assertFalse(channel.isOpen()); + } + + /** + * A failure to start the upload session surfaces on the first write, as an IOException. Close is + * then a no-op, and later writes report the closed channel. + */ + @Test + public void testV2CreateReportsUploadSessionFailureOnWrite() throws IOException { + Storage storage = Mockito.mock(Storage.class); + when(storage.writer(any(BlobInfo.class), any())) + .thenThrow(new StorageException(403, "Forbidden")); + GcsUtil gcsUtil = gcsUtilWithV2Storage(storage); + + WritableByteChannel channel = + gcsUtil.create( + GcsPath.fromComponents("testbucket", "testobject"), + CreateOptions.builder().setExpectFileToNotExist(true).build()); + + assertThrows(AccessDeniedException.class, () -> channel.write(ByteBuffer.wrap(new byte[] {1}))); + // The failure was already reported and there is no session to finalize, so close is a no-op. + channel.close(); + assertFalse(channel.isOpen()); + assertThrows( + ClosedChannelException.class, () -> channel.write(ByteBuffer.wrap(new byte[] {1}))); + } + + /** A java-storage client whose upload sessions do not start until {@code release} fires. */ + private static Storage storageWithBlockedUploadSession( + CountDownLatch release, WriteChannel writer) { + Storage storage = Mockito.mock(Storage.class); + when(storage.writer(any(BlobInfo.class), any())) + .thenAnswer( + invocation -> { + release.await(); + return writer; + }); + return storage; + } + + private static WritableByteChannel createTestObject(GcsUtil gcsUtil) throws IOException { + return gcsUtil.create( + GcsPath.fromComponents("testbucket", "testobject"), + CreateOptions.builder().setExpectFileToNotExist(true).build()); + } + + /** A closed channel rejects writes itself, rather than relying on the underlying channel. */ + @Test + public void testV2WriteAfterCloseThrowsClosedChannelException() throws IOException { + WriteChannel writer = Mockito.mock(WriteChannel.class); + WritableByteChannel channel = createTestObject(gcsUtilWithV2Storage(storageWritingTo(writer))); + + channel.close(); + + assertThrows( + ClosedChannelException.class, () -> channel.write(ByteBuffer.wrap(new byte[] {1}))); + verify(writer, never()).write(any(ByteBuffer.class)); + } + + /** + * When the session fails to start and nothing was written, close reports the failure. A later + * write must then report the closed channel, not the session failure again. + */ + @Test + public void testV2WriteAfterFailedCloseThrowsClosedChannelException() throws IOException { + Storage storage = Mockito.mock(Storage.class); + when(storage.writer(any(BlobInfo.class), any())) + .thenThrow(new StorageException(403, "Forbidden")); + WritableByteChannel channel = createTestObject(gcsUtilWithV2Storage(storage)); + + assertThrows(AccessDeniedException.class, channel::close); + + assertFalse(channel.isOpen()); + assertThrows( + ClosedChannelException.class, () -> channel.write(ByteBuffer.wrap(new byte[] {1}))); + } + + /** + * An interrupt while the first write waits for the session abandons the upload. Later writes + * report that as an IOException rather than leaking a CancellationException, and close is a + * no-op. + */ + @Test + public void testV2WriteInterruptedWhileStartingSession() throws IOException { + CountDownLatch release = new CountDownLatch(1); + WriteChannel writer = Mockito.mock(WriteChannel.class); + WritableByteChannel channel = + createTestObject(gcsUtilWithV2Storage(storageWithBlockedUploadSession(release, writer))); + try { + Thread.currentThread().interrupt(); + assertThrows( + InterruptedIOException.class, () -> channel.write(ByteBuffer.wrap(new byte[] {1}))); + // The interrupt is preserved for the caller. + assertTrue(Thread.interrupted()); + + assertThrows( + InterruptedIOException.class, () -> channel.write(ByteBuffer.wrap(new byte[] {1}))); + channel.close(); + assertFalse(channel.isOpen()); + verify(writer, never()).write(any(ByteBuffer.class)); + verify(writer, never()).close(); + } finally { + Thread.interrupted(); + release.countDown(); + } + } + + /** + * An interrupt while close waits for the session of an empty file is reported by close. A later + * write must then report the closed channel. + */ + @Test + public void testV2CloseInterruptedWhileStartingSession() throws IOException { + CountDownLatch release = new CountDownLatch(1); + WriteChannel writer = Mockito.mock(WriteChannel.class); + WritableByteChannel channel = + createTestObject(gcsUtilWithV2Storage(storageWithBlockedUploadSession(release, writer))); + try { + Thread.currentThread().interrupt(); + assertThrows(InterruptedIOException.class, channel::close); + assertTrue(Thread.interrupted()); + + assertFalse(channel.isOpen()); + assertThrows( + ClosedChannelException.class, () -> channel.write(ByteBuffer.wrap(new byte[] {1}))); + // A second close is a no-op. + channel.close(); + verify(writer, never()).close(); + } finally { + Thread.interrupted(); + release.countDown(); + } + } + /** Mirrors {@link GcsUtilV1Test#testBucketDoesNotExist} for V2. */ @Test public void testV2BucketAccessibleIsFalseWhenBucketDoesNotExist() throws IOException { @@ -724,18 +896,21 @@ private static Storage storageWritingTo(WriteChannel writer) { @Test public void testV2CreatePreconditions() throws IOException { GcsPath path = GcsPath.fromComponents("testbucket", "testobject"); + // Each channel is closed so that its upload session, started in the background, has been + // opened before it is verified. // Expected not to exist: no lookup is made. Storage expectedMissing = storageWritingTo(Mockito.mock(WriteChannel.class)); gcsUtilWithV2Storage(expectedMissing) - .create(path, CreateOptions.builder().setExpectFileToNotExist(true).build()); + .create(path, CreateOptions.builder().setExpectFileToNotExist(true).build()) + .close(); verify(expectedMissing).writer(any(BlobInfo.class), Mockito.eq(BlobWriteOption.doesNotExist())); verify(expectedMissing, never()).get(anyString(), anyString(), any()); // Looked up and missing. An unstubbed get() returns null, which is how java-storage reports a // missing object. Storage missing = storageWritingTo(Mockito.mock(WriteChannel.class)); - gcsUtilWithV2Storage(missing).create(path, CreateOptions.builder().build()); + gcsUtilWithV2Storage(missing).create(path, CreateOptions.builder().build()).close(); verify(missing).writer(any(BlobInfo.class), Mockito.eq(BlobWriteOption.doesNotExist())); // Looked up and found. @@ -743,7 +918,7 @@ public void testV2CreatePreconditions() throws IOException { Blob blob = Mockito.mock(Blob.class); when(blob.getGeneration()).thenReturn(42L); when(existing.get(Mockito.eq("testbucket"), Mockito.eq("testobject"), any())).thenReturn(blob); - gcsUtilWithV2Storage(existing).create(path, CreateOptions.builder().build()); + gcsUtilWithV2Storage(existing).create(path, CreateOptions.builder().build()).close(); verify(existing).writer(any(BlobInfo.class), Mockito.eq(BlobWriteOption.generationMatch(42L))); } @@ -765,21 +940,23 @@ public void testV2UploadChunkSize() throws IOException { pipelineWithSize.setGcsUploadBufferSizeBytes(2048); WriteChannel writer = Mockito.mock(WriteChannel.class); - gcsUtilWithV2Storage(pipelineWithSize, storageWritingTo(writer)).create(path, withSize); + gcsUtilWithV2Storage(pipelineWithSize, storageWritingTo(writer)).create(path, withSize).close(); verify(writer).setChunkSize(1024); writer = Mockito.mock(WriteChannel.class); - gcsUtilWithV2Storage(pipelineWithSize, storageWritingTo(writer)).create(path, withoutSize); + gcsUtilWithV2Storage(pipelineWithSize, storageWritingTo(writer)) + .create(path, withoutSize) + .close(); verify(writer).setChunkSize(2048); writer = Mockito.mock(WriteChannel.class); - gcsUtilWithV2Storage(storageWritingTo(writer)).create(path, withoutSize); + gcsUtilWithV2Storage(storageWritingTo(writer)).create(path, withoutSize).close(); verify(writer).setChunkSize(GcsUtilV2.DEFAULT_UPLOAD_CHUNK_SIZE_BYTES); } /** Mirrors {@link GcsUtilV1Test#testGCSWriteMetricsIsSet}. */ @Test - public void testV2WriteMetricsIsSet() { + public void testV2WriteMetricsIsSet() throws IOException { MetricsContainerImpl container = new MetricsContainerImpl(null); MetricsEnvironment.setProcessWideContainer(container); Storage storage = Mockito.mock(Storage.class); @@ -788,9 +965,10 @@ public void testV2WriteMetricsIsSet() { GcsUtil gcsUtil = gcsUtilWithV2Storage(storage); GcsPath path = GcsPath.fromComponents("testbucket", "testobject"); - assertThrows( - AccessDeniedException.class, - () -> gcsUtil.create(path, CreateOptions.builder().setExpectFileToNotExist(true).build())); + // The upload session is started in the background, so its failure surfaces on close. + WritableByteChannel channel = + gcsUtil.create(path, CreateOptions.builder().setExpectFileToNotExist(true).build()); + assertThrows(AccessDeniedException.class, channel::close); verifyMetricWasSet("my_project", "testbucket", "GcsInsert", "permission_denied", 1); verifyMetricWasSet("my_project", "testbucket", "GcsInsert", "ok", 0); From b54c607c4d52a4e9ab4d24d5849908129afdb03a Mon Sep 17 00:00:00 2001 From: Derrick Williams Date: Wed, 21 Oct 2026 11:47:36 -0400 Subject: [PATCH 077/108] move pipeline examples from blueprint to pipelines (#40615) --- .../transforms/{blueprint => pipelines}/bigquery_to_bigtable.yaml | 0 .../{blueprint => pipelines}/gcs_avro_to_cloud_bigtable.yaml | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename sdks/python/apache_beam/yaml/examples/transforms/{blueprint => pipelines}/bigquery_to_bigtable.yaml (100%) rename sdks/python/apache_beam/yaml/examples/transforms/{blueprint => pipelines}/gcs_avro_to_cloud_bigtable.yaml (100%) diff --git a/sdks/python/apache_beam/yaml/examples/transforms/blueprint/bigquery_to_bigtable.yaml b/sdks/python/apache_beam/yaml/examples/transforms/pipelines/bigquery_to_bigtable.yaml similarity index 100% rename from sdks/python/apache_beam/yaml/examples/transforms/blueprint/bigquery_to_bigtable.yaml rename to sdks/python/apache_beam/yaml/examples/transforms/pipelines/bigquery_to_bigtable.yaml diff --git a/sdks/python/apache_beam/yaml/examples/transforms/blueprint/gcs_avro_to_cloud_bigtable.yaml b/sdks/python/apache_beam/yaml/examples/transforms/pipelines/gcs_avro_to_cloud_bigtable.yaml similarity index 100% rename from sdks/python/apache_beam/yaml/examples/transforms/blueprint/gcs_avro_to_cloud_bigtable.yaml rename to sdks/python/apache_beam/yaml/examples/transforms/pipelines/gcs_avro_to_cloud_bigtable.yaml From 63d8acc795e4ea00c88b28a892b5fa1d3b528fcb Mon Sep 17 00:00:00 2001 From: GAURAV THAKUR Date: Wed, 21 Oct 2026 21:45:39 +0530 Subject: [PATCH 078/108] [BEAM-19954] Resolve string annotations in callable type hints (#40625) * [BEAM-19954] Resolve string annotations in callable type hints Use typing.get_type_hints for annotated functions and class constructors so forward references are converted to their intended Beam type hints. Preserve the existing Any fallback when annotations cannot be resolved. Add regression coverage for string and future annotations, methods, constructors, and unresolved names. * Apply Beam YAPF formatting to forward-reference fix Match the formatter's expected layout for the return annotation and postponed-annotation regression test. * [BEAM-19954] Resolve forward references independently and correct ParDo hint coverage * Mark intentionally unresolved forward references in tests --- .../apache_beam/typehints/decorators.py | 76 +++++++ .../apache_beam/typehints/decorators_test.py | 189 ++++++++++++++++++ .../typehints/typed_pipeline_test.py | 5 +- 3 files changed, 268 insertions(+), 2 deletions(-) diff --git a/sdks/python/apache_beam/typehints/decorators.py b/sdks/python/apache_beam/typehints/decorators.py index bc44ec745d5a..d3547f535681 100644 --- a/sdks/python/apache_beam/typehints/decorators.py +++ b/sdks/python/apache_beam/typehints/decorators.py @@ -80,11 +80,13 @@ def foo((a, b)): # pytype: skip-file # ruff: noqa: UP006 +import functools import inspect import itertools import logging import traceback import types +import typing from typing import Any from typing import Callable from typing import Dict @@ -131,6 +133,67 @@ def foo((a, b)): _disable_from_callable = False # pylint: disable=invalid-name +def _contains_forward_ref(annotation): + if isinstance(annotation, (str, typing.ForwardRef)): + return True + origin = get_origin(annotation) + if origin is Literal: + # Literal strings are values, not type names. + return False + args = get_args(annotation) + if origin is typing.Annotated: + args = args[:1] # The remaining arguments are metadata. + return any(_contains_forward_ref(arg) for arg in args) + + +def _annotation_namespaces(fn): + """Find the implementation and namespaces behind inspect.signature(fn).""" + target = fn + while isinstance(target, functools.partial): + target = target.func + localns = None + if inspect.isclass(target): + localns = dict(vars(target)) + localns[target.__name__] = target + if target.__init__ is not object.__init__: + target = target.__init__ + else: + target = target.__new__ + elif inspect.ismethod(target): + owner = target.__self__ + owner = owner if inspect.isclass(owner) else type(owner) + localns = dict(vars(owner)) + localns[owner.__name__] = owner + elif not (inspect.isfunction(target) or inspect.isbuiltin(target)): + owner = type(target) + target = target.__call__ + localns = dict(vars(owner)) + localns[owner.__name__] = owner + target = inspect.unwrap(target) + if inspect.ismethod(target): + target = target.__func__ + return getattr(target, '__globals__', {}), localns + + +def _resolve_annotation(annotation, globalns, localns): + if not _contains_forward_ref(annotation): + return annotation + + # get_type_hints resolves nested ForwardRefs and uses public typing APIs. + # A one-hint callable lets an unresolved name leave other hints intact. + def one_hint(): + pass + + one_hint.__annotations__ = {'value': annotation} + try: + return typing.get_type_hints( + one_hint, globalns=globalns, localns=localns, + include_extras=True)['value'] + except (AttributeError, NameError, SyntaxError, TypeError): + # convert_to_beam_type maps unresolved strings and ForwardRefs to Any. + return annotation + + def get_signature(func): """Like inspect.signature(), but supports Py2 as well. @@ -334,6 +397,19 @@ def from_callable(cls, fn: Callable) -> Optional['IOTypeHints']: for param in signature.parameters.values()) and signature.return_annotation == signature.empty): return None + if (any(_contains_forward_ref(param.annotation) + for param in signature.parameters.values()) or + _contains_forward_ref(signature.return_annotation)): + globalns, localns = _annotation_namespaces(fn) + signature = signature.replace( + parameters=[ + param.replace( + annotation=_resolve_annotation( + param.annotation, globalns, localns)) + for param in signature.parameters.values() + ], + return_annotation=_resolve_annotation( + signature.return_annotation, globalns, localns)) input_args = [] input_kwargs = {} for param in signature.parameters.values(): diff --git a/sdks/python/apache_beam/typehints/decorators_test.py b/sdks/python/apache_beam/typehints/decorators_test.py index 73efea9e9abf..3e9c3f182709 100644 --- a/sdks/python/apache_beam/typehints/decorators_test.py +++ b/sdks/python/apache_beam/typehints/decorators_test.py @@ -256,6 +256,195 @@ def fn( })) self.assertEqual(th.output_types, ((Tuple[Any, ...], ), {})) + def test_from_callable_resolves_string_annotations(self): + def fn(value: 'int') -> 'typehints.List[str]': + return [str(value)] + + th = decorators.IOTypeHints.from_callable(fn) + self.assertEqual(th.input_types, ((int, ), {})) + self.assertEqual(th.output_types, ((List[str], ), {})) + + def test_from_callable_pardo_output_is_an_iterable_of_elements(self): + def do_fn(element: 'int') -> 'typehints.List[typehints.List[str]]': + return [[str(element)] * 2] + + th = decorators.IOTypeHints.from_callable(do_fn) + self.assertEqual(th.input_types, ((int, ), {})) + self.assertEqual(th.strip_iterable().output_types, ((List[str], ), {})) + + def test_from_callable_resolves_future_annotations(self): + namespace = {'typehints': typehints} + exec( + 'from __future__ import annotations\n' + 'def fn(value: int) -> typehints.List[str]:\n' + ' return [str(value)]\n', + namespace) + + th = decorators.IOTypeHints.from_callable(namespace['fn']) + self.assertEqual(th.input_types, ((int, ), {})) + self.assertEqual(th.output_types, ((List[str], ), {})) + + def test_from_callable_resolves_class_constructor_string_annotations(self): + class Class(object): + def __init__(self, value: 'int'): + self.value = value + + th = decorators.IOTypeHints.from_callable(Class) + self.assertEqual(th.input_types, ((int, ), {})) + self.assertEqual(th.output_types, ((Class, ), {})) + + def test_from_callable_resolves_method_string_annotations(self): + class Class(object): + def process(self, value: 'int') -> 'typehints.List[str]': + return [str(value)] + + th = decorators.IOTypeHints.from_callable(Class().process) + self.assertEqual(th.input_types, ((int, ), {})) + self.assertEqual(th.output_types, ((List[str], ), {})) + + def test_from_callable_unresolved_string_annotations_fall_back_to_any(self): + def fn(value: 'MissingType') -> 'int': # noqa: F821 + return 1 + + th = decorators.IOTypeHints.from_callable(fn) + self.assertEqual(th.input_types, ((Any, ), {})) + self.assertEqual(th.output_types, ((int, ), {})) + + def test_from_callable_resolves_other_hints_after_missing_name(self): + namespace = {} + exec( + 'from __future__ import annotations\n' + 'def fn(missing: MissingType, value: int, *, name: str) -> int:\n' + ' return value\n', + namespace) + + th = decorators.IOTypeHints.from_callable(namespace['fn']) + self.assertEqual(th.input_types, ((Any, int), {'name': str})) + self.assertEqual(th.output_types, ((int, ), {})) + + def test_from_callable_resolves_nested_forward_references(self): + def fn( + values: typing.List['int'], + pair: typing.Tuple['str', int], + builtin: list['int'], + nested: typing.Dict[str, typing.List[typing.ForwardRef('int')]], + resolved: typing.List[int]) -> typing.List['str']: + return [str(values[0])] + + th = decorators.IOTypeHints.from_callable(fn) + self.assertEqual( + th.input_types, + (( + List[int], + Tuple[str, int], + List[int], + Dict[str, List[int]], + List[int]), {})) + self.assertEqual(th.output_types, ((List[str], ), {})) + + def test_from_callable_unresolved_nested_reference_falls_back_to_any(self): + def fn(values: typing.List['MissingType']) -> 'int': # noqa: F821 + return len(values) + + th = decorators.IOTypeHints.from_callable(fn) + self.assertEqual(th.input_types, ((List[Any], ), {})) + self.assertEqual(th.output_types, ((int, ), {})) + + def test_from_callable_partial_with_string_annotations(self): + def fn(bound: 'int', value: 'str', *, option: 'int') -> 'str': + return value + + th = decorators.IOTypeHints.from_callable(functools.partial(fn, 1)) + self.assertEqual(th.input_types, ((str, ), { + 'option': int + })) + self.assertEqual(th.output_types, ((str, ), {})) + + def test_from_callable_bound_and_unbound_methods_with_strings(self): + class Processor: + def process(self, value: 'int', *, label: 'str') -> 'str': + return label + + unbound = decorators.IOTypeHints.from_callable(Processor.process) + self.assertEqual(unbound.input_types, ((Any, int), {'label': str})) + self.assertEqual(unbound.output_types, ((str, ), {})) + bound = decorators.IOTypeHints.from_callable(Processor().process) + self.assertEqual(bound.input_types, ((int, ), { + 'label': str + })) + self.assertEqual(bound.output_types, ((str, ), {})) + + def test_from_callable_varargs_and_kwargs_with_strings(self): + def fn( + value: 'int', + *args: 'typing.Tuple[str]', + required: 'str', + **kwargs: 'typing.Dict[str, int]') -> 'int': + return value + + th = decorators.IOTypeHints.from_callable(fn) + self.assertEqual( + th.input_types, ((int, Tuple[str]), { + 'required': str, 'kwargs': Dict[str, int] + })) + self.assertEqual(th.output_types, ((int, ), {})) + + def test_from_callable_callable_instance_with_string_annotations(self): + class CallableClass: + def __call__(self, value: 'int') -> 'str': + return str(value) + + th = decorators.IOTypeHints.from_callable(CallableClass()) + self.assertEqual(th.input_types, ((int, ), {})) + self.assertEqual(th.output_types, ((str, ), {})) + + def test_from_callable_wrapped_function_uses_original_namespace(self): + namespace = {'Alias': str} + exec('def original(value: "Alias") -> "int":\n return 1\n', namespace) + + @functools.wraps(namespace['original']) + def wrapper(*args, **kwargs): + return namespace['original'](*args, **kwargs) + + th = decorators.IOTypeHints.from_callable(wrapper) + self.assertEqual(th.input_types, ((str, ), {})) + self.assertEqual(th.output_types, ((int, ), {})) + + def test_from_callable_class_new_and_inherited_init(self): + class WithNew: + def __new__(cls, value: 'int'): + return super().__new__(cls) + + class NoConstructor: + pass + + th = decorators.IOTypeHints.from_callable(WithNew) + self.assertEqual(th.input_types, ((int, ), {})) + self.assertEqual(th.output_types, ((WithNew, ), {})) + th = decorators.IOTypeHints.from_callable(NoConstructor) + self.assertEqual(th.input_types, ((), {})) + self.assertEqual(th.output_types, ((NoConstructor, ), {})) + + def test_from_callable_resolves_class_local_names(self): + class Node: + def __init__(self, previous: 'Node'): + self.previous = previous + + def link(self, next_node: 'Node') -> 'Node': + return next_node + + th = decorators.IOTypeHints.from_callable(Node) + self.assertEqual(th.input_types, ((Node, ), {})) + self.assertEqual(th.output_types, ((Node, ), {})) + th = decorators.IOTypeHints.from_callable(Node(None).link) + self.assertEqual(th.input_types, ((Node, ), {})) + self.assertEqual(th.output_types, ((Node, ), {})) + + def test_from_callable_annotated_preserves_metadata_during_resolution(self): + annotation = typing.Annotated[typing.List['int'], 'metadata'] + resolved = decorators._resolve_annotation(annotation, globals(), locals()) + self.assertEqual(resolved, typing.Annotated[typing.List[int], 'metadata']) + def test_from_callable_partial(self): def fn(a: int) -> int: return a diff --git a/sdks/python/apache_beam/typehints/typed_pipeline_test.py b/sdks/python/apache_beam/typehints/typed_pipeline_test.py index 29cb97dd7872..9a5efd3dead7 100644 --- a/sdks/python/apache_beam/typehints/typed_pipeline_test.py +++ b/sdks/python/apache_beam/typehints/typed_pipeline_test.py @@ -399,7 +399,7 @@ def process(self, element: int, **side_inputs: typehints.Dict[str, str]) \ _ = [1, 2, 3] | beam.ParDo(my_do_fn, a=1) def test_typed_callable_string_literals(self): - def do_fn(element: 'int') -> 'typehints.List[str]': + def do_fn(element: 'int') -> 'typehints.List[typehints.List[str]]': return [[str(element)] * 2] result = [1, 2] | beam.ParDo(do_fn) @@ -439,7 +439,8 @@ def fn(element: float): def test_typed_dofn_string_literals(self): class MyDoFn(beam.DoFn): - def process(self, element: 'int') -> 'typehints.List[str]': + def process( + self, element: 'int') -> 'typehints.List[typehints.List[str]]': return [[str(element)] * 2] result = [1, 2] | beam.ParDo(MyDoFn()) From 5b9c5ba2b971d2d240bccae8d3a7adb92ebfb4df Mon Sep 17 00:00:00 2001 From: Florent Biville <445792+fbiville@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:41:13 +0200 Subject: [PATCH 079/108] test: allow users to set custom Neo4j config (#40700) --- .../beam/it/neo4j/Neo4jResourceManager.java | 18 +++++++++++++++++- .../it/neo4j/Neo4jResourceManagerTest.java | 10 ++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/it/neo4j/src/main/java/org/apache/beam/it/neo4j/Neo4jResourceManager.java b/it/neo4j/src/main/java/org/apache/beam/it/neo4j/Neo4jResourceManager.java index a8ee4053fc54..10e41c7fe928 100644 --- a/it/neo4j/src/main/java/org/apache/beam/it/neo4j/Neo4jResourceManager.java +++ b/it/neo4j/src/main/java/org/apache/beam/it/neo4j/Neo4jResourceManager.java @@ -23,6 +23,7 @@ import com.google.common.annotations.VisibleForTesting; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -84,7 +85,7 @@ private Neo4jResourceManager(Builder builder) { @VisibleForTesting @SuppressWarnings("nullness") Neo4jResourceManager(@Nullable Driver neo4jDriver, Neo4jContainer container, Builder builder) { - super(container, builder); + super(withNeo4jConfig(container, builder.neo4jConfig), builder); this.adminPassword = builder.adminPassword; this.connectionString = @@ -205,12 +206,22 @@ public String getAdminPassword() { return adminPassword; } + private static Neo4jContainer withNeo4jConfig( + Neo4jContainer container, Map config) { + config.forEach( + (key, value) -> { + container.withNeo4jConfig(key, value); + }); + return container; + } + /** Builder for {@link Neo4jResourceManager}. */ public static final class Builder extends TestContainerResourceManager.Builder { private @Nullable String databaseName; private @Nullable DatabaseWaitOption waitOption; + private Map neo4jConfig = new HashMap<>(); private String adminPassword; @@ -266,6 +277,11 @@ public Builder setAdminPassword(String password) { return this; } + public Builder addNeo4jConfig(String key, String value) { + neo4jConfig.put(key, value); + return this; + } + @VisibleForTesting Builder setDriver(Driver driver) { this.driver = driver; diff --git a/it/neo4j/src/test/java/org/apache/beam/it/neo4j/Neo4jResourceManagerTest.java b/it/neo4j/src/test/java/org/apache/beam/it/neo4j/Neo4jResourceManagerTest.java index 49d9c7ec2322..f462b522a381 100644 --- a/it/neo4j/src/test/java/org/apache/beam/it/neo4j/Neo4jResourceManagerTest.java +++ b/it/neo4j/src/test/java/org/apache/beam/it/neo4j/Neo4jResourceManagerTest.java @@ -179,4 +179,14 @@ public void testCleanupAllShouldThrowErrorWhenNeo4jDriverFailsToClose() { assertThrows(Neo4jResourceManagerException.class, () -> testManager.cleanupAll()); } + + @Test + public void testSetsCustomNeo4jConfig() { + var builder = + Neo4jResourceManager.builder(TEST_ID) + .addNeo4jConfig("server.bolt.telemetry.enabled", "false"); + new Neo4jResourceManager(neo4jDriver, container, builder); + + verify(container).withNeo4jConfig("server.bolt.telemetry.enabled", "false"); + } } From 06e4102173b4e4ff72298f3562320e9aec9547ca Mon Sep 17 00:00:00 2001 From: Derrick Williams Date: Wed, 21 Oct 2026 13:31:22 -0400 Subject: [PATCH 080/108] [yaml] - fix spanner configs and examples (#40587) --- .../SpannerReadSchemaTransformProvider.java | 12 ++++++------ .../SpannerWriteSchemaTransformProvider.java | 8 ++++---- .../yaml/examples/testing/examples_test.py | 2 +- .../yaml/examples/transforms/io/spanner_read.yaml | 8 ++++---- .../yaml/examples/transforms/io/spanner_write.yaml | 8 ++++---- .../ml/enrich_spanner_with_bigquery.yaml | 14 +++++++------- .../transforms/pipelines/spanner_to_avro.yaml | 6 +++--- .../transforms/pipelines/spanner_to_bigquery.yaml | 6 +++--- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerReadSchemaTransformProvider.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerReadSchemaTransformProvider.java index 73f4c2dfe30c..f9b63330634e 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerReadSchemaTransformProvider.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerReadSchemaTransformProvider.java @@ -95,8 +95,8 @@ public String description() { + "\n" + " - type: ReadFromSpanner\n" + " config:\n" - + " instance_id: 'my-instance-id'\n" - + " database_id: 'my-database'\n" + + " instance: 'my-instance-id'\n" + + " database: 'my-database'\n" + " query: 'SELECT * FROM table'\n" + "\n" + "It is also possible to read a table by specifying a table name and a list of columns. For " @@ -104,8 +104,8 @@ public String description() { + "\n" + " - type: ReadFromSpanner\n" + " config:\n" - + " instance_id: 'my-instance-id'\n" - + " database_id: 'my-database'\n" + + " instance: 'my-instance-id'\n" + + " database: 'my-database'\n" + " table: 'my-table'\n" + " columns: ['col1', 'col2']\n" + "\n" @@ -114,8 +114,8 @@ public String description() { + "\n" + " - type: ReadFromSpanner\n" + " config:\n" - + " instance_id: 'my-instance-id'\n" - + " database_id: 'my-database'\n" + + " instance: 'my-instance-id'\n" + + " database: 'my-database'\n" + " table: 'my-table'\n" + " index: 'my-index'\n" + " columns: ['col1', 'col2']\n" diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteSchemaTransformProvider.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteSchemaTransformProvider.java index eee13b0a932f..63fc7574f41f 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteSchemaTransformProvider.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteSchemaTransformProvider.java @@ -84,11 +84,11 @@ public String description() { + "\n" + "Example configuration for performing a write to a single table: ::\n" + "\n" - + " - type: ReadFromSpanner\n" + + " - type: WriteToSpanner\n" + " config:\n" - + " project_id: 'my-project-id'\n" - + " instance_id: 'my-instance-id'\n" - + " database_id: 'my-database'\n" + + " project: 'my-project-id'\n" + + " instance: 'my-instance-id'\n" + + " database: 'my-database'\n" + " table: 'my-table'\n" + "\n" + "Note: See Date: Wed, 21 Oct 2026 13:56:18 -0400 Subject: [PATCH 081/108] Update xgboost dependency, execute in CI (#40694) * Update xgboost dependency, execute in CI * Apply correct link to entry in CHANGES.md * Fix typos * Comment cleanup * Mark datatable handler as deprecated --- .../beam_PostCommit_Python_Dependency.json | 2 +- .../beam_PreCommit_Python_ML.json | 2 +- CHANGES.md | 3 +++ .../ml/inference/xgboost_inference.py | 9 +++++++- .../ml/inference/xgboost_inference_test.py | 4 +++- .../inference/xgboost_tests_requirements.txt | 4 ++-- sdks/python/setup.py | 22 ++++++++++++++----- .../python/test-suites/tox/py311/build.gradle | 9 ++++++++ sdks/python/tox.ini | 12 +++++----- 9 files changed, 48 insertions(+), 19 deletions(-) diff --git a/.github/trigger_files/beam_PostCommit_Python_Dependency.json b/.github/trigger_files/beam_PostCommit_Python_Dependency.json index 16209484727a..9bc6afbb0969 100644 --- a/.github/trigger_files/beam_PostCommit_Python_Dependency.json +++ b/.github/trigger_files/beam_PostCommit_Python_Dependency.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 4 + "modification": 5 } diff --git a/.github/trigger_files/beam_PreCommit_Python_ML.json b/.github/trigger_files/beam_PreCommit_Python_ML.json index 7df0105a8112..2b9b913580a2 100644 --- a/.github/trigger_files/beam_PreCommit_Python_ML.json +++ b/.github/trigger_files/beam_PreCommit_Python_ML.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "revision": 22 + "revision": 23 } diff --git a/CHANGES.md b/CHANGES.md index 74dcb41af94b..4e2fffb89380 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -95,10 +95,13 @@ * (Python) `Regex.find_all` with `group=Regex.ALL` now returns every group, as `(match, group1, group2, ...)`, instead of only group 1, and with `outputEmpty=False` it drops a match only when the whole match is empty, matching the `group=0` behavior ([#40399](https://github.com/apache/beam/pull/40399)). * (Python) `Regex.all_matches` now returns every group of the regex, instead of failing with a `TypeError` when the regex has no groups and dropping nested or unmatched trailing groups ([#40562](https://github.com/apache/beam/issues/40562)). * (Python) `--dataflow_endpoint` now defaults to `''` instead of `https://dataflow.googleapis.com`. When unset, the Google Cloud client library resolves the Dataflow endpoint and honors `GOOGLE_CLOUD_UNIVERSE_DOMAIN`, so Dataflow jobs can be submitted in non-default universes. Code that reads `GoogleCloudOptions.dataflow_endpoint` should handle an empty value ([#40569](https://github.com/apache/beam/issues/40569)). +* (Python) The `xgboost` extras now install `xgboost>=2.0.0,<4` and `datatable>=1.1.0,<2`, moving both the lower and upper bounds for both. The previous minimum `xgboost` and `datatable` versions (`1.6.0` and `1.0.0` respectively) are no longer allowed ([#40694](https://github.com/apache/beam/issues/40694)). ## Deprecations * Support for Python 3.10 was removed following its end-of-life ([#40250](https://github.com/apache/beam/issues/40250)). +* (Python) `XGBoostModelHandlerDatatable` is deprecated and will be removed in Beam 2.79.0, along with the `datatable` dependency of the `xgboost` extra. `datatable` has not had a release since 2023 and has no wheels for Python 3.13+. Use `XGBoostModelHandlerPandas` (or the NumPy/SciPy handlers) instead ([#40705](https://github.com/apache/beam/issues/40705)). +* X behavior is deprecated and will be removed in X versions ([#X](https://github.com/apache/beam/issues/X)). ## Bugfixes diff --git a/sdks/python/apache_beam/ml/inference/xgboost_inference.py b/sdks/python/apache_beam/ml/inference/xgboost_inference.py index f11b892062e8..b27db8b2046b 100644 --- a/sdks/python/apache_beam/ml/inference/xgboost_inference.py +++ b/sdks/python/apache_beam/ml/inference/xgboost_inference.py @@ -36,6 +36,7 @@ from apache_beam.ml.inference.base import ModelT from apache_beam.ml.inference.base import PredictionResult from apache_beam.ml.inference.base import PredictionT +from apache_beam.utils.annotations import deprecated __all__ = [ 'XGBoostModelHandler', @@ -119,7 +120,7 @@ def __init__( before loading the model. **Supported Versions:** RunInference APIs in Apache Beam have been tested - with XGBoost 1.6.0 and 1.7.0 + with XGBoost 2.x and 3.x XGBoost 1.0.0 introduced support for using JSON to save and load XGBoost models. XGBoost 1.6.0, additional support for Universal Binary JSON. @@ -313,6 +314,12 @@ def get_num_bytes(self, batch: Sequence[scipy.sparse.csr_matrix]) -> int: return sum(sys.getsizeof(element) for element in batch) +@deprecated( + since='2.78.0', + extra_message=( + 'datatable has not had a release since 2023 and has no wheels for ' + 'Python 3.13+. XGBoostModelHandlerDatatable and the datatable ' + 'dependency will be removed in Beam 2.79.0.')) class XGBoostModelHandlerDatatable(XGBoostModelHandler[datatable.Frame, PredictionResult, Union[xgboost.Booster, diff --git a/sdks/python/apache_beam/ml/inference/xgboost_inference_test.py b/sdks/python/apache_beam/ml/inference/xgboost_inference_test.py index 8e3fb77615cc..e686ac5e6f25 100644 --- a/sdks/python/apache_beam/ml/inference/xgboost_inference_test.py +++ b/sdks/python/apache_beam/ml/inference/xgboost_inference_test.py @@ -69,7 +69,9 @@ def _compare_keyed_prediction_result( def predict_fn(self, data): - self.inference_calls += 1 + # Models loaded from disk inside the pipeline do not carry the counter set by + # build_monkeypatched_xgboost_classifier(). + self.inference_calls = getattr(self, 'inference_calls', 0) + 1 if isinstance(data, pandas.DataFrame): data = data.to_numpy() if isinstance(data, datatable.Frame): diff --git a/sdks/python/apache_beam/ml/inference/xgboost_tests_requirements.txt b/sdks/python/apache_beam/ml/inference/xgboost_tests_requirements.txt index e85f4131701b..b1936b4149e4 100644 --- a/sdks/python/apache_beam/ml/inference/xgboost_tests_requirements.txt +++ b/sdks/python/apache_beam/ml/inference/xgboost_tests_requirements.txt @@ -15,5 +15,5 @@ # limitations under the License. # -datatable==1.0.0 -xgboost>=1.6.0 \ No newline at end of file +datatable>=1.1.0,<2 +xgboost>=2.0.0,<4 diff --git a/sdks/python/setup.py b/sdks/python/setup.py index a983db5395ed..3279282fc6b9 100644 --- a/sdks/python/setup.py +++ b/sdks/python/setup.py @@ -166,6 +166,17 @@ def cythonize(*args, **kwargs): milvus_dependency = ['pymilvus>=2.5.10,<3.0.0'] qdrant_dependency = ['qdrant-client>=1.15.0'] +# NOTE: When updating these bounds, also update the bounds in +# apache_beam/ml/inference/xgboost_tests_requirements.txt, which is used by the +# XGBoost RunInference integration tests, and the py311-xgboost-* environments +# in tox.ini. +xgboost_dependency = [ + 'xgboost>=2.0.0,<4', + # apache_beam.ml.inference.xgboost_inference imports datatable directly. + # 1.1.0 is the first release with Python 3.10+ wheels; no release has + # wheels for Python 3.13+. + 'datatable>=1.1.0,<2', +] # google-adk / OpenTelemetry require protobuf>=5; tensorflow-transform in # ml_test is pinned to versions that require protobuf<5 on Python 3.10. Those @@ -608,13 +619,12 @@ def get_portability_package_data(): 'p310_ml_test': [ 'datatable', ] + ml_base + qdrant_dependency, - 'p311_ml_test': [ - 'datatable', - ] + ml_base + qdrant_dependency, + # xgboost_dependency includes datatable, which only has wheels for + # Python <=3.12, so it is limited to the 3.11 and 3.12 test extras. + 'p311_ml_test': ml_base + xgboost_dependency + qdrant_dependency, 'p312_ml_test': [ - 'datatable', 'typesafe-sdk>=0.7.1,<0.8', - ] + ml_base + qdrant_dependency, + ] + ml_base + xgboost_dependency + qdrant_dependency, # maintainer: milvus tests only run with this extension. Make sure it # is covered by docker-in-docker test when changing py version 'p313_ml_test': ml_base_core + milvus_dependency + qdrant_dependency, @@ -682,7 +692,7 @@ def get_portability_package_data(): 'transformers==4.25.1', # tensorflow transient dep 'absl-py>=0.12.0' ], - 'xgboost': ['xgboost>=1.6.0,<2.1.3', 'datatable==1.0.0'], + 'xgboost': xgboost_dependency, 'tensorflow-hub': ['tensorflow-hub>=0.14.0,<0.16.0'], 'milvus': milvus_dependency, 'qdrant': qdrant_dependency, diff --git a/sdks/python/test-suites/tox/py311/build.gradle b/sdks/python/test-suites/tox/py311/build.gradle index a2b0f0d5e235..433b0052b08b 100644 --- a/sdks/python/test-suites/tox/py311/build.gradle +++ b/sdks/python/test-suites/tox/py311/build.gradle @@ -158,6 +158,15 @@ toxTask "testPy311transformers-latest", "py311-transformers-latest", "${posargs} test.dependsOn "testPy311transformers-latest" postCommitPyDep.dependsOn "testPy311transformers-latest" +// Create a test task for each major version of xgboost +toxTask "testPy311xgboost-200", "py311-xgboost-200", "${posargs}" +test.dependsOn "testPy311xgboost-200" +postCommitPyDep.dependsOn "testPy311xgboost-200" + +toxTask "testPy311xgboost-300", "py311-xgboost-300", "${posargs}" +test.dependsOn "testPy311xgboost-300" +postCommitPyDep.dependsOn "testPy311xgboost-300" + toxTask "testPy311embeddingsMLTransform", "py311-embeddings", "${posargs}" test.dependsOn "testPy311embeddingsMLTransform" postCommitPyDep.dependsOn "testPy311embeddingsMLTransform" diff --git a/sdks/python/tox.ini b/sdks/python/tox.ini index c88e5e2fea6a..a1bc7ba3322d 100644 --- a/sdks/python/tox.ini +++ b/sdks/python/tox.ini @@ -489,14 +489,12 @@ commands = # Allow exit code 5 (no tests run) so that we can run this command safely on arbitrary subdirectories. /bin/sh -c 'pytest -o junit_suite_name={envname} --junitxml=pytest_{envname}.xml -n 6 -m uses_tf {posargs}; ret=$?; [ $ret = 5 ] && exit 0 || exit $ret' -[testenv:py311-xgboost-{160,170}] +[testenv:py311-xgboost-{200,300}] +# Keep these bounds in line with xgboost_dependency in setup.py. deps = - 160: - xgboost>=1.6.0,<1.7.0 - datatable==1.0.0 - 170: - xgboost>=1.7.0 - datatable==1.0.0 + 200: xgboost>=2.0.0,<3.0.0 + 300: xgboost>=3.0.0,<4 + datatable>=1.1.0,<2 extras = test,gcp commands = # Log XGBoost version for debugging From f3f7ad1c159a6597f4f3e0153b0c44b2e742301e Mon Sep 17 00:00:00 2001 From: Vitaly Terentyev Date: Wed, 21 Oct 2026 22:22:44 +0400 Subject: [PATCH 082/108] Fix prober community metrics (#40707) * Update legacy prober test * Remove jenkins check * Add comment --- .../src/test/groovy/ProberTests.groovy | 91 +++++++++++++++++-- 1 file changed, 84 insertions(+), 7 deletions(-) diff --git a/.test-infra/metrics/src/test/groovy/ProberTests.groovy b/.test-infra/metrics/src/test/groovy/ProberTests.groovy index ff7fe502da24..30b6a29d026f 100644 --- a/.test-infra/metrics/src/test/groovy/ProberTests.groovy +++ b/.test-infra/metrics/src/test/groovy/ProberTests.groovy @@ -18,8 +18,8 @@ package org.apache.beam.testinfra.metrics import org.junit.Test +import groovy.json.JsonOutput import groovy.json.JsonSlurper -import static groovy.test.GroovyAssert.shouldFail /** * Prober tests which performs health checks on deployed infrastructure for @@ -39,13 +39,90 @@ class ProberTests { assert dashboardNames.size() > 0 : "No dashboards found. Check Grafana dashboard initialization script." } + /** + * Queries Community Metrics data through the Grafana API and verifies that + * GitHub Issues and Pull Requests metrics are present and have been + * synchronized within the last 8 hours. + */ @Test - void CheckGrafanaStalenessAlerts() { - def alertsJson = "${grafanaEndpoint}/api/alerts?dashboardQuery=Source%20Data%20Freshness".toURL().text - def alerts = new JsonSlurper().parseText(alertsJson) - assert alerts.size() > 0 - alerts.each { alert -> - assert alert.state == 'ok' : "Input data is stale! ${alert}\n See: ${grafanaEndpoint}/d/data-freshness" + void CheckGrafanaDataFreshness() { + def query = ''' + WITH sources AS ( + SELECT + 'GitHub Issues' AS source, + timestamp AS last_sync + FROM gh_sync_metadata + WHERE name = 'gh_issue_sync' + + UNION ALL + + SELECT + 'GitHub Pull Requests' AS source, + timestamp AS last_sync + FROM gh_sync_metadata + WHERE name = 'gh_pr_sync' + ) + SELECT + COUNT(*) AS source_count, + COUNT(last_sync) AS synced_source_count, + MAX(EXTRACT(EPOCH FROM age(current_timestamp, last_sync)) / 60) + AS max_staleness_minutes + FROM sources + ''' + + def requestBody = [ + queries: [[ + refId: 'A', + datasource: [ + uid: 'beampsql', + type: 'grafana-postgresql-datasource' + ], + editorMode: 'code', + format: 'table', + rawQuery: true, + rawSql: query + ]], + from: 'now-5m', + to: 'now' + ] + + def connection = new URL("${grafanaEndpoint}/api/ds/query").openConnection() + connection.requestMethod = 'POST' + connection.doOutput = true + connection.setRequestProperty('Content-Type', 'application/json') + + connection.outputStream.withWriter('UTF-8') { + it << JsonOutput.toJson(requestBody) } + + assert connection.responseCode == 200 : + "Grafana datasource query failed with HTTP ${connection.responseCode}" + + def response = new JsonSlurper().parse(connection.inputStream) + def frame = response.results.A.frames[0] + + def fieldNames = frame.schema.fields.collect { it.name } + + def value = { String fieldName -> + def index = fieldNames.indexOf(fieldName) + assert index >= 0 : "Missing field '${fieldName}' in Grafana response" + frame.data.values[index][0] + } + + def sourceCount = value('source_count') + def syncedSourceCount = value('synced_source_count') + def maxStalenessMinutes = value('max_staleness_minutes') + + assert sourceCount == 2 : + "Expected 2 metrics sources, got ${sourceCount}" + + assert syncedSourceCount == 2 : + "One or more metrics sources have no synchronization timestamp" + + assert maxStalenessMinutes != null + + assert maxStalenessMinutes <= 480 : + "Input data is stale: ${maxStalenessMinutes} minutes\n" + + "See: ${grafanaEndpoint}/d/data-freshness" } } From f5f60065ec92242b562f87a5968337634c534b5d Mon Sep 17 00:00:00 2001 From: Abdelrahman Ibrahim Date: Thu, 22 Oct 2026 17:01:58 +0300 Subject: [PATCH 083/108] rename TestRecord (#40728) --- .../construction/CoderTranslationTest.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/sdks/java/core/src/test/java/org/apache/beam/sdk/util/construction/CoderTranslationTest.java b/sdks/java/core/src/test/java/org/apache/beam/sdk/util/construction/CoderTranslationTest.java index f743b9a0b60c..81a79b65c34c 100644 --- a/sdks/java/core/src/test/java/org/apache/beam/sdk/util/construction/CoderTranslationTest.java +++ b/sdks/java/core/src/test/java/org/apache/beam/sdk/util/construction/CoderTranslationTest.java @@ -224,14 +224,14 @@ public static Iterable> data() { .addAll(KNOWN_CODERS) .add( StringUtf8Coder.of(), - SerializableCoder.of(Record.class), - new RecordCoder(), - KvCoder.of(new RecordCoder(), StringUtf8Coder.of())) + SerializableCoder.of(TestRecord.class), + new TestRecordCoder(), + KvCoder.of(new TestRecordCoder(), StringUtf8Coder.of())) .add( StringUtf8Coder.of(), - SerializableCoder.of(Record.class), - new RecordCoder(), - KvCoder.of(new RecordCoder(), StringUtf8Coder.of())) + SerializableCoder.of(TestRecord.class), + new TestRecordCoder(), + KvCoder.of(new TestRecordCoder(), StringUtf8Coder.of())) .add(UnknownCoderWrapper.of("dummy_urn", new byte[] {})) .build(); } @@ -262,15 +262,16 @@ public void toAndFromProto() throws Exception { } } - static class Record implements Serializable {} + static class TestRecord implements Serializable {} - private static class RecordCoder extends AtomicCoder { + private static class TestRecordCoder extends AtomicCoder { @Override - public void encode(Record value, OutputStream outStream) throws CoderException, IOException {} + public void encode(TestRecord value, OutputStream outStream) + throws CoderException, IOException {} @Override - public Record decode(InputStream inStream) throws CoderException, IOException { - return new Record(); + public TestRecord decode(InputStream inStream) throws CoderException, IOException { + return new TestRecord(); } } } From 148463740053cfea70559303795c56da04b89547 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ibrahim Date: Thu, 22 Oct 2026 18:09:30 +0300 Subject: [PATCH 084/108] drop transformers 428 (#40719) --- .../trigger_files/beam_PostCommit_Python_Dependency.json | 2 +- sdks/python/test-suites/tox/py311/build.gradle | 8 +++----- sdks/python/tox.ini | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/trigger_files/beam_PostCommit_Python_Dependency.json b/.github/trigger_files/beam_PostCommit_Python_Dependency.json index 9bc6afbb0969..0c0e8ca6a12b 100644 --- a/.github/trigger_files/beam_PostCommit_Python_Dependency.json +++ b/.github/trigger_files/beam_PostCommit_Python_Dependency.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 5 + "modification": 8 } diff --git a/sdks/python/test-suites/tox/py311/build.gradle b/sdks/python/test-suites/tox/py311/build.gradle index 433b0052b08b..3228469cd6bd 100644 --- a/sdks/python/test-suites/tox/py311/build.gradle +++ b/sdks/python/test-suites/tox/py311/build.gradle @@ -145,11 +145,9 @@ toxTask "testPy311tensorflow-212", "py311-tensorflow-212", "${posargs}" test.dependsOn "testPy311tensorflow-212" postCommitPyDep.dependsOn "testPy311tensorflow-212" -// Create a test task for each minor version of transformers -toxTask "testPy311transformers-428", "py311-transformers-428", "${posargs}" -test.dependsOn "testPy311transformers-428" -postCommitPyDep.dependsOn "testPy311transformers-428" - +// Create a test task for each minor version of transformers. +// Dropped transformers-428: sentence-transformers==2.2.2 + torch<1.14 is +// ResolutionImpossible on Python 3.11 (see #30799). toxTask "testPy311transformers-447", "py311-transformers-447", "${posargs}" test.dependsOn "testPy311transformers-447" postCommitPyDep.dependsOn "testPy311transformers-447" diff --git a/sdks/python/tox.ini b/sdks/python/tox.ini index a1bc7ba3322d..7a822cab7593 100644 --- a/sdks/python/tox.ini +++ b/sdks/python/tox.ini @@ -503,15 +503,15 @@ commands = # Allow exit code 5 (no tests run) so that we can run this command safely on arbitrary subdirectories. /bin/sh -c 'pytest -o junit_suite_name={envname} --junitxml=pytest_{envname}.xml -n 6 -m uses_xgboost {posargs}; ret=$?; [ $ret = 5 ] && exit 0 || exit $ret' -[testenv:py311-transformers-{428,447,latest}] +[testenv:py311-transformers-{447,latest}] deps = # Environment dependencies are defined in the `setenv` section and installed in the `commands` section. pip_pre = False extras = test setenv = COMMON_DEPS = tensorflow==2.12.0 protobuf==4.25.5 pip==25.0.1 - # sentence-transformers 2.2.2 is the latest version that supports transformers 4.28.x - 428: DEPS = sentence-transformers==2.2.2 'transformers>=4.28.0,<4.29.0' 'torch>=1.9.0,<1.14.0' + # transformers-428 dropped: sentence-transformers==2.2.2 + torch<1.14 is + # ResolutionImpossible on Python 3.11. 447: DEPS = 'transformers>=4.47.0,<4.48.0' 'torch>=1.9.0,<1.14.0' latest: DEPS = 'transformers>=4.55.0' 'torch>=2.0.0' 'accelerate>=1.6.0' commands = From 9811046f33aca9de59ef389290f7229ca476a7e9 Mon Sep 17 00:00:00 2001 From: Tobias Kaymak Date: Thu, 22 Oct 2026 17:19:05 +0200 Subject: [PATCH 085/108] [DebeziumIO] Add opt-in full metadata (op and Debezium source fields) to readAsJson (#40702) * [DebeziumIO] Add opt-in full metadata (op and Debezium source fields) to readAsJson (#40701) --- .../trigger_files/beam_PostCommit_Python.json | 2 +- .../beam_PostCommit_XVR_Direct.json | 2 +- .../pkg/beam/io/xlang/debeziumio/debezium.go | 17 +++ .../beam/io/xlang/debeziumio/debezium_test.go | 29 +++++ sdks/java/io/debezium/src/README.md | 2 + .../apache/beam/io/debezium/DebeziumIO.java | 42 +++++++ .../debezium/DebeziumTransformRegistrar.java | 9 ++ .../io/debezium/KafkaSourceConsumerFn.java | 2 +- .../beam/io/debezium/SourceRecordJson.java | 119 +++++++++++++++++- .../debezium/DebeziumIOMySqlConnectorIT.java | 74 +++++++++++ .../beam/io/debezium/DebeziumIOTest.java | 46 +++++++ .../io/debezium/SourceRecordJsonTest.java | 89 +++++++++++++ sdks/python/apache_beam/io/debezium.py | 11 +- 13 files changed, 435 insertions(+), 9 deletions(-) create mode 100644 sdks/go/pkg/beam/io/xlang/debeziumio/debezium_test.go diff --git a/.github/trigger_files/beam_PostCommit_Python.json b/.github/trigger_files/beam_PostCommit_Python.json index b6d790320d0b..15dcafe6956f 100644 --- a/.github/trigger_files/beam_PostCommit_Python.json +++ b/.github/trigger_files/beam_PostCommit_Python.json @@ -1,5 +1,5 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run.", "pr": "40241", - "modification": 65 + "modification": 66 } diff --git a/.github/trigger_files/beam_PostCommit_XVR_Direct.json b/.github/trigger_files/beam_PostCommit_XVR_Direct.json index c04f8e34f87f..49cff1ed291e 100644 --- a/.github/trigger_files/beam_PostCommit_XVR_Direct.json +++ b/.github/trigger_files/beam_PostCommit_XVR_Direct.json @@ -1,4 +1,4 @@ { - "modification": 2, + "modification": 3, "https://github.com/apache/beam/pull/39990": "removing dead code from FnApiDoFnRunner" } diff --git a/sdks/go/pkg/beam/io/xlang/debeziumio/debezium.go b/sdks/go/pkg/beam/io/xlang/debeziumio/debezium.go index f31a71fbc9d9..88101aed0bc6 100644 --- a/sdks/go/pkg/beam/io/xlang/debeziumio/debezium.go +++ b/sdks/go/pkg/beam/io/xlang/debeziumio/debezium.go @@ -79,6 +79,7 @@ type readFromDebeziumSchema struct { ConnectionProperties []string StartOffset []string OffsetStoragePath *string + FullMetadata *bool } type debeziumConfig struct { @@ -189,6 +190,22 @@ func OffsetStoragePath(path string) readOption { } } +// FullMetadata adds op and all Debezium source fields not already present +// (e.g. MySQL file, pos, row, ts_ms; PostgreSQL lsn, txId, ts_ms) to the +// metadata of each JSON record. By default, only connector, version, name, +// database, schema and table are included. +// +// Example: +// +// debeziumio.Read(s, username, password, host, port, debeziumio.MySQL, +// reflectx.String, debeziumio.FullMetadata()) +func FullMetadata() readOption { + return func(cfg *debeziumConfig) { + b := true + cfg.readSchema.FullMetadata = &b + } +} + // ExpansionAddr sets the expansion service address to use for DebeziumIO cross-langauage transform. func ExpansionAddr(expansionAddr string) readOption { return func(cfg *debeziumConfig) { diff --git a/sdks/go/pkg/beam/io/xlang/debeziumio/debezium_test.go b/sdks/go/pkg/beam/io/xlang/debeziumio/debezium_test.go new file mode 100644 index 000000000000..165df74f77db --- /dev/null +++ b/sdks/go/pkg/beam/io/xlang/debeziumio/debezium_test.go @@ -0,0 +1,29 @@ +// 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 debeziumio + +import "testing" + +func TestFullMetadata(t *testing.T) { + dc := debeziumConfig{readSchema: &readFromDebeziumSchema{}} + if dc.readSchema.FullMetadata != nil { + t.Fatalf("FullMetadata = %v by default, want nil", *dc.readSchema.FullMetadata) + } + FullMetadata()(&dc) + if got := dc.readSchema.FullMetadata; got == nil || !*got { + t.Errorf("FullMetadata() set FullMetadata = %v, want true", got) + } +} diff --git a/sdks/java/io/debezium/src/README.md b/sdks/java/io/debezium/src/README.md index 677f42b59b02..cd5e4ceabacb 100644 --- a/sdks/java/io/debezium/src/README.md +++ b/sdks/java/io/debezium/src/README.md @@ -93,6 +93,8 @@ If you want to use this built-in JSON Mapper, you can do it by setting an instan ``` > **Note:** `SourceRecordJsonMapper`comes out of the box, but you may use any Format Function you prefer. +By default, the JSON `metadata` object holds `connector`, `version`, `name`, `database`, `schema` and `table`. Call `withFullMetadata(true)` on `readAsJson()` to also add the change event's `op` (`c`, `u`, `d`, `r` or `t`) and every other field of the Debezium `source` block to `metadata` (for example `ts_ms`, MySQL `file`, `pos` and `row`, or PostgreSQL `lsn` and `txId`). Fields that `metadata` already carries aren't repeated. For MySQL, `file` is the actual binlog file name, which the default output exposes under `schema`. The option is off by default, so the output is unchanged unless you enable it. + ## Quick Example The following example is how an actual setup would look like using a **MySQL Debezium Connector** and **SourceRecordJsonMapper** as the Serializable Function. diff --git a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumIO.java b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumIO.java index f12f9a95dedb..cc31c451e9c8 100644 --- a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumIO.java +++ b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumIO.java @@ -147,6 +147,8 @@ public abstract static class Read extends PTransform> abstract @Nullable OffsetRetainer getOffsetRetainer(); + abstract @Nullable Boolean getFullMetadata(); + abstract @Nullable Coder getCoder(); abstract Builder toBuilder(); @@ -169,6 +171,8 @@ abstract static class Builder { abstract Builder setOffsetRetainer(OffsetRetainer retainer); + abstract Builder setFullMetadata(Boolean fullMetadata); + abstract Read build(); } @@ -305,6 +309,44 @@ public Read withOffsetRetainer(OffsetRetainer retainer) { return toBuilder().setOffsetRetainer(retainer).build(); } + /** + * Adds {@code op} and the remaining Debezium {@code source} fields (e.g. MySQL {@code file}, + * {@code pos}, {@code row}, {@code ts_ms}; PostgreSQL {@code lsn}, {@code txId}, {@code ts_ms}) + * to {@code metadata} when reading as JSON. + * + *

Only applies to {@link DebeziumIO#readAsJson()} / {@link + * SourceRecordJson.SourceRecordJsonMapper}; combining it with a custom {@link + * #withFormatFunction(SourceRecordMapper)} fails with an {@link IllegalArgumentException} when + * the transform is expanded. Default false. + * + * @param fullMetadata Whether to emit the full Debezium metadata. + * @return PTransform {@link #read} + */ + public Read withFullMetadata(boolean fullMetadata) { + return toBuilder().setFullMetadata(fullMetadata).build(); + } + + /** + * Returns the format function to use, taking {@link #withFullMetadata(boolean)} into account. + * + * @throws IllegalArgumentException if full metadata is requested but the format function is not + * a {@link SourceRecordJson.SourceRecordJsonMapper}. + */ + SourceRecordMapper resolveFormatFunction() { + SourceRecordMapper formatFunction = getFormatFunction(); + if (!Boolean.TRUE.equals(getFullMetadata())) { + return formatFunction; + } + if (formatFunction instanceof SourceRecordJson.SourceRecordJsonMapper) { + @SuppressWarnings("unchecked") + SourceRecordMapper fullMetadataMapper = + (SourceRecordMapper) new SourceRecordJson.SourceRecordJsonMapper(true); + return fullMetadataMapper; + } + throw new IllegalArgumentException( + "withFullMetadata(true) only applies to readAsJson() / SourceRecordJsonMapper"); + } + protected Schema getRecordSchema() { KafkaSourceConsumerFn fn = new KafkaSourceConsumerFn<>(getConnectorConfiguration().getConnectorClass().get(), this); diff --git a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumTransformRegistrar.java b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumTransformRegistrar.java index 4b431ac38ba2..b236935814ab 100644 --- a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumTransformRegistrar.java +++ b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumTransformRegistrar.java @@ -81,6 +81,7 @@ public static class Configuration extends CrossLanguageConfiguration { private @Nullable Long maxTimeToRun; private @Nullable List startOffset; private @Nullable String offsetStoragePath; + private @Nullable Boolean fullMetadata; public void setConnectionProperties(@Nullable List connectionProperties) { this.connectionProperties = connectionProperties; @@ -101,6 +102,10 @@ public void setStartOffset(@Nullable List startOffset) { public void setOffsetStoragePath(@Nullable String offsetStoragePath) { this.offsetStoragePath = offsetStoragePath; } + + public void setFullMetadata(@Nullable Boolean fullMetadata) { + this.fullMetadata = fullMetadata; + } } @Override @@ -161,6 +166,10 @@ public PTransform> buildExternal(Configuration confi FileSystemOffsetRetainer.of(configuration.offsetStoragePath)); } + if (Boolean.TRUE.equals(configuration.fullMetadata)) { + readTransform = readTransform.withFullMetadata(true); + } + return readTransform; } } diff --git a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/KafkaSourceConsumerFn.java b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/KafkaSourceConsumerFn.java index 89fc2a5c085d..c9daf052de58 100644 --- a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/KafkaSourceConsumerFn.java +++ b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/KafkaSourceConsumerFn.java @@ -113,7 +113,7 @@ public class KafkaSourceConsumerFn extends DoFn, T> { // this(connectorClass, fn, maxRecords, null); this.connectorClass = (Class) connectorClass; this.spec = spec; - this.fn = spec.getFormatFunction(); + this.fn = spec.resolveFormatFunction(); this.pollingTimeOut = MoreObjects.firstNonNull(spec.getPollingTimeout(), DEFAULT_POLLING_TIMEOUT); } diff --git a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/SourceRecordJson.java b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/SourceRecordJson.java index 43b03605b559..694b232d4647 100644 --- a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/SourceRecordJson.java +++ b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/SourceRecordJson.java @@ -19,10 +19,15 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.Serializable; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.Gson; import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.GsonBuilder; +import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.JsonObject; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet; import org.apache.kafka.connect.data.Field; import org.apache.kafka.connect.data.Struct; import org.apache.kafka.connect.errors.DataException; @@ -45,12 +50,32 @@ *

  • after - {@link #loadAfter} *
  • metadata - {@link #loadMetadata} *
      - *
    • schema - Database Schema *
    • connector - Connector used *
    • version - Connector version + *
    • name - Connector name + *
    • database - Database name + *
    • schema - Database Schema (for MySQL, the binlog file name) + *
    • table - Table name *
    * * + *

    Full metadata

    + * + *

    When constructed with {@code fullMetadata = true} (see {@link + * SourceRecordJsonMapper#SourceRecordJsonMapper(boolean)} and {@link + * DebeziumIO.Read#withFullMetadata(boolean)}), extra keys are appended to {@code metadata} after + * {@code table}: + * + *

      + *
    • op - The Debezium operation ({@code c}, {@code u}, {@code d}, {@code r}, {@code t}), if the + * record envelope has an {@code op} field + *
    • Every other field of the Debezium {@code source} block, in schema order, that is not + * already represented above (for example {@code ts_ms}, {@code snapshot}, MySQL {@code file}, + * {@code pos}, {@code row}, or PostgreSQL {@code lsn}, {@code txId}) + *
    + * + *

    By default ({@code fullMetadata = false}) the output only contains the six keys above. + * *

    Usage Example

    * *

    Map each SourceRecord to JSON @@ -68,8 +93,16 @@ + " Making the class final or moving the throwing code" + " into a static factory method may fix it.") public class SourceRecordJson { + /** + * Source fields that are already represented in {@link Event.Metadata} (or would collide with an + * existing metadata key), so they are never added as full metadata extras. + */ + private static final Set RESERVED_METADATA_KEYS = + ImmutableSet.of("connector", "version", "name", "db", "database", "schema", "table", "op"); + private final @Nullable Struct value; private final @Nullable Event event; + private final Map metadataExtras; /** * Initializer. @@ -77,6 +110,17 @@ public class SourceRecordJson { * @param sourceRecord retrieved SourceRecord using a supported SourceConnector */ public SourceRecordJson(@Nullable SourceRecord sourceRecord) { + this(sourceRecord, false); + } + + /** + * Initializer. + * + * @param sourceRecord retrieved SourceRecord using a supported SourceConnector + * @param fullMetadata whether to add {@code op} and the remaining Debezium {@code source} fields + * to {@code metadata} + */ + public SourceRecordJson(@Nullable SourceRecord sourceRecord, boolean fullMetadata) { if (sourceRecord == null) { throw new IllegalArgumentException(); } @@ -85,12 +129,15 @@ public SourceRecordJson(@Nullable SourceRecord sourceRecord) { if (this.value == null) { this.event = new Event(null, null, null); + this.metadataExtras = Collections.emptyMap(); } else { Event.Metadata metadata = this.loadMetadata(); Event.Before before = this.loadBefore(); Event.After after = this.loadAfter(); this.event = new Event(metadata, before, after); + this.metadataExtras = + fullMetadata && metadata != null ? this.loadMetadataExtras() : Collections.emptyMap(); } } @@ -129,6 +176,33 @@ private Event.Metadata loadMetadata() { source.getString("table")); } + /** + * Extracts the full metadata extras: the envelope's {@code op} (if the value schema has one), + * then every field of the Debezium {@code source} block, in schema order, that isn't already + * represented in {@link Event.Metadata}. + * + * @return extra metadata keys, in insertion order + */ + private Map loadMetadataExtras() { + Struct source = (Struct) this.value.get("source"); + Map extras = new LinkedHashMap<>(); + if (this.value.schema().field("op") != null) { + extras.put("op", toJsonFriendly(this.value.get("op"))); + } + for (Field field : source.schema().fields()) { + String fieldName = field.name(); + if (!RESERVED_METADATA_KEYS.contains(fieldName)) { + extras.put(fieldName, toJsonFriendly(source.get(field))); + } + } + return extras; + } + + private static @Nullable Object toJsonFriendly(@Nullable Object value) { + // Source fields are flat primitives today; stringify a nested Struct to keep metadata flat. + return value instanceof Struct ? String.valueOf(value) : value; + } + /** * Extracts the before field within SourceRecord. * @@ -183,14 +257,31 @@ private Event.After loadAfter() { * @return JSON String */ public String toJson() { - return this.event.toJson(); + return this.event.toJson(this.metadataExtras); } /** {@link SourceRecordJson} implementation. */ public static class SourceRecordJsonMapper implements SourceRecordMapper { + private final boolean fullMetadata; + + /** Creates a mapper that emits the default metadata. */ + public SourceRecordJsonMapper() { + this(false); + } + + /** + * Creates a mapper. + * + * @param fullMetadata whether to add {@code op} and the remaining Debezium {@code source} + * fields to {@code metadata} + */ + public SourceRecordJsonMapper(boolean fullMetadata) { + this.fullMetadata = fullMetadata; + } + @Override public String mapSourceRecord(SourceRecord sourceRecord) throws Exception { - return new SourceRecordJson(sourceRecord).toJson(); + return new SourceRecordJson(sourceRecord, fullMetadata).toJson(); } } @@ -223,8 +314,28 @@ public Event( * @return JSON String */ public String toJson() { + return toJson(Collections.emptyMap()); + } + + /** + * Transforms the Event to a JSON string, appending the given extra keys to {@code metadata} + * after {@code table}. + * + * @param extras Additional metadata keys, in iteration order + * @return JSON String + */ + String toJson(Map extras) { Gson gson = new GsonBuilder().serializeNulls().create(); - return gson.toJson(this); + if (extras.isEmpty() || metadata == null) { + return gson.toJson(this); + } + // Gson can't flatten a map into an object, so append the extras to the metadata tree. + JsonObject tree = gson.toJsonTree(this).getAsJsonObject(); + JsonObject metadataJson = tree.getAsJsonObject("metadata"); + for (Map.Entry extra : extras.entrySet()) { + metadataJson.add(extra.getKey(), gson.toJsonTree(extra.getValue())); + } + return gson.toJson(tree); } /** Depicts the metadata within a SourceRecord. It has valuable fields. */ diff --git a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOMySqlConnectorIT.java b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOMySqlConnectorIT.java index 32edc4eb56b4..29c8e33ac06d 100644 --- a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOMySqlConnectorIT.java +++ b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOMySqlConnectorIT.java @@ -20,7 +20,11 @@ import static org.apache.beam.io.debezium.DebeziumIOPostgresSqlConnectorIT.TABLE_SCHEMA; import static org.apache.beam.sdk.testing.SerializableMatchers.hasItem; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import com.zaxxer.hikari.HikariConfig; @@ -31,6 +35,7 @@ import java.sql.SQLException; import java.sql.Statement; import java.time.Duration; +import java.util.List; import javax.sql.DataSource; import org.apache.beam.sdk.Pipeline; import org.apache.beam.sdk.coders.StringUtf8Coder; @@ -44,6 +49,8 @@ import org.apache.beam.sdk.values.PCollectionRowTuple; import org.apache.beam.sdk.values.Row; import org.apache.beam.sdk.values.TypeDescriptors; +import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.JsonObject; +import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.JsonParser; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists; import org.junit.ClassRule; import org.junit.Test; @@ -293,4 +300,71 @@ public void testDebeziumIOMySql() { p.run().waitUntilFinish(); } + + /** + * Debezium - MySQL connector Test with {@link DebeziumIO.Read#withFullMetadata(boolean)}. + * + *

    Tests that the JSON metadata carries {@code op} and the MySQL source fields, flattened. + */ + @Test + public void testDebeziumIOMySqlFullMetadata() { + + String kafkaBootstrapServers = KAFKA_CONTAINER.getBootstrapServers(); + String schemaHistoryTopic = "mysql-schema-history-full-metadata-" + System.nanoTime(); + + String host = MY_SQL_CONTAINER.getHost(); + String port = MY_SQL_CONTAINER.getMappedPort(3306).toString(); + + PipelineOptions options = PipelineOptionsFactory.create(); + Pipeline p = Pipeline.create(options); + PCollection results = + p.apply( + DebeziumIO.readAsJson() + .withConnectorConfiguration( + DebeziumIO.ConnectorConfiguration.create() + .withUsername("debezium") + .withPassword("dbz") + .withConnectorClass(MySqlConnector.class) + .withHostName(host) + .withPort(port) + .withConnectionProperty("database.server.id", "184055") + .withConnectionProperty("database.include.list", "inventory") + .withConnectionProperty("include.schema.changes", "false") + .withConnectionProperty( + "schema.history.internal.kafka.bootstrap.servers", + kafkaBootstrapServers) + .withConnectionProperty( + "schema.history.internal.kafka.topic", schemaHistoryTopic) + .withConnectionProperty( + "schema.history.internal", + "io.debezium.storage.kafka.history.KafkaSchemaHistory") + .withConnectionProperty( + "schema.history.internal.store.only.captured.tables.ddl", "true")) + .withFullMetadata(true) + .withMaxNumberOfRecords(10)); + + PAssert.that(results) + .satisfies( + (Iterable res) -> { + List records = Lists.newArrayList(res); + assertThat(records, not(empty())); + for (String record : records) { + JsonObject metadata = + JsonParser.parseString(record).getAsJsonObject().getAsJsonObject("metadata"); + // Snapshot rows are reads. + assertThat(metadata.get("op").getAsString(), equalTo("r")); + assertTrue(metadata.get("pos").getAsJsonPrimitive().isNumber()); + assertTrue(metadata.get("row").getAsJsonPrimitive().isNumber()); + assertTrue(metadata.get("ts_ms").getAsJsonPrimitive().isNumber()); + // MySQL keeps exposing the binlog file under "schema" for compatibility. + assertThat( + metadata.get("file").getAsString(), + equalTo(metadata.get("schema").getAsString())); + assertFalse(metadata.has("source")); + } + return null; + }); + + p.run().waitUntilFinish(); + } } diff --git a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOTest.java b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOTest.java index 074f0ba5b526..294b709dcedc 100644 --- a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOTest.java +++ b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOTest.java @@ -20,6 +20,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -33,6 +34,7 @@ import org.apache.beam.io.debezium.DebeziumIO.ConnectorConfiguration; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap; import org.apache.kafka.common.config.ConfigValue; +import org.apache.kafka.connect.source.SourceRecord; import org.checkerframework.checker.nullness.qual.Nullable; import org.junit.Test; import org.junit.runner.RunWith; @@ -235,6 +237,50 @@ public void testRetainerFallsBackToWithStartOffsetWhenLoadReturnsNull() throws E assertEquals(explicitOffset, restriction.offset); } + @Test + public void testReadAsJsonWithFullMetadata() throws Exception { + SourceRecord record = + SourceRecordJsonTest.buildEnvelopeRecord(SourceRecordJsonTest.postgresSource(), "u"); + SourceRecordMapper fn = + DebeziumIO.readAsJson().withFullMetadata(true).resolveFormatFunction(); + assertEquals(new SourceRecordJson(record, true).toJson(), fn.mapSourceRecord(record)); + } + + @Test + public void testReadAsJsonWithoutFullMetadata() { + DebeziumIO.Read read = DebeziumIO.readAsJson(); + assertSame(read.getFormatFunction(), read.resolveFormatFunction()); + } + + @Test + public void testFullMetadataWithCustomFormatFunctionThrows() { + DebeziumIO.Read read = + DebeziumIO.read() + .withFormatFunction(sourceRecord -> sourceRecord.toString()) + .withFullMetadata(true); + assertThrows(IllegalArgumentException.class, read::resolveFormatFunction); + } + + @Test + public void testBuildExternalWithFullMetadata() throws Exception { + DebeziumTransformRegistrar.ReadBuilder.Configuration config = + new DebeziumTransformRegistrar.ReadBuilder.Configuration(); + config.setUsername("user"); + config.setPassword("pass"); + config.setHost("localhost"); + config.setPort("3306"); + config.setConnectorClass("MySQL"); + config.setFullMetadata(true); + DebeziumIO.Read read = + (DebeziumIO.Read) + new DebeziumTransformRegistrar.ReadBuilder().buildExternal(config); + SourceRecord record = + SourceRecordJsonTest.buildEnvelopeRecord(SourceRecordJsonTest.postgresSource(), "u"); + assertEquals( + new SourceRecordJson(record, true).toJson(), + read.resolveFormatFunction().mapSourceRecord(record)); + } + @Test public void testBuildExternalThrowsOnMalformedStartOffsetEntry() { DebeziumTransformRegistrar.ReadBuilder.Configuration config = diff --git a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/SourceRecordJsonTest.java b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/SourceRecordJsonTest.java index add8844f48f8..bcb33e97e4df 100644 --- a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/SourceRecordJsonTest.java +++ b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/SourceRecordJsonTest.java @@ -18,13 +18,16 @@ package org.apache.beam.io.debezium; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import java.io.Serializable; import org.apache.kafka.connect.data.Schema; import org.apache.kafka.connect.data.SchemaBuilder; import org.apache.kafka.connect.data.Struct; import org.apache.kafka.connect.source.SourceRecord; +import org.checkerframework.checker.nullness.qual.Nullable; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -58,6 +61,92 @@ public void testSourceRecordJsonWhenSourceRecordIsNull() { assertThrows(IllegalArgumentException.class, () -> new SourceRecordJson(null)); } + @Test + public void testFullMetadata() { + // MySQL has no "schema" source field, so the binlog "file" is also exposed as "schema". + Schema mySqlSchema = + sourceSchemaBuilder() + .field("gtid", Schema.OPTIONAL_STRING_SCHEMA) + .field("file", Schema.STRING_SCHEMA) + .field("pos", Schema.INT64_SCHEMA) + .field("row", Schema.INT32_SCHEMA) + .build(); + Struct mySql = + sourceStruct(mySqlSchema, "mysql", "inventory") + .put("file", "mysql-bin.000003") + .put("pos", 796L) + .put("row", 0); + assertEquals( + "{\"metadata\":{\"connector\":\"mysql\",\"version\":\"3.5.2.Final\",\"name\":\"n\"," + + "\"database\":\"inventory\",\"schema\":\"mysql-bin.000003\",\"table\":\"customers\"," + + "\"op\":\"c\",\"ts_ms\":1791689147000,\"gtid\":null,\"file\":\"mysql-bin.000003\"," + + "\"pos\":796,\"row\":0},\"before\":null,\"after\":{\"fields\":{\"id\":1001}}}", + new SourceRecordJson(buildEnvelopeRecord(mySql, "c"), true).toJson()); + + // PostgreSQL "schema" is a source field: it stays the existing key and isn't repeated. + assertEquals( + "{\"metadata\":{\"connector\":\"postgresql\",\"version\":\"3.5.2.Final\",\"name\":\"n\"," + + "\"database\":\"postgres\",\"schema\":\"inventory\",\"table\":\"customers\"," + + "\"op\":\"u\",\"ts_ms\":1791689147000,\"lsn\":37094192}," + + "\"before\":null,\"after\":{\"fields\":{\"id\":1001}}}", + new SourceRecordJson(buildEnvelopeRecord(postgresSource(), "u"), true).toJson()); + } + + @Test + public void testFullMetadataWithoutOpField() { + String json = new SourceRecordJson(buildEnvelopeRecord(postgresSource(), null), true).toJson(); + assertFalse(json.contains("\"op\"")); + assertTrue(json.contains("\"lsn\":37094192")); + } + + private static SchemaBuilder sourceSchemaBuilder() { + return SchemaBuilder.struct() + .field("version", Schema.STRING_SCHEMA) + .field("connector", Schema.STRING_SCHEMA) + .field("name", Schema.STRING_SCHEMA) + .field("ts_ms", Schema.INT64_SCHEMA) + .field("db", Schema.STRING_SCHEMA) + .field("table", Schema.STRING_SCHEMA); + } + + private static Struct sourceStruct(Schema schema, String connector, String db) { + return new Struct(schema) + .put("version", "3.5.2.Final") + .put("connector", connector) + .put("name", "n") + .put("ts_ms", 1791689147000L) + .put("db", db) + .put("table", "customers"); + } + + static Struct postgresSource() { + Schema schema = + sourceSchemaBuilder() + .field("schema", Schema.STRING_SCHEMA) + .field("lsn", Schema.INT64_SCHEMA) + .build(); + return sourceStruct(schema, "postgresql", "postgres") + .put("schema", "inventory") + .put("lsn", 37094192L); + } + + /** A Debezium-like envelope with {@code before} null, {@code after.id} and an optional op. */ + static SourceRecord buildEnvelopeRecord(Struct source, @Nullable String op) { + Schema row = SchemaBuilder.struct().optional().field("id", Schema.INT32_SCHEMA).build(); + SchemaBuilder envelope = + SchemaBuilder.struct() + .field("before", row) + .field("after", row) + .field("source", source.schema()); + Schema schema = (op == null ? envelope : envelope.field("op", Schema.STRING_SCHEMA)).build(); + Struct value = + new Struct(schema).put("after", new Struct(row).put("id", 1001)).put("source", source); + if (op != null) { + value.put("op", op); + } + return new SourceRecord(null, null, null, schema, value); + } + private static Schema buildSourceSchema() { return SchemaBuilder.struct() .field("connector", Schema.STRING_SCHEMA) diff --git a/sdks/python/apache_beam/io/debezium.py b/sdks/python/apache_beam/io/debezium.py index 5b5efe86fba9..8e7b88da687d 100644 --- a/sdks/python/apache_beam/io/debezium.py +++ b/sdks/python/apache_beam/io/debezium.py @@ -110,7 +110,7 @@ class DriverClassName(Enum): ('host', str), ('port', str), ('max_number_of_records', Optional[int]), ('connection_properties', list[str]), ('start_offset', Optional[list[str]]), - ('offset_storage_path', Optional[str])]) + ('offset_storage_path', Optional[str]), ('full_metadata', Optional[bool])]) class ReadFromDebezium(PTransform): @@ -134,6 +134,7 @@ def __init__( connection_properties=None, start_offset=None, offset_storage_path=None, + full_metadata=None, expansion_service=None): """ Initializes a read operation from Debezium. @@ -179,6 +180,11 @@ def __init__( When set, takes precedence over ``start_offset``. + :param full_metadata: when True, adds ``op`` and all Debezium source + fields not already present (e.g. MySQL + ``file``, ``pos``, ``row``, ``ts_ms``; + PostgreSQL ``lsn``, ``txId``, ``ts_ms``) to + ``metadata``. Defaults to False. :param expansion_service: The address (host:port) of the ExpansionService. """ @@ -192,7 +198,8 @@ def __init__( max_number_of_records=max_number_of_records, connection_properties=connection_properties, start_offset=start_offset, - offset_storage_path=offset_storage_path) + offset_storage_path=offset_storage_path, + full_metadata=full_metadata) self.expansion_service = expansion_service or default_io_expansion_service() def expand(self, pbegin): From e261c9f7689dbf6f0c6ecca75a8c11dd0d5eb070 Mon Sep 17 00:00:00 2001 From: Tobias Kaymak Date: Thu, 22 Oct 2026 17:20:47 +0200 Subject: [PATCH 086/108] [#36841] Spark 4 streaming: windowed GroupByKey and Combine via transformWithState (#40703) * [#36841] Spark 4 streaming: windowed GroupByKey and Combine via transformWithState * Address review: move taskRunner to the ParDo processor, one SparkStateInternals per visit --- ...idatesRunner_SparkStructuredStreaming.json | 2 +- .../PipelineTranslatorStreaming.java | 51 +++- .../GroupByKeyStreamingTranslator.java | 72 +++++ .../StatefulParDoStreamingTranslator.java | 6 +- .../state/BeamStatefulProcessor.java | 173 +++-------- .../state/GroupByKeyStatefulProcessor.java | 141 +++++++++ .../state/ParDoStatefulProcessor.java | 156 ++++++++++ .../PipelineTranslatorStreamingTest.java | 55 +++- .../streaming/StatefulParDoStreamingTest.java | 15 +- .../streaming/StreamingTestUtils.java | 13 + .../WindowedGroupByKeyStreamingTest.java | 274 ++++++++++++++++++ 11 files changed, 792 insertions(+), 166 deletions(-) create mode 100644 runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/GroupByKeyStreamingTranslator.java create mode 100644 runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/GroupByKeyStatefulProcessor.java create mode 100644 runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/ParDoStatefulProcessor.java create mode 100644 runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/WindowedGroupByKeyStreamingTest.java diff --git a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json index c0d5a77072b2..1b832d7f19cb 100644 --- a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json +++ b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_SparkStructuredStreaming.json @@ -12,5 +12,5 @@ "https://github.com/apache/beam/pull/40103": "noting that PR #40103 should run this test", "https://github.com/apache/beam/issues/40427": "Spark ValidatesRunner tests run serially", "https://github.com/apache/beam/pull/40281": "noting that PR #40281 should run this test", - "https://github.com/apache/beam/issues/36841": "Spark 4 streaming null keys" + "https://github.com/apache/beam/issues/36841": "Spark 4 streaming GroupByKey" } diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreaming.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreaming.java index 5a18b5da6fd8..6f2d14a69b8a 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreaming.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreaming.java @@ -20,6 +20,7 @@ import java.util.Collection; import org.apache.beam.runners.spark.SparkCommonPipelineOptions; import org.apache.beam.runners.spark.structuredstreaming.translation.batch.PipelineTranslatorCommon; +import org.apache.beam.runners.spark.structuredstreaming.translation.streaming.GroupByKeyStreamingTranslator; import org.apache.beam.runners.spark.structuredstreaming.translation.streaming.ReadUnboundedTranslator; import org.apache.beam.runners.spark.structuredstreaming.translation.streaming.StatefulParDoStreamingTranslator; import org.apache.beam.sdk.annotations.Internal; @@ -33,20 +34,21 @@ import org.apache.beam.sdk.transforms.ParDo; import org.apache.beam.sdk.transforms.reflect.DoFnSignature; import org.apache.beam.sdk.transforms.reflect.DoFnSignatures; +import org.apache.beam.sdk.transforms.windowing.DefaultTrigger; import org.apache.beam.sdk.util.construction.SplittableParDo; import org.apache.beam.sdk.values.KV; import org.apache.beam.sdk.values.PCollection; import org.apache.beam.sdk.values.PInput; import org.apache.beam.sdk.values.POutput; import org.apache.beam.sdk.values.WindowingStrategy; +import org.apache.beam.sdk.values.WindowingStrategy.AccumulationMode; import org.apache.spark.sql.SparkSession; import org.checkerframework.checker.nullness.qual.Nullable; /** * Pipeline translator for streaming pipelines on Spark 4. It extends the common registry to reuse - * the stateless single output ParDo, Window.Assign, Flatten and Reshuffle translators, which are - * safe on a streaming Dataset. Every other primitive fails at translation, the batch translators - * for them persist or collect the Dataset, which Spark rejects for streaming plans. + * the stateless single output ParDo, Window.Assign, Flatten, Reshuffle and Combine.GroupedValues + * translators, which are safe on a streaming Dataset. Unsupported primitives fail at translation. */ @Internal public class PipelineTranslatorStreaming extends PipelineTranslatorCommon { @@ -61,7 +63,18 @@ public class PipelineTranslatorStreaming extends PipelineTranslatorCommon { protected void translate( ParDo.MultiOutput, Object> transform, Context cxt) { PCollection input = (PCollection) cxt.getInput(); - checkNotMergingWindows(input.getWindowingStrategy()); + checkNotMergingWindows(input.getWindowingStrategy(), "Stateful ParDo"); + super.translate(transform, cxt); + } + }; + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static final TransformTranslator GROUP_BY_KEY = + new GroupByKeyStreamingTranslator() { + @Override + protected void translate(GroupByKey transform, Context cxt) { + PCollection input = (PCollection) cxt.getInput(); + checkSupportedGroupByKey(input); super.translate(transform, cxt); } }; @@ -92,11 +105,17 @@ TransformTranslator getTransformTranslator(TransformT tra } if (transform instanceof GroupByKey) { - throw new UnsupportedOperationException("GroupByKey" + NOT_SUPPORTED); + return GROUP_BY_KEY; + } + + if (transform instanceof Combine.Globally + && ((Combine.Globally) transform).isInsertDefault()) { + throw new UnsupportedOperationException( + "Combine.globally() with a default value (use withoutDefaults())" + NOT_SUPPORTED); } - if (transform instanceof Combine.PerKey) { - throw new UnsupportedOperationException("Combine.perKey" + NOT_SUPPORTED); + if (transform instanceof Combine.PerKey || transform instanceof Combine.Globally) { + return null; } if (transform instanceof ParDo.MultiOutput) { @@ -153,10 +172,22 @@ private static void checkNoProcessingTimeTimers(DoFn doFn, DoFnSignature s } } - private static void checkNotMergingWindows(WindowingStrategy windowingStrategy) { - if (windowingStrategy.needsMerge()) { + private static void checkSupportedGroupByKey(PCollection input) { + WindowingStrategy strategy = input.getWindowingStrategy(); + checkNotMergingWindows(strategy, "GroupByKey"); + if (!(strategy.getTrigger() instanceof DefaultTrigger)) { throw new UnsupportedOperationException( - "Stateful ParDo over merging windows" + NOT_SUPPORTED); + "Custom triggers (" + strategy.getTrigger() + ")" + NOT_SUPPORTED); + } + if (strategy.getMode() != AccumulationMode.DISCARDING_FIRED_PANES) { + throw new UnsupportedOperationException("Accumulating fired panes" + NOT_SUPPORTED); + } + } + + private static void checkNotMergingWindows( + WindowingStrategy windowingStrategy, String feature) { + if (windowingStrategy.needsMerge()) { + throw new UnsupportedOperationException(feature + " over merging windows" + NOT_SUPPORTED); } } diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/GroupByKeyStreamingTranslator.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/GroupByKeyStreamingTranslator.java new file mode 100644 index 000000000000..b997f0d7498b --- /dev/null +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/GroupByKeyStreamingTranslator.java @@ -0,0 +1,72 @@ +/* + * 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 org.apache.beam.runners.spark.structuredstreaming.translation.streaming; + +import org.apache.beam.runners.spark.coders.CoderHelpers; +import org.apache.beam.runners.spark.structuredstreaming.translation.TransformTranslator; +import org.apache.beam.runners.spark.structuredstreaming.translation.streaming.state.GroupByKeyStatefulProcessor; +import org.apache.beam.sdk.annotations.Internal; +import org.apache.beam.sdk.coders.Coder; +import org.apache.beam.sdk.coders.KvCoder; +import org.apache.beam.sdk.transforms.GroupByKey; +import org.apache.beam.sdk.values.KV; +import org.apache.beam.sdk.values.PCollection; +import org.apache.beam.sdk.values.WindowedValue; +import org.apache.beam.sdk.values.WindowingStrategy; +import org.apache.spark.api.java.function.MapFunction; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.streaming.OutputMode; +import org.apache.spark.sql.streaming.TimeMode; + +/** Translates {@link GroupByKey} for Spark 4 Structured Streaming via transformWithState. */ +@Internal +public class GroupByKeyStreamingTranslator + extends TransformTranslator< + PCollection>, PCollection>>, GroupByKey> { + + public GroupByKeyStreamingTranslator() { + super(0.2f); + } + + @Override + protected void translate(GroupByKey transform, Context cxt) { + PCollection> input = cxt.getInput(); + WindowingStrategy windowing = input.getWindowingStrategy(); + KvCoder inputCoder = (KvCoder) input.getCoder(); + Coder keyCoder = inputCoder.getKeyCoder(); + PCollection>> output = cxt.getOutput(); + Coder>> outputCoder = output.getCoder(); + + GroupByKeyStatefulProcessor processor = + new GroupByKeyStatefulProcessor<>(inputCoder, windowing, cxt.getOptionsSupplier()); + + MapFunction>, byte[]> keyFn = + v -> CoderHelpers.toByteArray(v.getValue().getKey(), keyCoder); + Dataset>>> result = + cxt.getDataset(input) + .groupByKey(keyFn, Encoders.BINARY()) + .transformWithState( + processor, + TimeMode.EventTime(), + OutputMode.Append(), + cxt.windowedEncoder(outputCoder)); + + cxt.putDataset(output, result); + } +} diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTranslator.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTranslator.java index 199570ae64a1..3f43a6b25e67 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTranslator.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTranslator.java @@ -22,7 +22,7 @@ import org.apache.beam.runners.spark.structuredstreaming.translation.TransformTranslator; import org.apache.beam.runners.spark.structuredstreaming.translation.batch.DoFnRunnerFactory; import org.apache.beam.runners.spark.structuredstreaming.translation.batch.functions.SparkSideInputReader; -import org.apache.beam.runners.spark.structuredstreaming.translation.streaming.state.BeamStatefulProcessor; +import org.apache.beam.runners.spark.structuredstreaming.translation.streaming.state.ParDoStatefulProcessor; import org.apache.beam.sdk.annotations.Internal; import org.apache.beam.sdk.coders.Coder; import org.apache.beam.sdk.coders.KvCoder; @@ -68,8 +68,8 @@ protected void translate(ParDo.MultiOutput, OutputT> transform, Context cxt.getCurrentTransform(), input, SparkSideInputReader.empty(), false); MetricsAccumulator metrics = MetricsAccumulator.getInstance(cxt.getSparkSession()); - BeamStatefulProcessor processor = - new BeamStatefulProcessor<>( + ParDoStatefulProcessor processor = + new ParDoStatefulProcessor<>( doFn, runnerFactory, inputCoder, windowing, cxt.getOptionsSupplier(), metrics); MapFunction>, byte[]> keyFn = diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java index 7d794ef04506..568c41d23ec8 100644 --- a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/BeamStatefulProcessor.java @@ -25,29 +25,19 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.function.Supplier; -import org.apache.beam.runners.core.DoFnRunner; -import org.apache.beam.runners.core.DoFnRunners; -import org.apache.beam.runners.core.StatefulDoFnRunner; +import java.util.stream.Collectors; import org.apache.beam.runners.core.TimerInternals.TimerData; import org.apache.beam.runners.core.TimerInternals.TimerDataCoderV2; import org.apache.beam.runners.spark.coders.CoderHelpers; import org.apache.beam.runners.spark.stateful.SparkStateInternals; import org.apache.beam.runners.spark.stateful.SparkStateInternals.StateCells; import org.apache.beam.runners.spark.stateful.SparkTimerInternals; -import org.apache.beam.runners.spark.structuredstreaming.metrics.MetricsAccumulator; -import org.apache.beam.runners.spark.structuredstreaming.translation.batch.DoFnRunnerFactory; -import org.apache.beam.runners.spark.structuredstreaming.translation.batch.DoFnRunnerFactory.DoFnRunnerWithTeardown; -import org.apache.beam.runners.spark.structuredstreaming.translation.batch.StatefulTaskRunner; import org.apache.beam.runners.spark.structuredstreaming.translation.utils.ScalaInterop; import org.apache.beam.sdk.coders.Coder; import org.apache.beam.sdk.coders.KvCoder; import org.apache.beam.sdk.coders.ListCoder; -import org.apache.beam.sdk.options.PipelineOptions; import org.apache.beam.sdk.state.TimeDomain; -import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.sdk.transforms.windowing.BoundedWindow; -import org.apache.beam.sdk.transforms.windowing.GlobalWindow; import org.apache.beam.sdk.util.WindowedValueMultiReceiver; import org.apache.beam.sdk.values.KV; import org.apache.beam.sdk.values.TupleTag; @@ -67,43 +57,45 @@ import org.joda.time.Instant; import scala.collection.Iterator; -/** Runs a stateful {@link DoFn} within Spark 4 Structured Streaming {@code transformWithState}. */ -public class BeamStatefulProcessor +/** Base stateful processor bridging Beam state and timers over Spark transformWithState. */ +public abstract class BeamStatefulProcessor extends StatefulProcessor>, WindowedValue> { private static final String BEAM_STATE = "beamState"; private static final String BEAM_TIMERS = "beamTimers"; private static final String BEAM_WATERMARK = "beamWatermark"; - private final DoFn, OutputT> doFn; - private final DoFnRunnerFactory, OutputT> runnerFactory; - private final KvCoder inputCoder; - private final WindowingStrategy windowingStrategy; - private final Supplier optionsSupplier; - private final MetricsAccumulator metrics; - private final Coder windowCoder; + protected final KvCoder inputCoder; + protected final WindowingStrategy windowingStrategy; + protected final Coder windowCoder; private final TimerDataCoderV2 timerDataCoder; - private final StatefulTaskRunner, OutputT> taskRunner = new StatefulTaskRunner<>(); private transient @Nullable MapState beamState; private transient @Nullable ValueState beamTimers; private transient @Nullable ValueState beamWatermark; private transient @Nullable List> currentOutputs; - private transient boolean needsBundleStart; + + private transient @Nullable WindowedValueMultiReceiver outputReceiver; + + protected WindowedValueMultiReceiver outputReceiver() { + if (outputReceiver == null) { + outputReceiver = + new WindowedValueMultiReceiver() { + @Override + public void output(TupleTag tag, WindowedValue output) { + @SuppressWarnings("unchecked") + WindowedValue out = (WindowedValue) output; + checkStateNotNull(currentOutputs, "currentOutputs not initialized").add(out); + } + }; + } + return outputReceiver; + } public BeamStatefulProcessor( - DoFn, OutputT> doFn, - DoFnRunnerFactory, OutputT> runnerFactory, - KvCoder inputCoder, - WindowingStrategy windowingStrategy, - Supplier optionsSupplier, - MetricsAccumulator metrics) { - this.doFn = doFn; - this.runnerFactory = runnerFactory; + KvCoder inputCoder, WindowingStrategy windowingStrategy) { this.inputCoder = inputCoder; this.windowingStrategy = windowingStrategy; - this.optionsSupplier = optionsSupplier; - this.metrics = metrics; @SuppressWarnings("unchecked") Coder windowCoder = (Coder) windowingStrategy.getWindowFn().windowCoder(); @@ -151,16 +143,13 @@ public Iterator> handleInputRows( byte[] keyBytes, Iterator>> rows, TimerValues timerValues) { K key = CoderHelpers.fromByteArray(keyBytes, inputCoder.getKeyCoder()); List> outputs = new ArrayList<>(); + this.currentOutputs = outputs; SparkTimerInternals timerInternals = timerInternals(timerValues); + SparkStateInternals state = stateInternals(key); - DoFnRunner, OutputT> runner = keyRunner(key, timerInternals, outputs); - while (rows.hasNext()) { - runner.processElement(rows.next()); - } + processElements(key, rows, timerInternals, state); timerInternals.advanceWatermark(); - // The next bundle opens in keyRunner. - runner.finishBundle(); - needsBundleStart = true; + processTimers(key, timerInternals, state); persistTimers(timerInternals); reconcileWakeupTimer(timerInternals, null); @@ -173,14 +162,12 @@ public Iterator> handleExpiredTimer( byte[] keyBytes, TimerValues timerValues, ExpiredTimerInfo expiredTimerInfo) { K key = CoderHelpers.fromByteArray(keyBytes, inputCoder.getKeyCoder()); List> outputs = new ArrayList<>(); + this.currentOutputs = outputs; SparkTimerInternals timerInternals = timerInternals(timerValues); + SparkStateInternals state = stateInternals(key); timerInternals.advanceWatermark(); - DoFnRunner, OutputT> runner = keyRunner(key, timerInternals, outputs); - fireDueTimers(key, timerInternals.currentInputWatermarkTime(), timerInternals, runner); - // The next bundle opens in keyRunner. - runner.finishBundle(); - needsBundleStart = true; + processTimers(key, timerInternals, state); persistTimers(timerInternals); reconcileWakeupTimer(timerInternals, expiredTimerInfo.getExpiryTimeInMs()); @@ -188,70 +175,28 @@ public Iterator> handleExpiredTimer( return ScalaInterop.scalaIterator(outputs); } - @Override - public void close() { - taskRunner.teardownOnce(); + private SparkStateInternals stateInternals(K key) { + return SparkStateInternals.forKey( + key, new MapStateAdapter(checkStateNotNull(beamState, "beamState not initialized"))); } - /** Creates the task runner on first use, {@code create} opens the first bundle. */ - private DoFnRunnerWithTeardown, OutputT> baseRunner( - SparkStateInternals stateInternals, SparkTimerInternals timerInternals) { - return taskRunner.getOrCreate( - ctx -> { - ctx.set(stateInternals, timerInternals); - return runnerFactory.create( - optionsSupplier.get(), - metrics, - new WindowedValueMultiReceiver() { - @Override - public void output(TupleTag tag, WindowedValue output) { - @SuppressWarnings("unchecked") - WindowedValue out = (WindowedValue) output; - checkStateNotNull(currentOutputs, "currentOutputs not initialized").add(out); - } - }, - ctx); - }); + protected List dueEventTimeTimers(SparkTimerInternals timerInternals) { + Instant watermark = timerInternals.currentInputWatermarkTime(); + return timerInternals.getTimers().stream() + .filter( + t -> t.getDomain().equals(TimeDomain.EVENT_TIME) && watermark.isAfter(t.getTimestamp())) + .sorted(Comparator.comparing(TimerData::getTimestamp)) + .collect(Collectors.toList()); } - private DoFnRunner, OutputT> keyRunner( - K key, SparkTimerInternals timerInternals, List> outputs) { - this.currentOutputs = outputs; - MapState state = checkStateNotNull(beamState); - SparkStateInternals stateInternals = - SparkStateInternals.forKey(key, new MapStateAdapter(state)); - DoFnRunnerWithTeardown, OutputT> base = baseRunner(stateInternals, timerInternals); - taskRunner.stepContext().set(stateInternals, timerInternals); - - // Closed in handleInputRows and handleExpiredTimer. - if (needsBundleStart) { - needsBundleStart = false; - base.startBundle(); - } + protected abstract void processElements( + K key, + Iterator>> rows, + SparkTimerInternals timers, + SparkStateInternals state); - StatefulDoFnRunner.CleanupTimer> cleanupTimer = - new StatefulDoFnRunner.TimeInternalsCleanupTimer>( - timerInternals, windowingStrategy) { - @Override - public void setForWindow(KV input, BoundedWindow window) { - // GlobalWindow state is never garbage collected, as in the Flink runner. - if (!window.equals(GlobalWindow.INSTANCE)) { - super.setForWindow(input, window); - } - } - }; - StatefulDoFnRunner.StateCleaner stateCleaner = - new StatefulDoFnRunner.StateInternalsStateCleaner<>(doFn, stateInternals, windowCoder); - - return DoFnRunners.defaultStatefulDoFnRunner( - doFn, - inputCoder, - base, - taskRunner.stepContext(), - windowingStrategy, - cleanupTimer, - stateCleaner); - } + protected abstract void processTimers( + K key, SparkTimerInternals timers, SparkStateInternals state); private void restoreTimers(SparkTimerInternals timerInternals) { ValueState timersState = checkStateNotNull(beamTimers); @@ -278,28 +223,6 @@ private void persistTimers(SparkTimerInternals timerInternals) { } } - private void fireDueTimers( - K key, - Instant watermark, - SparkTimerInternals timerInternals, - DoFnRunner, OutputT> runner) { - while (true) { - TimerData next = - timerInternals.getTimers().stream() - .filter( - t -> - t.getDomain().equals(TimeDomain.EVENT_TIME) - && watermark.isAfter(t.getTimestamp())) - .min(Comparator.comparing(TimerData::getTimestamp)) - .orElse(null); - if (next == null) { - break; - } - timerInternals.deleteTimer(next); - StatefulTaskRunner.fireTimer(runner, key, next); - } - } - private void reconcileWakeupTimer( SparkTimerInternals timerInternals, @Nullable Long firedExpiryMs) { Long nextWakeupMs = null; diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/GroupByKeyStatefulProcessor.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/GroupByKeyStatefulProcessor.java new file mode 100644 index 000000000000..cbe592af0eae --- /dev/null +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/GroupByKeyStatefulProcessor.java @@ -0,0 +1,141 @@ +/* + * 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 org.apache.beam.runners.spark.structuredstreaming.translation.streaming.state; + +import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; +import org.apache.beam.runners.core.DoFnRunner; +import org.apache.beam.runners.core.DoFnRunners; +import org.apache.beam.runners.core.GroupAlsoByWindowViaWindowSetNewDoFn; +import org.apache.beam.runners.core.KeyedWorkItem; +import org.apache.beam.runners.core.KeyedWorkItemCoder; +import org.apache.beam.runners.core.KeyedWorkItems; +import org.apache.beam.runners.core.NullSideInputReader; +import org.apache.beam.runners.core.SystemReduceFn; +import org.apache.beam.runners.core.TimerInternals.TimerData; +import org.apache.beam.runners.spark.stateful.SparkStateInternals; +import org.apache.beam.runners.spark.stateful.SparkTimerInternals; +import org.apache.beam.runners.spark.structuredstreaming.translation.batch.StatefulTaskRunner.MutableStepContext; +import org.apache.beam.sdk.coders.Coder; +import org.apache.beam.sdk.coders.KvCoder; +import org.apache.beam.sdk.options.PipelineOptions; +import org.apache.beam.sdk.transforms.DoFn; +import org.apache.beam.sdk.transforms.DoFnSchemaInformation; +import org.apache.beam.sdk.util.WindowedValueMultiReceiver; +import org.apache.beam.sdk.values.KV; +import org.apache.beam.sdk.values.TupleTag; +import org.apache.beam.sdk.values.WindowedValue; +import org.apache.beam.sdk.values.WindowedValues; +import org.apache.beam.sdk.values.WindowingStrategy; +import org.checkerframework.checker.nullness.qual.Nullable; +import scala.collection.Iterator; + +/** Runs a {@link GroupAlsoByWindowViaWindowSetNewDoFn} in {@code transformWithState}. */ +public class GroupByKeyStatefulProcessor + extends BeamStatefulProcessor>> { + + private final Supplier optionsSupplier; + private final TupleTag>> mainTag = new TupleTag<>("output"); + + private transient @Nullable DoFnRunner, KV>> runner; + private transient @Nullable MutableStepContext stepContext; + + public GroupByKeyStatefulProcessor( + KvCoder inputCoder, + WindowingStrategy windowingStrategy, + Supplier optionsSupplier) { + super(inputCoder, windowingStrategy); + this.optionsSupplier = optionsSupplier; + } + + @Override + protected void processElements( + K key, + Iterator>> rows, + SparkTimerInternals timerInternals, + SparkStateInternals stateInternals) { + DoFnRunner, KV>> r = runner(); + checkStateNotNull(stepContext).set(stateInternals, timerInternals); + + List> elementValues = new ArrayList<>(); + while (rows.hasNext()) { + WindowedValue> row = rows.next(); + elementValues.add(row.withValue(row.getValue().getValue())); + } + r.processElement( + WindowedValues.valueInGlobalWindow(KeyedWorkItems.elementsWorkItem(key, elementValues))); + } + + @Override + protected void processTimers( + K key, SparkTimerInternals timerInternals, SparkStateInternals stateInternals) { + List dueTimers = dueEventTimeTimers(timerInternals); + if (dueTimers.isEmpty()) { + return; + } + for (TimerData timer : dueTimers) { + timerInternals.deleteTimer(timer); + } + DoFnRunner, KV>> r = runner(); + checkStateNotNull(stepContext).set(stateInternals, timerInternals); + r.processElement( + WindowedValues.valueInGlobalWindow(KeyedWorkItems.timersWorkItem(key, dueTimers))); + } + + private DoFnRunner, KV>> runner() { + if (runner == null) { + MutableStepContext ctx = new MutableStepContext(); + this.stepContext = ctx; + WindowedValueMultiReceiver receiver = outputReceiver(); + DoFn, KV>> gabwDoFn = + GroupAlsoByWindowViaWindowSetNewDoFn.create( + windowingStrategy, + k -> ctx.stateInternals(), + k -> ctx.timerInternals(), + NullSideInputReader.empty(), + SystemReduceFn.buffering(inputCoder.getValueCoder()), + receiver, + mainTag); + + Coder> itemCoder = + KeyedWorkItemCoder.of(inputCoder.getKeyCoder(), inputCoder.getValueCoder(), windowCoder); + + DoFnRunner, KV>> simpleRunner = + DoFnRunners.simpleRunner( + optionsSupplier.get(), + gabwDoFn, + NullSideInputReader.empty(), + receiver, + mainTag, + Collections.emptyList(), + ctx, + itemCoder, + Collections.emptyMap(), + windowingStrategy, + DoFnSchemaInformation.create(), + Collections.emptyMap()); + + runner = DoFnRunners.lateDataDroppingRunner(simpleRunner, ctx, windowingStrategy); + } + return runner; + } +} diff --git a/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/ParDoStatefulProcessor.java b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/ParDoStatefulProcessor.java new file mode 100644 index 000000000000..f25c523eaac6 --- /dev/null +++ b/runners/spark/4/src/main/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/state/ParDoStatefulProcessor.java @@ -0,0 +1,156 @@ +/* + * 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 org.apache.beam.runners.spark.structuredstreaming.translation.streaming.state; + +import java.util.List; +import java.util.function.Supplier; +import org.apache.beam.runners.core.DoFnRunner; +import org.apache.beam.runners.core.DoFnRunners; +import org.apache.beam.runners.core.StatefulDoFnRunner; +import org.apache.beam.runners.core.TimerInternals.TimerData; +import org.apache.beam.runners.spark.stateful.SparkStateInternals; +import org.apache.beam.runners.spark.stateful.SparkTimerInternals; +import org.apache.beam.runners.spark.structuredstreaming.metrics.MetricsAccumulator; +import org.apache.beam.runners.spark.structuredstreaming.translation.batch.DoFnRunnerFactory; +import org.apache.beam.runners.spark.structuredstreaming.translation.batch.DoFnRunnerFactory.DoFnRunnerWithTeardown; +import org.apache.beam.runners.spark.structuredstreaming.translation.batch.StatefulTaskRunner; +import org.apache.beam.sdk.coders.KvCoder; +import org.apache.beam.sdk.options.PipelineOptions; +import org.apache.beam.sdk.transforms.DoFn; +import org.apache.beam.sdk.transforms.windowing.BoundedWindow; +import org.apache.beam.sdk.transforms.windowing.GlobalWindow; +import org.apache.beam.sdk.values.KV; +import org.apache.beam.sdk.values.WindowedValue; +import org.apache.beam.sdk.values.WindowingStrategy; +import scala.collection.Iterator; + +/** Runs a stateful {@link DoFn} within Spark 4 Structured Streaming {@code transformWithState}. */ +public class ParDoStatefulProcessor extends BeamStatefulProcessor { + + private final DoFn, OutputT> doFn; + private final DoFnRunnerFactory, OutputT> runnerFactory; + private final Supplier optionsSupplier; + private final MetricsAccumulator metrics; + private final StatefulTaskRunner, OutputT> taskRunner = new StatefulTaskRunner<>(); + + private transient boolean needsBundleStart; + + public ParDoStatefulProcessor( + DoFn, OutputT> doFn, + DoFnRunnerFactory, OutputT> runnerFactory, + KvCoder inputCoder, + WindowingStrategy windowingStrategy, + Supplier optionsSupplier, + MetricsAccumulator metrics) { + super(inputCoder, windowingStrategy); + this.doFn = doFn; + this.runnerFactory = runnerFactory; + this.optionsSupplier = optionsSupplier; + this.metrics = metrics; + } + + @Override + protected void processElements( + K key, + Iterator>> rows, + SparkTimerInternals timerInternals, + SparkStateInternals stateInternals) { + DoFnRunner, OutputT> runner = keyRunner(stateInternals, timerInternals); + while (rows.hasNext()) { + runner.processElement(rows.next()); + } + // The next bundle opens in keyRunner. + runner.finishBundle(); + needsBundleStart = true; + } + + @Override + protected void processTimers( + K key, SparkTimerInternals timerInternals, SparkStateInternals stateInternals) { + if (dueEventTimeTimers(timerInternals).isEmpty()) { + return; + } + DoFnRunner, OutputT> runner = keyRunner(stateInternals, timerInternals); + fireDueTimers(key, timerInternals, runner); + // The next bundle opens in keyRunner. + runner.finishBundle(); + needsBundleStart = true; + } + + @Override + public void close() { + taskRunner.teardownOnce(); + } + + private void fireDueTimers( + K key, SparkTimerInternals timerInternals, DoFnRunner, OutputT> runner) { + while (true) { + List due = dueEventTimeTimers(timerInternals); + if (due.isEmpty()) { + break; + } + TimerData next = due.get(0); + timerInternals.deleteTimer(next); + StatefulTaskRunner.fireTimer(runner, key, next); + } + } + + private DoFnRunnerWithTeardown, OutputT> baseRunner( + SparkStateInternals stateInternals, SparkTimerInternals timerInternals) { + return taskRunner.getOrCreate( + ctx -> { + ctx.set(stateInternals, timerInternals); + return runnerFactory.create(optionsSupplier.get(), metrics, outputReceiver(), ctx); + }); + } + + private DoFnRunner, OutputT> keyRunner( + SparkStateInternals stateInternals, SparkTimerInternals timerInternals) { + DoFnRunnerWithTeardown, OutputT> base = baseRunner(stateInternals, timerInternals); + taskRunner.stepContext().set(stateInternals, timerInternals); + + // Closed in processElements and processTimers. + if (needsBundleStart) { + needsBundleStart = false; + base.startBundle(); + } + + StatefulDoFnRunner.CleanupTimer> cleanupTimer = + new StatefulDoFnRunner.TimeInternalsCleanupTimer>( + timerInternals, windowingStrategy) { + @Override + public void setForWindow(KV input, BoundedWindow window) { + // GlobalWindow state is never garbage collected, as in the Flink runner. + if (!window.equals(GlobalWindow.INSTANCE)) { + super.setForWindow(input, window); + } + } + }; + StatefulDoFnRunner.StateCleaner stateCleaner = + new StatefulDoFnRunner.StateInternalsStateCleaner<>(doFn, stateInternals, windowCoder); + + return DoFnRunners.defaultStatefulDoFnRunner( + doFn, + inputCoder, + base, + taskRunner.stepContext(), + windowingStrategy, + cleanupTimer, + stateCleaner); + } +} diff --git a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreamingTest.java b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreamingTest.java index 39992b76fe3e..a5afa7c1a4a6 100644 --- a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreamingTest.java +++ b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/PipelineTranslatorStreamingTest.java @@ -33,13 +33,16 @@ import org.apache.beam.sdk.state.TimeDomain; import org.apache.beam.sdk.state.TimerSpec; import org.apache.beam.sdk.state.TimerSpecs; -import org.apache.beam.sdk.transforms.Combine; +import org.apache.beam.sdk.transforms.Count; import org.apache.beam.sdk.transforms.Create; import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.sdk.transforms.GroupByKey; import org.apache.beam.sdk.transforms.ParDo; -import org.apache.beam.sdk.transforms.Sum; +import org.apache.beam.sdk.transforms.windowing.AfterPane; +import org.apache.beam.sdk.transforms.windowing.AfterWatermark; import org.apache.beam.sdk.transforms.windowing.FixedWindows; +import org.apache.beam.sdk.transforms.windowing.GlobalWindows; +import org.apache.beam.sdk.transforms.windowing.Repeatedly; import org.apache.beam.sdk.transforms.windowing.Sessions; import org.apache.beam.sdk.transforms.windowing.Window; import org.apache.beam.sdk.values.KV; @@ -84,17 +87,6 @@ private static void assertUnsupported(Pipeline pipeline, String expected) { throw new AssertionError("missing " + expected); } - @Test - public void rejectsGroupByKey() throws Exception { - assertUnsupported(kv("g").apply(GroupByKey.create()).getPipeline(), "GroupByKey"); - } - - @Test - public void rejectsCombinePerKey() throws Exception { - assertUnsupported( - kv("c").apply(Combine.perKey(Sum.ofIntegers())).getPipeline(), "Combine.perKey"); - } - @Test public void rejectsImpulseFromCreate() throws Exception { SparkStructuredStreamingPipelineOptions o = StreamingTestUtils.streamingOptions(temp); @@ -136,6 +128,43 @@ public void rejectsUnsupportedStatefulFeatures() throws Exception { .apply(ParDo.of(new StatefulDoFn())) .getPipeline(), "merging windows" + }, + { + kv("gbkmw") + .apply(Window.into(Sessions.withGapDuration(Duration.millis(10)))) + .apply(GroupByKey.create()) + .getPipeline(), + "merging windows" + }, + { + kv("trig") + .apply( + Window.>into(FixedWindows.of(Duration.millis(1))) + .triggering(Repeatedly.forever(AfterWatermark.pastEndOfWindow())) + .withAllowedLateness(Duration.millis(1)) + .discardingFiredPanes()) + .apply(GroupByKey.create()) + .getPipeline(), + "Custom triggers" + }, + { + kv("accum") + .apply( + Window.>into(FixedWindows.of(Duration.millis(1))) + .accumulatingFiredPanes()) + .apply(GroupByKey.create()) + .getPipeline(), + "Accumulating fired panes" + }, + { + kv("cgdef") + .apply( + Window.>into(new GlobalWindows()) + .triggering(Repeatedly.forever(AfterPane.elementCountAtLeast(1))) + .discardingFiredPanes()) + .apply(Count.globally()) + .getPipeline(), + "Combine.globally() with a default value (use withoutDefaults())" } }; diff --git a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java index 18143ce4d924..5679950e1893 100644 --- a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java +++ b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StatefulParDoStreamingTest.java @@ -21,7 +21,6 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import java.io.File; import java.io.Serializable; import java.util.Arrays; import java.util.Set; @@ -213,22 +212,10 @@ public void testExpiredKeyLeavesNoState() throws Exception { Set collected = StreamingTestUtils.collected(collectorId); assertTrue(collected.contains(TestUnboundedSource.element(TAG_EXPIRY, 0, 0))); for (String stateVarName : Arrays.asList("beamState", "beamTimers", "beamWatermark")) { - assertEquals(stateVarName, 0L, stateRows(checkpoint, stateVarName)); + assertEquals(stateVarName, 0L, StreamingTestUtils.stateRows(checkpoint, stateVarName)); } } - /** Rows of a state variable of the query of the only leaf, checkpointed under 0. */ - private static long stateRows(String checkpoint, String stateVarName) { - return SESSION - .getSession() - .read() - .format("statestore") - .option("path", new File(checkpoint, "0").getAbsolutePath()) - .option("stateVarName", stateVarName) - .load() - .count(); - } - private static final class KeySelectorFn extends DoFn> { @ProcessElement public void process(@Element String element, OutputReceiver> out) { diff --git a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StreamingTestUtils.java b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StreamingTestUtils.java index 3d7841154150..5616b5b4b7c8 100644 --- a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StreamingTestUtils.java +++ b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/StreamingTestUtils.java @@ -17,6 +17,7 @@ */ package org.apache.beam.runners.spark.structuredstreaming.translation.streaming; +import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; @@ -33,6 +34,7 @@ import org.apache.beam.sdk.options.PipelineOptionsFactory; import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions; +import org.apache.spark.sql.SparkSession; import org.joda.time.Duration; import org.junit.rules.TemporaryFolder; @@ -153,4 +155,15 @@ public static SparkStructuredStreamingPipelineOptions streamingOptions(String ch options.setCheckpointDir(checkpointPath); return options; } + + /** Rows of a state variable of the query of the only leaf, checkpointed under 0. */ + static long stateRows(String checkpoint, String stateVarName) { + return SparkSession.active() + .read() + .format("statestore") + .option("path", new File(checkpoint, "0").getAbsolutePath()) + .option("stateVarName", stateVarName) + .load() + .count(); + } } diff --git a/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/WindowedGroupByKeyStreamingTest.java b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/WindowedGroupByKeyStreamingTest.java new file mode 100644 index 000000000000..2b0305fa66d2 --- /dev/null +++ b/runners/spark/4/src/test/java/org/apache/beam/runners/spark/structuredstreaming/translation/streaming/WindowedGroupByKeyStreamingTest.java @@ -0,0 +1,274 @@ +/* + * 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 org.apache.beam.runners.spark.structuredstreaming.translation.streaming; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Set; +import org.apache.beam.runners.spark.StreamingTest; +import org.apache.beam.runners.spark.structuredstreaming.SparkSessionRule; +import org.apache.beam.runners.spark.structuredstreaming.SparkStructuredStreamingPipelineOptions; +import org.apache.beam.runners.spark.structuredstreaming.io.streaming.TestUnboundedSource; +import org.apache.beam.sdk.Pipeline; +import org.apache.beam.sdk.PipelineResult; +import org.apache.beam.sdk.coders.KvCoder; +import org.apache.beam.sdk.coders.StringUtf8Coder; +import org.apache.beam.sdk.coders.VarIntCoder; +import org.apache.beam.sdk.io.Read; +import org.apache.beam.sdk.state.TimeDomain; +import org.apache.beam.sdk.state.Timer; +import org.apache.beam.sdk.state.TimerSpec; +import org.apache.beam.sdk.state.TimerSpecs; +import org.apache.beam.sdk.transforms.Combine; +import org.apache.beam.sdk.transforms.Count; +import org.apache.beam.sdk.transforms.DoFn; +import org.apache.beam.sdk.transforms.GroupByKey; +import org.apache.beam.sdk.transforms.ParDo; +import org.apache.beam.sdk.transforms.windowing.FixedWindows; +import org.apache.beam.sdk.transforms.windowing.IntervalWindow; +import org.apache.beam.sdk.transforms.windowing.PaneInfo; +import org.apache.beam.sdk.transforms.windowing.SlidingWindows; +import org.apache.beam.sdk.transforms.windowing.Window; +import org.apache.beam.sdk.values.KV; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables; +import org.joda.time.Duration; +import org.joda.time.Instant; +import org.junit.After; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** End to end tests for windowed GroupByKey, Combine.perKey and Combine.globally on Spark 4. */ +@RunWith(JUnit4.class) +@Category(StreamingTest.class) +public class WindowedGroupByKeyStreamingTest implements Serializable { + + @ClassRule public static final SparkSessionRule SESSION = new SparkSessionRule(); + + @Rule public transient TemporaryFolder tempFolder = new TemporaryFolder(); + + private static final String TAG_FIXED = "fixed-gbk"; + private static final String TAG_SLIDING = "sliding-combine"; + private static final String TAG_LATE = "late-dropped"; + private static final String TAG_CHAINED = "chained-timer"; + private static final String TAG_GLOBALLY = "globally-combine"; + private static final String TAG_EXPIRY = "gbk-expiry"; + + @After + public void tearDown() { + TestUnboundedSource.forget(TAG_FIXED); + TestUnboundedSource.forget(TAG_SLIDING); + TestUnboundedSource.forget(TAG_LATE); + TestUnboundedSource.forget(TAG_CHAINED); + TestUnboundedSource.forget(TAG_GLOBALLY); + TestUnboundedSource.forget(TAG_EXPIRY); + } + + private SparkStructuredStreamingPipelineOptions options(String tag) throws Exception { + SparkStructuredStreamingPipelineOptions options = + StreamingTestUtils.streamingOptions(tempFolder.newFolder(tag).getAbsolutePath()); + SESSION.configure(options); + options.setMaxRecordsPerBatch(1L); + return options; + } + + @Test + public void testFixedWindowsGroupByKey() throws Exception { + String col = StreamingTestUtils.newCollectorId(TAG_FIXED); + Pipeline p = Pipeline.create(options(TAG_FIXED)); + p.apply(Read.from(new TestUnboundedSource(TAG_FIXED, 1, 6))) + .apply(ParDo.of(new ToKvFn())) + .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of())) + .apply(Window.into(FixedWindows.of(Duration.standardSeconds(2)))) + .apply(GroupByKey.create()) + .apply(ParDo.of(new FormatGbkFn())) + .apply(ParDo.of(new StreamingTestUtils.CollectDoFn<>(col))); + + assertEquals(PipelineResult.State.DONE, StreamingTestUtils.run(p).getState()); + Set res = StreamingTestUtils.collected(col); + assertTrue(res.contains(TestUnboundedSource.BASE_MILLIS + ":k=2")); + assertTrue(res.contains(TestUnboundedSource.BASE_MILLIS + 2000 + ":k=2")); + } + + @Test + public void testSlidingWindowsCombinePerKey() throws Exception { + String col = StreamingTestUtils.newCollectorId(TAG_SLIDING); + Pipeline p = Pipeline.create(options(TAG_SLIDING)); + p.apply(Read.from(new TestUnboundedSource(TAG_SLIDING, 1, 6))) + .apply(ParDo.of(new ToKvFn())) + .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of())) + .apply( + Window.into( + SlidingWindows.of(Duration.standardSeconds(4)).every(Duration.standardSeconds(2)))) + .apply(Count.perKey()) + .apply(ParDo.of(new FormatCountFn())) + .apply(ParDo.of(new StreamingTestUtils.CollectDoFn<>(col))); + + assertEquals(PipelineResult.State.DONE, StreamingTestUtils.run(p).getState()); + Set res = StreamingTestUtils.collected(col); + assertTrue(res.contains((TestUnboundedSource.BASE_MILLIS - 2000) + ":k=2:ON_TIME")); + assertTrue(res.contains(TestUnboundedSource.BASE_MILLIS + ":k=4:ON_TIME")); + } + + @Test + public void testCombineGloballyWithoutDefaults() throws Exception { + String col = StreamingTestUtils.newCollectorId(TAG_GLOBALLY); + Pipeline p = Pipeline.create(options(TAG_GLOBALLY)); + p.apply(Read.from(new TestUnboundedSource(TAG_GLOBALLY, 1, 6))) + .apply(Window.into(FixedWindows.of(Duration.standardSeconds(2)))) + .apply(Combine.globally(Count.combineFn()).withoutDefaults()) + .apply(ParDo.of(new FormatGlobalCountFn())) + .apply(ParDo.of(new StreamingTestUtils.CollectDoFn<>(col))); + + assertEquals(PipelineResult.State.DONE, StreamingTestUtils.run(p).getState()); + Set res = StreamingTestUtils.collected(col); + assertTrue(res.contains(TestUnboundedSource.BASE_MILLIS + "=2")); + assertTrue(res.contains((TestUnboundedSource.BASE_MILLIS + 2000) + "=2")); + } + + @Test + public void testLateDataDropped() throws Exception { + String col = StreamingTestUtils.newCollectorId(TAG_LATE); + Pipeline p = Pipeline.create(options(TAG_LATE)); + p.apply(Read.from(new TestUnboundedSource(TAG_LATE, 2, 8))) + .apply(ParDo.of(new ToKvFn())) + .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of())) + .apply(Window.into(FixedWindows.of(Duration.standardSeconds(4)))) + .apply(Count.perKey()) + .apply(ParDo.of(new FormatCountFn())) + .apply(ParDo.of(new StreamingTestUtils.CollectDoFn<>(col))); + + assertEquals(PipelineResult.State.DONE, StreamingTestUtils.run(p).getState()); + Set res = StreamingTestUtils.collected(col); + assertTrue(res.contains(TestUnboundedSource.BASE_MILLIS + ":k=1:ON_TIME")); + assertTrue(res.stream().noneMatch(s -> s.endsWith("LATE"))); + } + + @Test + public void testChainedStatefulParDoTimerOutputSurvives() throws Exception { + String col = StreamingTestUtils.newCollectorId(TAG_CHAINED); + Pipeline p = Pipeline.create(options(TAG_CHAINED)); + p.apply(Read.from(new TestUnboundedSource(TAG_CHAINED, 1, 4))) + .apply(ParDo.of(new ToKvFn())) + .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of())) + .apply(ParDo.of(new TimerEmitterFn())) + .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of())) + .apply(Window.into(FixedWindows.of(Duration.millis(500)))) + .apply(GroupByKey.create()) + .apply(ParDo.of(new FormatGbkFn())) + .apply(ParDo.of(new StreamingTestUtils.CollectDoFn<>(col))); + + assertEquals(PipelineResult.State.DONE, StreamingTestUtils.run(p).getState()); + Set res = StreamingTestUtils.collected(col); + assertTrue(res.contains(TestUnboundedSource.BASE_MILLIS + ":chained=2")); + } + + @Test + public void testExpiredKeyLeavesNoState() throws Exception { + String checkpoint = tempFolder.newFolder("cp").getAbsolutePath(); + String col = StreamingTestUtils.newCollectorId(TAG_EXPIRY); + SparkStructuredStreamingPipelineOptions options = + StreamingTestUtils.streamingOptions(checkpoint); + options.setMaxRecordsPerBatch(1L); + Pipeline p = Pipeline.create(options); + p.apply(Read.from(new TestUnboundedSource(TAG_EXPIRY, 1, 8))) + .apply(ParDo.of(new ExpiryKeySelectorFn())) + .setCoder(KvCoder.of(StringUtf8Coder.of(), VarIntCoder.of())) + .apply(Window.into(FixedWindows.of(Duration.standardSeconds(1)))) + .apply(GroupByKey.create()) + .apply(ParDo.of(new FormatGbkFn())) + .apply(ParDo.of(new StreamingTestUtils.CollectDoFn<>(col))); + + assertEquals(PipelineResult.State.DONE, StreamingTestUtils.run(p).getState()); + Set res = StreamingTestUtils.collected(col); + assertTrue(res.contains(TestUnboundedSource.BASE_MILLIS + ":k=1")); + for (String stateVarName : Arrays.asList("beamState", "beamTimers", "beamWatermark")) { + assertEquals(stateVarName, 0L, StreamingTestUtils.stateRows(checkpoint, stateVarName)); + } + } + + private static final class ToKvFn extends DoFn> { + @ProcessElement + public void process(@Element String el, OutputReceiver> out) { + out.output(KV.of("k", TestUnboundedSource.indexOf(el))); + } + } + + private static final class ExpiryKeySelectorFn extends DoFn> { + @ProcessElement + public void process(@Element String el, OutputReceiver> out) { + int i = TestUnboundedSource.indexOf(el); + if (i == 0) { + out.output(KV.of("k", i)); + } + } + } + + private static final class FormatGbkFn extends DoFn>, String> { + @ProcessElement + public void process( + @Element KV> el, IntervalWindow w, OutputReceiver out) { + out.output(w.start().getMillis() + ":" + el.getKey() + "=" + Iterables.size(el.getValue())); + } + } + + private static final class FormatCountFn extends DoFn, String> { + @ProcessElement + public void process( + @Element KV el, IntervalWindow w, PaneInfo pane, OutputReceiver out) { + out.output( + w.start().getMillis() + ":" + el.getKey() + "=" + el.getValue() + ":" + pane.getTiming()); + } + } + + private static final class FormatGlobalCountFn extends DoFn { + @ProcessElement + public void process(@Element Long count, IntervalWindow w, OutputReceiver out) { + out.output(w.start().getMillis() + "=" + count); + } + } + + private static final class TimerEmitterFn extends DoFn, KV> { + @TimerId("t") + private final TimerSpec timer = TimerSpecs.timer(TimeDomain.EVENT_TIME); + + @ProcessElement + public void process( + @Element KV el, + @Timestamp Instant ts, + @TimerId("t") Timer t, + OutputReceiver> out) { + out.output(KV.of("chained", el.getValue())); + if (el.getValue() == 0) { + t.set(ts.plus(Duration.millis(250))); + } + } + + @OnTimer("t") + public void onTimer(@Timestamp Instant ts, OutputReceiver> out) { + out.outputWithTimestamp(KV.of("chained", 1), ts); + } + } +} From cae975bc6076fa474a335c8fd3e68a423bfecc3a Mon Sep 17 00:00:00 2001 From: Ganesh Sivakumar Date: Thu, 22 Oct 2026 20:58:58 +0530 Subject: [PATCH 087/108] Update FlareDB runner doc (#40576) --- .../en/documentation/runners/flaredb.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/website/www/site/content/en/documentation/runners/flaredb.md b/website/www/site/content/en/documentation/runners/flaredb.md index dd080df5edac..2e4f1c1f7811 100644 --- a/website/www/site/content/en/documentation/runners/flaredb.md +++ b/website/www/site/content/en/documentation/runners/flaredb.md @@ -19,7 +19,7 @@ limitations under the License. # Overview -[FlareDB](https://github.com/flare-db/flare-db) is an Apache Beam native streaming database for running Beam pipelines. It's built in Rust, a modern systems programming language, and it uses a streams-tables architecture inspired by the ideas described in the *Streaming Systems* book (Chp 6). +[FlareDB](https://github.com/flare-db/flare-db) is an Apache Beam native streaming database for running Beam pipelines. It's built in Rust, a modern systems programming language, and it uses a streams-tables architecture inspired by the ideas described in the [*Streaming Systems*](http://www.streamingbook.net/) book (Chp 6).
    @@ -42,7 +42,7 @@ Install FlareDB CLI to spawn up and manage FlareDB instance and run Beam pipelin If you are on **Linux or macOS**, please run the following command to install the CLI: ```bash -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/flare-db/flare-db/releases/download/flare-cli-v0.3.2/flare-cli-installer.sh | sh +curl -sSL https://install.flare-db.com | bash ``` If you are on **Windows** use WSL. @@ -158,8 +158,20 @@ python3 wordcount.py Check out the full Python [wordcount example](https://github.com/flare-db/flare-db/blob/main/example/python/wordcount.py) pipeline. {{< /paragraph >}} +## 5. Check pipeline Logs -## 5. Stop FlareDB instance +Each submitted job is assigned a unique `JOB_ID`, which is printed when you submit the job through the runner SDK. Use it to stream logs for a specific job. + +If you omit the ID, the CLI streams logs for the running job, or for the most recently executed job if nothing is running. + +```bash +flare logs # Stream logs for the running (or most recent) job +flare logs # Stream logs for a specific job +``` + +> **Note:** Always use the `logging` module in Python and a logger like SLF4J in Java instead of `print` or `System.out.println`, so your messages carry log levels and show up cleanly in `flare logs`. + +## 6. Stop FlareDB instance After executing pipelines, run this command to stop FlareDB instance From 248548d64f0e3e9b4519d9db1171715f96e11181 Mon Sep 17 00:00:00 2001 From: reuvenlax Date: Thu, 22 Oct 2026 08:32:26 -0700 Subject: [PATCH 088/108] Merge pull request #40303 from reuvenlax/merge_validates_runner_phase2 Merge ValidatesRunner tests into small number of Dataflow jobs, each job hosting multiple tests. --- ...tCommit_Java_ValidatesRunner_Dataflow.json | 2 +- ...stCommit_Java_ValidatesRunner_Dataflow.yml | 2 +- ..._ValidatesRunner_Dataflow_JavaVersions.yml | 2 +- ...ommit_Java_ValidatesRunner_Dataflow_V2.yml | 2 +- .../google-cloud-dataflow-java/build.gradle | 48 +- .../beam/runners/dataflow/DataflowClient.java | 11 + .../DataflowTestBatchCoordinator.java | 1414 ++++++++++++++++ .../runners/dataflow/TestDataflowRunner.java | 282 +++- .../runners/dataflow/DataflowRunnerTest.java | 172 +- .../DataflowRunnerValidatesRunnerTest.java | 218 +++ .../DataflowTestBatchCoordinatorTest.java | 1477 +++++++++++++++++ .../DataflowTestBatchFallbackTest.java | 132 ++ .../dataflow/TestDataflowRunnerTest.java | 111 ++ .../sdk/testing/BeamParallelJunit4Runner.java | 136 +- .../org/apache/beam/sdk/testing/PAssert.java | 8 +- .../apache/beam/sdk/testing/TestPipeline.java | 338 +++- .../testing/BeamParallelJunit4RunnerTest.java | 154 ++ .../apache/beam/sdk/testing/PAssertTest.java | 39 + .../beam/sdk/testing/TestPipelineTest.java | 202 +++ .../sdk/testing/TestPipelineExtension.java | 3 + 20 files changed, 4539 insertions(+), 214 deletions(-) create mode 100644 runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowTestBatchCoordinator.java create mode 100644 runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerValidatesRunnerTest.java create mode 100644 runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowTestBatchCoordinatorTest.java create mode 100644 runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowTestBatchFallbackTest.java diff --git a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Dataflow.json b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Dataflow.json index 97b7503f5445..f7bf5e8a08a0 100644 --- a/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Dataflow.json +++ b/.github/trigger_files/beam_PostCommit_Java_ValidatesRunner_Dataflow.json @@ -1,5 +1,5 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run!", - "modification": 7, + "modification": 14, } diff --git a/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow.yml b/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow.yml index 45231941ce02..76b842f1e11b 100644 --- a/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow.yml +++ b/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow.yml @@ -74,7 +74,7 @@ jobs: uses: ./.github/actions/gradle-command-self-hosted-action with: gradle-command: :runners:google-cloud-dataflow-java:validatesRunner - max-workers: 12 + max-workers: 24 - name: Archive JUnit Test Results uses: actions/upload-artifact@v7 if: ${{ !success() }} diff --git a/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow_JavaVersions.yml b/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow_JavaVersions.yml index 92639eeb10c2..995764cef7d2 100644 --- a/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow_JavaVersions.yml +++ b/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow_JavaVersions.yml @@ -81,7 +81,7 @@ jobs: arguments: | -PtestJavaVersion=${{ matrix.java_version }} \ -Pjava${{ matrix.java_version }}Home=$JAVA_HOME_${{ matrix.java_version }}_X64 \ - max-workers: 12 + max-workers: 24 - name: Archive JUnit Test Results uses: actions/upload-artifact@v7 if: ${{ !success() }} diff --git a/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow_V2.yml b/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow_V2.yml index 443f0c920d23..55922dfb3ab6 100644 --- a/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow_V2.yml +++ b/.github/workflows/beam_PostCommit_Java_ValidatesRunner_Dataflow_V2.yml @@ -74,7 +74,7 @@ jobs: uses: ./.github/actions/gradle-command-self-hosted-action with: gradle-command: :runners:google-cloud-dataflow-java:validatesRunnerV2 - max-workers: 12 + max-workers: 24 - name: Archive JUnit Test Results uses: actions/upload-artifact@v7 if: ${{ !success() }} diff --git a/runners/google-cloud-dataflow-java/build.gradle b/runners/google-cloud-dataflow-java/build.gradle index c11a04a71979..d0aa2dfc0b10 100644 --- a/runners/google-cloud-dataflow-java/build.gradle +++ b/runners/google-cloud-dataflow-java/build.gradle @@ -132,6 +132,11 @@ dependencies { implementation library.java.joda_time implementation library.java.slf4j_api implementation library.java.vendored_grpc_1_69_0 + // TestDataflowRunner inspects TestPipeline, a JUnit TestRule, which needs JUnit at compile + // time (as in sdks/java/core and runners/direct-java). Users of TestDataflowRunner already have + // JUnit on their classpath. + provided library.java.junit + permitUnusedDeclared library.java.junit testImplementation library.java.guava_testlib testImplementation library.java.junit testImplementation project(path: ":sdks:java:io:google-cloud-platform", configuration: "testRuntimeMigration") @@ -241,13 +246,38 @@ def commonRunnerV2ExcludeCategories = [ 'org.apache.beam.sdk.testing.UsesBoundedTrieMetrics', // Dataflow QM as of now does not support returning back BoundedTrie in metric result. ] - -def validatesRunnerParallelThreads = project.findProperty('parallelThreads') ?: '6' +// ValidatesRunner parallelism. Each test worker JVM runs ValidatesRunner tests on several threads; +// where TestDataflowRunner can merge tests into one Dataflow job (-PtestBatching, batch mode only +// for now) many more threads are useful since most of them just contribute a member to the next +// merged job rather than a job of their own. Tests that cannot be merged, including every test +// of a streaming task, are still one job each, so their thread count is kept at the pre-batching +// level to bound the number of concurrent jobs per worker JVM. +def validatesRunnerTestBatching = project.findProperty('testBatching') ?: 'true' +def validatesRunnerTestBatchWindowMs = project.findProperty('testBatchWindowMs') ?: '3000' +def validatesRunnerMaxConcurrentStandaloneJobs = project.findProperty('maxConcurrentStandaloneJobs') ?: '12' + +def configureValidatesRunnerParallelism = { Test task, List pipelineOptions -> + def streaming = pipelineOptions.any { it == '--streaming' || it == '--streaming=true' } + def batching = validatesRunnerTestBatching == 'true' && !streaming + def parallelThreads = project.findProperty('parallelThreads') ?: (batching ? '30' : '6') + def batchMaxSize = project.findProperty('testBatchMaxSize') ?: parallelThreads + + task.systemProperty "beam.validatesRunner.parallelThreads", parallelThreads + task.systemProperty "beam.dataflow.testBatching", batching.toString() + task.systemProperty "beam.dataflow.testBatchMaxSize", batchMaxSize + task.systemProperty "beam.dataflow.testBatchWindowMs", validatesRunnerTestBatchWindowMs + task.systemProperty "beam.dataflow.maxConcurrentStandaloneJobs", validatesRunnerMaxConcurrentStandaloneJobs + // Merged jobs tell member tests apart by their unique TestPipeline root names. + task.systemProperty "beamTestPipelineUniqueRootNames", batching.toString() +} def isValidatesRunnerTestClass = { FileTreeElement element -> if (element.isDirectory()) { return true } + // Classes that reference the ValidatesRunner annotation but are not ValidatesRunner tests: + // BeamParallelJunit4RunnerTest's nested fixtures carry @Category(ValidatesRunner.class) to test + // the runner itself and must not be executed against a real service. if (!element.name.endsWith('.class') || element.name.startsWith('ValidateRunnerXlangTest') || element.name.startsWith('BeamParallelJunit4RunnerTest')) { @@ -278,7 +308,7 @@ def createLegacyWorkerValidatesRunnerTest = { Map args -> dependsOn ":runners:google-cloud-dataflow-java:worker:shadowJar" systemProperty "beamTestPipelineOptions", JsonOutput.toJson(pipelineOptions) - systemProperty "beam.validatesRunner.parallelThreads", validatesRunnerParallelThreads + configureValidatesRunnerParallelism(it, pipelineOptions) // Increase test parallelism up to the number of Gradle workers. By default this is equal // to the number of CPU cores, but can be increased by setting --max-workers=N. @@ -314,12 +344,14 @@ def createRunnerV2ValidatesRunnerTest = { Map args -> return tasks.create(name: name, type: Test, group: "Verification") { dependsOn buildAndPushDockerJavaContainer systemProperty "beamTestPipelineOptions", JsonOutput.toJson(pipelineOptions) - systemProperty "beam.validatesRunner.parallelThreads", validatesRunnerParallelThreads + configureValidatesRunnerParallelism(it, pipelineOptions) // Increase test parallelism up to the number of Gradle workers. By default this is equal // to the number of CPU cores, but can be increased by setting --max-workers=N. maxParallelForks Integer.MAX_VALUE - maxHeapSize = '768m' + // Same as the legacy task: in batch mode a fork hosts up to 30 concurrent test threads plus + // the translation of each merged job, not just a handful of standalone pipelines. + maxHeapSize = '2g' classpath = configurations.validatesRunner testClassesDirs = files(project(":sdks:java:core").sourceSets.test.output.classesDirs) + files(project(project.path).sourceSets.test.output.classesDirs) @@ -646,8 +678,8 @@ task validatesRunnerV2 { 'org.apache.beam.sdk.transforms.ReshuffleTest.testReshuffleWithTimestampsStreaming', // TODO(https://github.com/apache/beam/issues/37371): Identify the cause of assertion failure for these tests - 'org.apache.beam.runners.dataflow.DataflowRunnerTest.testBatchGroupIntoBatchesOverrideBytes', - 'org.apache.beam.runners.dataflow.DataflowRunnerTest.testBatchGroupIntoBatchesOverrideCount', + 'org.apache.beam.runners.dataflow.DataflowRunnerValidatesRunnerTest.testBatchGroupIntoBatchesOverrideBytes', + 'org.apache.beam.runners.dataflow.DataflowRunnerValidatesRunnerTest.testBatchGroupIntoBatchesOverrideCount', // These tests use static state and don't work with remote execution. 'org.apache.beam.sdk.transforms.ParDoLifecycleTest.testTeardownCalledAfterExceptionInFinishBundle', @@ -689,7 +721,7 @@ task validatesRunnerV2Streaming { // TestStream with processing time control not supported 'org.apache.beam.sdk.transforms.GroupByKeyTest$BasicTests.testAfterProcessingTimeContinuationTriggerUsingState', - 'org.apache.beam.sdk.transforms.GroupByKeyTest.testCombiningAccumulatingProcessingTime', + 'org.apache.beam.sdk.transforms.GroupByKeyTest$BasicTests.testCombiningAccumulatingProcessingTime', // These tests use static state and don't work with remote execution. 'org.apache.beam.sdk.transforms.ParDoLifecycleTest.testTeardownCalledAfterExceptionInFinishBundle', diff --git a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowClient.java b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowClient.java index d643c9e40cde..933ef5aad45c 100644 --- a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowClient.java +++ b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowClient.java @@ -94,6 +94,14 @@ public Job updateJob(@Nonnull String jobId, @Nonnull Job content) throws IOExcep /** Gets the Dataflow {@link Job} with the given {@code jobId}. */ public Job getJob(@Nonnull String jobId) throws IOException { + return getJob(jobId, null); + } + + /** + * Gets the Dataflow {@link Job} with the given {@code jobId} using the given {@code view} (one of + * the {@code JOB_VIEW_*} values), or the service default view if {@code null}. + */ + public Job getJob(@Nonnull String jobId, @Nullable String view) throws IOException { checkNotNull(jobId, "jobId"); Jobs.Get jobsGet = dataflow @@ -101,6 +109,9 @@ public Job getJob(@Nonnull String jobId) throws IOException { .locations() .jobs() .get(options.getProject(), options.getRegion(), jobId); + if (view != null) { + jobsGet.setView(view); + } return jobsGet.execute(); } diff --git a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowTestBatchCoordinator.java b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowTestBatchCoordinator.java new file mode 100644 index 000000000000..874f1cd202c6 --- /dev/null +++ b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/DataflowTestBatchCoordinator.java @@ -0,0 +1,1414 @@ +/* + * 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 org.apache.beam.runners.dataflow; + +import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument; +import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkState; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.api.services.dataflow.model.ComponentSource; +import com.google.api.services.dataflow.model.ComponentTransform; +import com.google.api.services.dataflow.model.ExecutionStageState; +import com.google.api.services.dataflow.model.ExecutionStageSummary; +import com.google.api.services.dataflow.model.Job; +import com.google.api.services.dataflow.model.JobMetrics; +import com.google.api.services.dataflow.model.StageSource; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.beam.model.pipeline.v1.RunnerApi; +import org.apache.beam.runners.dataflow.TestDataflowRunner.ErrorMonitorMessagesHandler; +import org.apache.beam.runners.dataflow.TestDataflowRunner.PAssertCounts; +import org.apache.beam.runners.dataflow.util.MonitoringUtil; +import org.apache.beam.sdk.CompositePipeline; +import org.apache.beam.sdk.Pipeline; +import org.apache.beam.sdk.Pipeline.PipelineVisitor; +import org.apache.beam.sdk.PipelineResult.State; +import org.apache.beam.sdk.metrics.MetricFiltering; +import org.apache.beam.sdk.metrics.MetricKey; +import org.apache.beam.sdk.metrics.MetricNameFilter; +import org.apache.beam.sdk.metrics.MetricQueryResults; +import org.apache.beam.sdk.metrics.MetricResult; +import org.apache.beam.sdk.metrics.MetricResults; +import org.apache.beam.sdk.metrics.MetricsFilter; +import org.apache.beam.sdk.options.ApplicationNameOptions; +import org.apache.beam.sdk.options.PipelineOptions; +import org.apache.beam.sdk.options.PipelineOptionsFactory; +import org.apache.beam.sdk.runners.AppliedPTransform; +import org.apache.beam.sdk.runners.TransformHierarchy; +import org.apache.beam.sdk.testing.BeamParallelJunit4Runner; +import org.apache.beam.sdk.testing.PAssert; +import org.apache.beam.sdk.testing.SerializableMatchers; +import org.apache.beam.sdk.testing.TestPipeline; +import org.apache.beam.sdk.testing.TestPipelineOptions; +import org.apache.beam.sdk.util.HistogramData; +import org.apache.beam.sdk.util.common.ReflectHelpers; +import org.apache.beam.sdk.values.PCollection; +import org.apache.beam.sdk.values.PCollection.IsBounded; +import org.apache.beam.sdk.values.PValue; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.BiMap; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.hash.Hashing; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.joda.time.DateTimeZone; +import org.joda.time.Duration; +import org.joda.time.format.DateTimeFormat; +import org.joda.time.format.DateTimeFormatter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Coordinates merging concurrent batch {@code TestPipeline} executions within a JVM into a single + * Dataflow job per batch, with scoped {@link PAssert} and {@link MetricResults} verification. + * + *

    Batching spans runner instances: every test builds its own {@link TestDataflowRunner}, and the + * pipelines merged into one job come from different tests, threads and classes. Runners therefore + * share the single process-wide coordinator returned by {@link #shared()}; tests may construct + * private instances instead. + * + *

    Scoping: members of a merged job are told apart by their {@linkplain TestPipeline#getRootName + * unique root name}, which {@link TestPipeline} assigns (when {@code + * -DbeamTestPipelineUniqueRootNames=true}) before the test applies any transform, so that every + * step, PCollection and metric of a member is named {@code /...}. The coordinator never + * renames or otherwise mutates a member pipeline; a pipeline without a root name simply runs as its + * own job. + * + *

    Threading model: a test thread only enqueues its pipeline and then blocks on a per-pipeline + * future. Per {@linkplain #compatibilityKey options-compatibility group}, a daemon scheduler thread + * drains the queue and groups pipelines into batches; each closed batch is handed to a daemon + * executor that submits the merged job, waits for it and reaches a verdict for every member. A + * member is credited from the merged job when its scoped PAsserts are verified and — if the + * job did not end {@code DONE} — every stage that may belong to it is {@code DONE} (see + * {@link StageAttribution}). Any other member gets {@link TestPipeline.StandaloneRerunRequested} + * thrown out of its {@code run()}, and its test is executed again from scratch as a standalone job + * by {@link BeamParallelJunit4Runner}. (A pipeline for which no merged job was built at all, + * because no partner arrived in time, simply runs standalone on its own test thread.) No test + * thread ever performs work on behalf of another test, so a test timeout or interrupt affects only + * that test. + * + *

    No-hang guarantee: a member's future is always completed, whatever happens on the scheduler or + * batch-runner threads (see {@link BatchCollector} and {@link #executeBatch}); the merged job is + * waited on for at most the shortest member test timeout ({@link #mergedJobTimeout}); and as a last + * resort each test thread's wait is bounded ({@link PendingItem#awaitResult()}), so an unforeseen + * failure surfaces as a diagnosable test failure rather than a fork that never finishes. + */ +@SuppressWarnings({ + "nullness" // TODO(https://github.com/apache/beam/issues/20497) +}) +class DataflowTestBatchCoordinator { + private static final Logger LOG = LoggerFactory.getLogger(DataflowTestBatchCoordinator.class); + + static final String TEST_BATCHING_PROPERTY = "beam.dataflow.testBatching"; + static final String TEST_BATCH_MAX_SIZE_PROPERTY = "beam.dataflow.testBatchMaxSize"; + static final String TEST_BATCH_WINDOW_MS_PROPERTY = "beam.dataflow.testBatchWindowMs"; + + private static final long DEFAULT_BATCH_WINDOW_MS = 3000L; + private static final int DEFAULT_BATCH_MAX_SIZE = 30; + + /** + * Run-wide batching configuration. Batching spans many pipelines, so it is configured once per + * test JVM through system properties (see the ValidatesRunner tasks in {@code build.gradle}) + * rather than through any individual pipeline's {@link PipelineOptions}. + */ + @VisibleForTesting + static final class BatchingConfig { + final boolean enabled; + final int maxBatchSize; + final long windowMs; + + BatchingConfig(boolean enabled, int maxBatchSize, long windowMs) { + this.enabled = enabled; + this.maxBatchSize = maxBatchSize; + this.windowMs = windowMs; + } + + static BatchingConfig fromSystemProperties() { + // Unless sized explicitly, a batch may hold one pipeline per concurrently running test. + int defaultSize = + Integer.getInteger( + BeamParallelJunit4Runner.VALIDATES_RUNNER_THREADS_PROPERTY, DEFAULT_BATCH_MAX_SIZE); + return new BatchingConfig( + Boolean.getBoolean(TEST_BATCHING_PROPERTY), + Integer.getInteger(TEST_BATCH_MAX_SIZE_PROPERTY, Math.max(1, defaultSize)), + Long.getLong(TEST_BATCH_WINDOW_MS_PROPERTY, DEFAULT_BATCH_WINDOW_MS)); + } + } + + private static final AtomicInteger INSTANCE_COUNTER = new AtomicInteger(1); + private static volatile @Nullable DataflowTestBatchCoordinator shared; + + /** The process-wide coordinator shared by all {@link TestDataflowRunner} instances. */ + static DataflowTestBatchCoordinator shared() { + DataflowTestBatchCoordinator result = shared; + if (result == null) { + synchronized (DataflowTestBatchCoordinator.class) { + result = shared; + if (result == null) { + result = new DataflowTestBatchCoordinator(BatchingConfig.fromSystemProperties()); + shared = result; + } + } + } + return result; + } + + private final BatchingConfig config; + private final String name; + private final AtomicInteger batchCounter = new AtomicInteger(1); + private final AtomicInteger collectorCounter = new AtomicInteger(1); + private final ConcurrentHashMap collectors = new ConcurrentHashMap<>(); + private volatile boolean closed; + + /** Runs merged jobs so that schedulers keep collecting while jobs are in flight. */ + private final ExecutorService batchRunners; + + DataflowTestBatchCoordinator(BatchingConfig config) { + this.config = config; + this.name = "beam-test-batch-" + INSTANCE_COUNTER.getAndIncrement(); + this.batchRunners = Executors.newCachedThreadPool(daemonThreadFactory(name + "-runner-")); + } + + /** + * Stops the scheduler threads and the batch-runner pool. Members still waiting for a verdict fail + * promptly and later submissions are rejected. Tests only. + */ + @VisibleForTesting + void shutdown() { + closed = true; + collectors.values().forEach(BatchCollector::stop); + batchRunners.shutdownNow(); + } + + // ObjectMapper used to serialize PipelineOptions to determine batch keys. + private static final ObjectMapper MAPPER = + new ObjectMapper() + .registerModules(ObjectMapper.findModules(ReflectHelpers.findClassLoader())); + + /** + * Options that are expected to differ between tests which may nevertheless share a merged job. + * + *

    The merged job is submitted with the first member's options only, so every other + * option that has been set on a member must be identical across the batch. Keep this list to + * per-test identity and local harness knobs; never add anything that influences how the service + * runs the job. + */ + @VisibleForTesting + static final ImmutableSet PER_TEST_OPTIONS = + ImmutableSet.of( + // Identity of the individual test / job. + "jobName", + "appName", + "optionsId", + // Derived per job from tempRoot + jobName by TestDataflowRunner.fromOptions. + "tempLocation", + "gcpTempLocation", + // Local harness behaviour; does not affect the submitted job. + "testTimeoutSeconds"); + + /** + * Returns a key identifying the group of pipelines that may share a merged job with one built + * from {@code options}, or {@code null} if the options cannot be serialized, in which case the + * pipeline must run standalone. + * + *

    The key is a digest of every option that has been explicitly set on the options object + * through any {@link PipelineOptions} interface it has been viewed as, minus {@link + * #PER_TEST_OPTIONS}. Any option added to any interface (worker pool, debug, GCP, SDK harness, + * user-defined, ...) automatically splits batches when it differs instead of being silently + * dropped for all but the first member. + * + *

    Defaults that were merely bound by a getter are left out. The serialized form contains them + * too, but which defaults are bound depends on which getters the test's transforms happened to + * call during construction (for example {@code View.asList()} and {@code Reshuffle} read {@code + * updateCompatibilityVersion}), and two tests must not end up in different batches because one of + * them read an option both would have received the same default for. {@link + * org.apache.beam.sdk.testing.TestPipeline} hands the runner a JSON copy of the test's options; + * it serializes that copy with {@link PipelineOptionsFactory#SERIALIZE_DEFAULTS_ATTRIBUTE} so + * that the distinction survives the round trip. + */ + @VisibleForTesting + static @Nullable String compatibilityKey(PipelineOptions options) { + String canonical = canonicalOptionsJson(options); + return canonical == null ? null : digest(canonical); + } + + private static String digest(String canonicalOptions) { + return Hashing.sha256().hashString(canonicalOptions, StandardCharsets.UTF_8).toString(); + } + + @VisibleForTesting + static @Nullable String canonicalOptionsJson(PipelineOptions options) { + JsonNode serialized; + Set explicitlySet; + try { + serialized = MAPPER.valueToTree(options).get("options"); + explicitlySet = PipelineOptionsFactory.explicitlySetProperties(options); + } catch (RuntimeException e) { + LOG.warn( + "Pipeline options for {} cannot be serialized; the pipeline will run standalone instead" + + " of in a merged Dataflow test job.", + options.as(ApplicationNameOptions.class).getAppName(), + e); + return null; + } + if (serialized == null || !serialized.isObject()) { + return null; + } + ObjectNode filtered = MAPPER.createObjectNode(); + for (Map.Entry entry : sortedFields(serialized).entrySet()) { + if (explicitlySet.contains(entry.getKey()) && !PER_TEST_OPTIONS.contains(entry.getKey())) { + filtered.set(entry.getKey(), canonicalize(entry.getValue())); + } + } + return filtered.toString(); + } + + /** Recursively orders object fields so that structurally equal values serialize identically. */ + private static JsonNode canonicalize(JsonNode node) { + if (node.isObject()) { + ObjectNode out = MAPPER.createObjectNode(); + for (Map.Entry entry : sortedFields(node).entrySet()) { + out.set(entry.getKey(), canonicalize(entry.getValue())); + } + return out; + } + if (node.isArray()) { + ArrayNode out = MAPPER.createArrayNode(); + for (JsonNode element : node) { + out.add(canonicalize(element)); + } + return out; + } + return node; + } + + private static TreeMap sortedFields(JsonNode node) { + TreeMap sorted = new TreeMap<>(); + node.fields().forEachRemaining(entry -> sorted.put(entry.getKey(), entry.getValue())); + return sorted; + } + + boolean isEligibleForBatching(Pipeline pipeline, TestDataflowPipelineOptions options) { + if (!config.enabled || config.maxBatchSize <= 1) { + return false; + } + if (!options.isBlockOnRun()) { + return false; + } + if (options.isStreaming()) { + // Streaming batches need their own verdict strategy (see executeBatchInternal); until that + // exists streaming tests always run as individual jobs. Say so once so the flag is not + // silently ignored by the streaming ValidatesRunner tasks. + if (STREAMING_UNSUPPORTED_LOGGED.compareAndSet(false, true)) { + LOG.info( + "Dataflow test batching does not yet support streaming jobs; streaming tests run as" + + " individual jobs."); + } + return false; + } + if (!(pipeline instanceof TestPipeline)) { + return false; + } + TestPipeline testPipeline = (TestPipeline) pipeline; + if (testPipeline.isStandaloneExecutionRequired()) { + return false; + } + if (testPipeline.getRootName().isEmpty()) { + // Members of a merged job are told apart purely by their root name, so an unnamed pipeline + // can never be merged. + if (UNNAMED_PIPELINE_WARNED.compareAndSet(false, true)) { + LOG.warn( + "Dataflow test batching is enabled but test pipelines have no unique root name, so" + + " every test runs as its own job. Set -D{}=true to enable merging.", + TestPipeline.PROPERTY_BEAM_TEST_PIPELINE_UNIQUE_ROOT_NAMES); + } + return false; + } + if (!isDefaultMatcher(options.getOnCreateMatcher()) + || !isDefaultMatcher(options.getOnSuccessMatcher())) { + return false; + } + EligibilityVisitor visitor = new EligibilityVisitor(); + pipeline.traverseTopologically(visitor); + return visitor.hasPrimitiveTransform && !visitor.hasUnboundedPCollection; + } + + private static final AtomicBoolean UNNAMED_PIPELINE_WARNED = new AtomicBoolean(false); + private static final AtomicBoolean STREAMING_UNSUPPORTED_LOGGED = new AtomicBoolean(false); + + private static boolean isDefaultMatcher(@Nullable Object matcher) { + return matcher == null + || matcher instanceof TestPipelineOptions.AlwaysPassMatcher + || SerializableMatchers.anything().equals(matcher); + } + + /** + * Submits {@code pipeline} for batched execution and blocks until a verdict has been reached. + * Callers must have checked {@link #isEligibleForBatching} first; in particular the pipeline must + * have a unique root name. + */ + DataflowPipelineJob runInBatch( + TestPipeline pipeline, + TestDataflowPipelineOptions options, + TestDataflowRunner runner, + DataflowRunner delegateRunner) { + final String canonicalOptions = canonicalOptionsJson(options); + if (canonicalOptions == null) { + return runner.runStandalone(pipeline, delegateRunner); + } + String key = digest(canonicalOptions); + PendingItem item = + new PendingItem(pipeline, options, runner, delegateRunner, verdictTimeoutMillis(options)); + while (true) { + if (closed) { + throw new IllegalStateException( + "Dataflow test batch coordinator " + name + " has been shut down"); + } + BatchCollector collector = + collectors.computeIfAbsent( + key, + k -> { + BatchCollector created = + new BatchCollector(k, name + "-group-" + collectorCounter.getAndIncrement()); + LOG.info( + "Created Dataflow test batch group {} ({}) for options compatible with {}", + created.name, + k, + options.getAppName()); + LOG.debug("Batch group {} options: {}", created.name, canonicalOptions); + created.start(); + return created; + }); + if (collector.enqueue(item)) { + // A collector created after shutdown() iterated the registry would never be stopped and + // the item would sit until its verdict timeout. Stopping is idempotent, and close() fails + // whatever is queued, so the item gets a prompt verdict either way. + if (closed) { + collector.stop(); + } + break; + } + // Collectors never close on their own: enqueue only fails if this coordinator is shutting + // down (the re-check at the top of the loop then throws) or the scheduler thread died from an + // Error, in which case a fresh collector takes over for later tests. close() unregisters the + // collector too, but sets `closed` first; removing here as well covers that window so the + // loop cannot spin on the same dead instance. + collectors.remove(key, collector); + } + return item.awaitResult(); + } + + /** + * How long a test thread waits for the batch machinery's verdict before failing its test, or a + * negative value for no limit. Every path through the machinery completes the member's future, so + * this is a safety net that turns an unknown bug into a diagnosable failure rather than a hung + * fork. It is deliberately generous: the merged job itself is only waited on for the shortest + * member test timeout (see {@link #mergedJobTimeout}); the rest is slack for queueing (at most + * two batch windows), submission, metrics retrieval and cancellation. + */ + private long verdictTimeoutMillis(TestDataflowPipelineOptions options) { + Long testTimeoutSeconds = options.getTestTimeoutSeconds(); + if (testTimeoutSeconds == null || testTimeoutSeconds <= 0) { + return -1; + } + return 2 * config.windowMs + 2 * TimeUnit.SECONDS.toMillis(testTimeoutSeconds); + } + + /** + * The bound on waiting for a merged job: the shortest {@code testTimeoutSeconds} among its + * members, so that no member waits longer for the merged job than it would have for its own + * standalone job. Negative (unbounded) only if no member has a timeout configured. + */ + @VisibleForTesting + static Duration mergedJobTimeout(List items) { + long minSeconds = Long.MAX_VALUE; + for (PendingItem item : items) { + Long timeout = item.options.getTestTimeoutSeconds(); + if (timeout != null && timeout > 0) { + minSeconds = Math.min(minSeconds, timeout); + } + } + return Duration.standardSeconds(minSeconds == Long.MAX_VALUE ? -1 : minSeconds); + } + + private static class EligibilityVisitor extends PipelineVisitor.Defaults { + private boolean hasPrimitiveTransform = false; + private boolean hasUnboundedPCollection = false; + + @Override + public void visitPrimitiveTransform(TransformHierarchy.Node node) { + hasPrimitiveTransform = true; + } + + @Override + public void visitValue(PValue value, TransformHierarchy.Node producer) { + if (value instanceof PCollection + && ((PCollection) value).isBounded() == IsBounded.UNBOUNDED) { + hasUnboundedPCollection = true; + } + } + } + + /** + * The verdict the batch machinery reaches for one member pipeline: credited with a {@link #job} + * from the merged run, declined before any merged job was built from it, or in need of a fresh + * standalone execution after a merged attempt that yielded no verdict for it. + */ + @VisibleForTesting + static final class BatchOutcome { + enum Kind { + /** Credited from the merged job; {@link #job} is the member's scoped view of it. */ + PASSED, + /** + * Batching was declined before any merged job was built from the pipeline (for example, no + * partner arrived within the window). The pipeline is untouched, so the member's own test + * thread runs it standalone exactly as if it had been ineligible. + */ + DECLINED, + /** + * A merged job was built from the pipeline but did not yield a verdict for this member. The + * pipeline object is not run again (building the merged job rewrote it with another runner); + * the member's test is re-executed from scratch instead, see {@link + * TestPipeline.StandaloneRerunRequested}. + */ + RERUN_REQUIRED + } + + final Kind kind; + final @Nullable DataflowPipelineJob job; + + /** Why no verdict could be reached; {@link Kind#RERUN_REQUIRED} only. */ + final @Nullable String reason; + + private BatchOutcome(Kind kind, @Nullable DataflowPipelineJob job, @Nullable String reason) { + this.kind = kind; + this.job = job; + this.reason = reason; + } + + static BatchOutcome passed(DataflowPipelineJob job) { + return new BatchOutcome(Kind.PASSED, job, null); + } + + static BatchOutcome declined() { + return new BatchOutcome(Kind.DECLINED, null, null); + } + + static BatchOutcome rerunRequired(String reason) { + return new BatchOutcome(Kind.RERUN_REQUIRED, null, reason); + } + } + + @VisibleForTesting + static final class PendingItem { + final TestPipeline pipeline; + final TestDataflowPipelineOptions options; + final TestDataflowRunner runner; + final DataflowRunner delegateRunner; + final int expectedAssertions; + final long verdictTimeoutMillis; + final CompletableFuture resultFuture = new CompletableFuture<>(); + + /** + * The pipeline's unique root name (see {@link TestPipeline#getRootName()}). Every transform and + * PCollection of this member is named {@code /...}, which is how the member's steps, + * stages and metrics are told apart from the other members' in the merged job. + */ + final String scope; + + /** Assigned by the batch runner once the merged job is submitted; {@code null} before that. */ + volatile @Nullable String mergedJobId; + + PendingItem( + TestPipeline pipeline, + TestDataflowPipelineOptions options, + TestDataflowRunner runner, + DataflowRunner delegateRunner, + long verdictTimeoutMillis) { + this.pipeline = pipeline; + this.options = options; + this.runner = runner; + this.delegateRunner = delegateRunner; + this.verdictTimeoutMillis = verdictTimeoutMillis; + this.expectedAssertions = PAssert.countAsserts(pipeline); + this.scope = pipeline.getRootName(); + checkArgument(!scope.isEmpty(), "Batched test pipelines must have a root name"); + } + + /** + * Blocks the calling (test) thread until the batch machinery has reached a verdict, then + * returns the merged job, runs this pipeline standalone on the calling thread (if batching was + * declined before a merged job was built from it), or throws {@link + * TestPipeline.StandaloneRerunRequested} so that the test runner executes the test again from + * scratch as a job of its own. + * + *

    The wait is bounded by {@link #verdictTimeoutMillis}. Exceeding it means the machinery + * failed to deliver a verdict at all, which is a harness bug; the test fails with a description + * of where the member got stuck rather than silently hanging its fork. + */ + DataflowPipelineJob awaitResult() { + BatchOutcome outcome; + try { + outcome = + verdictTimeoutMillis < 0 + ? resultFuture.get() + : resultFuture.get(verdictTimeoutMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + "Interrupted while waiting for the merged Dataflow test batch containing " + + options.getAppName(), + e); + } catch (TimeoutException e) { + String jobId = mergedJobId; + throw new IllegalStateException( + String.format( + "No verdict for test %s from the Dataflow test batch machinery after %d ms" + + " (batch scope: %s; merged job: %s). This is a bug in the test batching" + + " machinery, not in the test.", + options.getAppName(), + verdictTimeoutMillis, + scope, + jobId == null ? "never submitted" : jobId), + e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } else if (cause instanceof Error) { + throw (Error) cause; + } + throw new RuntimeException(cause != null ? cause : e); + } + switch (outcome.kind) { + case PASSED: + return outcome.job; + case DECLINED: + return runner.runStandalone(pipeline, delegateRunner); + case RERUN_REQUIRED: + default: + String jobId = mergedJobId; + throw new TestPipeline.StandaloneRerunRequested( + String.format( + "Test %s (scope %s) was merged into Dataflow job %s, which yielded no verdict" + + " for it: %s. The test has to be executed again as a job of its own;" + + " BeamParallelJunit4Runner does so automatically.", + options.getAppName(), + scope, + jobId == null ? "(never submitted)" : jobId, + outcome.reason)); + } + } + } + + private static ThreadFactory daemonThreadFactory(String namePrefix) { + AtomicInteger counter = new AtomicInteger(1); + return runnable -> { + Thread thread = new Thread(runnable, namePrefix + counter.getAndIncrement()); + thread.setDaemon(true); + return thread; + }; + } + + /** + * Groups pipelines that share an options-compatibility key into batches. + * + *

    Test threads only {@link #enqueue} and then wait on their {@link PendingItem#resultFuture}. + * A single daemon scheduler thread drains the queue: it takes the first pipeline, waits up to the + * batch window for more (closing early once the batch is full), and hands the closed batch to the + * coordinator's {@link #batchRunners} so that it can immediately start collecting the next batch + * while the merged job is in flight. A batch of one is not worth a merged job and is sent + * straight back to its test thread to run standalone. + * + *

    Lifecycle: however the scheduler thread ends (interrupt, or an unexpected error), the + * collector {@link #close}s: it marks itself closed, unregisters from the coordinator so that the + * next submission gets a fresh collector, and fails everything still queued. {@link #enqueue} + * cooperates with that so no item can be added after the final drain and never be seen again. + */ + private final class BatchCollector { + private final String key; + private final String name; + private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(); + private @Nullable Thread scheduler; + + /** Once set, nothing in {@link #queue} will ever be dispatched; see {@link #enqueue}. */ + private volatile boolean closed; + + BatchCollector(String key, String name) { + this.key = key; + this.name = name; + } + + synchronized void start() { + if (scheduler == null) { + scheduler = new Thread(this::schedulerLoop, name + "-scheduler"); + scheduler.setDaemon(true); + scheduler.start(); + } + } + + synchronized void stop() { + if (scheduler != null) { + scheduler.interrupt(); + } + } + + /** + * Returns {@code false} if this collector is already closed and the caller must use another + * one. Returns {@code true} once {@code item} is guaranteed a verdict: either the scheduler + * dispatches it, or it is failed when the collector closes. + */ + boolean enqueue(PendingItem item) { + if (closed) { + return false; + } + queue.add(item); + if (closed) { + // Raced with close(): its drain may have run before our add. Draining again is idempotent + // (futures complete at most once), so whichever side sees the item fails it. + failQueued(null); + } + return true; + } + + private void schedulerLoop() { + Throwable failure = null; + try { + while (true) { + dispatch(collectBatch()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Throwable t) { + failure = t; + LOG.error( + "Dataflow test batch scheduler {} died unexpectedly; queued tests will fail and later" + + " tests will use a new scheduler.", + name, + t); + } finally { + close(failure); + } + } + + private void close(@Nullable Throwable cause) { + closed = true; + collectors.remove(key, this); + failQueued(cause); + } + + private void failQueued(@Nullable Throwable cause) { + List orphans = new ArrayList<>(); + queue.drainTo(orphans); + for (PendingItem orphan : orphans) { + orphan.resultFuture.completeExceptionally( + new IllegalStateException( + "Dataflow test batch scheduler " + + name + + " stopped before scheduling " + + orphan.options.getAppName(), + cause)); + } + } + + /** + * Blocks until a member arrives, then keeps collecting for up to the batch window or until the + * batch is full. Never returns an empty batch. + */ + private List collectBatch() throws InterruptedException { + List batch = new ArrayList<>(); + try { + batch.add(queue.take()); + // The window opens when the first member arrives, not while waiting for it. + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(config.windowMs); + while (batch.size() < config.maxBatchSize) { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + break; + } + PendingItem next = queue.poll(remainingNanos, TimeUnit.NANOSECONDS); + if (next == null) { + break; + } + batch.add(next); + } + } catch (InterruptedException e) { + // Hand the partial batch back so that close() fails these members too. + queue.addAll(batch); + throw e; + } + return batch; + } + + /** + * Hands a closed batch off without doing any job work on the scheduler thread; this returns + * immediately either way. + * + *

    A batch of one is not executed through {@link #executeBatch}: that would wrap the lone + * pipeline in a renamed composite, apply scoped PAssert/stage verification, and — on + * failure — require the test to be executed again. Instead the member is told that + * batching was declined, which makes it behave exactly like an ineligible pipeline: its own + * test thread runs it standalone (see {@link PendingItem#awaitResult()}) with the full {@link + * TestDataflowRunner} semantics and unmodified names. + */ + private void dispatch(List batch) { + try { + if (batch.size() == 1) { + // Verdict only; the job is submitted by the test thread. + batch.get(0).resultFuture.complete(BatchOutcome.declined()); + return; + } + batchRunners.execute(() -> executeBatch(batch)); + } catch (Throwable t) { + for (PendingItem item : batch) { + item.resultFuture.completeExceptionally(t); + } + } + } + } + + /** + * Runs one merged Dataflow job for {@code items} and completes every member's future. Never + * throws and never leaves a future incomplete: an unexpected failure anywhere in here is + * propagated to all member test threads instead of hanging them. + */ + @VisibleForTesting + void executeBatch(List items) { + try { + executeBatchInternal(items); + } catch (Throwable t) { + LOG.error( + "Unexpected failure while executing merged Dataflow test batch; failing {} tests.", + items.size(), + t); + for (PendingItem item : items) { + item.resultFuture.completeExceptionally(t); + } + } finally { + for (PendingItem item : items) { + if (!item.resultFuture.isDone()) { + item.resultFuture.completeExceptionally( + new IllegalStateException( + "Merged Dataflow test batch finished without a verdict for " + + item.options.getAppName())); + } + } + } + } + + /** + * The most conservative Dataflow job-name length we know of. The current REST reference allows + * {@code [a-z]([-a-z0-9]{0,1022}[a-z0-9])?}, but the service historically enforced 63 (and the + * Python SDK still tests against that), so merged job names are kept within it. + */ + @VisibleForTesting static final int MAX_JOB_NAME_LENGTH = 63; + + /** + * Every merged job's name starts with this, which is how a pipeline running on Dataflow can tell + * (via its {@code jobName} option) that it is part of a merged job rather than a standalone one. + */ + static final String BATCH_JOB_NAME_PREFIX = "batch-"; + + private static final DateTimeFormatter JOB_NAME_TIMESTAMP = + DateTimeFormat.forPattern("MMddHHmmss").withZone(DateTimeZone.UTC); + + /** Builds the merged job's name; see {@link #batchJobName(int, String, long, int)}. */ + private static String batchJobName(int batchNumber, @Nullable String leaderAppName) { + return batchJobName( + batchNumber, + leaderAppName, + System.currentTimeMillis(), + ThreadLocalRandom.current().nextInt()); + } + + /** + * Builds {@code batch----} within {@link + * #MAX_JOB_NAME_LENGTH}. + * + *

    Uniqueness comes from the timestamp and random suffix, the same scheme as {@code + * PipelineOptions.JobNameFactory} uses for standalone jobs. It must not come from the batch + * number: that counter is per JVM, and the same test classes run concurrently in several JVMs and + * postcommits that all count from one. A colliding name makes Dataflow return the other job and + * the whole batch falls back to standalone runs. The leader's app name is only a hint for humans + * reading the console and is the only part that is ever truncated. + */ + @VisibleForTesting + static String batchJobName( + int batchNumber, @Nullable String leaderAppName, long nowMillis, int random) { + String prefix = BATCH_JOB_NAME_PREFIX + batchNumber; + String suffix = "-" + JOB_NAME_TIMESTAMP.print(nowMillis) + "-" + Integer.toHexString(random); + String hint = + leaderAppName == null ? "" : leaderAppName.toLowerCase().replaceAll("[^a-z0-9]", "0"); + int room = MAX_JOB_NAME_LENGTH - prefix.length() - suffix.length() - 1; + if (hint.length() > room) { + hint = hint.substring(0, Math.max(0, room)); + } + return hint.isEmpty() ? prefix + suffix : prefix + "-" + hint + suffix; + } + + private void executeBatchInternal(List items) { + int batchNumber = batchCounter.getAndIncrement(); + List memberPipelines = new ArrayList<>(items.size()); + List scopes = new ArrayList<>(items.size()); + StringBuilder membership = new StringBuilder(); + for (int i = 0; i < items.size(); i++) { + PendingItem item = items.get(i); + memberPipelines.add(item.pipeline); + scopes.add(item.scope); + membership + .append(i == 0 ? "" : ", ") + .append(item.scope) + .append('=') + .append(item.options.getAppName()); + } + LOG.info( + "Merging {} ValidatesRunner test pipelines into Dataflow batch #{}: {}", + items.size(), + batchNumber, + membership); + + PendingItem leader = items.get(0); + String originalLeaderJobName = leader.options.getJobName(); + String batchJobName = batchJobName(batchNumber, leader.options.getAppName()); + leader.options.setJobName(batchJobName); + + DataflowPipelineJob batchJob = null; + State terminalState = null; + ErrorMonitorMessagesHandler messages = null; + String noVerdictReason = null; + try { + // The constructor rejects members whose transform names collide. That cannot happen with + // TestPipeline's sequence-numbered root names, but if it did the catch below sends every + // member back for a fresh standalone execution rather than risk mis-crediting a test. + CompositePipeline compositePipeline = new CompositePipeline(leader.options, memberPipelines); + batchJob = leader.runner.submit(leader.delegateRunner, compositePipeline); + for (PendingItem item : items) { + item.mergedJobId = batchJob.getJobId(); + } + LOG.info( + "Submitted merged Dataflow job {} for batch #{} ({} tests)", + batchJob.getJobId(), + batchNumber, + items.size()); + messages = TestDataflowRunner.errorMonitorFor(batchJob); + Duration timeout = mergedJobTimeout(items); + terminalState = leader.runner.waitForMergedJobTermination(batchJob, timeout, messages); + if (terminalState == null) { + noVerdictReason = + "the merged job did not terminate within " + timeout + " and was cancelled"; + LOG.warn( + "Merged Dataflow job {} for batch #{} did not terminate within {}; cancelling it; all" + + " {} tests will be executed again standalone.{}", + batchJob.getJobId(), + batchNumber, + timeout, + items.size(), + errorSuffix(messages)); + cancelQuietly(batchJob); + } + } catch (Exception e) { + noVerdictReason = "the merged job failed during submission or execution (" + e + ")"; + LOG.warn( + "Merged Dataflow batch #{} failed during submission or execution; all {} tests will be" + + " executed again standalone.{}", + batchNumber, + items.size(), + errorSuffix(messages), + e); + cancelQuietly(batchJob); + } finally { + leader.options.setJobName(originalLeaderJobName); + } + + List outcomes; + if (batchJob == null || terminalState == null) { + outcomes = new ArrayList<>(items.size()); + for (int i = 0; i < items.size(); i++) { + outcomes.add(BatchOutcome.rerunRequired(noVerdictReason + errorSuffix(messages))); + } + } else { + // How members are credited depends on the execution mode, and all members of a batch share + // one mode because `streaming` is part of the compatibility key. Only batch mode is eligible + // today (see isEligibleForBatching); a streaming strategy would plug in here, e.g. crediting + // everyone on DONE without error messages and nobody otherwise, since streaming jobs expose + // neither per-stage terminal states nor the batch verification counters. + checkState(!leader.options.isStreaming(), "Streaming batches are not supported yet"); + outcomes = batchModeVerdicts(batchJob, terminalState, messages, items, scopes); + } + + // Complete futures only after every member has been evaluated. + for (int i = 0; i < items.size(); i++) { + items.get(i).resultFuture.complete(outcomes.get(i)); + } + } + + /** + * Credits members of a terminated batch-mode merged job. A member is credited when its + * scoped PAssert counters verify and, unless the job ended {@code DONE}, every stage that may + * belong to it ended {@code DONE} (see {@link StageAttribution}); anyone else must be executed + * again standalone. + */ + private List batchModeVerdicts( + DataflowPipelineJob batchJob, + State terminalState, + ErrorMonitorMessagesHandler messages, + List items, + List scopes) { + List outcomes = new ArrayList<>(items.size()); + // Metrics are fetched regardless of terminal state: counters reported by stages that + // completed before the job failed are retained by the service. + JobMetrics metrics = leader(items).runner.getJobMetrics(batchJob); + StageAttribution attribution = null; + if (terminalState != State.DONE) { + attribution = + StageAttribution.fromJob(leader(items).runner.getJobWithExecutionDetails(batchJob)); + if (attribution == null) { + LOG.warn( + "Merged Dataflow job {} terminated in state {} and per-stage execution state is" + + " unavailable; all {} tests will be executed again standalone.{}", + batchJob.getJobId(), + terminalState, + items.size(), + errorSuffix(messages)); + } + } + for (PendingItem item : items) { + String scope = item.scope; + PAssertCounts counts = scopedPAssertCounts(batchJob, metrics, scope); + boolean countersOk = counts != null && counts.satisfy(item.expectedAssertions); + String passertVerdict = + (countersOk ? "PAsserts verified (" : "PAsserts unverified (") + + (counts == null ? "metrics unavailable" : counts) + + " out of " + + item.expectedAssertions + + " expected assertions)"; + String verdict; + boolean credited; + if (terminalState == State.DONE) { + credited = countersOk; + verdict = "job DONE, " + passertVerdict; + } else if (attribution == null) { + credited = false; + verdict = "job " + terminalState + ", stage states unavailable"; + } else { + StageAttribution.ScopeStatus status = attribution.statusForScope(scope, scopes); + credited = countersOk && status.isComplete(); + verdict = "job " + terminalState + ", " + status + ", " + passertVerdict; + } + if (credited) { + LOG.info( + "Test {} (scope {}) passed in merged Dataflow job {} ({}).", + item.options.getAppName(), + scope, + batchJob.getJobId(), + verdict); + outcomes.add(BatchOutcome.passed(new ScopedDataflowPipelineJob(batchJob, scope))); + } else { + LOG.warn( + "Test {} (scope {}) could not be credited from merged Dataflow job {} ({}); it will be" + + " executed again standalone.{}", + item.options.getAppName(), + scope, + batchJob.getJobId(), + verdict, + errorSuffix(messages)); + outcomes.add(BatchOutcome.rerunRequired(verdict + errorSuffix(messages))); + } + } + return outcomes; + } + + private static PendingItem leader(List items) { + return items.get(0); + } + + /** The job's error messages, formatted for appending to a log line, or empty if none. */ + private static String errorSuffix(@Nullable ErrorMonitorMessagesHandler messages) { + if (messages == null || !messages.hasSeenError()) { + return ""; + } + return " Job errors: " + messages.getErrorMessage(); + } + + private static void cancelQuietly(@Nullable DataflowPipelineJob job) { + if (job == null) { + return; + } + try { + if (!job.getState().isTerminal()) { + LOG.info("Cancelling abandoned merged Dataflow job {}", job.getJobId()); + job.cancel(); + } + } catch (Exception e) { + LOG.warn("Failed to cancel merged Dataflow job {}", job.getJobId(), e); + } + } + + /** + * Per-stage execution state of a Dataflow job, attributed to batch member scopes. + * + *

    Attribution is purely by the user-facing names a stage reports (component transforms and + * sources, input and output sources): a stage "may belong to" a scope if any of its names is + * under that scope's prefix. No assumption is made about how the service fuses steps into stages: + * a stage that reports no names, or a name under no known scope, is treated as possibly belonging + * to every scope. This makes the verdict conservative in exactly the cases where the service's + * view cannot be mapped back to a member. + */ + @VisibleForTesting + static final class StageAttribution { + private static final String STAGE_DONE = "JOB_STATE_DONE"; + + private final Map stateByStage; + private final Map> namesByStage; + + private StageAttribution( + Map stateByStage, Map> namesByStage) { + this.stateByStage = stateByStage; + this.namesByStage = namesByStage; + } + + /** Returns {@code null} if the job does not carry both stage states and stage descriptions. */ + static @Nullable StageAttribution fromJob(@Nullable Job job) { + if (job == null + || job.getStageStates() == null + || job.getStageStates().isEmpty() + || job.getPipelineDescription() == null + || job.getPipelineDescription().getExecutionPipelineStage() == null + || job.getPipelineDescription().getExecutionPipelineStage().isEmpty()) { + return null; + } + Map stateByStage = new HashMap<>(); + for (ExecutionStageState stageState : job.getStageStates()) { + if (stageState.getExecutionStageName() != null + && stageState.getExecutionStageState() != null) { + stateByStage.put(stageState.getExecutionStageName(), stageState.getExecutionStageState()); + } + } + Map> namesByStage = new LinkedHashMap<>(); + for (ExecutionStageSummary stage : job.getPipelineDescription().getExecutionPipelineStage()) { + if (stage.getName() == null) { + continue; + } + Set names = new HashSet<>(); + if (stage.getComponentTransform() != null) { + for (ComponentTransform t : stage.getComponentTransform()) { + addName(names, t.getUserName()); + addName(names, t.getOriginalTransform()); + } + } + if (stage.getComponentSource() != null) { + for (ComponentSource s : stage.getComponentSource()) { + addName(names, s.getUserName()); + addName(names, s.getOriginalTransformOrCollection()); + } + } + addSourceNames(names, stage.getInputSource()); + addSourceNames(names, stage.getOutputSource()); + namesByStage.put(stage.getName(), names); + } + return new StageAttribution(stateByStage, namesByStage); + } + + private static void addSourceNames(Set names, @Nullable List sources) { + if (sources == null) { + return; + } + for (StageSource source : sources) { + addName(names, source.getUserName()); + addName(names, source.getOriginalTransformOrCollection()); + } + } + + private static void addName(Set names, @Nullable String name) { + if (name != null && !name.isEmpty()) { + names.add(name); + } + } + + /** Summarises the stages that may belong to one scope. */ + static final class ScopeStatus { + private final int doneStages; + private final List incompleteStages; + + private ScopeStatus(int doneStages, List incompleteStages) { + this.doneStages = doneStages; + this.incompleteStages = incompleteStages; + } + + /** True iff at least one stage may belong to the scope and every such stage is DONE. */ + boolean isComplete() { + return doneStages > 0 && incompleteStages.isEmpty(); + } + + @Override + public String toString() { + return doneStages + + " stage(s) done" + + (incompleteStages.isEmpty() ? "" : ", not done: " + incompleteStages); + } + } + + ScopeStatus statusForScope(String scope, Collection allScopes) { + int done = 0; + List incomplete = new ArrayList<>(); + for (Map.Entry> stage : namesByStage.entrySet()) { + if (!mayBelongToScope(stage.getValue(), scope, allScopes)) { + continue; + } + String state = stateByStage.get(stage.getKey()); + if (STAGE_DONE.equals(state)) { + done++; + } else { + incomplete.add(stage.getKey() + "=" + (state == null ? "UNKNOWN" : state)); + } + } + return new ScopeStatus(done, incomplete); + } + + private static boolean mayBelongToScope( + Set names, String scope, Collection allScopes) { + if (names.isEmpty()) { + return true; + } + for (String name : names) { + if (matchesScopePrefix(name, scope)) { + return true; + } + boolean underSomeScope = false; + for (String other : allScopes) { + if (matchesScopePrefix(name, other)) { + underSomeScope = true; + break; + } + } + if (!underSomeScope) { + return true; + } + } + return false; + } + } + + /** + * Sums the {@link PAssert} counters of the steps under {@code scopePrefix} in a merged job, or + * returns {@code null} if the job's metrics are unavailable. + */ + static @Nullable PAssertCounts scopedPAssertCounts( + DataflowPipelineJob batchJob, @Nullable JobMetrics metrics, String scopePrefix) { + if (metrics == null || metrics.getMetrics() == null) { + return null; + } + return TestDataflowRunner.countPAssertCounters( + metrics, + internalStepName -> { + if (internalStepName.isEmpty()) { + return false; + } + String userStepName = resolveUserStepName(batchJob, internalStepName); + return userStepName != null && matchesScopePrefix(userStepName, scopePrefix); + }); + } + + static @Nullable String resolveUserStepName(DataflowPipelineJob job, String internalStepName) { + RunnerApi.@Nullable Pipeline pipelineProto = job.getPipelineProto(); + if (pipelineProto != null) { + RunnerApi.@Nullable PTransform transform = + pipelineProto.getComponents().getTransformsMap().get(internalStepName); + if (transform != null && !transform.getUniqueName().isEmpty()) { + return transform.getUniqueName(); + } + } + BiMap, String> stepNames = job.getTransformStepNames(); + if (stepNames != null) { + AppliedPTransform applied = stepNames.inverse().get(internalStepName); + if (applied != null) { + return applied.getFullName(); + } + } + return internalStepName; + } + + /** + * Whether a step, stage or metric name belongs to the member whose root is {@code scopePrefix}. + * + *

    Beam full names are {@code /}-separated ({@code t3-Foo-bar/ParDo(Anon)}), but Runner V2 + * stage descriptions and some step names come back mangled with {@code -} in place of {@code /} + * and output suffixes appended ({@code t3-Foo-bar-ParDo-Anon--out0}), so both separators are + * accepted. A separator (or an exact match) is required so that {@code t1} does not claim {@code + * t10/...}. + */ + static boolean matchesScopePrefix(String stepName, String scopePrefix) { + return stepName.startsWith(scopePrefix + "/") + || stepName.startsWith(scopePrefix + "-") + || stepName.equals(scopePrefix); + } + + /** Inverse of {@link #matchesScopePrefix}: removes the root and its separator, if present. */ + static String stripScopePrefix(String stepName, String scopePrefix) { + if (stepName.startsWith(scopePrefix + "/") || stepName.startsWith(scopePrefix + "-")) { + return stepName.substring(scopePrefix.length() + 1); + } + if (stepName.equals(scopePrefix)) { + return ""; + } + return stepName; + } + + /** + * A {@link DataflowPipelineJob} view for a single member pipeline within a merged batch job that + * filters and unprefixes {@link MetricResults} to that member's transform scope. + * + *

    Instances are only ever handed to members that were credited by the coordinator, + * i.e. every execution stage belonging to the member finished successfully and its PAssert + * counters were satisfied. The underlying batch job may nevertheless be in a non-{@code DONE} + * terminal state because of a different member, so this view always reports {@link + * State#DONE} rather than delegating to the batch job's state. + * + *

    The view deliberately holds no {@link DataflowClient}: it must never act on the shared batch + * job on behalf of one member (cancelling or draining it would abort the other members' work), + * and its state is already known. Every public operation that would otherwise contact the service + * — waiting, cancelling, draining, polling state — is therefore overridden to answer + * locally, and metrics are served through the batch job's own client. + */ + static final class ScopedDataflowPipelineJob extends DataflowPipelineJob { + private final MetricResults scopedMetrics; + + ScopedDataflowPipelineJob(DataflowPipelineJob delegate, String scopePrefix) { + super( + /* dataflowClient= */ null, + delegate.getJobId(), + delegate.getDataflowOptions(), + delegate.getTransformStepNames(), + delegate.getPipelineProto()); + this.scopedMetrics = new ScopedMetricResults(delegate, scopePrefix); + } + + @Override + public State getState() { + return State.DONE; + } + + @Override + public @Nullable State waitUntilFinish() { + return State.DONE; + } + + @Override + public @Nullable State waitUntilFinish(Duration duration) { + return State.DONE; + } + + @Override + public @Nullable State waitUntilFinish( + Duration duration, MonitoringUtil.JobMessagesHandler messageHandler) { + return State.DONE; + } + + @Override + public State cancel() { + // The member's work is already complete; cancelling is a no-op and must not affect the + // other members sharing the batch job. + return State.DONE; + } + + @Override + public State drain() { + // As for cancel(): draining the shared batch job would abort the other members. + return State.DONE; + } + + @Override + public MetricResults metrics() { + return scopedMetrics; + } + } + + private static final class ScopedMetricResults extends MetricResults { + private final DataflowPipelineJob delegateJob; + private final String scopePrefix; + + private ScopedMetricResults(DataflowPipelineJob delegateJob, String scopePrefix) { + this.delegateJob = delegateJob; + this.scopePrefix = scopePrefix; + } + + @Override + public MetricQueryResults queryMetrics(@Nullable MetricsFilter filter) { + MetricsFilter.Builder nameOnlyFilterBuilder = MetricsFilter.builder(); + if (filter != null) { + for (MetricNameFilter nameFilter : filter.names()) { + nameOnlyFilterBuilder.addNameFilter(nameFilter); + } + } + MetricQueryResults rawResults; + synchronized (delegateJob) { + rawResults = delegateJob.metrics().queryMetrics(nameOnlyFilterBuilder.build()); + } + return MetricQueryResults.create( + filterAndStrip(rawResults.getCounters(), filter), + filterAndStrip(rawResults.getDistributions(), filter), + filterAndStrip(rawResults.getGauges(), filter), + filterAndStrip(rawResults.getStringSets(), filter), + filterAndStrip(rawResults.getBoundedTries(), filter), + Collections.>emptyList()); + } + + private List> filterAndStrip( + Iterable> results, @Nullable MetricsFilter filter) { + List> scoped = new ArrayList<>(); + for (MetricResult result : results) { + MetricKey key = result.getKey(); + String stepName = key.stepName(); + if (stepName == null || !matchesScopePrefix(stepName, scopePrefix)) { + continue; + } + String unprefixedStep = stripScopePrefix(stepName, scopePrefix); + MetricKey unprefixedKey = MetricKey.create(unprefixedStep, key.metricName()); + if (filter == null || MetricFiltering.matches(filter, unprefixedKey)) { + scoped.add( + MetricResult.create( + unprefixedKey, result.getCommittedOrNull(), result.getAttempted())); + } + } + return scoped; + } + } +} diff --git a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/TestDataflowRunner.java b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/TestDataflowRunner.java index 89d198154ad3..07e809dedfc3 100644 --- a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/TestDataflowRunner.java +++ b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/TestDataflowRunner.java @@ -19,29 +19,41 @@ import static org.hamcrest.MatcherAssert.assertThat; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.util.BackOff; +import com.google.api.client.util.BackOffUtils; +import com.google.api.client.util.Sleeper; +import com.google.api.services.dataflow.model.Job; import com.google.api.services.dataflow.model.JobMessage; import com.google.api.services.dataflow.model.JobMetrics; +import com.google.api.services.dataflow.model.MetricStructuredName; import com.google.api.services.dataflow.model.MetricUpdate; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.File; import java.io.IOException; import java.math.BigDecimal; import java.util.List; +import java.util.Map; import java.util.concurrent.Callable; +import java.util.concurrent.Semaphore; +import java.util.function.Predicate; import org.apache.beam.runners.dataflow.options.DataflowPipelineOptions; import org.apache.beam.runners.dataflow.util.MonitoringUtil; import org.apache.beam.runners.dataflow.util.MonitoringUtil.JobMessagesHandler; import org.apache.beam.sdk.Pipeline; import org.apache.beam.sdk.PipelineResult.State; import org.apache.beam.sdk.PipelineRunner; +import org.apache.beam.sdk.extensions.gcp.util.BackOffAdapter; import org.apache.beam.sdk.io.FileSystems; import org.apache.beam.sdk.options.PipelineOptions; import org.apache.beam.sdk.testing.PAssert; import org.apache.beam.sdk.testing.TestPipeline; import org.apache.beam.sdk.testing.TestPipelineOptions; +import org.apache.beam.sdk.util.FluentBackoff; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Optional; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Throwables; import org.checkerframework.checker.nullness.qual.Nullable; import org.joda.time.Duration; import org.slf4j.Logger; @@ -58,17 +70,66 @@ }) public class TestDataflowRunner extends PipelineRunner { private static final String TENTATIVE_COUNTER = "tentative"; + static final String MAX_CONCURRENT_STANDALONE_JOBS_PROPERTY = + "beam.dataflow.maxConcurrentStandaloneJobs"; + private static final int DEFAULT_MAX_CONCURRENT_STANDALONE_JOBS = 12; + + /** + * Per-JVM cap on concurrently running standalone jobs. The permit count is a system property set + * once per test JVM by Gradle ({@link #MAX_CONCURRENT_STANDALONE_JOBS_PROPERTY}). + * + *

    Every path that runs a test as a job of its own takes a permit here: tests that are not + * eligible for batching, tests whose batch turned out to be a batch of one, and tests executed + * again standalone after a merged job yielded no verdict for them. The last group can arrive in a + * burst (every member of an abandoned merged job at once), which is exactly when a shared cap + * matters. Merged jobs do not take a permit: there are only ever a handful in flight per JVM + * (bounded by test threads divided by batch size), each stands in for up to a whole batch of + * tests, and queueing one behind single-test jobs would invert that priority. + * + *

    Fair, so the test that has waited longest for a permit is next: with many test threads per + * JVM an unfair semaphore can let a test starve behind a steady stream of newer arrivals. + */ + private static final Semaphore STANDALONE_SEMAPHORE = + new Semaphore( + Math.max( + 1, + Integer.getInteger( + MAX_CONCURRENT_STANDALONE_JOBS_PROPERTY, DEFAULT_MAX_CONCURRENT_STANDALONE_JOBS)), + true); + private static final Logger LOG = LoggerFactory.getLogger(TestDataflowRunner.class); + /** + * Backoff for job submissions rejected by the service for quota (HTTP 429). Many test JVMs share + * the project's job-creation quota with no coordination between them, so a rejection usually just + * means a brief burst; waiting it out beats failing the test. Roughly 30s, 1m, 2m, 2m, 2m (with + * jitter), i.e. up to five retries over about eight minutes. + */ + private static final FluentBackoff SUBMISSION_BACKOFF_FACTORY = + FluentBackoff.DEFAULT + .withInitialBackoff(Duration.standardSeconds(30)) + .withMaxBackoff(Duration.standardMinutes(2)) + .withMaxRetries(5); + private final TestDataflowPipelineOptions options; private final DataflowClient dataflowClient; private final DataflowRunner runner; + private final DataflowTestBatchCoordinator batchCoordinator; + private Sleeper sleeper = Sleeper.DEFAULT; private int expectedNumberOfAssertions = 0; TestDataflowRunner(TestDataflowPipelineOptions options, DataflowClient client) { + this(options, client, DataflowTestBatchCoordinator.shared()); + } + + TestDataflowRunner( + TestDataflowPipelineOptions options, + DataflowClient client, + DataflowTestBatchCoordinator batchCoordinator) { this.options = options; this.dataflowClient = client; this.runner = DataflowRunner.fromOptions(options); + this.batchCoordinator = batchCoordinator; } /** Constructs a runner from the provided options. */ @@ -102,17 +163,115 @@ static TestDataflowRunner fromOptionsAndClient( return new TestDataflowRunner(options, client); } + @VisibleForTesting + static TestDataflowRunner fromOptionsAndClient( + TestDataflowPipelineOptions options, + DataflowClient client, + DataflowTestBatchCoordinator batchCoordinator) { + return new TestDataflowRunner(options, client, batchCoordinator); + } + @Override public DataflowPipelineJob run(Pipeline pipeline) { return run(pipeline, runner); } DataflowPipelineJob run(Pipeline pipeline, DataflowRunner runner) { + return batchCoordinator.isEligibleForBatching(pipeline, options) + ? batchCoordinator.runInBatch((TestPipeline) pipeline, options, this, runner) + : runStandalone(pipeline, runner); + } + + @VisibleForTesting + void setSleeper(Sleeper sleeper) { + this.sleeper = sleeper; + } + + /** + * Submits {@code pipeline} through {@code runner}, retrying with backoff while the service + * rejects the submission for quota (HTTP 429). Any other failure propagates immediately. + */ + DataflowPipelineJob submit(DataflowRunner runner, Pipeline pipeline) { + BackOff backOff = BackOffAdapter.toGcpBackOff(SUBMISSION_BACKOFF_FACTORY.backoff()); + while (true) { + try { + return runner.run(pipeline); + } catch (RuntimeException e) { + GoogleJsonResponseException quota = quotaRejection(e); + if (quota == null) { + throw e; + } + boolean retry; + try { + retry = BackOffUtils.next(sleeper, backOff); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw e; + } catch (IOException ioe) { + throw e; + } + if (!retry) { + LOG.warn("Dataflow job submission still rejected for quota after retries; giving up."); + throw e; + } + LOG.warn( + "Dataflow job submission rejected for quota ({}); retrying.", + quota.getDetails() != null ? quota.getDetails().getMessage() : quota.getMessage()); + } + } + } + + /** The HTTP 429 response behind {@code t}, if that is what caused it. */ + @VisibleForTesting + static @Nullable GoogleJsonResponseException quotaRejection(Throwable t) { + java.util.Optional handledCause = + Throwables.getCausalChain(t).stream() + .filter(cause -> cause instanceof GoogleJsonResponseException) + .map(cause -> (GoogleJsonResponseException) cause) + .filter(cause -> cause.getStatusCode() == 429) + .findAny(); + return handledCause.orElse(null); + } + + /** + * Runs {@code pipeline} as its own Dataflow job, holding a permit from {@link + * #STANDALONE_SEMAPHORE} for its duration. + */ + DataflowPipelineJob runStandalone(Pipeline pipeline, DataflowRunner runner) { + boolean acquired = false; + try { + STANDALONE_SEMAPHORE.acquire(); + acquired = true; + return runStandaloneInternal(pipeline, runner); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } finally { + if (acquired) { + STANDALONE_SEMAPHORE.release(); + } + } + } + + /** + * Fetches {@code job} with {@code JOB_VIEW_ALL}, which carries per-stage execution states and the + * stage-to-user-step description needed to attribute stages to batch members. Returns {@code + * null} if the service call fails. + */ + @Nullable Job getJobWithExecutionDetails(DataflowPipelineJob job) { + try { + return dataflowClient.getJob(job.getJobId(), "JOB_VIEW_ALL"); + } catch (IOException e) { + LOG.warn("Failed to get execution details for Dataflow job {}: ", job.getJobId(), e); + return null; + } + } + + private DataflowPipelineJob runStandaloneInternal(Pipeline pipeline, DataflowRunner runner) { updatePAssertCount(pipeline); TestPipelineOptions testPipelineOptions = options.as(TestPipelineOptions.class); - final DataflowPipelineJob job; - job = runner.run(pipeline); + final DataflowPipelineJob job = submit(runner, pipeline); LOG.info( "Running Dataflow job {} with {} expected assertions.", @@ -203,6 +362,32 @@ private boolean waitForStreamingJobTermination( } } + /** + * Waits up to {@code timeout} for a merged test job to terminate, feeding its log messages to + * {@code messageHandler}. Returns its terminal state, or {@code null} if the job did not + * terminate in time or the wait was interrupted. + * + *

    Unlike a standalone batch job, which this runner waits on indefinitely, a merged job holds + * the verdict for several tests at once, so it is never allowed to block them forever. + */ + @Nullable State waitForMergedJobTermination( + DataflowPipelineJob job, Duration timeout, ErrorMonitorMessagesHandler messageHandler) { + try { + State state = job.waitUntilFinish(timeout, messageHandler); + return state != null && state.isTerminal() ? state : null; + } catch (IOException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + } + + /** An {@link ErrorMonitorMessagesHandler} for {@code job} that also logs every message. */ + static ErrorMonitorMessagesHandler errorMonitorFor(DataflowPipelineJob job) { + return new ErrorMonitorMessagesHandler(job, new MonitoringUtil.LoggingHandler()); + } + /** Return {@code true} if job state is {@code State.DONE}. {@code false} otherwise. */ private boolean waitForBatchJobTermination( DataflowPipelineJob job, ErrorMonitorMessagesHandler messageHandler) { @@ -259,21 +444,9 @@ Optional checkForPAssertSuccess(DataflowPipelineJob job) { return Optional.absent(); } - int successes = 0; - int failures = 0; - for (MetricUpdate metric : metrics.getMetrics()) { - if (metric.getName() == null - || metric.getName().getContext() == null - || !metric.getName().getContext().containsKey(TENTATIVE_COUNTER)) { - // Don't double count using the non-tentative version of the metric. - continue; - } - if (PAssert.SUCCESS_COUNTER.equals(metric.getName().getName())) { - successes += ((BigDecimal) metric.getScalar()).intValue(); - } else if (PAssert.FAILURE_COUNTER.equals(metric.getName().getName())) { - failures += ((BigDecimal) metric.getScalar()).intValue(); - } - } + PAssertCounts counts = countPAssertCounters(metrics, step -> true); + int successes = counts.successes; + int failures = counts.failures; if (failures > 0) { LOG.info( @@ -315,6 +488,68 @@ Optional checkForPAssertSuccess(DataflowPipelineJob job) { return Optional.absent(); } + /** Totals of the tentative {@link PAssert} success and failure counters reported by a job. */ + static final class PAssertCounts { + final int successes; + final int failures; + + PAssertCounts(int successes, int failures) { + this.successes = successes; + this.failures = failures; + } + + /** Whether no assertion failed and at least {@code expectedAssertions} succeeded. */ + boolean satisfy(int expectedAssertions) { + return failures == 0 && successes >= expectedAssertions; + } + + @Override + public String toString() { + return successes + " success, " + failures + " failures"; + } + } + + /** + * Sums the {@link PAssert} success and failure counters in {@code metrics}, considering only the + * updates whose Dataflow step (the {@code step} entry of the metric context, or the empty string + * if absent) is accepted by {@code stepFilter}. Only the tentative flavour of each counter is + * counted so that its committed duplicate does not double count. + */ + static PAssertCounts countPAssertCounters(JobMetrics metrics, Predicate stepFilter) { + int successes = 0; + int failures = 0; + List updates = metrics.getMetrics(); + if (updates == null) { + return new PAssertCounts(successes, failures); + } + for (MetricUpdate metric : updates) { + MetricStructuredName name = metric.getName(); + if (name == null) { + continue; + } + boolean isSuccess = PAssert.SUCCESS_COUNTER.equals(name.getName()); + boolean isFailure = PAssert.FAILURE_COUNTER.equals(name.getName()); + if (!isSuccess && !isFailure) { + continue; + } + Map context = name.getContext(); + if (context == null || !context.containsKey(TENTATIVE_COUNTER)) { + continue; + } + String step = context.get("step"); + if (!stepFilter.test(step == null ? "" : step)) { + continue; + } + int value = ((BigDecimal) metric.getScalar()).intValue(); + if (isSuccess) { + successes += value; + } else { + failures += value; + } + } + return new PAssertCounts(successes, failures); + } + @VisibleForTesting @Nullable JobMetrics getJobMetrics(DataflowPipelineJob job) { JobMetrics metrics = null; @@ -336,14 +571,13 @@ public String toString() { * *

    Creates an error message representing the concatenation of all error messages seen. */ - private static class ErrorMonitorMessagesHandler implements JobMessagesHandler { + static class ErrorMonitorMessagesHandler implements JobMessagesHandler { private final DataflowPipelineJob job; private final JobMessagesHandler messageHandler; private final StringBuilder errorMessage; private volatile boolean hasSeenError; - private ErrorMonitorMessagesHandler( - DataflowPipelineJob job, JobMessagesHandler messageHandler) { + ErrorMonitorMessagesHandler(DataflowPipelineJob job, JobMessagesHandler messageHandler) { this.job = job; this.messageHandler = messageHandler; this.errorMessage = new StringBuilder(); @@ -374,12 +608,16 @@ String getErrorMessage() { } } - private static class CancelOnError implements Callable { + /** + * Polls a job and cancels it as soon as {@code messageHandler} has seen an error. Used for + * streaming jobs, where a failing step retries forever instead of failing the job. + */ + static class CancelOnError implements Callable { private final DataflowPipelineJob job; private final ErrorMonitorMessagesHandler messageHandler; - public CancelOnError(DataflowPipelineJob job, ErrorMonitorMessagesHandler messageHandler) { + CancelOnError(DataflowPipelineJob job, ErrorMonitorMessagesHandler messageHandler) { this.job = job; this.messageHandler = messageHandler; } diff --git a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java index 02e701332939..2246f8b3ee6d 100644 --- a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java +++ b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerTest.java @@ -42,7 +42,6 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import static org.junit.Assume.assumeFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; @@ -146,8 +145,6 @@ import org.apache.beam.sdk.testing.ExpectedLogs; import org.apache.beam.sdk.testing.PAssert; import org.apache.beam.sdk.testing.TestPipeline; -import org.apache.beam.sdk.testing.UsesStatefulParDo; -import org.apache.beam.sdk.testing.ValidatesRunner; import org.apache.beam.sdk.transforms.Create; import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.sdk.transforms.GroupIntoBatches; @@ -155,7 +152,6 @@ import org.apache.beam.sdk.transforms.PTransform; import org.apache.beam.sdk.transforms.ParDo; import org.apache.beam.sdk.transforms.Redistribute; -import org.apache.beam.sdk.transforms.Reshuffle; import org.apache.beam.sdk.transforms.SerializableFunctions; import org.apache.beam.sdk.transforms.SimpleFunction; import org.apache.beam.sdk.transforms.resourcehints.ResourceHints; @@ -173,17 +169,11 @@ import org.apache.beam.sdk.util.construction.PipelineTranslation; import org.apache.beam.sdk.util.construction.SdkComponents; import org.apache.beam.sdk.util.construction.TransformPayloadTranslatorRegistrar; -import org.apache.beam.sdk.values.CausedByDrain; import org.apache.beam.sdk.values.KV; import org.apache.beam.sdk.values.PCollection; -import org.apache.beam.sdk.values.PCollectionTuple; import org.apache.beam.sdk.values.PValues; import org.apache.beam.sdk.values.TimestampedValue; -import org.apache.beam.sdk.values.TupleTag; -import org.apache.beam.sdk.values.TupleTagList; import org.apache.beam.sdk.values.TypeDescriptors; -import org.apache.beam.sdk.values.ValueKind; -import org.apache.beam.sdk.values.WindowedValues; import org.apache.beam.sdk.values.WindowingStrategy; import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.InvalidProtocolBufferException; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; @@ -199,10 +189,8 @@ import org.joda.time.Duration; import org.joda.time.Instant; import org.junit.Before; -import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; -import org.junit.experimental.categories.Category; import org.junit.rules.ExpectedException; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; @@ -233,7 +221,6 @@ public class DataflowRunnerTest implements Serializable { @Rule public transient TemporaryFolder tmpFolder = new TemporaryFolder(); @Rule public transient ExpectedException thrown = ExpectedException.none(); @Rule public transient ExpectedLogs expectedLogs = ExpectedLogs.none(DataflowRunner.class); - @Rule public final transient TestPipeline pipeline = TestPipeline.create(); private transient Dataflow.Projects.Locations.Jobs mockJobs; private transient GcsUtil mockGcsUtil; @@ -2240,7 +2227,11 @@ public void testMergingStatefulRejectedInBatch() throws Exception { verifyMergingStatefulParDoRejected(options); } - private void verifyGroupIntoBatchesOverrideCount( + /** + * Builds a {@link GroupIntoBatches} pipeline on {@code p}, runs it and checks whether the runner + * substituted its override. Shared with {@link DataflowRunnerValidatesRunnerTest}. + */ + static void verifyGroupIntoBatchesOverrideCount( Pipeline p, Boolean withShardedKey, Boolean expectOverridden) { final int batchSize = 2; List> testValues = @@ -2318,7 +2309,8 @@ public CompositeBehavior enterCompositeTransform(Node node) { } } - private void verifyGroupIntoBatchesOverrideBytes( + /** Byte-size counterpart of {@link #verifyGroupIntoBatchesOverrideCount}. */ + static void verifyGroupIntoBatchesOverrideBytes( Pipeline p, Boolean withShardedKey, Boolean expectOverridden) { final long batchSizeBytes = 2; List> testValues = @@ -2392,22 +2384,6 @@ public CompositeBehavior enterCompositeTransform(Node node) { } } - @Test - @Category({ValidatesRunner.class, UsesStatefulParDo.class}) - public void testBatchGroupIntoBatchesOverrideCount() { - // Ignore this test for streaming pipelines. - assumeFalse(pipeline.getOptions().as(StreamingOptions.class).isStreaming()); - verifyGroupIntoBatchesOverrideCount(pipeline, false, true); - } - - @Test - @Category({ValidatesRunner.class, UsesStatefulParDo.class}) - public void testBatchGroupIntoBatchesOverrideBytes() { - // Ignore this test for streaming pipelines. - assumeFalse(pipeline.getOptions().as(StreamingOptions.class).isStreaming()); - verifyGroupIntoBatchesOverrideBytes(pipeline, false, true); - } - @Test public void testBatchGroupIntoBatchesWithShardedKeyOverrideCount() throws IOException { PipelineOptions options = buildPipelineOptions(); @@ -2862,140 +2838,6 @@ public void process() {} p.run(); } - @Test - @Category({ValidatesRunner.class}) - public void testValueKindParameterAndOutputWithKind() { - boolean isRunnerV2 = false; - @Nullable List experiments = - pipeline.getOptions().as(DataflowPipelineOptions.class).getExperiments(); - if (experiments != null - && (experiments.contains("use_unified_worker") || experiments.contains("use_runner_v2"))) { - isRunnerV2 = true; - } - // Skipp runner v2 because its Create uses a splittable DoFn, which contains a shuffle. - // ValueKind is not supported in Dataflow shuffle yet - assumeFalse(isRunnerV2); - - PCollection input = pipeline.apply(Create.of("a", "b", "c", "d")); - TupleTag mainTag = new TupleTag() {}; - TupleTag sideTag = new TupleTag() {}; - - PCollectionTuple tuple = - input.apply( - "SetKind", - ParDo.of( - new DoFn() { - @ProcessElement - public void processElement( - @Element String element, - @Timestamp org.joda.time.Instant timestamp, - BoundedWindow window, - PaneInfo paneInfo, - ProcessContext c, - MultiOutputReceiver outputReceiver) { - switch (element) { - case "a": - c.output(element); // default: INSERT - return; - case "b": - c.outputWindowedValue( - WindowedValues.of( - element, - timestamp, - Collections.singleton(window), - paneInfo, - null, - null, - CausedByDrain.NORMAL, - null, - ValueKind.UPDATE_BEFORE)); - return; - case "c": - outputReceiver - .get(mainTag) - .builder(element) - .setValueKind(ValueKind.UPDATE_AFTER) - .output(); - return; - case "d": - outputReceiver - .get(sideTag) - .builder(element) - .setValueKind(ValueKind.DELETE) - .output(); - } - } - }) - .withOutputTags(mainTag, TupleTagList.of(sideTag))); - - PCollection main = - tuple - .get(mainTag) - .apply( - "ReadKind", - ParDo.of( - new DoFn() { - @ProcessElement - public void processElement( - @Element String element, ProcessContext c, ValueKind kind) { - c.output(element + ":" + kind); - } - })); - - PCollection side = - tuple - .get(sideTag) - .apply( - "ReadKind-SideTag", - ParDo.of( - new DoFn() { - @ProcessElement - public void processElement( - @Element String element, ProcessContext c, ValueKind kind) { - c.output(element + ":" + kind); - } - })); - - PAssert.that(main).containsInAnyOrder("a:INSERT", "b:UPDATE_BEFORE", "c:UPDATE_AFTER"); - PAssert.that(side).containsInAnyOrder("d:DELETE"); - pipeline.run(); - } - - @Test - @Ignore("enable once when element metadata is supported in shuffle") - @Category({ValidatesRunner.class}) - public void testValueKindPreservedAcrossShuffle() { - PCollection> input = pipeline.apply(Create.of(KV.of("key", "value"))); - - PCollection output = - input - .apply( - "SetKind", - ParDo.of( - new DoFn, KV>() { - @ProcessElement - public void processElement( - @Element KV element, - OutputReceiver> out) { - out.builder(element).setValueKind(ValueKind.UPDATE_BEFORE).output(); - } - })) - .apply(Reshuffle.of()) - .apply( - "ReadKind", - ParDo.of( - new DoFn, String>() { - @ProcessElement - public void processElement( - @Element KV element, ProcessContext c, ValueKind kind) { - c.output(element.getValue() + ":" + kind); - } - })); - - PAssert.that(output).containsInAnyOrder("value:UPDATE_BEFORE"); - pipeline.run(); - } - @Test public void testStreamingStateTagEncodingV2PreCompatibility() throws Exception { DataflowPipelineOptions options = buildPipelineOptions(); diff --git a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerValidatesRunnerTest.java b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerValidatesRunnerTest.java new file mode 100644 index 000000000000..24ac1c21717f --- /dev/null +++ b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowRunnerValidatesRunnerTest.java @@ -0,0 +1,218 @@ +/* + * 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 org.apache.beam.runners.dataflow; + +import static org.junit.Assume.assumeFalse; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import org.apache.beam.runners.dataflow.options.DataflowPipelineOptions; +import org.apache.beam.sdk.options.StreamingOptions; +import org.apache.beam.sdk.testing.BeamParallelJunit4Runner; +import org.apache.beam.sdk.testing.PAssert; +import org.apache.beam.sdk.testing.TestPipeline; +import org.apache.beam.sdk.testing.UsesStatefulParDo; +import org.apache.beam.sdk.testing.ValidatesRunner; +import org.apache.beam.sdk.transforms.Create; +import org.apache.beam.sdk.transforms.DoFn; +import org.apache.beam.sdk.transforms.ParDo; +import org.apache.beam.sdk.transforms.Reshuffle; +import org.apache.beam.sdk.transforms.windowing.BoundedWindow; +import org.apache.beam.sdk.transforms.windowing.PaneInfo; +import org.apache.beam.sdk.values.CausedByDrain; +import org.apache.beam.sdk.values.KV; +import org.apache.beam.sdk.values.PCollection; +import org.apache.beam.sdk.values.PCollectionTuple; +import org.apache.beam.sdk.values.TupleTag; +import org.apache.beam.sdk.values.TupleTagList; +import org.apache.beam.sdk.values.ValueKind; +import org.apache.beam.sdk.values.WindowedValues; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; + +/** + * {@link ValidatesRunner} tests for {@link DataflowRunner} that execute a pipeline on the service. + * + *

    These live apart from {@link DataflowRunnerTest} so that they can run under {@link + * BeamParallelJunit4Runner} (and thus concurrently, and merged into a single Dataflow job by {@link + * TestDataflowRunner}) while the unit tests in {@link DataflowRunnerTest}, some of which rely on + * process-wide state such as log capture, keep running serially under plain JUnit4. + * + *

    {@link Serializable} because the anonymous {@link TupleTag}s and {@link DoFn}s below capture + * the enclosing instance. + */ +@RunWith(BeamParallelJunit4Runner.class) +public class DataflowRunnerValidatesRunnerTest implements Serializable { + + @Rule public final transient TestPipeline pipeline = TestPipeline.create(); + + @Test + @Category({ValidatesRunner.class, UsesStatefulParDo.class}) + public void testBatchGroupIntoBatchesOverrideCount() { + // Ignore this test for streaming pipelines. + assumeFalse(pipeline.getOptions().as(StreamingOptions.class).isStreaming()); + DataflowRunnerTest.verifyGroupIntoBatchesOverrideCount(pipeline, false, true); + } + + @Test + @Category({ValidatesRunner.class, UsesStatefulParDo.class}) + public void testBatchGroupIntoBatchesOverrideBytes() { + // Ignore this test for streaming pipelines. + assumeFalse(pipeline.getOptions().as(StreamingOptions.class).isStreaming()); + DataflowRunnerTest.verifyGroupIntoBatchesOverrideBytes(pipeline, false, true); + } + + @Test + @Category({ValidatesRunner.class}) + public void testValueKindParameterAndOutputWithKind() { + boolean isRunnerV2 = false; + @Nullable List experiments = + pipeline.getOptions().as(DataflowPipelineOptions.class).getExperiments(); + if (experiments != null + && (experiments.contains("use_unified_worker") || experiments.contains("use_runner_v2"))) { + isRunnerV2 = true; + } + // Skipp runner v2 because its Create uses a splittable DoFn, which contains a shuffle. + // ValueKind is not supported in Dataflow shuffle yet + assumeFalse(isRunnerV2); + + PCollection input = pipeline.apply(Create.of("a", "b", "c", "d")); + TupleTag mainTag = new TupleTag() {}; + TupleTag sideTag = new TupleTag() {}; + + PCollectionTuple tuple = + input.apply( + "SetKind", + ParDo.of( + new DoFn() { + @ProcessElement + public void processElement( + @Element String element, + @Timestamp org.joda.time.Instant timestamp, + BoundedWindow window, + PaneInfo paneInfo, + ProcessContext c, + MultiOutputReceiver outputReceiver) { + switch (element) { + case "a": + c.output(element); // default: INSERT + return; + case "b": + c.outputWindowedValue( + WindowedValues.of( + element, + timestamp, + Collections.singleton(window), + paneInfo, + null, + null, + CausedByDrain.NORMAL, + null, + ValueKind.UPDATE_BEFORE)); + return; + case "c": + outputReceiver + .get(mainTag) + .builder(element) + .setValueKind(ValueKind.UPDATE_AFTER) + .output(); + return; + case "d": + outputReceiver + .get(sideTag) + .builder(element) + .setValueKind(ValueKind.DELETE) + .output(); + } + } + }) + .withOutputTags(mainTag, TupleTagList.of(sideTag))); + + PCollection main = + tuple + .get(mainTag) + .apply( + "ReadKind", + ParDo.of( + new DoFn() { + @ProcessElement + public void processElement( + @Element String element, ProcessContext c, ValueKind kind) { + c.output(element + ":" + kind); + } + })); + + PCollection side = + tuple + .get(sideTag) + .apply( + "ReadKind-SideTag", + ParDo.of( + new DoFn() { + @ProcessElement + public void processElement( + @Element String element, ProcessContext c, ValueKind kind) { + c.output(element + ":" + kind); + } + })); + + PAssert.that(main).containsInAnyOrder("a:INSERT", "b:UPDATE_BEFORE", "c:UPDATE_AFTER"); + PAssert.that(side).containsInAnyOrder("d:DELETE"); + pipeline.run(); + } + + @Test + @Ignore("enable once when element metadata is supported in shuffle") + @Category({ValidatesRunner.class}) + public void testValueKindPreservedAcrossShuffle() { + PCollection> input = pipeline.apply(Create.of(KV.of("key", "value"))); + + PCollection output = + input + .apply( + "SetKind", + ParDo.of( + new DoFn, KV>() { + @ProcessElement + public void processElement( + @Element KV element, + OutputReceiver> out) { + out.builder(element).setValueKind(ValueKind.UPDATE_BEFORE).output(); + } + })) + .apply(Reshuffle.of()) + .apply( + "ReadKind", + ParDo.of( + new DoFn, String>() { + @ProcessElement + public void processElement( + @Element KV element, ProcessContext c, ValueKind kind) { + c.output(element.getValue() + ":" + kind); + } + })); + + PAssert.that(output).containsInAnyOrder("value:UPDATE_BEFORE"); + pipeline.run(); + } +} diff --git a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowTestBatchCoordinatorTest.java b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowTestBatchCoordinatorTest.java new file mode 100644 index 000000000000..2657a2d787aa --- /dev/null +++ b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowTestBatchCoordinatorTest.java @@ -0,0 +1,1477 @@ +/* + * 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 org.apache.beam.runners.dataflow; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.isEmptyString; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.dataflow.model.ComponentTransform; +import com.google.api.services.dataflow.model.ExecutionStageState; +import com.google.api.services.dataflow.model.ExecutionStageSummary; +import com.google.api.services.dataflow.model.Job; +import com.google.api.services.dataflow.model.JobMetrics; +import com.google.api.services.dataflow.model.MetricStructuredName; +import com.google.api.services.dataflow.model.MetricUpdate; +import com.google.api.services.dataflow.model.PipelineDescription; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.beam.model.pipeline.v1.RunnerApi; +import org.apache.beam.runners.dataflow.DataflowPipelineTranslator.JobSpecification; +import org.apache.beam.runners.dataflow.DataflowTestBatchCoordinator.BatchingConfig; +import org.apache.beam.runners.dataflow.DataflowTestBatchCoordinator.ScopedDataflowPipelineJob; +import org.apache.beam.runners.dataflow.DataflowTestBatchCoordinator.StageAttribution; +import org.apache.beam.runners.dataflow.options.DataflowPipelineDebugOptions; +import org.apache.beam.runners.dataflow.options.DataflowPipelineWorkerPoolOptions; +import org.apache.beam.sdk.CompositePipeline; +import org.apache.beam.sdk.Pipeline; +import org.apache.beam.sdk.PipelineResult; +import org.apache.beam.sdk.PipelineResult.State; +import org.apache.beam.sdk.extensions.gcp.auth.TestCredential; +import org.apache.beam.sdk.extensions.gcp.options.GcpOptions; +import org.apache.beam.sdk.extensions.gcp.storage.NoopPathValidator; +import org.apache.beam.sdk.io.FileSystems; +import org.apache.beam.sdk.io.GenerateSequence; +import org.apache.beam.sdk.metrics.MetricKey; +import org.apache.beam.sdk.metrics.MetricName; +import org.apache.beam.sdk.metrics.MetricNameFilter; +import org.apache.beam.sdk.metrics.MetricQueryResults; +import org.apache.beam.sdk.metrics.MetricResult; +import org.apache.beam.sdk.metrics.MetricsFilter; +import org.apache.beam.sdk.options.PipelineOptions; +import org.apache.beam.sdk.options.PipelineOptionsFactory; +import org.apache.beam.sdk.options.StreamingOptions; +import org.apache.beam.sdk.testing.PAssert; +import org.apache.beam.sdk.testing.SerializableMatcher; +import org.apache.beam.sdk.testing.SerializableMatchers; +import org.apache.beam.sdk.testing.TestPipeline; +import org.apache.beam.sdk.transforms.Create; +import org.apache.beam.sdk.transforms.DoFn; +import org.apache.beam.sdk.transforms.ParDo; +import org.apache.beam.sdk.util.common.ReflectHelpers; +import org.apache.beam.sdk.util.construction.Environments; +import org.apache.beam.sdk.util.construction.PipelineTranslation; +import org.apache.beam.sdk.util.construction.SdkComponents; +import org.apache.beam.sdk.values.PCollection; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.hamcrest.Matchers; +import org.joda.time.Duration; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +/** + * Tests for {@link DataflowTestBatchCoordinator}, including Dataflow translation of a {@link + * CompositePipeline} (its runner-independent behavior is covered in {@code CompositePipelineTest}). + */ +@RunWith(JUnit4.class) +public class DataflowTestBatchCoordinatorTest { + + private static final long WAIT_SECONDS = 60; + private static final long NEVER_EXPIRING_WINDOW_MS = TimeUnit.MINUTES.toMillis(10); + private static final String STAGE_DONE = "JOB_STATE_DONE"; + private static final String STAGE_FAILED = "JOB_STATE_FAILED"; + private static final String STAGE_PENDING = "JOB_STATE_PENDING"; + + @Mock private DataflowClient mockClient; + private TestDataflowPipelineOptions options; + private ExecutorService pool; + private DataflowTestBatchCoordinator coordinator; + + /** Metrics served by {@link #mockClient}, keyed by job id. Safe to populate from any thread. */ + private final Map metricsByJobId = new ConcurrentHashMap<>(); + + /** The {@code JOB_VIEW_ALL} job served by {@link #mockClient} for any job id. */ + private final AtomicReference executionDetails = new AtomicReference<>(); + + private static class IdentityFn extends DoFn { + @ProcessElement + public void processElement(ProcessContext c) { + c.output(c.element()); + } + } + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + options = createTestOptions("TestBatchApp"); + pool = Executors.newCachedThreadPool(); + // Far longer than WAIT_SECONDS: batches in these tests close by reaching maxBatchSize, never + // by the window expiring, so member submissions can't race the timer. Tests that exercise + // window expiry configure their own short window. + configureBatching(2, NEVER_EXPIRING_WINDOW_MS); + when(mockClient.getJobMetrics(anyString())) + .thenAnswer(inv -> metricsByJobId.get(inv.getArgument(0))); + when(mockClient.getJob(anyString(), eq("JOB_VIEW_ALL"))) + .thenAnswer(inv -> executionDetails.get()); + } + + @After + public void tearDown() { + pool.shutdownNow(); + coordinator.shutdown(); + } + + /** + * Gives the current test a private coordinator with the given run-wide configuration. Must be + * called before any {@link Member} is created. + */ + private void configureBatching(int maxBatchSize, long windowMs) { + if (coordinator != null) { + coordinator.shutdown(); + } + coordinator = + new DataflowTestBatchCoordinator( + new BatchingConfig(/* enabled= */ true, maxBatchSize, windowMs)); + } + + private static TestDataflowPipelineOptions createTestOptions(String appName) { + TestDataflowPipelineOptions opts = PipelineOptionsFactory.as(TestDataflowPipelineOptions.class); + opts.setAppName(appName); + opts.setProject("test-project"); + opts.setRegion("us-central1"); + opts.setTempLocation("gs://test/temp/location"); + opts.setTempRoot("gs://test"); + opts.setGcpCredential(new TestCredential()); + opts.setRunner(TestDataflowRunner.class); + opts.setPathValidatorClass(NoopPathValidator.class); + // Pipeline.create registers file systems as a side effect but TestPipeline.fromOptions does + // not, and DataflowRunner.fromOptions needs the gs:// scheme to derive the staging location. + FileSystems.setDefaultPipelineOptions(opts); + return opts; + } + + /** One test pipeline together with the options and runner it is submitted through. */ + private final class Member { + final TestDataflowPipelineOptions opts; + final TestPipeline pipeline; + final TestDataflowRunner runner; + + Member(String appName) { + this.opts = createTestOptions(appName); + this.pipeline = namedTestPipeline(opts, appName); + this.runner = TestDataflowRunner.fromOptionsAndClient(opts, mockClient, coordinator); + } + + /** Applies {@code Create -> ParDo(stepName)} and a PAssert expecting {@code expected}. */ + Member withStep(String stepName, Integer... expected) { + PCollection out = + pipeline + .apply("Create-" + stepName, Create.of(1, 2)) + .apply(stepName, ParDo.of(new IdentityFn())); + PAssert.that(out).containsInAnyOrder(expected); + return this; + } + + Future submit(DataflowRunner delegate) { + return pool.submit(() -> runner.run(pipeline, delegate)); + } + } + + /** + * A {@link TestPipeline} with a unique root name, as {@link TestPipeline}'s JUnit rule produces + * when {@code -DbeamTestPipelineUniqueRootNames=true}. + */ + private static TestPipeline namedTestPipeline(TestDataflowPipelineOptions opts, String testName) { + TestPipeline pipeline = TestPipeline.fromOptions(opts); + pipeline.nameRoot(testName); + assertThat(pipeline.getRootName(), not(isEmptyString())); + return pipeline; + } + + // --------------------------------------------------------------------------------------------- + // Composite pipeline construction + // --------------------------------------------------------------------------------------------- + + @Test + public void testCompositePipelineTranslatesToDataflowWithScopedNames() { + TestPipeline p0 = namedTestPipeline(createTestOptions("App0"), "App0"); + PCollection out0 = + p0.apply("CreateValues", Create.of(1, 2, 3)).apply("MyStep", ParDo.of(new IdentityFn())); + PAssert.that(out0).containsInAnyOrder(1, 2, 3); + + TestPipeline p1 = namedTestPipeline(createTestOptions("App1"), "App1"); + PCollection out1 = + p1.apply("CreateValues", Create.of(4, 5)).apply("MyStep", ParDo.of(new IdentityFn())); + PAssert.that(out1).containsInAnyOrder(4, 5); + + String root0 = p0.getRootName(); + String root1 = p1.getRootName(); + assertThat(root0, not(equalTo(root1))); + // Root names also scope PCollection names, which Dataflow uses for output step names. + assertThat(out0.getName(), startsWith(root0 + "/")); + assertThat(out1.getName(), startsWith(root1 + "/")); + + CompositePipeline composite = new CompositePipeline(options, ImmutableList.of(p0, p1)); + // The coordinator attributes PAsserts per member, never on the composite as a whole. + assertEquals(1, PAssert.countAsserts(p0)); + assertEquals(1, PAssert.countAsserts(p1)); + + DataflowRunner runner = DataflowRunner.fromOptions(options); + SdkComponents portableComponents = + SdkComponents.create(options, Environments.createDockerEnvironment("test-image")); + RunnerApi.Pipeline portableProto = + PipelineTranslation.toProto(composite, portableComponents, false); + + Set uniqueNames = new HashSet<>(); + for (RunnerApi.PTransform transform : + portableProto.getComponents().getTransformsMap().values()) { + uniqueNames.add(transform.getUniqueName()); + } + assertThat(uniqueNames, hasItem(root0 + "/MyStep")); + assertThat(uniqueNames, hasItem(root1 + "/MyStep")); + assertThat(uniqueNames, not(hasItem("MyStep"))); + + runner.replaceV1Transforms(composite); + SdkComponents v1Components = + SdkComponents.create(options, Environments.createDockerEnvironment("test-image")); + RunnerApi.Pipeline v1Proto = PipelineTranslation.toProto(composite, v1Components, true, false); + DataflowPipelineTranslator translator = DataflowPipelineTranslator.fromOptions(options); + JobSpecification jobSpec = + translator.translate(composite, v1Proto, v1Components, runner, Collections.emptyList()); + assertTrue(jobSpec.getStepNames().size() >= 2); + + // Nothing about a member changes by having been part of a composite: it translates on its own + // with exactly the same names, so a standalone fallback run is equivalent to a fresh run. + RunnerApi.Pipeline aloneProto = PipelineTranslation.toProto(p0); + Set aloneUniqueNames = new HashSet<>(); + for (RunnerApi.PTransform transform : aloneProto.getComponents().getTransformsMap().values()) { + aloneUniqueNames.add(transform.getUniqueName()); + } + assertThat(aloneUniqueNames, hasItem(root0 + "/MyStep")); + assertEquals(root0, p0.getRootName()); + } + + @Test + public void testEligibilityChecks() { + TestPipeline boundedPipeline = namedTestPipeline(options, "Bounded"); + boundedPipeline.apply(Create.of(1, 2, 3)); + assertTrue(coordinator.isEligibleForBatching(boundedPipeline, options)); + + // Streaming pipelines bypass batching. + TestDataflowPipelineOptions streamingOpts = createTestOptions("StreamingApp"); + streamingOpts.setStreaming(true); + assertFalse(coordinator.isEligibleForBatching(boundedPipeline, streamingOpts)); + + // Unbounded pipelines bypass batching. + TestPipeline unboundedPipeline = namedTestPipeline(options, "Unbounded"); + unboundedPipeline.apply(GenerateSequence.from(0)); + assertFalse(coordinator.isEligibleForBatching(unboundedPipeline, options)); + + // Only TestPipelines with a unique root name can be told apart inside a merged job. + Pipeline plainPipeline = Pipeline.create(options); + plainPipeline.apply(Create.of(1, 2, 3)); + assertFalse(coordinator.isEligibleForBatching(plainPipeline, options)); + TestPipeline unnamedPipeline = TestPipeline.fromOptions(options); + unnamedPipeline.apply(Create.of(1, 2, 3)); + assertThat(unnamedPipeline.getRootName(), isEmptyString()); + assertFalse(coordinator.isEligibleForBatching(unnamedPipeline, options)); + + // Batching is a run-wide switch (coordinator config), not a per-pipeline option. + DataflowTestBatchCoordinator disabled = + new DataflowTestBatchCoordinator(new BatchingConfig(false, 2, 5000L)); + try { + assertFalse(disabled.isEligibleForBatching(boundedPipeline, options)); + } finally { + disabled.shutdown(); + } + DataflowTestBatchCoordinator singletonBatches = + new DataflowTestBatchCoordinator(new BatchingConfig(true, 1, 5000L)); + try { + assertFalse(singletonBatches.isEligibleForBatching(boundedPipeline, options)); + } finally { + singletonBatches.shutdown(); + } + + // A non-blocking run has no verdict to wait for. + TestDataflowPipelineOptions nonBlocking = createTestOptions("NonBlocking"); + nonBlocking.setBlockOnRun(false); + assertFalse(coordinator.isEligibleForBatching(boundedPipeline, nonBlocking)); + + // Custom result matchers would be evaluated against the merged job, not the member's own. + SerializableMatcher customMatcher = + SerializableMatchers.fromSupplier(() -> Matchers.notNullValue(PipelineResult.class)); + TestDataflowPipelineOptions successMatcher = createTestOptions("SuccessMatcher"); + successMatcher.setOnSuccessMatcher(customMatcher); + assertFalse(coordinator.isEligibleForBatching(boundedPipeline, successMatcher)); + TestDataflowPipelineOptions createMatcher = createTestOptions("CreateMatcher"); + createMatcher.setOnCreateMatcher(customMatcher); + assertFalse(coordinator.isEligibleForBatching(boundedPipeline, createMatcher)); + + // Whatever TestPipeline decides requires standalone execution (expected failures, serial + // tests, runtime value providers, ...) is honoured; newProvider is the simplest trigger. + TestPipeline standalone = namedTestPipeline(options, "Standalone"); + standalone.apply(Create.of(1, 2, 3)); + standalone.newProvider("runtime-value"); + assertTrue(standalone.isStandaloneExecutionRequired()); + assertFalse(coordinator.isEligibleForBatching(standalone, options)); + + // A pipeline with no primitive transform has nothing to run or verify. + assertFalse(coordinator.isEligibleForBatching(namedTestPipeline(options, "Empty"), options)); + + // An already-merged pipeline is never merged again. + assertFalse( + coordinator.isEligibleForBatching( + new CompositePipeline(options, ImmutableList.of(boundedPipeline)), options)); + } + + // --------------------------------------------------------------------------------------------- + // Options compatibility + // --------------------------------------------------------------------------------------------- + + /** An options interface the coordinator knows nothing about. */ + public interface CustomTestOptions extends PipelineOptions { + @Nullable String getFlavor(); + + void setFlavor(String value); + + @Nullable Object getOpaque(); + + void setOpaque(Object value); + } + + @Test + public void testCompatibilityKeyIgnoresPerTestIdentityOptions() { + TestDataflowPipelineOptions a = createTestOptions("AppA"); + TestDataflowPipelineOptions b = createTestOptions("AppB"); + b.setJobName("some-other-job-name"); + b.setTempLocation("gs://test/other/temp"); + b.setGcpTempLocation("gs://test/other/gcptemp"); + b.setOptionsId(a.getOptionsId() + 1); + b.setTestTimeoutSeconds(42L); + assertNotNull(DataflowTestBatchCoordinator.compatibilityKey(a)); + assertEquals( + DataflowTestBatchCoordinator.compatibilityKey(a), + DataflowTestBatchCoordinator.compatibilityKey(b)); + } + + @Test + public void testCompatibilityKeyDiffersForAnyJobAffectingOption() { + String base = DataflowTestBatchCoordinator.compatibilityKey(createTestOptions("App")); + + TestDataflowPipelineOptions machineType = createTestOptions("App"); + machineType.as(DataflowPipelineWorkerPoolOptions.class).setWorkerMachineType("n2-standard-8"); + assertNotEquals(base, DataflowTestBatchCoordinator.compatibilityKey(machineType)); + + TestDataflowPipelineOptions experiments = createTestOptions("App"); + experiments.setExperiments(ImmutableList.of("use_runner_v2")); + assertNotEquals(base, DataflowTestBatchCoordinator.compatibilityKey(experiments)); + + TestDataflowPipelineOptions debug = createTestOptions("App"); + debug.as(DataflowPipelineDebugOptions.class).setNumberOfWorkerHarnessThreads(3); + assertNotEquals(base, DataflowTestBatchCoordinator.compatibilityKey(debug)); + + TestDataflowPipelineOptions region = createTestOptions("App"); + region.setRegion("europe-west1"); + assertNotEquals(base, DataflowTestBatchCoordinator.compatibilityKey(region)); + + TestDataflowPipelineOptions streaming = createTestOptions("App"); + streaming.setStreaming(true); + assertNotEquals(base, DataflowTestBatchCoordinator.compatibilityKey(streaming)); + + // Options set through an interface the coordinator has never heard of still count. + TestDataflowPipelineOptions vanilla = createTestOptions("App"); + vanilla.as(CustomTestOptions.class).setFlavor("vanilla"); + TestDataflowPipelineOptions vanillaAgain = createTestOptions("App"); + vanillaAgain.as(CustomTestOptions.class).setFlavor("vanilla"); + TestDataflowPipelineOptions chocolate = createTestOptions("App"); + chocolate.as(CustomTestOptions.class).setFlavor("chocolate"); + assertNotEquals(base, DataflowTestBatchCoordinator.compatibilityKey(vanilla)); + assertEquals( + DataflowTestBatchCoordinator.compatibilityKey(vanilla), + DataflowTestBatchCoordinator.compatibilityKey(vanillaAgain)); + assertNotEquals( + DataflowTestBatchCoordinator.compatibilityKey(vanilla), + DataflowTestBatchCoordinator.compatibilityKey(chocolate)); + } + + @Test + public void testCompatibilityKeyIsNullForUnserializableOptions() { + TestDataflowPipelineOptions opts = createTestOptions("App"); + opts.as(CustomTestOptions.class).setOpaque(new Object()); + assertNull(DataflowTestBatchCoordinator.compatibilityKey(opts)); + } + + @Test + public void testCompatibilityKeyIgnoresDefaultsBoundByGetters() { + TestDataflowPipelineOptions untouched = createTestOptions("App"); + String base = DataflowTestBatchCoordinator.compatibilityKey(untouched); + + // What View.asList() and Reshuffle do during expansion: read an option nobody has set. The + // getter binds the default, which the serialized form then includes; the key must not change. + TestDataflowPipelineOptions read = createTestOptions("App"); + assertNull(read.as(StreamingOptions.class).getUpdateCompatibilityVersion()); + assertEquals(0, read.as(DataflowPipelineWorkerPoolOptions.class).getNumWorkers()); + assertEquals(base, DataflowTestBatchCoordinator.compatibilityKey(read)); + + // Explicitly setting a value, even one equal to the default, still counts: it is what the test + // asked for and the merged job must honour it. + TestDataflowPipelineOptions set = createTestOptions("App"); + set.as(DataflowPipelineWorkerPoolOptions.class).setNumWorkers(0); + assertNotEquals(base, DataflowTestBatchCoordinator.compatibilityKey(set)); + } + + @Test + public void testCompatibilityKeyHandlesStructuredValuesAcrossTestPipelineRoundTrip() + throws Exception { + // An explicitly set map: its content is part of the key, its insertion order is not. + TestDataflowPipelineOptions ab = createTestOptions("App"); + Map labelsAb = new LinkedHashMap<>(); + labelsAb.put("team", "beam"); + labelsAb.put("tier", "test"); + ab.setLabels(labelsAb); + TestDataflowPipelineOptions ba = createTestOptions("App"); + Map labelsBa = new LinkedHashMap<>(); + labelsBa.put("tier", "test"); + labelsBa.put("team", "beam"); + ba.setLabels(labelsBa); + TestDataflowPipelineOptions other = createTestOptions("App"); + other.setLabels(ImmutableMap.of("team", "beam", "tier", "prod")); + String keyAb = DataflowTestBatchCoordinator.compatibilityKey(ab); + assertEquals(keyAb, DataflowTestBatchCoordinator.compatibilityKey(ba)); + assertNotEquals(keyAb, DataflowTestBatchCoordinator.compatibilityKey(other)); + + // Lists are compared in order: this errs on the side of splitting a batch. + TestDataflowPipelineOptions xy = createTestOptions("App"); + xy.setExperiments(ImmutableList.of("x", "y")); + TestDataflowPipelineOptions yx = createTestOptions("App"); + yx.setExperiments(ImmutableList.of("y", "x")); + assertNotEquals( + DataflowTestBatchCoordinator.compatibilityKey(xy), + DataflowTestBatchCoordinator.compatibilityKey(yx)); + + // A structured default merely read by a getter stays out of the key, and an explicitly set + // structured value stays in it, also on the JSON copy TestPipeline.run hands the runner. + TestDataflowPipelineOptions untouched = createTestOptions("App"); + String base = DataflowTestBatchCoordinator.compatibilityKey(untouched); + TestDataflowPipelineOptions read = createTestOptions("App"); + assertNotNull(read.as(GcpOptions.class).getGcpOauthScopes()); + assertEquals(base, DataflowTestBatchCoordinator.compatibilityKey(read)); + assertEquals(base, DataflowTestBatchCoordinator.compatibilityKey(testPipelineCopy(read))); + assertEquals(keyAb, DataflowTestBatchCoordinator.compatibilityKey(testPipelineCopy(ab))); + assertEquals(keyAb, DataflowTestBatchCoordinator.compatibilityKey(testPipelineCopy(ba))); + assertNotEquals(keyAb, DataflowTestBatchCoordinator.compatibilityKey(testPipelineCopy(other))); + } + + /** The JSON round trip {@code TestPipeline.run(PipelineOptions)} performs before running. */ + private static TestDataflowPipelineOptions testPipelineCopy(PipelineOptions options) + throws IOException { + ObjectMapper mapper = + new ObjectMapper() + .registerModules(ObjectMapper.findModules(ReflectHelpers.findClassLoader())); + byte[] json = + mapper + .writer() + .withAttribute(PipelineOptionsFactory.SERIALIZE_DEFAULTS_ATTRIBUTE, true) + .writeValueAsBytes(options); + return mapper.readValue(json, PipelineOptions.class).as(TestDataflowPipelineOptions.class); + } + + @Test + public void testMembersWithIncompatibleOptionsAreNeverMerged() throws Exception { + // Each member ends up alone in its group; keep the wait for a partner short. + configureBatching(2, 300L); + Member small = new Member("SmallWorker").withStep("StepA", 1, 2); + Member big = new Member("BigWorker").withStep("StepB", 1, 2); + big.opts.as(DataflowPipelineWorkerPoolOptions.class).setWorkerMachineType("n2-standard-8"); + + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + int callNum = submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat( + "pipelines with different worker options must not share a job", + submitted, + not(instanceOf(CompositePipeline.class))); + String jobId = "standalone-" + callNum; + metricsByJobId.put( + jobId, + new JobMetrics() + .setMetrics( + ImmutableList.of( + createTentativePAssertUpdate("s1", PAssert.SUCCESS_COUNTER, 1)))); + return newStandaloneJob(jobId, State.DONE); + }); + + Future f0 = small.submit(delegate); + Future f1 = big.submit(delegate); + assertEquals(State.DONE, f0.get(WAIT_SECONDS, TimeUnit.SECONDS).getState()); + assertEquals(State.DONE, f1.get(WAIT_SECONDS, TimeUnit.SECONDS).getState()); + assertEquals(2, submittedJobs.get()); + } + + // --------------------------------------------------------------------------------------------- + // Merged job succeeds + // --------------------------------------------------------------------------------------------- + + @Test + public void testConcurrentPipelinesMergedIntoSingleBatchJobWithScopedMetrics() throws Exception { + Member m0 = new Member("App0").withStep("StepA", 1, 2); + Member m1 = new Member("App1").withStep("StepB", 1, 2); + + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + RunnerApi.Pipeline proto = PipelineTranslation.toProto(invocation.getArgument(0)); + List updates = new ArrayList<>(); + updates.addAll( + createCounterUpdates( + transformIdOf(proto, "StepA"), "CustomNs", "myCounter", 10L)); + updates.add(passertUpdate(proto, "StepA", PAssert.SUCCESS_COUNTER)); + updates.addAll( + createCounterUpdates( + transformIdOf(proto, "StepB"), "CustomNs", "myCounter", 20L)); + updates.add(passertUpdate(proto, "StepB", PAssert.SUCCESS_COUNTER)); + metricsByJobId.put("batch-job-1", new JobMetrics().setMetrics(updates)); + return newBatchJob("batch-job-1", State.DONE, proto); + }); + // The spy's DataflowMetrics was constructed around the un-spied job, so metric queries reach + // the real getState(), which polls the client. + when(mockClient.getJob(anyString())).thenReturn(new Job().setCurrentState(STAGE_DONE)); + + Future f0 = m0.submit(delegate); + Future f1 = m1.submit(delegate); + DataflowPipelineJob result0 = f0.get(WAIT_SECONDS, TimeUnit.SECONDS); + DataflowPipelineJob result1 = f1.get(WAIT_SECONDS, TimeUnit.SECONDS); + + assertEquals(1, submittedJobs.get()); + assertThat(result0, instanceOf(ScopedDataflowPipelineJob.class)); + assertThat(result1, instanceOf(ScopedDataflowPipelineJob.class)); + assertEquals(State.DONE, result0.getState()); + assertEquals(State.DONE, result1.waitUntilFinish()); + + assertSingleCounter(result0, "StepA", 10L); + assertSingleCounter(result1, "StepB", 20L); + // Members are not mutated by the merged run: their root names (and thus graphs) are unchanged. + assertThat(m0.pipeline.getRootName(), startsWith("t")); + assertThat(m0.pipeline.getRootName(), not(equalTo(m1.pipeline.getRootName()))); + } + + @Test + public void testPartialFailureInBatchOnlyRequiresTheFailingTestToBeExecutedAgain() + throws Exception { + configureBatching(3, NEVER_EXPIRING_WINDOW_MS); + Member good0 = new Member("GoodTest0").withStep("GoodStep0", 1, 2); + Member failing1 = new Member("FailingTest1").withStep("FailingStep1", 99); + Member good2 = new Member("GoodTest2").withStep("GoodStep2", 1, 2); + + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + RunnerApi.Pipeline proto = PipelineTranslation.toProto(submitted); + metricsByJobId.put( + "batch-job", + new JobMetrics() + .setMetrics( + ImmutableList.of( + passertUpdate(proto, "GoodStep0", PAssert.SUCCESS_COUNTER), + passertUpdate(proto, "FailingStep1", PAssert.FAILURE_COUNTER), + passertUpdate(proto, "GoodStep2", PAssert.SUCCESS_COUNTER)))); + return newBatchJob("batch-job", State.DONE, proto); + }); + + Future f0 = good0.submit(delegate); + Future f1 = failing1.submit(delegate); + Future f2 = good2.submit(delegate); + + // GoodTest0 and GoodTest2 are credited directly from the batch job. + assertThat(f0.get(WAIT_SECONDS, TimeUnit.SECONDS), instanceOf(ScopedDataflowPipelineJob.class)); + assertThat(f2.get(WAIT_SECONDS, TimeUnit.SECONDS), instanceOf(ScopedDataflowPipelineJob.class)); + + // FailingTest1 is not run again here: its test gets a request to be executed from scratch. + String reason = rerunReasonOf(f1); + assertThat(reason, containsString("FailingTest1")); + assertThat(reason, containsString("batch-job")); + assertThat(reason, containsString("job DONE, PAsserts unverified")); + + // The merged job is the only one submitted through the coordinator. + assertEquals(1, submittedJobs.get()); + } + + // --------------------------------------------------------------------------------------------- + // Merged job fails + // --------------------------------------------------------------------------------------------- + + @Test + public void testFailedBatchJobWithoutStageStatesRequiresEveryMemberToBeExecutedAgain() + throws Exception { + Member good0 = new Member("GoodTest0").withStep("GoodStep0", 1, 2); + Member failing1 = new Member("FailingTest1").withStep("FailingStep1", 99); + + // executionDetails stays null: the service returned no per-stage information. + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + return newBatchJob( + "failed-batch-job", State.FAILED, PipelineTranslation.toProto(submitted)); + }); + + Future f0 = good0.submit(delegate); + Future f1 = failing1.submit(delegate); + + // Without stage states nobody can be credited, not even the healthy test. + for (Future f : Arrays.asList(f0, f1)) { + String reason = rerunReasonOf(f); + assertThat(reason, containsString("failed-batch-job")); + assertThat(reason, containsString("stage states unavailable")); + } + assertEquals(1, submittedJobs.get()); + } + + @Test + public void testFailedBatchJobCreditsMembersWhoseStagesAllCompleted() throws Exception { + configureBatching(3, NEVER_EXPIRING_WINDOW_MS); + Member good0 = new Member("GoodTest0").withStep("GoodStep0", 1, 2); + Member failing1 = new Member("FailingTest1").withStep("FailingStep1", 99); + Member pending2 = new Member("PendingTest2").withStep("PendingStep2", 1, 2); + + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + RunnerApi.Pipeline proto = PipelineTranslation.toProto(submitted); + String s0 = scopeOf(proto, "GoodStep0"); + String s1 = scopeOf(proto, "FailingStep1"); + String s2 = scopeOf(proto, "PendingStep2"); + // The service reports per-stage states; only the failing member's stage failed. + // PendingTest2 has a success counter but one of its stages never reached DONE, so it + // must not be credited either. + executionDetails.set( + executionDetails( + stage("F1", STAGE_DONE, s0 + "/Create-GoodStep0/Read", s0 + "/GoodStep0"), + stage("F2", STAGE_FAILED, s1 + "/FailingStep1"), + stage("F3", STAGE_DONE, s2 + "/PendingStep2"), + stage("F4", STAGE_PENDING, s2 + "/Create-PendingStep2/Read"))); + metricsByJobId.put( + "failed-batch-job", + new JobMetrics() + .setMetrics( + ImmutableList.of( + passertUpdate(proto, "GoodStep0", PAssert.SUCCESS_COUNTER), + passertUpdate(proto, "PendingStep2", PAssert.SUCCESS_COUNTER)))); + return newBatchJob("failed-batch-job", State.FAILED, proto); + }); + + Future f0 = good0.submit(delegate); + Future f1 = failing1.submit(delegate); + Future f2 = pending2.submit(delegate); + + DataflowPipelineJob result0 = f0.get(WAIT_SECONDS, TimeUnit.SECONDS); + assertThat(result0, instanceOf(ScopedDataflowPipelineJob.class)); + assertEquals(State.DONE, result0.getState()); + assertEquals(State.DONE, result0.waitUntilFinish()); + + // The others must be executed again from scratch; the reason says why each was not credited. + String reason1 = rerunReasonOf(f1); + assertThat(reason1, containsString("FailingTest1")); + assertThat(reason1, containsString("failed-batch-job")); + assertThat(reason1, containsString("PAsserts unverified")); + String reason2 = rerunReasonOf(f2); + assertThat(reason2, containsString("PendingTest2")); + assertThat(reason2, containsString("PAsserts verified")); + + // Only the merged job was ever submitted through the coordinator. + assertEquals(1, submittedJobs.get()); + } + + @Test + public void testFailedBatchJobWithUnattributableFailedStageCreditsNobody() throws Exception { + Member m0 = new Member("Test0").withStep("Step0", 1, 2); + Member m1 = new Member("Test1").withStep("Step1", 1, 2); + + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + RunnerApi.Pipeline proto = PipelineTranslation.toProto(submitted); + // Both members' own stages are DONE and both have success counters, but a stage + // that names no user steps failed. It cannot be attributed, so it counts against + // everyone. + executionDetails.set( + executionDetails( + stage("F1", STAGE_DONE, scopeOf(proto, "Step0") + "/Step0"), + stage("F2", STAGE_DONE, scopeOf(proto, "Step1") + "/Step1"), + stage("F99", STAGE_FAILED))); + metricsByJobId.put( + "failed-batch-job", + new JobMetrics() + .setMetrics( + ImmutableList.of( + passertUpdate(proto, "Step0", PAssert.SUCCESS_COUNTER), + passertUpdate(proto, "Step1", PAssert.SUCCESS_COUNTER)))); + return newBatchJob("failed-batch-job", State.FAILED, proto); + }); + + Future f0 = m0.submit(delegate); + Future f1 = m1.submit(delegate); + assertThat(rerunReasonOf(f0), containsString("job FAILED")); + assertThat(rerunReasonOf(f1), containsString("job FAILED")); + assertEquals(1, submittedJobs.get()); + } + + @Test + public void testStageAttributionMatching() { + List scopes = ImmutableList.of("t0", "t1", "t10"); + StageAttribution attribution = + StageAttribution.fromJob( + executionDetails( + stage("F1", STAGE_DONE, "t0/A"), + stage("F2", STAGE_FAILED, "t1/B"), + // Runner V2 reports mangled names using '-' instead of '/'. + stage("F3", STAGE_DONE, "t0-A-out0"), + // "t10/..." must not be mistaken for scope "t1". + stage("F4", STAGE_FAILED, "t10/C"))); + assertNotNull(attribution); + assertTrue(attribution.statusForScope("t0", scopes).isComplete()); + assertFalse(attribution.statusForScope("t1", scopes).isComplete()); + assertFalse(attribution.statusForScope("t10", scopes).isComplete()); + assertThat(attribution.statusForScope("t0", scopes).toString(), is("2 stage(s) done")); + assertThat(attribution.statusForScope("t1", scopes).toString(), containsString("F2")); + assertThat(attribution.statusForScope("t1", scopes).toString(), not(containsString("F4"))); + + // A scope that owns no stages at all is not complete. + assertFalse(attribution.statusForScope("t7", scopes).isComplete()); + + // A failed stage whose names fall under no known scope counts against every scope. + StageAttribution withUnknown = + StageAttribution.fromJob( + executionDetails( + stage("F1", STAGE_DONE, "t0/A"), stage("F5", STAGE_FAILED, "Mystery/X"))); + assertNotNull(withUnknown); + assertFalse(withUnknown.statusForScope("t0", scopes).isComplete()); + + // A stage with no state entry at all is treated as not done. + StageAttribution missingState = + new StageAttributionBuilder() + .describe("F1", "t0/A") + .describe("F2", "t0/B") + .state("F1", STAGE_DONE) + .build(); + assertNotNull(missingState); + assertFalse(missingState.statusForScope("t0", scopes).isComplete()); + assertThat(missingState.statusForScope("t0", scopes).toString(), containsString("F2=UNKNOWN")); + } + + @Test + public void testStageAttributionRequiresBothStatesAndDescriptions() { + assertNull(StageAttribution.fromJob(null)); + assertNull(StageAttribution.fromJob(new Job())); + assertNull( + StageAttribution.fromJob( + new Job() + .setStageStates( + ImmutableList.of( + new ExecutionStageState() + .setExecutionStageName("F1") + .setExecutionStageState(STAGE_DONE))))); + assertNull( + StageAttribution.fromJob( + new Job() + .setPipelineDescription( + new PipelineDescription() + .setExecutionPipelineStage( + ImmutableList.of(new ExecutionStageSummary().setName("F1")))))); + assertNotNull(StageAttribution.fromJob(executionDetails(stage("F1", STAGE_DONE, "t0/A")))); + } + + // --------------------------------------------------------------------------------------------- + // Submission failures, interruption and unexpected errors + // --------------------------------------------------------------------------------------------- + + @Test + public void testFailedSubmissionRequiresEveryMemberToBeExecutedAgain() throws Exception { + Member m0 = new Member("App0").withStep("Step0", 1, 2); + Member m1 = new Member("App1").withStep("Step1", 1, 2); + + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + throw new RuntimeException("Simulated batch submission failure"); + }); + + Future f0 = m0.submit(delegate); + Future f1 = m1.submit(delegate); + + for (Future f : Arrays.asList(f0, f1)) { + String reason = rerunReasonOf(f); + assertThat(reason, containsString("(never submitted)")); + assertThat(reason, containsString("Simulated batch submission failure")); + } + // The member pipelines themselves are never submitted by the coordinator. + assertEquals(1, submittedJobs.get()); + } + + @Test + public void testInterruptedMemberDoesNotAffectOtherMembers() throws Exception { + Member m0 = new Member("App0").withStep("Step0", 1, 2); + Member m1 = new Member("App1").withStep("Step1", 1, 2); + + CountDownLatch compositeEntered = new CountDownLatch(1); + CountDownLatch releaseComposite = new CountDownLatch(1); + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + compositeEntered.countDown(); + assertTrue(releaseComposite.await(WAIT_SECONDS, TimeUnit.SECONDS)); + RunnerApi.Pipeline proto = PipelineTranslation.toProto(submitted); + metricsByJobId.put( + "batch-job", + new JobMetrics() + .setMetrics( + ImmutableList.of( + passertUpdate(proto, "Step0", PAssert.SUCCESS_COUNTER), + passertUpdate(proto, "Step1", PAssert.SUCCESS_COUNTER)))); + return newBatchJob("batch-job", State.DONE, proto); + }); + + AtomicReference failure0 = new AtomicReference<>(); + Future f0 = + pool.submit( + () -> { + try { + return m0.runner.run(m0.pipeline, delegate); + } catch (RuntimeException e) { + failure0.set(e); + throw e; + } + }); + Future f1 = m1.submit(delegate); + + assertTrue(compositeEntered.await(WAIT_SECONDS, TimeUnit.SECONDS)); + // Member 0's test is interrupted (e.g. by a JUnit timeout) while the merged job is running. + f0.cancel(true); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(WAIT_SECONDS); + while (failure0.get() == null && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertNotNull("member 0 should have observed the interrupt", failure0.get()); + assertThat(failure0.get().getCause(), instanceOf(InterruptedException.class)); + + releaseComposite.countDown(); + DataflowPipelineJob result1 = f1.get(WAIT_SECONDS, TimeUnit.SECONDS); + assertThat(result1, instanceOf(ScopedDataflowPipelineJob.class)); + assertEquals(1, submittedJobs.get()); + } + + @Test + public void testUnexpectedCoordinatorFailureFailsAllMembersPromptly() throws Exception { + Member m0 = new Member("App0").withStep("Step0", 1, 2); + Member m1 = new Member("App1").withStep("Step1", 1, 2); + + // Not an IOException, so it escapes TestDataflowRunner.getJobMetrics and surfaces as an + // unexpected failure inside the batch runner after the merged job has completed. + when(mockClient.getJobMetrics(anyString())) + .thenThrow(new IllegalStateException("metrics backend exploded")); + + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + return newBatchJob("batch-job", State.DONE, PipelineTranslation.toProto(submitted)); + }); + + Future f0 = m0.submit(delegate); + Future f1 = m1.submit(delegate); + for (Future f : Arrays.asList(f0, f1)) { + Throwable cause = causeOf(f); + assertThat(cause, instanceOf(IllegalStateException.class)); + assertThat(cause.getMessage(), containsString("metrics backend exploded")); + } + // Nobody fell back to a standalone run: the failure was in the test harness, not the job. + assertEquals(1, submittedJobs.get()); + } + + // --------------------------------------------------------------------------------------------- + // Scheduling and concurrency + // --------------------------------------------------------------------------------------------- + + @Test + public void testStragglerIsNotBlockedByInFlightBatch() throws Exception { + // A and B are submitted back-to-back and close a batch well within the window; the straggler + // then waits out one full window alone, so keep it short. + configureBatching(2, 1000L); + Member a = new Member("A").withStep("StepA", 1, 2); + Member b = new Member("B").withStep("StepB", 1, 2); + Member straggler = new Member("Straggler").withStep("StepC", 1, 2); + + CountDownLatch compositeEntered = new CountDownLatch(1); + CountDownLatch releaseComposite = new CountDownLatch(1); + AtomicBoolean stragglerRanWhileBatchInFlight = new AtomicBoolean(false); + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + int callNum = submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + RunnerApi.Pipeline proto = PipelineTranslation.toProto(submitted); + if (submitted instanceof CompositePipeline) { + compositeEntered.countDown(); + assertTrue(releaseComposite.await(WAIT_SECONDS, TimeUnit.SECONDS)); + metricsByJobId.put( + "batch-job", + new JobMetrics() + .setMetrics( + ImmutableList.of( + passertUpdate(proto, "StepA", PAssert.SUCCESS_COUNTER), + passertUpdate(proto, "StepB", PAssert.SUCCESS_COUNTER)))); + return newBatchJob("batch-job", State.DONE, proto); + } + assertTrue(hasTransform(proto, "StepC")); + stragglerRanWhileBatchInFlight.set(releaseComposite.getCount() == 1); + String jobId = "standalone-" + callNum; + metricsByJobId.put( + jobId, + new JobMetrics() + .setMetrics( + ImmutableList.of( + createTentativePAssertUpdate("s1", PAssert.SUCCESS_COUNTER, 1)))); + return newStandaloneJob(jobId, State.DONE); + }); + + Future fa = a.submit(delegate); + Future fb = b.submit(delegate); + assertTrue(compositeEntered.await(WAIT_SECONDS, TimeUnit.SECONDS)); + + // While the {A, B} job is in flight, a third test arrives. Its batch window closes with no + // partner, so it must run standalone right away rather than wait for the in-flight job. + Future fc = straggler.submit(delegate); + DataflowPipelineJob resultC = fc.get(WAIT_SECONDS, TimeUnit.SECONDS); + assertThat(resultC, not(instanceOf(ScopedDataflowPipelineJob.class))); + assertTrue(stragglerRanWhileBatchInFlight.get()); + + releaseComposite.countDown(); + assertThat(fa.get(WAIT_SECONDS, TimeUnit.SECONDS), instanceOf(ScopedDataflowPipelineJob.class)); + assertThat(fb.get(WAIT_SECONDS, TimeUnit.SECONDS), instanceOf(ScopedDataflowPipelineJob.class)); + assertEquals(2, submittedJobs.get()); + } + + @Test + public void testBatchClosesAtMaxSizeWithoutWaitingForWindow() throws Exception { + // The window is far longer than this test is willing to wait, so the merged job can only be + // submitted in time if the batch closes as soon as it reaches maxBatchSize. + long windowMs = NEVER_EXPIRING_WINDOW_MS; + configureBatching(2, windowMs); + Member a = new Member("A").withStep("StepA", 1, 2); + Member b = new Member("B").withStep("StepB", 1, 2); + + CountDownLatch compositeEntered = new CountDownLatch(1); + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + RunnerApi.Pipeline proto = PipelineTranslation.toProto(submitted); + compositeEntered.countDown(); + metricsByJobId.put( + "batch-job", + new JobMetrics() + .setMetrics( + ImmutableList.of( + passertUpdate(proto, "StepA", PAssert.SUCCESS_COUNTER), + passertUpdate(proto, "StepB", PAssert.SUCCESS_COUNTER)))); + return newBatchJob("batch-job", State.DONE, proto); + }); + + long startNanos = System.nanoTime(); + Future fa = a.submit(delegate); + Future fb = b.submit(delegate); + assertTrue(compositeEntered.await(WAIT_SECONDS, TimeUnit.SECONDS)); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + assertTrue( + "Batch of maxBatchSize members waited " + elapsedMs + "ms, i.e. for the window to expire", + elapsedMs < windowMs / 2); + + assertThat(fa.get(WAIT_SECONDS, TimeUnit.SECONDS), instanceOf(ScopedDataflowPipelineJob.class)); + assertThat(fb.get(WAIT_SECONDS, TimeUnit.SECONDS), instanceOf(ScopedDataflowPipelineJob.class)); + assertEquals(1, submittedJobs.get()); + } + + // --------------------------------------------------------------------------------------------- + // Merged job naming + // --------------------------------------------------------------------------------------------- + + private static final String DATAFLOW_JOB_NAME_REGEX = "[a-z]([-a-z0-9]*[a-z0-9])?"; + + @Test + public void testBatchJobNameIsValidAndKeepsUniqueSuffix() { + // 2026-10-02T16:00:00Z + long now = 1790956800000L; + String name = + DataflowTestBatchCoordinator.batchJobName(7, "ParDoTest$Basic-tests", now, 0xdeadbeef); + assertEquals("batch-7-pardotest0basic0tests-1002160000-deadbeef", name); + assertTrue(name, name.matches(DATAFLOW_JOB_NAME_REGEX)); + + // A long app name (typical for ValidatesRunner suites) is truncated, but only the hint is: + // the timestamp and random suffix that make the name unique always survive. + StringBuilder longAppName = new StringBuilder(); + for (int i = 0; i < 20; i++) { + longAppName.append("SomeVeryLongTestClassName"); + } + String truncated = + DataflowTestBatchCoordinator.batchJobName(7, longAppName.toString(), now, 0xdeadbeef); + assertEquals(DataflowTestBatchCoordinator.MAX_JOB_NAME_LENGTH, truncated.length()); + assertTrue(truncated, truncated.startsWith("batch-7-someverylongtestclassname")); + assertTrue(truncated, truncated.endsWith("-1002160000-deadbeef")); + assertTrue(truncated, truncated.matches(DATAFLOW_JOB_NAME_REGEX)); + + // Same batch number (as in two JVMs running the same suite concurrently) and same app name + // still yield distinct names thanks to the random part. + assertNotEquals( + DataflowTestBatchCoordinator.batchJobName(1, longAppName.toString(), now, 1), + DataflowTestBatchCoordinator.batchJobName(1, longAppName.toString(), now, 2)); + + // Degenerate hints never produce an invalid name. + assertTrue( + DataflowTestBatchCoordinator.batchJobName(1, null, now, 1) + .matches(DATAFLOW_JOB_NAME_REGEX)); + assertTrue( + DataflowTestBatchCoordinator.batchJobName(1, "", now, 1).matches(DATAFLOW_JOB_NAME_REGEX)); + assertTrue( + DataflowTestBatchCoordinator.batchJobName(1, "---", now, 1) + .matches(DATAFLOW_JOB_NAME_REGEX)); + } + + @Test + public void testMergedJobIsSubmittedUnderBatchNameAndLeaderNameIsRestored() throws Exception { + Member m0 = new Member("App0").withStep("Step0", 1, 2); + Member m1 = new Member("App1").withStep("Step1", 1, 2); + String leaderJobName = m0.opts.getJobName(); + String otherJobName = m1.opts.getJobName(); + + AtomicReference submittedName = new AtomicReference<>(); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + submittedName.set(submitted.getOptions().getJobName()); + RunnerApi.Pipeline proto = PipelineTranslation.toProto(submitted); + metricsByJobId.put( + "batch-job", + new JobMetrics() + .setMetrics( + ImmutableList.of( + passertUpdate(proto, "Step0", PAssert.SUCCESS_COUNTER), + passertUpdate(proto, "Step1", PAssert.SUCCESS_COUNTER)))); + return newBatchJob("batch-job", State.DONE, proto); + }); + + Future f0 = m0.submit(delegate); + Future f1 = m1.submit(delegate); + f0.get(WAIT_SECONDS, TimeUnit.SECONDS); + f1.get(WAIT_SECONDS, TimeUnit.SECONDS); + + String name = submittedName.get(); + assertNotNull(name); + assertTrue(name, name.startsWith("batch-")); + assertTrue(name, name.matches(DATAFLOW_JOB_NAME_REGEX)); + assertTrue(name, name.length() <= DataflowTestBatchCoordinator.MAX_JOB_NAME_LENGTH); + assertNotEquals(leaderJobName, name); + // The leader's own job name is intact afterwards; the batch name was only ever borrowed. + assertEquals(leaderJobName, m0.opts.getJobName()); + assertEquals(otherJobName, m1.opts.getJobName()); + } + + // --------------------------------------------------------------------------------------------- + // No-hang guarantee + // --------------------------------------------------------------------------------------------- + + @Test + public void testMergedJobExceedingShortestMemberTimeoutIsCancelledAndMembersRerun() + throws Exception { + Member m0 = new Member("App0").withStep("Step0", 1, 2); + Member m1 = new Member("App1").withStep("Step1", 1, 2); + // Different per-test timeouts do not prevent merging; the merged job gets the shortest one. + m0.opts.setTestTimeoutSeconds(7L); + m1.opts.setTestTimeoutSeconds(3L); + + AtomicReference batchJob = new AtomicReference<>(); + AtomicReference waitedFor = new AtomicReference<>(); + AtomicInteger submittedJobs = new AtomicInteger(0); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + submittedJobs.incrementAndGet(); + Pipeline submitted = invocation.getArgument(0); + assertThat(submitted, instanceOf(CompositePipeline.class)); + DataflowPipelineJob job = + newBatchJob("batch-job", State.RUNNING, PipelineTranslation.toProto(submitted)); + // The merged job never terminates: waitUntilFinish gives up and returns null. + Mockito.doAnswer( + inv -> { + waitedFor.set(inv.getArgument(0)); + return null; + }) + .when(job) + .waitUntilFinish(any(), any()); + Mockito.doReturn(State.CANCELLED).when(job).cancel(); + batchJob.set(job); + return job; + }); + + Future f0 = m0.submit(delegate); + Future f1 = m1.submit(delegate); + + // Nobody waited beyond the shortest member timeout; the runaway job was cancelled and every + // member is sent back to be executed again from scratch. + assertThat(rerunReasonOf(f0), containsString("did not terminate within PT3S")); + assertThat(rerunReasonOf(f1), containsString("did not terminate within PT3S")); + assertEquals(Duration.standardSeconds(3), waitedFor.get()); + Mockito.verify(batchJob.get()).cancel(); + assertEquals(1, submittedJobs.get()); + } + + @Test + public void testStalledHarnessFailsTestWithDiagnosticsInsteadOfHanging() throws Exception { + // Short enough to keep the verdict timeout (2 windows + 2 x test timeout = 4 s) small, long + // enough that both members reliably enqueue before the batch would close without the second. + configureBatching(2, 1000L); + Member m0 = new Member("App0").withStep("Step0", 1, 2); + Member m1 = new Member("App1").withStep("Step1", 1, 2); + m0.opts.setTestTimeoutSeconds(1L); + m1.opts.setTestTimeoutSeconds(1L); + + // Simulate the batch runner wedging somewhere it never should (here: inside submission). + CountDownLatch release = new CountDownLatch(1); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + assertThat(invocation.getArgument(0), instanceOf(CompositePipeline.class)); + assertTrue(release.await(WAIT_SECONDS, TimeUnit.SECONDS)); + throw new RuntimeException("released"); + }); + + try { + Future f0 = m0.submit(delegate); + Future f1 = m1.submit(delegate); + for (Future f : Arrays.asList(f0, f1)) { + Throwable cause = causeOf(f); + assertThat(cause, instanceOf(IllegalStateException.class)); + assertThat(cause.getMessage(), containsString("No verdict for test App")); + assertThat(cause.getMessage(), containsString("bug in the test batching machinery")); + // The diagnostics say how far the member got: assembled into a batch, job never submitted. + assertThat(cause.getMessage(), containsString("batch scope: t")); + assertThat(cause.getMessage(), containsString("merged job: never submitted")); + } + } finally { + release.countDown(); + } + } + + @Test + public void testShutdownFailsWaitingMembersPromptlyAndRejectsNewSubmissions() throws Exception { + // A window far longer than the test so the member is parked in the scheduler when we stop it. + configureBatching(2, 60_000L); + Member m0 = new Member("App0").withStep("Step0", 1, 2); + DataflowRunner delegate = Mockito.mock(DataflowRunner.class); + when(delegate.run(any(Pipeline.class))) + .thenThrow(new AssertionError("nothing should be submitted")); + + Future f0 = m0.submit(delegate); + // Let the scheduler take the member into its (open) batch window; whether or not it has by + // the time we stop, the member must fail rather than be stranded. + Thread.sleep(200); + coordinator.shutdown(); + + Throwable cause = causeOf(f0); + assertThat(cause, instanceOf(IllegalStateException.class)); + assertThat(cause.getMessage(), containsString("stopped before scheduling App0")); + + // Later submissions are rejected outright instead of being enqueued onto a dead scheduler. + Member m1 = new Member("App1").withStep("Step1", 1, 2); + Throwable rejected = causeOf(m1.submit(delegate)); + assertThat(rejected, instanceOf(IllegalStateException.class)); + assertThat(rejected.getMessage(), containsString("has been shut down")); + } + + // --------------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------------- + + /** Returns the cause of the {@link ExecutionException} thrown by {@code future}. */ + private static Throwable causeOf(Future future) { + try { + future.get(WAIT_SECONDS, TimeUnit.SECONDS); + } catch (ExecutionException e) { + return e.getCause(); + } catch (Exception e) { + throw new AssertionError("Unexpected exception", e); + } + fail("Expected the future to fail"); + return null; + } + + /** + * Asserts that the member behind {@code future} was told to have its test executed again as a job + * of its own (see {@link TestPipeline.StandaloneRerunRequested}) and returns the request's + * message, which names the test, the merged job and the reason. + */ + private static String rerunReasonOf(Future future) { + Throwable cause = causeOf(future); + assertThat(cause, instanceOf(TestPipeline.StandaloneRerunRequested.class)); + assertThat(cause.getMessage(), containsString("BeamParallelJunit4Runner")); + return cause.getMessage(); + } + + private DataflowPipelineJob newBatchJob(String jobId, State state, RunnerApi.Pipeline proto) + throws Exception { + DataflowPipelineJob job = + Mockito.spy( + new DataflowPipelineJob(mockClient, jobId, options, Collections.emptyMap(), proto)); + Mockito.doReturn(state).when(job).getState(); + Mockito.doReturn(state).when(job).waitUntilFinish(any(), any()); + return job; + } + + private static DataflowPipelineJob newStandaloneJob(String jobId, State state) throws Exception { + DataflowPipelineJob job = Mockito.mock(DataflowPipelineJob.class); + when(job.getJobId()).thenReturn(jobId); + when(job.getState()).thenReturn(state); + when(job.waitUntilFinish(any(), any())).thenReturn(state); + return job; + } + + /** Returns the id of the transform whose unique name is {@code stepName} or ends in it. */ + private static String transformIdOf(RunnerApi.Pipeline proto, String stepName) { + for (Map.Entry entry : + proto.getComponents().getTransformsMap().entrySet()) { + String uniqueName = entry.getValue().getUniqueName(); + if (uniqueName.equals(stepName) || uniqueName.endsWith("/" + stepName)) { + return entry.getKey(); + } + } + throw new AssertionError("No transform named " + stepName + " in pipeline"); + } + + private static boolean hasTransform(RunnerApi.Pipeline proto, String stepName) { + for (RunnerApi.PTransform t : proto.getComponents().getTransformsMap().values()) { + if (t.getUniqueName().equals(stepName) || t.getUniqueName().endsWith("/" + stepName)) { + return true; + } + } + return false; + } + + /** Returns the batch scope prefix (e.g. {@code t1}) the member owning {@code stepName} got. */ + private static String scopeOf(RunnerApi.Pipeline proto, String stepName) { + String uniqueName = + proto + .getComponents() + .getTransformsMap() + .get(transformIdOf(proto, stepName)) + .getUniqueName(); + int slash = uniqueName.indexOf('/'); + assertTrue("Expected a scoped name but got " + uniqueName, slash > 0); + return uniqueName.substring(0, slash); + } + + private static MetricUpdate passertUpdate( + RunnerApi.Pipeline proto, String stepName, String counterName) { + return createTentativePAssertUpdate(transformIdOf(proto, stepName), counterName, 1); + } + + private static void assertSingleCounter(DataflowPipelineJob job, String step, long expected) { + MetricQueryResults metrics = + job.metrics() + .queryMetrics( + MetricsFilter.builder() + .addNameFilter(MetricNameFilter.named("CustomNs", "myCounter")) + .build()); + List> counters = ImmutableList.copyOf(metrics.getCounters()); + assertEquals(1, counters.size()); + assertEquals( + MetricKey.create(step, MetricName.named("CustomNs", "myCounter")), + counters.get(0).getKey()); + assertThat(counters.get(0).getCommitted(), is(expected)); + } + + private static final class StageInfo { + final String name; + final String state; + final List userNames; + + StageInfo(String name, String state, List userNames) { + this.name = name; + this.state = state; + this.userNames = userNames; + } + } + + private static StageInfo stage(String name, String state, String... userNames) { + return new StageInfo(name, state, Arrays.asList(userNames)); + } + + /** Builds a {@code JOB_VIEW_ALL} job carrying stage states and stage descriptions. */ + private static Job executionDetails(StageInfo... stages) { + StageAttributionBuilder builder = new StageAttributionBuilder(); + for (StageInfo s : stages) { + builder.state(s.name, s.state); + builder.describe(s.name, s.userNames.toArray(new String[0])); + } + return builder.job(); + } + + private static final class StageAttributionBuilder { + private final List states = new ArrayList<>(); + private final List summaries = new ArrayList<>(); + + StageAttributionBuilder state(String stageName, String state) { + states.add( + new ExecutionStageState().setExecutionStageName(stageName).setExecutionStageState(state)); + return this; + } + + StageAttributionBuilder describe(String stageName, String... userNames) { + List transforms = new ArrayList<>(); + for (String userName : userNames) { + transforms.add(new ComponentTransform().setUserName(userName)); + } + summaries.add( + new ExecutionStageSummary().setName(stageName).setComponentTransform(transforms)); + return this; + } + + Job job() { + return new Job() + .setStageStates(states) + .setPipelineDescription(new PipelineDescription().setExecutionPipelineStage(summaries)); + } + + StageAttribution build() { + return StageAttribution.fromJob(job()); + } + } + + private static MetricUpdate createTentativePAssertUpdate( + String stepId, String counterName, int value) { + MetricStructuredName name = + new MetricStructuredName() + .setName(counterName) + .setOrigin("user") + .setContext( + ImmutableMap.of( + "step", stepId, "namespace", PAssert.class.getName(), "tentative", "true")); + return new MetricUpdate().setName(name).setScalar(new BigDecimal(value)); + } + + private static List createCounterUpdates( + String stepId, String namespace, String counterName, long value) { + MetricStructuredName committedName = + new MetricStructuredName() + .setName(counterName) + .setOrigin("user") + .setContext(ImmutableMap.of("step", stepId, "namespace", namespace)); + MetricStructuredName tentativeName = + new MetricStructuredName() + .setName(counterName) + .setOrigin("user") + .setContext( + ImmutableMap.of("step", stepId, "namespace", namespace, "tentative", "true")); + return ImmutableList.of( + new MetricUpdate().setName(committedName).setScalar(new BigDecimal(value)), + new MetricUpdate().setName(tentativeName).setScalar(new BigDecimal(value))); + } +} diff --git a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowTestBatchFallbackTest.java b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowTestBatchFallbackTest.java new file mode 100644 index 000000000000..f5581e914dfb --- /dev/null +++ b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/DataflowTestBatchFallbackTest.java @@ -0,0 +1,132 @@ +/* + * 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 org.apache.beam.runners.dataflow; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; + +import org.apache.beam.runners.dataflow.DataflowTestBatchCoordinator.ScopedDataflowPipelineJob; +import org.apache.beam.sdk.PipelineResult; +import org.apache.beam.sdk.PipelineResult.State; +import org.apache.beam.sdk.testing.BeamParallelJunit4Runner; +import org.apache.beam.sdk.testing.PAssert; +import org.apache.beam.sdk.testing.TestPipeline; +import org.apache.beam.sdk.testing.ValidatesRunner; +import org.apache.beam.sdk.transforms.Create; +import org.apache.beam.sdk.transforms.DoFn; +import org.apache.beam.sdk.transforms.ParDo; +import org.apache.beam.sdk.values.PCollection; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.runner.RunWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ValidatesRunner tests that deliberately exercise {@link DataflowTestBatchCoordinator}'s fallback + * path against the real service: one test fails only while it is part of a merged job, so the + * merged job fails, the coordinator attributes the failure, that test's {@code run()} ends in + * {@link TestPipeline.StandaloneRerunRequested}, and {@link BeamParallelJunit4Runner} executes the + * test again from scratch as a job of its own (where it passes). The other tests are healthy + * companions that give the merged job stages to attribute; they pass whether they are credited from + * the merged job or executed again. + * + *

    Keep this class small. Everything merged with the failing test may have to be executed again, + * and since a test JVM runs one test class at a time a merged job only ever contains tests of a + * single class, so the size of this class bounds that cost. All tests run concurrently under {@link + * BeamParallelJunit4Runner} so that they do end up in the same batch. + * + *

    When batching is disabled (streaming tasks, {@code -PtestBatching=false}) every test here runs + * standalone and simply passes. + */ +@RunWith(BeamParallelJunit4Runner.class) +public class DataflowTestBatchFallbackTest { + private static final Logger LOG = LoggerFactory.getLogger(DataflowTestBatchFallbackTest.class); + + @Rule public final transient TestPipeline p = TestPipeline.create(); + + /** Fails inside a merged job (recognised by its job name) and passes in a standalone one. */ + private static class FailWhenMergedFn extends DoFn { + @ProcessElement + public void processElement(ProcessContext c) { + String jobName = c.getPipelineOptions().getJobName(); + if (jobName != null + && jobName.startsWith(DataflowTestBatchCoordinator.BATCH_JOB_NAME_PREFIX)) { + throw new IllegalStateException( + "Deliberate failure inside merged Dataflow test job " + jobName); + } + c.output(c.element()); + } + } + + private static class IdentityFn extends DoFn { + @ProcessElement + public void processElement(ProcessContext c) { + c.output(c.element()); + } + } + + @Test + @Category(ValidatesRunner.class) + public void testMemberFailingOnlyWhenMergedFallsBackAndPasses() { + PCollection out = + p.apply(Create.of(1, 2, 3)).apply("FailWhenMerged", ParDo.of(new FailWhenMergedFn())); + PAssert.that(out).containsInAnyOrder(1, 2, 3); + + PipelineResult result = p.run(); + + // This test can never be credited from a merged job: its DoFn throws before any PAssert runs + // there, so the merged attempt has no success counters for it. Either batching was off, or this + // is the standalone re-execution of the test. + assertThat(result, not(instanceOf(ScopedDataflowPipelineJob.class))); + assertEquals(State.DONE, result.getState()); + } + + @Test + @Category(ValidatesRunner.class) + public void testHealthyCompanionA() { + runHealthyCompanion("CompanionA"); + } + + @Test + @Category(ValidatesRunner.class) + public void testHealthyCompanionB() { + runHealthyCompanion("CompanionB"); + } + + private void runHealthyCompanion(String name) { + PCollection out = p.apply(Create.of(4, 5, 6)).apply(name, ParDo.of(new IdentityFn())); + PAssert.that(out).containsInAnyOrder(4, 5, 6); + + PipelineResult result = p.run(); + + // Whether the coordinator could credit this test from the failed merged job (stage states + // attributable) or had to re-run it is informative for the postcommit log, but both are passes. + LOG.info( + "{} finished {} ({})", + name, + result.getState(), + result instanceof ScopedDataflowPipelineJob + ? "credited from merged job" + : "standalone or re-run"); + assertEquals(State.DONE, result.getState()); + } +} diff --git a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/TestDataflowRunnerTest.java b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/TestDataflowRunnerTest.java index 5cd16757e36b..f22766043998 100644 --- a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/TestDataflowRunnerTest.java +++ b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/TestDataflowRunnerTest.java @@ -19,7 +19,10 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @@ -32,12 +35,18 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.api.client.googleapis.json.GoogleJsonError; +import com.google.api.client.googleapis.json.GoogleJsonResponseException; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.util.Sleeper; import com.google.api.services.dataflow.model.JobMessage; import com.google.api.services.dataflow.model.JobMetrics; import com.google.api.services.dataflow.model.MetricStructuredName; import com.google.api.services.dataflow.model.MetricUpdate; import java.io.IOException; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -669,4 +678,106 @@ public boolean matches(Object o) { @Override public void describeTo(Description description) {} } + + /** A sleeper that records requested sleeps instead of performing them. */ + private static class RecordingSleeper implements Sleeper { + final List sleepsMillis = new ArrayList<>(); + + @Override + public void sleep(long millis) { + sleepsMillis.add(millis); + } + } + + private static RuntimeException quotaRejection() { + GoogleJsonError details = new GoogleJsonError(); + details.setMessage("Quota exceeded for quota metric 'Job creation requests'"); + GoogleJsonResponseException http429 = + new GoogleJsonResponseException( + new HttpResponseException.Builder(429, "Too Many Requests", new HttpHeaders()), + details); + // DataflowRunner wraps the service exception exactly like this. + return new RuntimeException( + "Failed to create a workflow job: " + details.getMessage(), http429); + } + + @Test + public void testSubmitRetriesQuotaRejections() { + Pipeline p = Pipeline.create(options); + DataflowPipelineJob mockJob = Mockito.mock(DataflowPipelineJob.class); + DataflowRunner mockRunner = Mockito.mock(DataflowRunner.class); + when(mockRunner.run(any(Pipeline.class))) + .thenThrow(quotaRejection()) + .thenThrow(quotaRejection()) + .thenReturn(mockJob); + + TestDataflowRunner runner = TestDataflowRunner.fromOptionsAndClient(options, mockClient); + RecordingSleeper sleeper = new RecordingSleeper(); + runner.setSleeper(sleeper); + + assertSame(mockJob, runner.submit(mockRunner, p)); + verify(mockRunner, Mockito.times(3)).run(p); + assertEquals(2, sleeper.sleepsMillis.size()); + for (long sleep : sleeper.sleepsMillis) { + assertThat(sleep, greaterThan(0L)); + } + } + + @Test + public void testSubmitDoesNotRetryOtherFailures() { + Pipeline p = Pipeline.create(options); + DataflowRunner mockRunner = Mockito.mock(DataflowRunner.class); + RuntimeException failure = new RuntimeException("Failed to create a workflow job: bad graph"); + when(mockRunner.run(any(Pipeline.class))).thenThrow(failure); + + TestDataflowRunner runner = TestDataflowRunner.fromOptionsAndClient(options, mockClient); + RecordingSleeper sleeper = new RecordingSleeper(); + runner.setSleeper(sleeper); + + try { + runner.submit(mockRunner, p); + fail("Expected the submission failure to propagate"); + } catch (RuntimeException e) { + assertSame(failure, e); + } + verify(mockRunner, Mockito.times(1)).run(p); + assertThat(sleeper.sleepsMillis, empty()); + } + + @Test + public void testSubmitGivesUpAfterRepeatedQuotaRejections() { + Pipeline p = Pipeline.create(options); + DataflowRunner mockRunner = Mockito.mock(DataflowRunner.class); + when(mockRunner.run(any(Pipeline.class))) + .thenAnswer( + invocation -> { + throw quotaRejection(); + }); + + TestDataflowRunner runner = TestDataflowRunner.fromOptionsAndClient(options, mockClient); + RecordingSleeper sleeper = new RecordingSleeper(); + runner.setSleeper(sleeper); + + try { + runner.submit(mockRunner, p); + fail("Expected the submission to fail once retries are exhausted"); + } catch (RuntimeException e) { + assertThat(TestDataflowRunner.quotaRejection(e), notNullValue()); + } + // One initial attempt plus a bounded number of retries, each preceded by a sleep. + int attempts = Mockito.mockingDetails(mockRunner).getInvocations().size(); + assertThat(attempts, greaterThan(1)); + assertEquals(attempts - 1, sleeper.sleepsMillis.size()); + } + + @Test + public void testQuotaRejectionDetection() { + assertThat(TestDataflowRunner.quotaRejection(quotaRejection()), notNullValue()); + assertNull(TestDataflowRunner.quotaRejection(new RuntimeException("boom"))); + GoogleJsonResponseException http400 = + new GoogleJsonResponseException( + new HttpResponseException.Builder(400, "Bad Request", new HttpHeaders()), + new GoogleJsonError()); + assertNull(TestDataflowRunner.quotaRejection(new RuntimeException("wrapped", http400))); + } } diff --git a/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/BeamParallelJunit4Runner.java b/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/BeamParallelJunit4Runner.java index 59527b161f08..6df2c8cf36c8 100644 --- a/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/BeamParallelJunit4Runner.java +++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/BeamParallelJunit4Runner.java @@ -17,23 +17,32 @@ */ package org.apache.beam.sdk.testing; +import java.lang.annotation.Annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.Collection; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; import org.apache.beam.sdk.annotations.Internal; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting; import org.checkerframework.checker.nullness.qual.Nullable; import org.junit.experimental.categories.Category; +import org.junit.runner.Description; import org.junit.runner.notification.RunNotifier; import org.junit.runners.BlockJUnit4ClassRunner; import org.junit.runners.model.FrameworkMethod; import org.junit.runners.model.InitializationError; +import org.junit.runners.model.MultipleFailureException; import org.junit.runners.model.RunnerScheduler; +import org.junit.runners.model.Statement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * A JUnit 4 runner that supports concurrent execution of {@code @Test} methods within a test class, @@ -49,10 +58,18 @@ * *

    Classes or methods annotated with {@link SerialTest} are always executed sequentially on the * calling thread after draining any in-flight parallel test methods in the class. + * + *

    Runners that merge the pipelines of concurrently running tests into one job rely on this + * runner in two ways: it lets each {@link TestPipeline} rule see its test instance (to recognize + * tests that must not be merged), and it re-executes a test once, from scratch and with merging + * disabled, when the runner could not reach a verdict for it from a merged job (see {@link + * TestPipeline.StandaloneRerunRequested}). */ @Internal public final class BeamParallelJunit4Runner extends BlockJUnit4ClassRunner { + private static final Logger LOG = LoggerFactory.getLogger(BeamParallelJunit4Runner.class); + /** * Marks a test class or {@code @Test} method as requiring serial (non-parallel) execution even * when parallel test execution is enabled. @@ -108,6 +125,18 @@ public void finished() { }); } + /** + * Hands the test instance to its {@link TestPipeline} rule(s), which inspect the instance's other + * rules to decide whether the test's pipeline may be merged with others. A {@code TestRule} never + * sees the instance itself; the runner is the only place that does. + */ + @Override + protected Object createTest() throws Exception { + Object instance = super.createTest(); + TestPipeline.attachTestInstance(instance); + return instance; + } + private void awaitPendingFutures() { try { pendingFutures.join(); @@ -116,6 +145,91 @@ private void awaitPendingFutures() { } } + // --------------------------------------------------------------------------------------------- + // Re-executing a test whose pipeline was merged into a shared job without a verdict + // --------------------------------------------------------------------------------------------- + // + // A runner that merges several tests' pipelines into one job may be unable to tell from that job + // whether a given test passed (see TestPipeline.StandaloneRerunRequested). The pipeline object + // cannot simply be run again, so the test is executed a second time from scratch: new instance, + // @Before/@After, rules and all, with its TestPipeline told to insist on a job of its own. Both + // attempts are one test as far as JUnit is concerned; it sees a single start/finish pair. + + /** + * Tests currently in their standalone re-execution, by {@link Description}. Keyed by description + * rather than thread because the test body does not necessarily run on the thread that started it + * (JUnit's Timeout rule, for one, evaluates it on a thread of its own). A method is never + * re-executed concurrently with itself, so an entry unambiguously refers to the current attempt. + */ + private static final Set STANDALONE_RERUNS = ConcurrentHashMap.newKeySet(); + + /** + * Returns {@code true} while the test described by {@code description} is being re-executed after + * an inconclusive merged attempt; {@link TestPipeline} consults this when its rule is applied. + */ + static boolean isStandaloneRerun(Description description) { + return STANDALONE_RERUNS.contains(description); + } + + /** + * The {@link TestPipeline.StandaloneRerunRequested} behind {@code failure}, or {@code null} if + * there is none. Test code and JUnit itself may wrap what {@code run()} threw ({@code + * assertThrows}, {@code @Test(expected)}, an {@code @After} failing alongside it), so the cause + * chain and {@link MultipleFailureException}'s failures are searched too. + */ + @VisibleForTesting + static TestPipeline.@Nullable StandaloneRerunRequested standaloneRerunRequest(Throwable failure) { + int depth = 0; + for (Throwable t = failure; t != null && depth < 32; t = t.getCause(), depth++) { + if (t instanceof TestPipeline.StandaloneRerunRequested) { + return (TestPipeline.StandaloneRerunRequested) t; + } + if (t instanceof MultipleFailureException) { + for (Throwable each : ((MultipleFailureException) t).getFailures()) { + TestPipeline.StandaloneRerunRequested nested = standaloneRerunRequest(each); + if (nested != null) { + return nested; + } + } + } + } + return null; + } + + /** + * Like {@link BlockJUnit4ClassRunner#runChild}, except that a test ending in {@link + * TestPipeline.StandaloneRerunRequested} is executed a second time (see above). A request raised + * by that second attempt is a bug in the requesting runner and is reported as the test's failure. + */ + private void runChildInternal(final FrameworkMethod method, final RunNotifier notifier) { + final Description description = describeChild(method); + Statement statement = + new Statement() { + @Override + public void evaluate() throws Throwable { + try { + methodBlock(method).evaluate(); + } catch (Throwable firstAttempt) { + TestPipeline.StandaloneRerunRequested request = standaloneRerunRequest(firstAttempt); + if (request == null) { + throw firstAttempt; + } + LOG.info( + "Re-executing {} with its pipeline as a job of its own. {}", + description.getDisplayName(), + String.valueOf(request)); + STANDALONE_RERUNS.add(description); + try { + methodBlock(method).evaluate(); + } finally { + STANDALONE_RERUNS.remove(description); + } + } + } + }; + runLeaf(statement, description, notifier); + } + @Override protected void runChild(final FrameworkMethod method, final RunNotifier notifier) { if (isIgnored(method)) { @@ -124,7 +238,7 @@ protected void runChild(final FrameworkMethod method, final RunNotifier notifier } ExecutorService executor = selectExecutor(method); if (executor == null) { - super.runChild(method, notifier); + runChildInternal(method, notifier); return; } if (isClassMarkedSerial(getTestClass().getJavaClass()) || isMethodMarkedSerial(method)) { @@ -132,13 +246,13 @@ protected void runChild(final FrameworkMethod method, final RunNotifier notifier // this test // serially. awaitPendingFutures(); - super.runChild(method, notifier); + runChildInternal(method, notifier); return; } pendingFutures = CompletableFuture.allOf( pendingFutures, - CompletableFuture.runAsync(() -> super.runChild(method, notifier), executor)); + CompletableFuture.runAsync(() -> runChildInternal(method, notifier), executor)); } private @Nullable ExecutorService selectExecutor(FrameworkMethod method) { @@ -171,7 +285,21 @@ private static boolean isMethodMarkedSerial(FrameworkMethod method) { return method.getAnnotation(SerialTest.class) != null; } - private static boolean isClassMarkedSerial(@Nullable Class clazz) { + /** Returns {@code true} if {@code annotations} contains {@link SerialTest}. */ + static boolean hasSerialAnnotation(Collection annotations) { + for (Annotation annotation : annotations) { + if (annotation.annotationType() == SerialTest.class) { + return true; + } + } + return false; + } + + /** + * Returns {@code true} if {@code clazz}, any of its superclasses, or any of its enclosing classes + * is annotated with {@link SerialTest}. + */ + static boolean isClassMarkedSerial(@Nullable Class clazz) { if (clazz == null || clazz == Object.class) { return false; } diff --git a/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/PAssert.java b/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/PAssert.java index 4406bf0d7692..fc34401c9c50 100644 --- a/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/PAssert.java +++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/PAssert.java @@ -75,6 +75,7 @@ import org.apache.beam.sdk.values.PDone; import org.apache.beam.sdk.values.ValueInSingleWindow; import org.apache.beam.sdk.values.WindowingStrategy; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Objects; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables; @@ -123,10 +124,13 @@ public class PAssert { Metrics.counter(PAssert.class, PAssert.FAILURE_COUNTER); // Atomic so that PAssert transforms constructed concurrently (e.g. by tests running under - // BeamParallelJunit4Runner) never receive duplicate names within a pipeline. + // BeamParallelJunit4Runner) never receive duplicate names within a pipeline. With a plain int a + // lost update can move the counter backwards, so a single thread can be handed a name it already + // used in its own pipeline, which stableUniqueNames=ERROR then rejects. private static final AtomicInteger assertCount = new AtomicInteger(0); - private static String nextAssertionName() { + @VisibleForTesting + static String nextAssertionName() { return "PAssert$" + assertCount.getAndIncrement(); } diff --git a/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/TestPipeline.java b/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/TestPipeline.java index b157e4ad785d..76f6c69cc01d 100644 --- a/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/TestPipeline.java +++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/testing/TestPipeline.java @@ -25,11 +25,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.beam.sdk.Pipeline; import org.apache.beam.sdk.PipelineResult; import org.apache.beam.sdk.annotations.Internal; @@ -46,7 +52,10 @@ import org.apache.beam.sdk.options.ValueProvider.StaticValueProvider; import org.apache.beam.sdk.runners.TransformHierarchy; import org.apache.beam.sdk.transforms.SerializableFunction; +import org.apache.beam.sdk.transforms.resourcehints.ResourceHints; import org.apache.beam.sdk.util.common.ReflectHelpers; +import org.apache.beam.sdk.values.PCollection; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Optional; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Predicate; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Predicates; @@ -56,10 +65,14 @@ import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Maps; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; import org.checkerframework.checker.nullness.qual.Nullable; +import org.junit.Test; import org.junit.experimental.categories.Category; +import org.junit.rules.ExpectedException; import org.junit.rules.TestRule; import org.junit.runner.Description; import org.junit.runners.model.Statement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * A creator of test pipelines that can be used inside of tests that can be configured to run @@ -130,6 +143,7 @@ * documentation section. */ public class TestPipeline extends Pipeline implements TestRule { + private static final Logger LOG = LoggerFactory.getLogger(TestPipeline.class); private final PipelineOptions options; @@ -306,11 +320,12 @@ static TestPipeline createWithEnforcement() { } public static TestPipeline fromOptions(PipelineOptions options) { - return new TestPipeline(options); + return new TestPipeline(new TransformHierarchy(ResourceHints.fromOptions(options)), options); } - private TestPipeline(final PipelineOptions options) { - super(options); + private TestPipeline(final TransformHierarchy hierarchy, final PipelineOptions options) { + super(hierarchy, options); + this.hierarchy = hierarchy; this.options = options; } @@ -324,10 +339,7 @@ void setDeducedEnforcementLevel(Collection annotations) { // if the enforcement level has not been set by the user do auto-inference if (!enforcement.isPresent()) { - final boolean annotatedWithNeedsRunner = - FluentIterable.from(annotations) - .filter(Annotations.Predicates.isAnnotationOfType(Category.class)) - .anyMatch(Annotations.Predicates.isCategoryOf(NeedsRunner.class, true)); + final boolean annotatedWithNeedsRunner = hasCategory(annotations, NeedsRunner.class); final boolean crashingRunner = CrashingRunner.class.isAssignableFrom(options.getRunner()); @@ -348,15 +360,56 @@ void afterUserCodeFinished() { enforcement.get().afterUserCodeFinished(); } + private boolean standaloneExecutionRequired = false; + private int initialOptionsRevision = -1; + + private static boolean hasCategory(Collection annotations, Class targetCategory) { + return FluentIterable.from(annotations) + .filter(Annotations.Predicates.isAnnotationOfType(Category.class)) + .anyMatch(Annotations.Predicates.isCategoryOf(targetCategory, true)); + } + + /** + * Whether {@code testClass}, a superclass, or an enclosing class carries a {@link Category} that + * is or extends {@code targetCategory}. A method's {@link Description} only exposes the method's + * own annotations, so class-level categories have to be looked up here. + */ + private static boolean hasClassCategory(@Nullable Class testClass, Class targetCategory) { + for (Class clazz = testClass; clazz != null; clazz = clazz.getEnclosingClass()) { + Category category = clazz.getAnnotation(Category.class); // @Inherited: covers superclasses + if (category != null + && Annotations.Predicates.isCategoryOf(targetCategory, true).apply(category)) { + return true; + } + } + return false; + } + @Override public Statement apply(final Statement statement, final Description description) { return new Statement() { @Override public void evaluate() throws Throwable { - options.as(ApplicationNameOptions.class).setAppName(getAppName(description)); + String appName = getAppName(description); + options.as(ApplicationNameOptions.class).setAppName(appName); + initialOptionsRevision = options.revision(); + if (Boolean.getBoolean(PROPERTY_BEAM_TEST_PIPELINE_UNIQUE_ROOT_NAMES)) { + nameRoot(appName); + } - setDeducedEnforcementLevel(description.getAnnotations()); + Collection annotations = description.getAnnotations(); + setDeducedEnforcementLevel(annotations); + + Test testAnnotation = description.getAnnotation(Test.class); + if ((testAnnotation != null && testAnnotation.expected() != Test.None.class) + || hasCategory(annotations, UsesFailureMessage.class) + || hasClassCategory(description.getTestClass(), UsesFailureMessage.class) + || BeamParallelJunit4Runner.hasSerialAnnotation(annotations) + || BeamParallelJunit4Runner.isClassMarkedSerial(description.getTestClass()) + || BeamParallelJunit4Runner.isStandaloneRerun(description)) { + standaloneExecutionRequired = true; + } // statement.evaluate() essentially runs the user code contained in the unit test at hand. // Exceptions thrown during the execution of the user's test code will propagate here, @@ -372,6 +425,268 @@ public void evaluate() throws Throwable { }; } + // --------------------------------------------------------------------------------------------- + // Unique root names + // --------------------------------------------------------------------------------------------- + + /** + * System property which, when {@code true}, gives every {@link TestPipeline} a unique root + * transform name of the form {@code t-} as soon as its JUnit rule is applied. Every + * transform and {@link PCollection} name in the test is then prefixed by it (for example {@code + * t12-ParDoTest$BasicTests-testParDo/ParDo(Anonymous)}), so the graphs of different tests never + * share a name. Runners that merge several test pipelines into one job rely on this; see {@link + * #getRootName()}. + */ + public static final String PROPERTY_BEAM_TEST_PIPELINE_UNIQUE_ROOT_NAMES = + "beamTestPipelineUniqueRootNames"; + + /** + * Upper bound on a root name. Generous enough that real test names ({@code Class$Nested-method}, + * typically 40-80 characters) survive intact, since the method name at the end is the most + * informative part, while still bounding pathological names. + */ + @VisibleForTesting static final int MAX_ROOT_NAME_LENGTH = 100; + + private static final AtomicInteger ROOT_NAME_COUNTER = new AtomicInteger(1); + + private final TransformHierarchy hierarchy; + private String rootName = ""; + + /** + * For internal use only; no backwards-compatibility guarantees. + * + *

    Returns the unique root transform name of this pipeline, or the empty string if it has none + * (see {@link #PROPERTY_BEAM_TEST_PIPELINE_UNIQUE_ROOT_NAMES}). When non-empty, every full + * transform name in this pipeline starts with {@code getRootName() + "/"}. + */ + @Internal + public String getRootName() { + return rootName; + } + + /** + * For internal use only; no backwards-compatibility guarantees. + * + *

    Gives this pipeline a unique root name of the form {@code t-} (sanitized and + * truncated). Normally invoked by the JUnit rule or extension; only has an effect if no transform + * has been applied yet, otherwise a warning is logged and the pipeline stays unnamed. + */ + @Internal + public void nameRoot(String testName) { + String candidate = rootNameFor(ROOT_NAME_COUNTER.getAndIncrement(), testName); + try { + hierarchy.setRootName(candidate); + rootName = candidate; + } catch (IllegalStateException e) { + LOG.warn( + "Not giving the TestPipeline of {} a unique root name: transforms were applied to it" + + " before the test started. Its transform names stay unprefixed.", + testName); + } + } + + /** + * Builds {@code t-}. The sequence number alone makes the name unique, so the + * test name is only a readability aid: it is sanitized to a conservative character set (notably + * no {@code /}, which separates name segments) and truncated to keep the root within {@link + * #MAX_ROOT_NAME_LENGTH}. + */ + @VisibleForTesting + static String rootNameFor(int sequence, String testName) { + String prefix = "t" + sequence + "-"; + String hint = testName.replaceAll("[^A-Za-z0-9_.$-]", "_"); + int room = MAX_ROOT_NAME_LENGTH - prefix.length(); + if (hint.length() > room) { + hint = hint.substring(0, Math.max(0, room)); + } + return prefix + hint; + } + + /** + * For internal use only; no backwards-compatibility guarantees. + * + *

    Returns {@code true} if this {@link TestPipeline} should not be merged into a shared batch + * pipeline (for example, when the test expects an exception or assertion failure, uses custom + * per-test option arguments, is marked for serial execution, or is being re-executed after a + * merged attempt; see {@link StandaloneRerunRequested}). + */ + @Internal + public boolean isStandaloneExecutionRequired() { + return standaloneExecutionRequired + || (initialOptionsRevision >= 0 && options.revision() != initialOptionsRevision) + || !providerRuntimeValues.isEmpty() + || testExpectsException(); + } + + /** + * For internal use only; no backwards-compatibility guarantees. + * + *

    Thrown out of {@link #run()} by a runner that merged this pipeline into a job shared with + * other tests and could not reach a verdict for this test from that job. The pipeline is not run + * again by the runner: once the shared job was built from it, this pipeline's graph is no longer + * guaranteed to be the one the test constructed. Instead the whole test has to be executed again + * from scratch, with {@linkplain #isStandaloneExecutionRequired() standalone execution} forced so + * that the fresh pipeline runs as a job of its own. {@link BeamParallelJunit4Runner} does that + * automatically (once); under any other runner this surfaces as the test's failure. + * + *

    An {@link Error} rather than an exception so that test code wrapping {@code run()} in {@code + * catch (Exception e)} or {@code assertThrows(Exception.class, ...)} cannot mistake it for the + * failure it was looking for. + */ + @Internal + public static final class StandaloneRerunRequested extends Error { + public StandaloneRerunRequested(String message) { + super(message); + } + } + + // --------------------------------------------------------------------------------------------- + // Detecting tests that expect run() to fail + // --------------------------------------------------------------------------------------------- + // + // A test that expects run() to throw must not be merged with other tests: it would fail the + // shared job. Static signals (@Test(expected), categories, serial markers) are read from the + // JUnit Description in apply(). The dynamic one, an ExpectedException rule on which the test + // has called expect(...), lives on the test instance, which a TestRule never sees; the runner + // hands the instance to its TestPipeline rule(s) through attachTestInstance so that it can be + // inspected here at run() time. The instance is attached to the rule rather than published in a + // thread-local because the test body does not necessarily run on the thread that created the + // instance: JUnit's Timeout rule, for one, evaluates the test on a thread of its own. + // A missed detection is a performance problem (that test and its co-members re-run standalone), + // never a correctness one. + + /** The test-class instance this rule belongs to, if the runner has told us; see above. */ + private transient @Nullable Object testInstance; + + /** + * {@code ExpectedException#isAnyExceptionExpected()}, the rule's own (private) notion of whether + * the test has called {@code expect(...)}. Resolved once; {@code null} if this JUnit version does + * not have it, in which case such tests are simply not recognized. + */ + private static final @Nullable Method IS_ANY_EXCEPTION_EXPECTED = lookupIsAnyExceptionExpected(); + + private static final AtomicBoolean PROBE_FAILURE_LOGGED = new AtomicBoolean(false); + + /** {@link ExpectedException}-typed fields per test class, resolved once. */ + private static final ConcurrentHashMap, List> EXPECTED_EXCEPTION_FIELDS = + new ConcurrentHashMap<>(); + + /** {@link TestPipeline}-typed fields per test class, resolved once. */ + private static final ConcurrentHashMap, List> TEST_PIPELINE_FIELDS = + new ConcurrentHashMap<>(); + + /** + * Attaches {@code testInstance} to every {@link TestPipeline} held in one of its fields (declared + * in its class or a superclass), so that those pipelines can inspect the instance's other rules. + * Called by {@link BeamParallelJunit4Runner} for each freshly created test instance. + */ + static void attachTestInstance(Object testInstance) { + for (Field field : + fieldsOfType(TEST_PIPELINE_FIELDS, testInstance.getClass(), TestPipeline.class)) { + try { + Object value = field.get(testInstance); + if (value instanceof TestPipeline) { + ((TestPipeline) value).testInstance = testInstance; + } + } catch (ReflectiveOperationException | RuntimeException e) { + if (PROBE_FAILURE_LOGGED.compareAndSet(false, true)) { + LOG.warn( + "Failed to read TestPipeline field {} of {}; tests that expect an exception via an" + + " ExpectedException rule may be merged into shared test jobs.", + field.getName(), + testInstance.getClass().getName(), + e); + } + } + } + } + + /** + * Returns {@code true} if the test this pipeline belongs to has armed an {@link + * ExpectedException} rule, i.e. it expects to fail. Only {@link ExpectedException} fields of the + * test instance are consulted; tests that expect failure some other way (for example {@code + * assertThrows} around {@code run()}) are not recognized, and neither is anything when the test + * runner has not attached the instance (see {@link #attachTestInstance}). + */ + @VisibleForTesting + boolean testExpectsException() { + Object instance = testInstance; + if (instance == null || IS_ANY_EXCEPTION_EXPECTED == null) { + return false; + } + for (Field field : + fieldsOfType(EXPECTED_EXCEPTION_FIELDS, instance.getClass(), ExpectedException.class)) { + try { + Object rule = field.get(instance); + if (rule instanceof ExpectedException + && Boolean.TRUE.equals(IS_ANY_EXCEPTION_EXPECTED.invoke(rule))) { + return true; + } + } catch (ReflectiveOperationException | RuntimeException e) { + if (PROBE_FAILURE_LOGGED.compareAndSet(false, true)) { + LOG.warn( + "Failed to inspect ExpectedException rule {} of {}; tests that expect an exception" + + " via that rule may be merged into shared test jobs.", + field.getName(), + instance.getClass().getName(), + e); + } + } + } + return false; + } + + private static @Nullable Method lookupIsAnyExceptionExpected() { + try { + Method method = ExpectedException.class.getDeclaredMethod("isAnyExceptionExpected"); + method.setAccessible(true); + return method; + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.warn( + "Cannot inspect JUnit's ExpectedException rule; tests that expect an exception via that" + + " rule will not be recognized as such and may be merged into shared test jobs.", + e); + return null; + } + } + + /** + * The instance fields of {@code testClass} (or a superclass) whose declared type is assignable to + * {@code type}, made accessible, cached in {@code cache}. + */ + private static List fieldsOfType( + ConcurrentHashMap, List> cache, Class testClass, Class type) { + return cache.computeIfAbsent( + testClass, + k -> { + List fields = new ArrayList<>(); + for (Class clazz = k; + clazz != null && clazz != Object.class; + clazz = clazz.getSuperclass()) { + for (Field field : clazz.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) + || !type.isAssignableFrom(field.getType())) { + continue; + } + try { + field.setAccessible(true); + fields.add(field); + } catch (RuntimeException e) { + if (PROBE_FAILURE_LOGGED.compareAndSet(false, true)) { + LOG.warn( + "Cannot access field {} of {}; tests that expect an exception via an" + + " ExpectedException rule may be merged into shared test jobs.", + field.getName(), + k.getName(), + e); + } + } + } + } + return fields; + }); + } + /** * Runs this {@link TestPipeline}, unwrapping any {@code AssertionError} that is raised during * testing. @@ -390,6 +705,7 @@ public PipelineResult run() { *

    Most of logic is similar to {@link #testingPipelineOptions}. */ public PipelineResult runWithAdditionalOptionArgs(List additionalArgs) { + standaloneExecutionRequired = true; try { String beamTestPipelineOptions = System.getProperty(PROPERTY_BEAM_TEST_PIPELINE_OPTIONS, ""); List args = new ArrayList<>(); @@ -430,6 +746,10 @@ public PipelineResult run(PipelineOptions options) { enforcement.isPresent(), "Is your TestPipeline declaration missing a @Rule annotation? Usage: " + "@Rule public final transient TestPipeline pipeline = TestPipeline.create();"); + if (options != this.options + || (initialOptionsRevision >= 0 && options.revision() != initialOptionsRevision)) { + standaloneExecutionRequired = true; + } final PipelineResult pipelineResult; try { diff --git a/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/BeamParallelJunit4RunnerTest.java b/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/BeamParallelJunit4RunnerTest.java index 1fd2b168bd06..7f16bb5266c3 100644 --- a/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/BeamParallelJunit4RunnerTest.java +++ b/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/BeamParallelJunit4RunnerTest.java @@ -17,10 +17,20 @@ */ package org.apache.beam.sdk.testing; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; @@ -28,14 +38,20 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import org.apache.beam.sdk.testing.BeamParallelJunit4Runner.SerialTest; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.experimental.categories.Category; +import org.junit.rules.ExpectedException; import org.junit.rules.TestRule; +import org.junit.rules.Timeout; import org.junit.runner.JUnitCore; import org.junit.runner.Result; import org.junit.runner.RunWith; +import org.junit.runner.notification.Failure; import org.junit.runners.JUnit4; +import org.junit.runners.model.MultipleFailureException; /** Tests for {@link BeamParallelJunit4Runner}. */ @RunWith(JUnit4.class) @@ -200,4 +216,142 @@ public void testSerialTestMethodDrainsAndRunsAlone() { IN_TEST_HARNESS.set(false); } } + + @RunWith(BeamParallelJunit4Runner.class) + public static class SampleExpectedExceptionCases { + static final AtomicBoolean OBSERVED_WHEN_EXPECTING = new AtomicBoolean(false); + static final AtomicBoolean OBSERVED_WHEN_NOT_EXPECTING = new AtomicBoolean(true); + static final AtomicBoolean OBSERVED_ON_CHILD_THREAD = new AtomicBoolean(false); + + @Rule public ExpectedException thrown = ExpectedException.none(); + @Rule public final transient TestPipeline pipeline = TestPipeline.create(); + + @Test + public void testExpecting() throws InterruptedException { + assumeTrue(IN_TEST_HARNESS.get()); + thrown.expect(IllegalStateException.class); + OBSERVED_WHEN_EXPECTING.set(pipeline.testExpectsException()); + // Detection is tied to the test instance, not to a thread: rules such as Timeout evaluate + // the test body on a thread of their own. + Thread child = + new Thread(() -> OBSERVED_ON_CHILD_THREAD.set(pipeline.testExpectsException())); + child.start(); + child.join(); + throw new IllegalStateException("expected"); + } + + @Test + public void testNotExpecting() { + assumeTrue(IN_TEST_HARNESS.get()); + OBSERVED_WHEN_NOT_EXPECTING.set(pipeline.testExpectsException()); + } + } + + @Test + public void testDetectsActiveExpectedExceptionRule() { + IN_TEST_HARNESS.set(true); + try { + Result result = JUnitCore.runClasses(SampleExpectedExceptionCases.class); + assertEquals(0, result.getFailureCount()); + assertEquals(2, result.getRunCount()); + assertTrue(SampleExpectedExceptionCases.OBSERVED_WHEN_EXPECTING.get()); + assertFalse(SampleExpectedExceptionCases.OBSERVED_WHEN_NOT_EXPECTING.get()); + assertTrue(SampleExpectedExceptionCases.OBSERVED_ON_CHILD_THREAD.get()); + } finally { + IN_TEST_HARNESS.set(false); + } + } + + @RunWith(BeamParallelJunit4Runner.class) + public static class SampleStandaloneRerunCases { + static final AtomicInteger BEFORE_CALLS = new AtomicInteger(0); + static final List RERUN_INSTANCES = Collections.synchronizedList(new ArrayList<>()); + static final AtomicBoolean STANDALONE_ON_FIRST_ATTEMPT = new AtomicBoolean(true); + static final AtomicBoolean STANDALONE_ON_RERUN = new AtomicBoolean(false); + static final AtomicInteger ALWAYS_REQUESTING_ATTEMPTS = new AtomicInteger(0); + + @Rule public final transient TestPipeline pipeline = TestPipeline.create(); + // Evaluates the test body on a thread of its own, as in the ValidatesRunner suites. + @Rule public final Timeout timeout = Timeout.seconds(30); + + @Before + public void before() { + BEFORE_CALLS.incrementAndGet(); + } + + @Test + public void testRerunOnce() { + assumeTrue(IN_TEST_HARNESS.get()); + RERUN_INSTANCES.add(this); + if (RERUN_INSTANCES.size() == 1) { + STANDALONE_ON_FIRST_ATTEMPT.set(pipeline.isStandaloneExecutionRequired()); + // Wrapped, as test code catching what run() threw would do. + throw new RuntimeException( + "wrapped", new TestPipeline.StandaloneRerunRequested("no verdict from merged job")); + } + STANDALONE_ON_RERUN.set(pipeline.isStandaloneExecutionRequired()); + } + + @Test + public void testAlwaysRequesting() { + assumeTrue(IN_TEST_HARNESS.get()); + ALWAYS_REQUESTING_ATTEMPTS.incrementAndGet(); + throw new TestPipeline.StandaloneRerunRequested("still no verdict"); + } + } + + @Test + public void testReexecutesTestOnceWhenStandaloneRerunIsRequested() { + System.setProperty(BeamParallelJunit4Runner.DEFAULT_TEST_THREADS_PROPERTY, "2"); + IN_TEST_HARNESS.set(true); + try { + Result result = JUnitCore.runClasses(SampleStandaloneRerunCases.class); + // Two attempts are one test to JUnit. + assertEquals(2, result.getRunCount()); + + // testRerunOnce: a second, fresh instance (and @Before) with standalone execution forced. + assertEquals(2, SampleStandaloneRerunCases.RERUN_INSTANCES.size()); + assertNotSame( + SampleStandaloneRerunCases.RERUN_INSTANCES.get(0), + SampleStandaloneRerunCases.RERUN_INSTANCES.get(1)); + assertFalse(SampleStandaloneRerunCases.STANDALONE_ON_FIRST_ATTEMPT.get()); + assertTrue(SampleStandaloneRerunCases.STANDALONE_ON_RERUN.get()); + + // testAlwaysRequesting: re-executed exactly once, then the request is the failure. + assertEquals(2, SampleStandaloneRerunCases.ALWAYS_REQUESTING_ATTEMPTS.get()); + assertEquals(1, result.getFailureCount()); + Failure failure = result.getFailures().get(0); + assertEquals("testAlwaysRequesting", failure.getDescription().getMethodName()); + assertThat(failure.getException(), instanceOf(TestPipeline.StandaloneRerunRequested.class)); + + // Two tests, two attempts each. + assertEquals(4, SampleStandaloneRerunCases.BEFORE_CALLS.get()); + } finally { + IN_TEST_HARNESS.set(false); + } + } + + @Test + public void testStandaloneRerunRequestIsFoundBehindWrappers() { + TestPipeline.StandaloneRerunRequested request = + new TestPipeline.StandaloneRerunRequested("no verdict"); + assertSame(request, BeamParallelJunit4Runner.standaloneRerunRequest(request)); + assertSame( + request, + BeamParallelJunit4Runner.standaloneRerunRequest( + new AssertionError("unexpected exception type", request))); + assertSame( + request, + BeamParallelJunit4Runner.standaloneRerunRequest( + new MultipleFailureException( + ImmutableList.of(new IllegalStateException("@After failed"), request)))); + assertNull(BeamParallelJunit4Runner.standaloneRerunRequest(new RuntimeException("other"))); + // A cause cycle must not hang detection. + RuntimeException a = new RuntimeException("a"); + RuntimeException b = new RuntimeException("b", a); + a.initCause(b); + assertNull(BeamParallelJunit4Runner.standaloneRerunRequest(a)); + // It is an Error so that test code catching Exception around run() cannot swallow it. + assertThat(request, not(instanceOf(Exception.class))); + } } diff --git a/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/PAssertTest.java b/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/PAssertTest.java index bec3de5a6241..21b746dccd4a 100644 --- a/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/PAssertTest.java +++ b/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/PAssertTest.java @@ -31,7 +31,15 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.Serializable; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.regex.Pattern; import org.apache.beam.sdk.Pipeline; import org.apache.beam.sdk.coders.AtomicCoder; @@ -138,6 +146,37 @@ private void throwWrappedError() { } } + @Test + public void testAssertionNamesAreUniqueAcrossThreads() throws Exception { + // ValidatesRunner suites build pipelines from many threads at once; a lost update on the + // assertion counter would hand two PAsserts the same name (possibly within one pipeline). + final int threads = 16; + final int namesPerThread = 2000; + final Set names = ConcurrentHashMap.newKeySet(); + final CyclicBarrier start = new CyclicBarrier(threads); + ExecutorService executor = Executors.newFixedThreadPool(threads); + try { + List> futures = new ArrayList<>(); + for (int t = 0; t < threads; t++) { + futures.add( + executor.submit( + () -> { + start.await(); + for (int i = 0; i < namesPerThread; i++) { + assertTrue("duplicate assertion name", names.add(PAssert.nextAssertionName())); + } + return null; + })); + } + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + assertEquals(threads * namesPerThread, names.size()); + } + @Test public void testFailureWithExceptionEncodedDecoded() throws IOException { Throwable error; diff --git a/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/TestPipelineTest.java b/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/TestPipelineTest.java index a79bfd739234..932657ecb218 100644 --- a/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/TestPipelineTest.java +++ b/sdks/java/core/src/test/java/org/apache/beam/sdk/testing/TestPipelineTest.java @@ -18,13 +18,18 @@ package org.apache.beam.sdk.testing; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.everyItem; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.hamcrest.Matchers.matchesPattern; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.sameInstance; import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import com.fasterxml.jackson.databind.ObjectMapper; @@ -44,6 +49,7 @@ import org.apache.beam.sdk.options.PipelineOptionsFactory; import org.apache.beam.sdk.options.StreamingOptions; import org.apache.beam.sdk.options.ValueProvider; +import org.apache.beam.sdk.runners.TransformHierarchy; import org.apache.beam.sdk.transforms.Create; import org.apache.beam.sdk.transforms.MapElements; import org.apache.beam.sdk.transforms.PTransform; @@ -60,8 +66,10 @@ import org.junit.rules.ExpectedException; import org.junit.rules.RuleChain; import org.junit.rules.TestRule; +import org.junit.rules.Timeout; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import org.junit.runners.model.Statement; /** Tests for {@link TestPipeline}. */ public class TestPipelineTest implements Serializable { @@ -201,6 +209,200 @@ public int hashCode() { } } + /** Tests for {@link TestPipeline#PROPERTY_BEAM_TEST_PIPELINE_UNIQUE_ROOT_NAMES}. */ + @RunWith(JUnit4.class) + public static class UniqueRootNamesTest implements Serializable { + /** Turns unique root names on before the {@link TestPipeline} rule below evaluates. */ + private static final TestRule ENABLE_UNIQUE_ROOT_NAMES = + (base, description) -> + new Statement() { + @Override + public void evaluate() throws Throwable { + System.setProperty( + TestPipeline.PROPERTY_BEAM_TEST_PIPELINE_UNIQUE_ROOT_NAMES, "true"); + base.evaluate(); + } + }; + + private final transient TestPipeline pipeline = TestPipeline.create(); + + @Rule + public final transient RuleChain rules = + RuleChain.outerRule(new RestoreSystemProperties()) + .around(ENABLE_UNIQUE_ROOT_NAMES) + .around(pipeline); + + @Test + public void testRuleNamesRootAfterTest() { + String root = pipeline.getRootName(); + assertThat( + root, matchesPattern("t[0-9]+-TestPipelineTest\\$UniqueRootNamesTest-testRuleNames.*")); + assertThat(root.length(), lessThanOrEqualTo(TestPipeline.MAX_ROOT_NAME_LENGTH)); + + PCollection created = pipeline.apply("MyCreate", Create.of(1, 2, 3)); + PCollection mapped = + created.apply( + "MyMap", + MapElements.via( + new SimpleFunction() { + @Override + public Integer apply(Integer input) { + return input; + } + })); + assertThat(created.getName(), startsWith(root + "/")); + assertThat(mapped.getName(), startsWith(root + "/")); + + List fullNames = new ArrayList<>(); + pipeline.traverseTopologically( + new Pipeline.PipelineVisitor.Defaults() { + @Override + public CompositeBehavior enterCompositeTransform(TransformHierarchy.Node node) { + if (!node.isRootNode()) { + fullNames.add(node.getFullName()); + } + return CompositeBehavior.ENTER_TRANSFORM; + } + + @Override + public void visitPrimitiveTransform(TransformHierarchy.Node node) { + fullNames.add(node.getFullName()); + } + }); + assertThat(fullNames, hasItem(root + "/MyCreate")); + assertThat(fullNames, hasItem(root + "/MyMap")); + assertThat(fullNames, everyItem(startsWith(root + "/"))); + } + + @Test + public void testRootNamesAreUniqueAcrossPipelines() { + TestPipeline other = TestPipeline.create(); + other.nameRoot("someOtherTest"); + assertThat(other.getRootName(), not(equalTo(pipeline.getRootName()))); + assertThat(other.getRootName(), matchesPattern("t[0-9]+-someOtherTest")); + } + + @Test + public void testNameRootIsIgnoredOnceTransformsExist() { + TestPipeline late = TestPipeline.create(); + late.apply(Create.of(1)); + late.nameRoot("tooLate"); + assertEquals("", late.getRootName()); + } + + @Test + public void testRootNameForSanitizesAndTruncates() { + assertEquals("t7-Foo$Bar-baz", TestPipeline.rootNameFor(7, "Foo$Bar-baz")); + // Characters outside the conservative set, notably the '/' name separator, are replaced. + assertEquals("t7-a_b_c_d", TestPipeline.rootNameFor(7, "a/b c[d")); + // Only the human-readable part is truncated; the unique sequence prefix is kept intact. + String longName = "x".repeat(200); + String truncated = TestPipeline.rootNameFor(123456, longName); + assertEquals(TestPipeline.MAX_ROOT_NAME_LENGTH, truncated.length()); + assertThat(truncated, startsWith("t123456-xxx")); + assertEquals("t1-", TestPipeline.rootNameFor(1, "")); + } + } + + /** Tests for the default (unnamed) root. */ + @RunWith(JUnit4.class) + public static class DefaultRootNameTest { + @Rule public transient TestPipeline pipeline = TestPipeline.create(); + + @Test + public void testRootIsUnnamedUnlessPropertySet() { + assertEquals("", pipeline.getRootName()); + PCollection created = pipeline.apply("MyCreate", Create.of(1)); + assertThat(created.getName(), startsWith("MyCreate")); + } + } + + /** + * Tests for each condition under which {@link TestPipeline#isStandaloneExecutionRequired()} + * reports that the pipeline must not be merged with others. Runs under {@link + * BeamParallelJunit4Runner} because that is what hands the test instance to the rule, which the + * {@link ExpectedException} detection relies on. The {@link Timeout} rule is deliberate: like + * many ValidatesRunner suites this class then executes its test bodies on a thread of JUnit's + * choosing rather than the runner's, and detection must not depend on which thread that is. + */ + @RunWith(BeamParallelJunit4Runner.class) + public static class StandaloneExecutionTest { + @Rule public transient ExpectedException thrown = ExpectedException.none(); + @Rule public transient TestPipeline pipeline = TestPipeline.create(); + @Rule public transient Timeout globalTimeout = Timeout.seconds(60); + + @Test + public void testPlainTestDoesNotRequireStandalone() { + assertFalse(pipeline.isStandaloneExecutionRequired()); + pipeline.apply("MyCreate", Create.of(1)); + assertFalse(pipeline.isStandaloneExecutionRequired()); + } + + @Test(expected = IllegalStateException.class) + public void testExpectedAnnotationRequiresStandalone() { + assertTrue(pipeline.isStandaloneExecutionRequired()); + throw new IllegalStateException("expected"); + } + + @Test + @Category(UsesFailureMessage.class) + public void testUsesFailureMessageCategoryRequiresStandalone() { + assertTrue(pipeline.isStandaloneExecutionRequired()); + } + + @Test + @BeamParallelJunit4Runner.SerialTest + public void testSerialTestMethodRequiresStandalone() { + assertTrue(pipeline.isStandaloneExecutionRequired()); + } + + @Test + public void testExpectedExceptionRuleRequiresStandalone() { + assertFalse(pipeline.isStandaloneExecutionRequired()); + thrown.expect(IllegalStateException.class); + assertTrue(pipeline.isStandaloneExecutionRequired()); + throw new IllegalStateException("expected"); + } + + @Test + public void testOptionsMutatedAfterRuleRequiresStandalone() { + assertFalse(pipeline.isStandaloneExecutionRequired()); + pipeline.getOptions().setJobName("custom-job-name"); + assertTrue(pipeline.isStandaloneExecutionRequired()); + } + + @Test + public void testRuntimeValueProviderRequiresStandalone() { + assertFalse(pipeline.isStandaloneExecutionRequired()); + pipeline.newProvider("runtime-value"); + assertTrue(pipeline.isStandaloneExecutionRequired()); + } + } + + /** Class-level {@link BeamParallelJunit4Runner.SerialTest} also forces standalone execution. */ + @RunWith(JUnit4.class) + @BeamParallelJunit4Runner.SerialTest + public static class SerialClassStandaloneExecutionTest { + @Rule public transient TestPipeline pipeline = TestPipeline.create(); + + @Test + public void testSerialTestClassRequiresStandalone() { + assertTrue(pipeline.isStandaloneExecutionRequired()); + } + } + + /** Class-level {@code @Category(UsesFailureMessage.class)} also forces standalone execution. */ + @RunWith(JUnit4.class) + @Category(UsesFailureMessage.class) + public static class UsesFailureMessageClassStandaloneExecutionTest { + @Rule public transient TestPipeline pipeline = TestPipeline.create(); + + @Test + public void testUsesFailureMessageClassRequiresStandalone() { + assertTrue(pipeline.isStandaloneExecutionRequired()); + } + } + /** * Tests for {@link TestPipeline}'s detection of missing {@link Pipeline#run()}, or abandoned * (dangling) {@link PAssert} or {@link org.apache.beam.sdk.transforms.PTransform} nodes. diff --git a/sdks/java/testing/junit/src/main/java/org/apache/beam/sdk/testing/TestPipelineExtension.java b/sdks/java/testing/junit/src/main/java/org/apache/beam/sdk/testing/TestPipelineExtension.java index 12c05ff47fd1..7bd03ac3d041 100644 --- a/sdks/java/testing/junit/src/main/java/org/apache/beam/sdk/testing/TestPipelineExtension.java +++ b/sdks/java/testing/junit/src/main/java/org/apache/beam/sdk/testing/TestPipelineExtension.java @@ -114,6 +114,9 @@ public void beforeEach(ExtensionContext context) { // Set application name based on test method String appName = getAppName(context); pipeline.getOptions().as(ApplicationNameOptions.class).setAppName(appName); + if (Boolean.getBoolean(TestPipeline.PROPERTY_BEAM_TEST_PIPELINE_UNIQUE_ROOT_NAMES)) { + pipeline.nameRoot(appName); + } // Set up enforcement based on annotations pipeline.setDeducedEnforcementLevel(getAnnotations(context)); From 3d5375ac59d0a93f5537375080eb1dd43637c1d8 Mon Sep 17 00:00:00 2001 From: Yi Hu Date: Thu, 22 Oct 2026 12:04:59 -0400 Subject: [PATCH 089/108] Expand Beam SQL TPC-DS query support and add Python TPC-DS suite (#40695) * Expand Beam SQL TPC-DS query support and add Python TPC-DS suite - Add `BeamCrossJoinRel` and `BeamCrossJoinRule` to support bounded-by-bounded pure cross joins in Beam Calcite SQL. - Extend `BeamCoGBKJoinRule` to split mixed equi/non-equi inner join predicates into an equi-join followed by a post-join `BeamCalcRel` filter. - Support `LAG`, `LEAD`, `FIRST_VALUE`, `LAST_VALUE`, `NTH_VALUE` (`RESPECT`/`IGNORE NULLS`), and `CUME_DIST` analytic window functions in `BeamWindowRel` and `BeamBuiltinAnalyticFunctions`, and convert window inputs to `BeamLogicalConvention` in `BeamWindowRule`. - Fix `NULLS FIRST` and `NULLS LAST` comparator ordering in `BeamSortRel` when combined with `ASC`/`DESC`. - Normalize set operator (`UNION`, `INTERSECT`, `EXCEPT`) input schemas in `BeamSetOperatorRelBase` when inputs differ only by field names or nullability. - Return `NULL` instead of `0.0` from `VarianceFn` for empty or single-element sample variance/stddev groups to match SQL standard semantics. - Remove workarounds in TPC-DS table schemas used to cast `DECIMAL` to `DOUBLE` for fractional columns Having these improvements, 55 out of 103 queries now passing (up from 24). Then for test suite, - add `--queries=supported` and `10M`/`10MB` dataset size support to `TpcdsParametersReader`. - Add the Python TPC-DS benchmark and regression test suite (`sdks/python/apache_beam/testing/benchmarks/tpcds`) running on `PrismRunner` and cross-language `SqlTransform`. - Update Java TPC-DS GitHub Actions workflows to use `--queries=supported` and add the `beam_PostCommit_Python_Tpcds_Prism.yml` workflow. - Move query result file to a temp bucket with lifecycle policy, prevent resource leak --- .../beam_PostCommit_Java_Tpcds_Dataflow.json | 1 + .../beam_PostCommit_Java_Tpcds_Flink.json | 2 +- .../beam_PostCommit_Java_Tpcds_Spark.json | 2 +- .github/workflows/README.md | 1 + .../beam_PostCommit_Java_Tpcds_Dataflow.yml | 8 +- .../beam_PostCommit_Java_Tpcds_Flink.yml | 4 +- .../beam_PostCommit_Java_Tpcds_Spark.yml | 4 +- .../beam_PostCommit_Python_Tpcds_Prism.yml | 75 +++ .../sql/impl/CalciteQueryPlanner.java | 5 + .../sql/impl/planner/BeamRuleSets.java | 3 + .../sql/impl/planner/RelMdNodeStats.java | 6 + .../sql/impl/rel/BeamCrossJoinRel.java | 173 +++++++ .../sql/impl/rel/BeamSetOperatorRelBase.java | 94 +++- .../extensions/sql/impl/rel/BeamSortRel.java | 11 +- .../sql/impl/rel/BeamWindowRel.java | 104 +++- .../impl/rule/BeamBasicAggregationRule.java | 4 +- .../sql/impl/rule/BeamCoGBKJoinRule.java | 119 ++++- .../sql/impl/rule/BeamCrossJoinRule.java | 79 +++ .../sql/impl/rule/BeamWindowRule.java | 4 +- .../sql/impl/schema/BeamTableUtils.java | 2 + .../transform/BeamBuiltinAggregations.java | 10 +- .../BeamBuiltinAnalyticFunctions.java | 246 +++++++++- .../sql/impl/transform/agg/VarianceFn.java | 22 +- .../sql/BeamAnalyticFunctionsTest.java | 112 +++++ .../extensions/sql/BeamSqlDslJoinTest.java | 13 +- .../BeamCoGBKJoinRelBoundedVsBoundedTest.java | 42 +- .../apache/beam/sdk/tpcds/QueryReader.java | 4 +- .../beam/sdk/tpcds/TableSchemaJSONLoader.java | 4 +- .../apache/beam/sdk/tpcds/TpcdsOptions.java | 5 +- .../beam/sdk/tpcds/TpcdsParametersReader.java | 20 +- .../apache/beam/sdk/tpcds/TpcdsSchemas.java | 166 +++---- .../sdk/tpcds/TableSchemaJSONLoaderTest.java | 24 +- .../sdk/tpcds/TpcdsParametersReaderTest.java | 9 + .../beam/sdk/tpcds/TpcdsSchemasTest.java | 6 +- .../testing/benchmarks/tpcds/README.md | 57 +++ .../testing/benchmarks/tpcds/__init__.py | 18 + .../testing/benchmarks/tpcds/build.gradle | 84 ++++ .../benchmarks/tpcds/tpcds_launcher.py | 460 ++++++++++++++++++ .../testing/benchmarks/tpcds/tpcds_queries.py | 195 ++++++++ .../testing/benchmarks/tpcds/tpcds_schemas.py | 141 ++++++ .../testing/benchmarks/tpcds/tpcds_test.py | 78 +++ settings.gradle.kts | 1 + 42 files changed, 2239 insertions(+), 179 deletions(-) create mode 100644 .github/trigger_files/beam_PostCommit_Java_Tpcds_Dataflow.json create mode 100644 .github/workflows/beam_PostCommit_Python_Tpcds_Prism.yml create mode 100644 sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamCrossJoinRel.java create mode 100644 sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamCrossJoinRule.java create mode 100644 sdks/python/apache_beam/testing/benchmarks/tpcds/README.md create mode 100644 sdks/python/apache_beam/testing/benchmarks/tpcds/__init__.py create mode 100644 sdks/python/apache_beam/testing/benchmarks/tpcds/build.gradle create mode 100644 sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_launcher.py create mode 100644 sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_queries.py create mode 100644 sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_schemas.py create mode 100644 sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_test.py diff --git a/.github/trigger_files/beam_PostCommit_Java_Tpcds_Dataflow.json b/.github/trigger_files/beam_PostCommit_Java_Tpcds_Dataflow.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/.github/trigger_files/beam_PostCommit_Java_Tpcds_Dataflow.json @@ -0,0 +1 @@ +{} diff --git a/.github/trigger_files/beam_PostCommit_Java_Tpcds_Flink.json b/.github/trigger_files/beam_PostCommit_Java_Tpcds_Flink.json index e3d6056a5de9..b26833333238 100644 --- a/.github/trigger_files/beam_PostCommit_Java_Tpcds_Flink.json +++ b/.github/trigger_files/beam_PostCommit_Java_Tpcds_Flink.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 1 + "modification": 2 } diff --git a/.github/trigger_files/beam_PostCommit_Java_Tpcds_Spark.json b/.github/trigger_files/beam_PostCommit_Java_Tpcds_Spark.json index e3d6056a5de9..b26833333238 100644 --- a/.github/trigger_files/beam_PostCommit_Java_Tpcds_Spark.json +++ b/.github/trigger_files/beam_PostCommit_Java_Tpcds_Spark.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 1 + "modification": 2 } diff --git a/.github/workflows/README.md b/.github/workflows/README.md index c41191e4fe35..178fb6502680 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -372,6 +372,7 @@ PostCommit Jobs run in a schedule against master branch and generally do not get | [ PostCommit Python Examples Spark ](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_Examples_Spark.yml) | ['3.9','3.11','3.12'] |`beam_PostCommit_Python_Examples_Spark.json`| [![.github/workflows/beam_PostCommit_Python_Examples_Spark.yml](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_Examples_Spark.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_Examples_Spark.yml?query=event%3Aschedule) | | [ PostCommit Python MongoDBIO IT ](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_MongoDBIO_IT.yml) | N/A |`beam_PostCommit_Python_MongoDBIO_IT.json`| [![.github/workflows/beam_PostCommit_Python_MongoDBIO_IT.yml](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_MongoDBIO_IT.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_MongoDBIO_IT.yml?query=event%3Aschedule) | | [ PostCommit Python Nexmark Direct ](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_Nexmark_Direct.yml) | N/A |`beam_PostCommit_Python_Nexmark_Direct.json`| [![.github/workflows/beam_PostCommit_Python_Nexmark_Direct.yml](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_Nexmark_Direct.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_Nexmark_Direct.yml?query=event%3Aschedule) | +| [ PostCommit Python Tpcds Prism ](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_Tpcds_Prism.yml) | N/A |`beam_PostCommit_Python_Tpcds_Prism.json`| [![.github/workflows/beam_PostCommit_Python_Tpcds_Prism.yml](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_Tpcds_Prism.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_Tpcds_Prism.yml?query=event%3Aschedule) | | [ PostCommit Python ValidatesContainer Dataflow ](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_ValidatesContainer_Dataflow.yml) | ['3.9','3.10','3.11','3.12'] |`beam_PostCommit_Python_ValidatesContainer_Dataflow.json`| [![.github/workflows/beam_PostCommit_Python_ValidatesContainer_Dataflow.yml](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_ValidatesContainer_Dataflow.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_ValidatesContainer_Dataflow.yml?query=event%3Aschedule) | | [ PostCommit Python ValidatesContainer Dataflow With RC ](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_ValidatesContainer_Dataflow_With_RC.yml) | ['3.9','3.10','3.11','3.12'] |`beam_PostCommit_Python_ValidatesContainer_Dataflow_With_RC.json`| [![.github/workflows/beam_PostCommit_Python_ValidatesContainer_Dataflow_With_RC.yml](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_ValidatesContainer_Dataflow_With_RC.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_ValidatesContainer_Dataflow_With_RC.yml?query=event%3Aschedule) | | [ PostCommit Python ValidatesRunner Dataflow ](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_ValidatesRunner_Dataflow.yml) | ['3.9','3.11','3.12'] |`beam_PostCommit_Python_ValidatesRunner_Dataflow.json`| [![.github/workflows/beam_PostCommit_Python_ValidatesRunner_Dataflow.yml](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_ValidatesRunner_Dataflow.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_PostCommit_Python_ValidatesRunner_Dataflow.yml?query=event%3Aschedule) | diff --git a/.github/workflows/beam_PostCommit_Java_Tpcds_Dataflow.yml b/.github/workflows/beam_PostCommit_Java_Tpcds_Dataflow.yml index 35bd18a7d541..e5bc892bb2ec 100644 --- a/.github/workflows/beam_PostCommit_Java_Tpcds_Dataflow.yml +++ b/.github/workflows/beam_PostCommit_Java_Tpcds_Dataflow.yml @@ -17,7 +17,7 @@ name: PostCommit Java Tpcds Dataflow on: schedule: - - cron: '30 4/6 * * *' + - cron: '30 4/8 * * *' pull_request: paths: ['release/trigger_all_tests.json', '.github/trigger_files/beam_PostCommit_Java_Tpcds_Dataflow.json'] workflow_dispatch: @@ -54,8 +54,8 @@ env: --dataSize=1GB --sourceType=PARQUET --dataDirectory=gs://beam-tpcds/datasets/parquet/nonpartitioned - --resultsDirectory=gs://beam-tpcds/results/dataflow/ - --tpcParallel=1 + --resultsDirectory=gs://temp-storage-for-perf-tests/tpcds/results/dataflow + --tpcParallel=4 INFLUXDB_USER: ${{ secrets.INFLUXDB_USER }} INFLUXDB_USER_PASSWORD: ${{ secrets.INFLUXDB_USER_PASSWORD }} tpcdsBigQueryArgs: | @@ -71,7 +71,7 @@ env: --baseInfluxMeasurement=tpcds --exportSummaryToInfluxDB=true --influxRetentionPolicy=forever - tpcdsQueriesArg: 3,7,10,25,26,29,35,38,40,42,43,52,55,69,79,83,84,87,93,96 + tpcdsQueriesArg: supported jobs: beam_PostCommit_Java_Tpcds_Dataflow: diff --git a/.github/workflows/beam_PostCommit_Java_Tpcds_Flink.yml b/.github/workflows/beam_PostCommit_Java_Tpcds_Flink.yml index f84dea31c345..bb4c3066ddf2 100644 --- a/.github/workflows/beam_PostCommit_Java_Tpcds_Flink.yml +++ b/.github/workflows/beam_PostCommit_Java_Tpcds_Flink.yml @@ -51,7 +51,7 @@ env: --dataSize=1GB --sourceType=PARQUET --dataDirectory=gs://beam-tpcds/datasets/parquet/nonpartitioned - --resultsDirectory=gs://beam-tpcds/results/flink/ + --resultsDirectory=gs://temp-storage-for-perf-tests/tpcds/results/flink --tpcParallel=1 INFLUXDB_USER: ${{ secrets.INFLUXDB_USER }} INFLUXDB_USER_PASSWORD: ${{ secrets.INFLUXDB_USER_PASSWORD }} @@ -68,7 +68,7 @@ env: --baseInfluxMeasurement=tpcds --exportSummaryToInfluxDB=true --influxRetentionPolicy=forever - tpcdsQueriesArg: 3,7,10,25,26,29,35,38,40,42,43,52,55,69,79,83,84,87,93,96 + tpcdsQueriesArg: supported jobs: beam_PostCommit_Java_Tpcds_Flink: diff --git a/.github/workflows/beam_PostCommit_Java_Tpcds_Spark.yml b/.github/workflows/beam_PostCommit_Java_Tpcds_Spark.yml index 6ce87271a4b9..8f67af866360 100644 --- a/.github/workflows/beam_PostCommit_Java_Tpcds_Spark.yml +++ b/.github/workflows/beam_PostCommit_Java_Tpcds_Spark.yml @@ -49,7 +49,7 @@ env: --dataSize=1GB --sourceType=PARQUET --dataDirectory=gs://beam-tpcds/datasets/parquet/nonpartitioned - --resultsDirectory=gs://beam-tpcds/results/spark3-rdd/ + --resultsDirectory=gs://temp-storage-for-perf-tests/tpcds/results/spark3-rdd --tpcParallel=1 INFLUXDB_USER: ${{ secrets.INFLUXDB_USER }} INFLUXDB_USER_PASSWORD: ${{ secrets.INFLUXDB_USER_PASSWORD }} @@ -66,7 +66,7 @@ env: --baseInfluxMeasurement=tpcds --exportSummaryToInfluxDB=true --influxRetentionPolicy=forever - tpcdsQueriesArg: 3,7,10,25,26,29,35,38,40,42,43,52,55,69,79,83,84,87,93,96 + tpcdsQueriesArg: supported jobs: beam_PostCommit_Java_Tpcds_Spark: diff --git a/.github/workflows/beam_PostCommit_Python_Tpcds_Prism.yml b/.github/workflows/beam_PostCommit_Python_Tpcds_Prism.yml new file mode 100644 index 000000000000..168d60b65f3d --- /dev/null +++ b/.github/workflows/beam_PostCommit_Python_Tpcds_Prism.yml @@ -0,0 +1,75 @@ +# 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. + +name: PostCommit Python Tpcds Prism + +on: + schedule: + - cron: '30 4/6 * * *' + pull_request: + paths: ['release/trigger_all_tests.json', '.github/trigger_files/beam_PostCommit_Python_Tpcds_Prism.json'] + workflow_dispatch: + +# Setting explicit permissions for the action +permissions: + actions: write + pull-requests: read + checks: read + contents: read + deployments: read + id-token: none + issues: read + discussions: read + packages: read + pages: read + repository-projects: read + security-events: read + statuses: read + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.number || github.sha || github.head_ref || github.ref }}-${{ github.event.schedule || github.event.sender.login }}' + cancel-in-progress: true + +env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.BEAM_DEVELOCITY_ACCESS_KEY }} + +jobs: + beam_PostCommit_Python_Tpcds_Prism: + if: | + github.event_name == 'workflow_dispatch' || + github.event_name == 'pull_request' || + (github.event_name == 'schedule' && github.repository == 'apache/beam') + runs-on: [self-hosted, ubuntu-24.04, main] + timeout-minutes: 60 + name: ${{ matrix.job_name }} + strategy: + matrix: + job_name: ["beam_PostCommit_Python_Tpcds_Prism"] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Setup repository + uses: ./.github/actions/setup-action + - name: Setup environment + uses: ./.github/actions/setup-environment-action + with: + python-version: default + go-version: default + - name: Run full TPC-DS suite on PrismRunner with regression check + uses: ./.github/actions/gradle-command-self-hosted-action + with: + gradle-command: :sdks:python:apache_beam:testing:benchmarks:tpcds:checkAllQueries diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/CalciteQueryPlanner.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/CalciteQueryPlanner.java index cedfc5dc6d1d..729ab8f8d84c 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/CalciteQueryPlanner.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/CalciteQueryPlanner.java @@ -239,6 +239,11 @@ public BeamRelNode convertToBeamRel(String sqlStatement, QueryParameters queryPa String.format("Unable to convert query %s", sqlStatement), e); } catch (SqlParseException | ValidationException e) { throw new ParseException(String.format("Unable to parse query %s", sqlStatement), e); + } catch (RuntimeException e) { + if (e.getCause() instanceof SqlConversionException) { + throw new SqlConversionException(e.getMessage(), e.getCause()); + } + throw e; } finally { planner.close(); } diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/planner/BeamRuleSets.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/planner/BeamRuleSets.java index 8382fcb6e382..2e3ef1823863 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/planner/BeamRuleSets.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/planner/BeamRuleSets.java @@ -26,6 +26,7 @@ import org.apache.beam.sdk.extensions.sql.impl.rule.BeamCalcMergeRule; import org.apache.beam.sdk.extensions.sql.impl.rule.BeamCalcRule; import org.apache.beam.sdk.extensions.sql.impl.rule.BeamCoGBKJoinRule; +import org.apache.beam.sdk.extensions.sql.impl.rule.BeamCrossJoinRule; import org.apache.beam.sdk.extensions.sql.impl.rule.BeamEnumerableConverterRule; import org.apache.beam.sdk.extensions.sql.impl.rule.BeamIOPushDownRule; import org.apache.beam.sdk.extensions.sql.impl.rule.BeamIntersectRule; @@ -60,6 +61,7 @@ public class BeamRuleSets { ImmutableList.of( // Rules for window functions CoreRules.PROJECT_TO_LOGICAL_PROJECT_AND_WINDOW, + CoreRules.CALC_TO_WINDOW, // Rules so we only have to implement Calc CoreRules.FILTER_TO_CALC, CoreRules.PROJECT_TO_CALC, @@ -144,6 +146,7 @@ public class BeamRuleSets { BeamUnnestRule.INSTANCE, BeamSideInputJoinRule.INSTANCE, BeamCoGBKJoinRule.INSTANCE, + BeamCrossJoinRule.INSTANCE, BeamSideInputLookupJoinRule.INSTANCE, BeamMatchRule.INSTANCE); diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/planner/RelMdNodeStats.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/planner/RelMdNodeStats.java index 40db8b074efa..51b197a7eac4 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/planner/RelMdNodeStats.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/planner/RelMdNodeStats.java @@ -24,6 +24,7 @@ import org.apache.beam.sdk.extensions.sql.impl.rel.BeamRelNode; import org.apache.beam.vendor.calcite.v1_40_0.com.google.common.collect.Table; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.RelNode; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.core.Values; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.metadata.MetadataDef; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.metadata.MetadataHandler; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.metadata.ReflectiveRelMetadataProvider; @@ -57,6 +58,11 @@ public NodeStats getNodeStats(RelNode rel, RelMetadataQuery mq) { return this.getBeamNodeStats((BeamRelNode) rel, bmq); } + if (rel instanceof Values) { + Values values = (Values) rel; + return NodeStats.create(values.getTuples().size()); + } + // We can later define custom methods for all different RelNodes to prevent hitting this point. // Similar to RelMdRowCount in calcite. diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamCrossJoinRel.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamCrossJoinRel.java new file mode 100644 index 000000000000..ebdc73796887 --- /dev/null +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamCrossJoinRel.java @@ -0,0 +1,173 @@ +/* + * 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 org.apache.beam.sdk.extensions.sql.impl.rel; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import org.apache.beam.sdk.extensions.sql.impl.utils.CalciteUtils; +import org.apache.beam.sdk.schemas.Schema; +import org.apache.beam.sdk.transforms.DoFn; +import org.apache.beam.sdk.transforms.PTransform; +import org.apache.beam.sdk.transforms.ParDo; +import org.apache.beam.sdk.transforms.View; +import org.apache.beam.sdk.transforms.windowing.TimestampCombiner; +import org.apache.beam.sdk.transforms.windowing.Window; +import org.apache.beam.sdk.values.PCollection; +import org.apache.beam.sdk.values.PCollectionList; +import org.apache.beam.sdk.values.PCollectionView; +import org.apache.beam.sdk.values.Row; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.plan.RelOptCluster; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.plan.RelTraitSet; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.RelNode; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.core.CorrelationId; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.core.Join; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.core.JoinRelType; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rex.RexNode; + +/** + * A {@code BeamJoinRel} that implements a BOUNDED cartesian (CROSS) join, used for the literal-TRUE + * join conditions that Calcite emits when it decorrelates uncorrelated scalar / IN / EXISTS + * subqueries and lateral joins. + * + *

    Only supported when BOTH inputs are bounded. The right side is broadcast as a side input and + * the left side is iterated; every (left, right) pair is emitted as one combined row (left values + * followed by right values). + * + *

    First increment: INNER join with an always-true condition. A non-trivial (residual) condition + * is not yet evaluated here. + */ +public class BeamCrossJoinRel extends BeamJoinRel { + + public BeamCrossJoinRel( + RelOptCluster cluster, + RelTraitSet traitSet, + RelNode left, + RelNode right, + RexNode condition, + Set variablesSet, + JoinRelType joinType) { + super(cluster, traitSet, left, right, condition, variablesSet, joinType); + } + + @Override + public Join copy( + RelTraitSet traitSet, + RexNode conditionExpr, + RelNode left, + RelNode right, + JoinRelType joinType, + boolean semiJoinDone) { + return new BeamCrossJoinRel( + getCluster(), traitSet, left, right, conditionExpr, variablesSet, joinType); + } + + @Override + public PTransform, PCollection> buildPTransform() { + BeamRelNode leftRelNode = BeamSqlRelUtils.getBeamRelInput(left); + BeamRelNode rightRelNode = BeamSqlRelUtils.getBeamRelInput(right); + if (leftRelNode.isBounded() != PCollection.IsBounded.BOUNDED + || rightRelNode.isBounded() != PCollection.IsBounded.BOUNDED) { + throw new UnsupportedOperationException( + "Bounded cross join requires both inputs to be bounded."); + } + if (joinType != JoinRelType.INNER && joinType != JoinRelType.LEFT) { + throw new UnsupportedOperationException( + "Only INNER and LEFT cross join are supported for now, got " + joinType.name()); + } + if (!condition.isAlwaysTrue()) { + throw new UnsupportedOperationException( + "Cross join with a residual (non-equi) condition is not supported yet."); + } + return new CrossJoin(); + } + + private class CrossJoin extends PTransform, PCollection> { + @Override + public PCollection expand(PCollectionList pinput) { + PCollection leftRows = + pinput + .get(0) + .apply( + "left_TimestampCombiner", + Window.configure().withTimestampCombiner(TimestampCombiner.EARLIEST)); + PCollection rightRows = + pinput + .get(1) + .apply( + "right_TimestampCombiner", + Window.configure().withTimestampCombiner(TimestampCombiner.EARLIEST)); + + final Schema outputSchema = CalciteUtils.toSchema(getRowType()); + final PCollectionView> rightView = rightRows.apply(View.asList()); + final boolean leftJoin = joinType == JoinRelType.LEFT; + final int rightFieldCount = getRowType().getFieldCount() - left.getRowType().getFieldCount(); + + return leftRows + .apply( + ParDo.of(new CrossJoinDoFn(outputSchema, rightView, leftJoin, rightFieldCount)) + .withSideInputs(rightView)) + .setRowSchema(outputSchema); + } + } + + /** + * Emits the cartesian product of each left row with the broadcast right rows. A static {@link + * DoFn} (not an anonymous/inner class) so it does not capture the non-serializable enclosing rel. + */ + private static class CrossJoinDoFn extends DoFn { + private final Schema outputSchema; + private final PCollectionView> rightView; + private final boolean leftJoin; + private final int rightFieldCount; + + CrossJoinDoFn( + Schema outputSchema, + PCollectionView> rightView, + boolean leftJoin, + int rightFieldCount) { + this.outputSchema = outputSchema; + this.rightView = rightView; + this.leftJoin = leftJoin; + this.rightFieldCount = rightFieldCount; + } + + // Row.Builder.addValues expects List<@Nullable Object>, while Row.getBaseValues() returns + // List<@NonNull Object>, so it is safe to suppress nullness here. + @SuppressWarnings("nullness") + @ProcessElement + public void processElement(ProcessContext c) { + Row leftRow = c.element(); + List rightRowList = c.sideInput(rightView); + for (Row rightRow : rightRowList) { + c.output( + Row.withSchema(outputSchema) + .addValues(leftRow.getBaseValues()) + .addValues(rightRow.getBaseValues()) + .build()); + } + if (leftJoin && rightRowList.isEmpty()) { + c.output( + Row.withSchema(outputSchema) + .addValues(leftRow.getBaseValues()) + .addValues(Collections.nCopies(rightFieldCount, null)) + .build()); + } + } + } +} diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamSetOperatorRelBase.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamSetOperatorRelBase.java index cad845c2b3a9..efbdfc4111b5 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamSetOperatorRelBase.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamSetOperatorRelBase.java @@ -17,12 +17,19 @@ */ package org.apache.beam.sdk.extensions.sql.impl.rel; -import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument; - import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import org.apache.beam.sdk.extensions.sql.impl.transform.BeamSetOperatorsTransforms; +import org.apache.beam.sdk.extensions.sql.impl.utils.CalciteUtils; +import org.apache.beam.sdk.schemas.Schema; +import org.apache.beam.sdk.schemas.Schema.FieldType; +import org.apache.beam.sdk.schemas.Schema.TypeName; import org.apache.beam.sdk.schemas.transforms.CoGroup; import org.apache.beam.sdk.schemas.transforms.CoGroup.By; +import org.apache.beam.sdk.transforms.Flatten; +import org.apache.beam.sdk.transforms.MapElements; import org.apache.beam.sdk.transforms.PTransform; import org.apache.beam.sdk.transforms.ParDo; import org.apache.beam.sdk.transforms.windowing.WindowFn; @@ -30,6 +37,7 @@ import org.apache.beam.sdk.values.PCollectionList; import org.apache.beam.sdk.values.PCollectionTuple; import org.apache.beam.sdk.values.Row; +import org.apache.beam.sdk.values.TypeDescriptors; /** * Delegate for Set operators: {@code BeamUnionRel}, {@code BeamIntersectRel} and {@code @@ -59,13 +67,31 @@ public BeamSetOperatorRelBase(BeamRelNode beamRelNode, OpType opType, boolean al @Override public PCollection expand(PCollectionList inputs) { - checkArgument( - inputs.size() == 2, - "Wrong number of arguments to %s: %s", - beamRelNode.getClass().getSimpleName(), - inputs); - PCollection leftRows = inputs.get(0); - PCollection rightRows = inputs.get(1); + Schema targetSchema = CalciteUtils.toSchema(beamRelNode.getRowType()); + + if (opType == OpType.UNION && all) { + PCollectionList coercedInputs = PCollectionList.empty(inputs.getPipeline()); + for (PCollection input : inputs.getAll()) { + coercedInputs = coercedInputs.and(coerce(input, targetSchema)); + } + return coercedInputs.apply("UnionAllFlatten", Flatten.pCollections()); + } + + if (inputs.size() == 2) { + return expandPair(coerce(inputs.get(0), targetSchema), coerce(inputs.get(1), targetSchema)); + } else if (inputs.size() > 2) { + PCollection result = coerce(inputs.get(0), targetSchema); + for (int i = 1; i < inputs.size(); i++) { + result = expandPair(result, coerce(inputs.get(i), targetSchema)); + } + return result; + } else { + throw new IllegalArgumentException( + "Too few arguments to " + beamRelNode.getClass().getSimpleName()); + } + } + + private PCollection expandPair(PCollection leftRows, PCollection rightRows) { WindowFn leftWindow = leftRows.getWindowingStrategy().getWindowFn(); WindowFn rightWindow = rightRows.getWindowingStrategy().getWindowFn(); @@ -95,4 +121,54 @@ public PCollection expand(PCollectionList inputs) { lhsTag, rhsTag, opType, all))) .setRowSchema(joined.getSchema().getField("key").getType().getRowSchema()); } + + private static PCollection coerce(PCollection input, Schema targetSchema) { + if (input.getSchema().equals(targetSchema)) { + return input; + } + return input + .apply( + "CoerceSchema", + MapElements.into(TypeDescriptors.rows()).via(row -> coerceRow(row, targetSchema))) + .setRowSchema(targetSchema); + } + + private static Row coerceRow(Row row, Schema targetSchema) { + Row.Builder builder = Row.withSchema(targetSchema); + for (int i = 0; i < targetSchema.getFieldCount(); i++) { + Schema.Field targetField = targetSchema.getField(i); + Object value = row.getValue(i); + builder.addValue( + coerceValue(value, row.getSchema().getField(i).getType(), targetField.getType())); + } + return builder.build(); + } + + private static Object coerceValue(Object value, FieldType inputType, FieldType targetType) { + if (value == null) { + return null; + } + if (inputType.equals(targetType)) { + return value; + } + if (inputType.getTypeName() == TypeName.ARRAY && targetType.getTypeName() == TypeName.ARRAY) { + List list = (List) value; + if (list.isEmpty()) { + return Collections.emptyList(); + } + List coercedList = new ArrayList<>(list.size()); + for (Object element : list) { + coercedList.add( + coerceValue( + element, + inputType.getCollectionElementType(), + targetType.getCollectionElementType())); + } + return coercedList; + } + if (inputType.getTypeName() == TypeName.ROW && targetType.getTypeName() == TypeName.ROW) { + return coerceRow((Row) value, targetType.getRowSchema()); + } + return value; + } } diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamSortRel.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamSortRel.java index c7a0b9136bbb..a338d6b2e8b6 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamSortRel.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamSortRel.java @@ -378,9 +378,12 @@ public int compare(Row row1, Row row2) { if (isValue1Null && isValue2Null) { continue; } else if (isValue1Null && !isValue2Null) { - fieldRet = -1 * (nullsFirst.get(i) ? -1 : 1); + // Return early as NULLS FIRST/LAST is independent of ASC/DESC orientation. + fieldRet = nullsFirst.get(i) ? -1 : 1; + return fieldRet; } else if (!isValue1Null && isValue2Null) { - fieldRet = 1 * (nullsFirst.get(i) ? -1 : 1); + fieldRet = nullsFirst.get(i) ? 1 : -1; + return fieldRet; } else { switch (sqlTypeName) { case TINYINT: @@ -389,8 +392,12 @@ public int compare(Row row1, Row row2) { case BIGINT: case FLOAT: case DOUBLE: + case DECIMAL: + case BOOLEAN: case VARCHAR: + case CHAR: case DATE: + case TIME: case TIMESTAMP: Comparable v1 = row1.getBaseValue(fieldIndex, Comparable.class); Comparable v2 = row2.getBaseValue(fieldIndex, Comparable.class); diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamWindowRel.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamWindowRel.java index e597b6c63ed4..6fa4e383a2be 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamWindowRel.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamWindowRel.java @@ -54,6 +54,7 @@ import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rex.RexLiteral; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rex.RexNode; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists; +import org.joda.time.ReadableInstant; /** * {@code BeamRelNode} to replace a {@code Window} node. @@ -142,9 +143,27 @@ public PTransform, PCollection> buildPTransform() { List argList = anAggCall.getArgList(); Schema.Field field = CalciteUtils.toField(anAggCall.getName(), anAggCall.getType()); - Combine.CombineFn combineFn = - AggregationCombineFnAdapter.createCombineFnAnalyticsFunctions( - anAggCall, field, anAggCall.getAggregation().getName()); + String aggName = anAggCall.getAggregation().getName(); + Combine.CombineFn combineFn; + if (anAggCall.ignoreNulls() + && ("FIRST_VALUE".equals(aggName) || "LAST_VALUE".equals(aggName))) { + combineFn = + "FIRST_VALUE".equals(aggName) + ? BeamBuiltinAnalyticFunctions.navigationFirstValue(true) + : BeamBuiltinAnalyticFunctions.navigationLastValue(true); + } else if ("NTH_VALUE".equals(aggName)) { + long nthN = readNthValueOffset(argList); + combineFn = + BeamBuiltinAnalyticFunctions.navigationNthValue( + nthN, anAggCall.ignoreNulls()); + // Keep only the value column in argList; the offset n is resolved from + // constants. + argList = argList.isEmpty() ? argList : argList.subList(0, 1); + } else { + combineFn = + AggregationCombineFnAdapter.createCombineFnAnalyticsFunctions( + anAggCall, field, aggName); + } FieldAggregation fieldAggregation = new FieldAggregation( partitionKeysDef, @@ -169,6 +188,31 @@ private BigDecimal getLiteralValueConstants(RexNode n) { return (BigDecimal) this.constants.get(idx).getValue(); } + /** + * Extracts the 1-based position {@code n} of an {@code NTH_VALUE(expr, n)} call. The value column + * is {@code argList[0]}; {@code argList[1]} references the {@code n} literal, which the planner + * stores in the Window {@code constants} list at index {@code arg - inputFieldCount}. Defaults to + * 1 (== FIRST_VALUE) if the position cannot be resolved. + */ + private long readNthValueOffset(List argList) { + if (argList.size() < 2) { + return 1L; + } + int inputFieldCount = input.getRowType().getFieldCount(); + // Calcite stores window literal arguments in `constants` indexed past the input fields. + int constIdx = argList.get(1) - inputFieldCount; + if (constIdx >= 0 && constIdx < this.constants.size()) { + Object v = this.constants.get(constIdx).getValue(); + if (v instanceof BigDecimal) { + return ((BigDecimal) v).longValueExact(); + } + if (v instanceof Number) { + return ((Number) v).longValue(); + } + } + return 1L; + } + private static class FieldAggregation implements Serializable { private List partitionKeys; @@ -208,8 +252,9 @@ public FieldAggregation( @Override public NodeStats estimateNodeStats(BeamRelMetadataQuery mq) { - NodeStats inputStat = BeamSqlRelUtils.getNodeStats(this.input, mq); - return inputStat; + // Use mq.getRowCount so RelSubset inputs do not return UNKNOWN/infinite cost. + double rows = mq.getRowCount(this.input); + return NodeStats.create(rows, 0d, rows); } /** @@ -220,10 +265,12 @@ public NodeStats estimateNodeStats(BeamRelMetadataQuery mq) { */ @Override public BeamCostModel beamComputeSelfCost(RelOptPlanner planner, BeamRelMetadataQuery mq) { - NodeStats inputStat = BeamSqlRelUtils.getNodeStats(this.input, mq); + // Use the planner's row-count (RelSubset-safe) rather than BeamSqlRelUtils.getNodeStats, which + // yields UNKNOWN/infinite for a RelSubset input and prevents the Volcano cost fixpoint from + // converging. + double rows = mq.getRowCount(this.input); float multiplier = 1f + 0.125f; - return BeamCostModel.FACTORY.makeCost( - inputStat.getRowCount() * multiplier, inputStat.getRate() * multiplier); + return BeamCostModel.FACTORY.makeCost(rows * multiplier, 0d); } private static class Transform extends PTransform, PCollection> { @@ -309,16 +356,21 @@ public void processElement( if (fieldAgg.combineFn instanceof BeamBuiltinAnalyticFunctions.PositionAwareCombineFn) { BeamBuiltinAnalyticFunctions.PositionAwareCombineFn fn = (BeamBuiltinAnalyticFunctions.PositionAwareCombineFn) fieldAgg.combineFn; + Object positionInputValue = + aggFieldIndex >= 0 ? aggRow.getBaseValue(aggFieldIndex) : (Object) aggRow; accumulator = fn.addInput( accumulator, getOrderByValue(aggRow), + positionInputValue, count, (long) idx, (long) sortedRowsAsList.size()); } else { - accumulator = - fieldAgg.combineFn.addInput(accumulator, aggRow.getBaseValue(aggFieldIndex)); + // Pass the row itself for argumentless functions like COUNT(*) OVER (...). + Object inputValue = + aggFieldIndex >= 0 ? aggRow.getBaseValue(aggFieldIndex) : (Object) aggRow; + accumulator = fieldAgg.combineFn.addInput(accumulator, inputValue); } count++; } @@ -384,8 +436,33 @@ private BigDecimal getOrderByValue(Row r) { if (fieldAgg.orderKeys.size() == 0) { return null; } else { - return new BigDecimal(((Number) r.getBaseValue(fieldAgg.orderKeys.get(0))).toString()); + return toComparableBigDecimal(r.getBaseValue(fieldAgg.orderKeys.get(0))); + } + } + + /** + * Convert an ORDER BY key's base value into a comparable {@link BigDecimal} for RANGE + * framing. + * + *

    Numeric values keep their exact decimal representation (byte-identical to the historical + * {@code new BigDecimal(((Number) v).toString())} path). Temporal values (DATE / TIME / + * TIMESTAMP / TIMESTAMP_WITH_LOCAL_TIME_ZONE) all surface a base value of joda {@link + * ReadableInstant}; their epoch-millis are used so a RANGE frame ordered by a temporal column + * compares correctly instead of throwing a {@link ClassCastException}. A null value (NULL + * ordering key) maps to {@code null}, leaving the surrounding callers' existing null handling + * intact. + */ + private BigDecimal toComparableBigDecimal(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return new BigDecimal(((Number) value).toString()); + } + if (value instanceof ReadableInstant) { + return BigDecimal.valueOf(((ReadableInstant) value).getMillis()); } + return BigDecimal.ZERO; } private List getRows(List input, int index) { @@ -395,8 +472,9 @@ private List getRows(List input, int index) { fieldAgg.upperLimit != null ? fieldAgg.upperLimit.intValue() : Integer.MAX_VALUE; int lowerIndex = ll == Integer.MAX_VALUE ? Integer.MIN_VALUE : index - ll; int upperIndex = ul == Integer.MAX_VALUE ? Integer.MAX_VALUE : index + ul + 1; - lowerIndex = lowerIndex < 0 ? 0 : lowerIndex; - upperIndex = upperIndex > input.size() ? input.size() : upperIndex; + // Clamp bounds to [0, size] so frames completely outside the partition yield an empty list. + lowerIndex = Math.max(0, Math.min(lowerIndex, input.size())); + upperIndex = Math.max(lowerIndex, Math.min(upperIndex, input.size())); List out = input.subList(lowerIndex, upperIndex); return out; } diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamBasicAggregationRule.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamBasicAggregationRule.java index e2df3c3a685f..002477a717b8 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamBasicAggregationRule.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamBasicAggregationRule.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +import org.apache.beam.sdk.extensions.sql.impl.SqlConversionException; import org.apache.beam.sdk.extensions.sql.impl.rel.BeamAggregationRel; import org.apache.beam.sdk.extensions.sql.impl.rel.BeamLogicalConvention; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.plan.RelOptRule; @@ -61,7 +62,8 @@ public void onMatch(RelOptRuleCall call) { RelNode relNode = call.rel(1); if (aggregate.getGroupType() != Aggregate.Group.SIMPLE) { - return; + throw new SqlConversionException( + "GROUP BY " + aggregate.getGroupType() + " is not supported."); } if (relNode instanceof Project || relNode instanceof Calc || relNode instanceof Filter) { diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamCoGBKJoinRule.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamCoGBKJoinRule.java index f44839de062d..156b3740e7b4 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamCoGBKJoinRule.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamCoGBKJoinRule.java @@ -17,16 +17,28 @@ */ package org.apache.beam.sdk.extensions.sql.impl.rule; +import java.util.ArrayList; +import java.util.List; +import org.apache.beam.sdk.extensions.sql.impl.rel.BeamCalcRel; import org.apache.beam.sdk.extensions.sql.impl.rel.BeamCoGBKJoinRel; import org.apache.beam.sdk.extensions.sql.impl.rel.BeamJoinRel; import org.apache.beam.sdk.extensions.sql.impl.rel.BeamLogicalConvention; import org.apache.beam.sdk.values.PCollection; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.plan.RelOptRule; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.plan.RelOptRuleCall; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.plan.RelOptUtil; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.RelNode; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.core.Join; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.core.JoinRelType; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.core.RelFactories; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.logical.LogicalJoin; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rex.RexBuilder; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rex.RexCall; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rex.RexFieldAccess; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rex.RexInputRef; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rex.RexNode; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rex.RexProgramBuilder; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.sql.fun.SqlStdOperatorTable; /** * Rule to convert {@code LogicalJoin} node to {@code BeamCoGBKJoinRel} node. @@ -55,6 +67,12 @@ public boolean matches(RelOptRuleCall call) { if (BeamJoinRel.containsSeekableInput(call.rel(0))) { return false; } + // Bounded pure cross joins are handled by BeamCrossJoinRule. + if (((Join) call.rel(0)).getCondition().isAlwaysTrue() + && BeamJoinRel.getBoundednessOfRelNode(call.rel(1)) == PCollection.IsBounded.BOUNDED + && BeamJoinRel.getBoundednessOfRelNode(call.rel(2)) == PCollection.IsBounded.BOUNDED) { + return false; + } PCollection.IsBounded boundednessOfLeftRel = BeamJoinRel.getBoundednessOfRelNode(call.rel(1)); PCollection.IsBounded boundednessOfRightRel = BeamJoinRel.getBoundednessOfRelNode(call.rel(2)); return (boundednessOfLeftRel == boundednessOfRightRel); @@ -64,6 +82,49 @@ public boolean matches(RelOptRuleCall call) { public void onMatch(RelOptRuleCall call) { Join join = (Join) call.rel(0); + RexNode condition = join.getCondition(); + List conjuncts = new ArrayList<>(); + RelOptUtil.decomposeConjunction(condition, conjuncts); + + List equiConditions = new ArrayList<>(); + List residualConditions = new ArrayList<>(); + int leftFieldCount = join.getLeft().getRowType().getFieldCount(); + + for (RexNode conjunct : conjuncts) { + if (isEquiJoinConjunct(conjunct, leftFieldCount)) { + equiConditions.add(conjunct); + } else { + residualConditions.add(conjunct); + } + } + + if (equiConditions.isEmpty() + || (!residualConditions.isEmpty() && join.getJoinType() != JoinRelType.INNER)) { + BeamCoGBKJoinRel rel = + new BeamCoGBKJoinRel( + join.getCluster(), + join.getTraitSet().replace(BeamLogicalConvention.INSTANCE), + convert( + join.getLeft(), + join.getLeft().getTraitSet().replace(BeamLogicalConvention.INSTANCE)), + convert( + join.getRight(), + join.getRight().getTraitSet().replace(BeamLogicalConvention.INSTANCE)), + condition, + join.getVariablesSet(), + join.getJoinType()); + call.transformTo(rel); + return; + } + + RexBuilder rexBuilder = join.getCluster().getRexBuilder(); + RexNode finalEquiCondition; + if (equiConditions.size() == 1) { + finalEquiCondition = equiConditions.get(0); + } else { + finalEquiCondition = rexBuilder.makeCall(SqlStdOperatorTable.AND, equiConditions); + } + BeamCoGBKJoinRel rel = new BeamCoGBKJoinRel( join.getCluster(), @@ -74,9 +135,63 @@ public void onMatch(RelOptRuleCall call) { convert( join.getRight(), join.getRight().getTraitSet().replace(BeamLogicalConvention.INSTANCE)), - join.getCondition(), + finalEquiCondition, join.getVariablesSet(), join.getJoinType()); - call.transformTo(rel); + + if (residualConditions.isEmpty()) { + call.transformTo(rel); + } else { + RexNode finalResidualCondition; + if (residualConditions.size() == 1) { + finalResidualCondition = residualConditions.get(0); + } else { + finalResidualCondition = rexBuilder.makeCall(SqlStdOperatorTable.AND, residualConditions); + } + RexProgramBuilder programBuilder = new RexProgramBuilder(rel.getRowType(), rexBuilder); + programBuilder.addIdentity(); + programBuilder.addCondition(finalResidualCondition); + BeamCalcRel calcRel = + new BeamCalcRel( + join.getCluster(), + join.getTraitSet().replace(BeamLogicalConvention.INSTANCE), + rel, + programBuilder.getProgram()); + call.transformTo(calcRel); + } + } + + private static int extractInputIndex(RexNode node) { + if (node instanceof RexInputRef) { + return ((RexInputRef) node).getIndex(); + } + if (node instanceof RexFieldAccess) { + return extractInputIndex(((RexFieldAccess) node).getReferenceExpr()); + } + return -1; + } + + private boolean isEquiJoinConjunct(RexNode conjunct, int leftFieldCount) { + if (!(conjunct instanceof RexCall)) { + return false; + } + RexCall call = (RexCall) conjunct; + if (!call.getOperator().getName().equals("=")) { + return false; + } + if (call.getOperands().size() != 2) { + return false; + } + RexNode op0 = call.getOperands().get(0); + RexNode op1 = call.getOperands().get(1); + + int idx0 = extractInputIndex(op0); + int idx1 = extractInputIndex(op1); + if (idx0 < 0 || idx1 < 0) { + return false; + } + boolean op0Left = idx0 < leftFieldCount; + boolean op1Left = idx1 < leftFieldCount; + return op0Left != op1Left; } } diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamCrossJoinRule.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamCrossJoinRule.java new file mode 100644 index 000000000000..18ceb9f0dd33 --- /dev/null +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamCrossJoinRule.java @@ -0,0 +1,79 @@ +/* + * 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 org.apache.beam.sdk.extensions.sql.impl.rule; + +import org.apache.beam.sdk.extensions.sql.impl.rel.BeamCrossJoinRel; +import org.apache.beam.sdk.extensions.sql.impl.rel.BeamJoinRel; +import org.apache.beam.sdk.extensions.sql.impl.rel.BeamLogicalConvention; +import org.apache.beam.sdk.values.PCollection; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.plan.RelOptRule; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.plan.RelOptRuleCall; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.RelNode; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.core.Join; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.core.RelFactories; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.rel.logical.LogicalJoin; + +/** + * Rule to convert a {@code LogicalJoin} with a cross / non-equi condition into a {@code + * BeamCrossJoinRel}, but only when BOTH inputs are bounded. Equi-joins keep using the existing + * CoGBK / side-input rules; those rules are gated with {@code isJoinLegal} so they no longer match + * the cross / non-equi case. Unbounded cross joins are intentionally not matched (rejected). + */ +public class BeamCrossJoinRule extends RelOptRule { + public static final BeamCrossJoinRule INSTANCE = new BeamCrossJoinRule(); + + private BeamCrossJoinRule() { + super( + operand(LogicalJoin.class, operand(RelNode.class, any()), operand(RelNode.class, any())), + RelFactories.LOGICAL_BUILDER, + "BeamCrossJoinRule"); + } + + @Override + public boolean matches(RelOptRuleCall call) { + Join join = (Join) call.rel(0); + // Only match pure cross joins; non-equi joins stay on CoGBK/side-input rules. + if (!join.getCondition().isAlwaysTrue()) { + return false; + } + if (BeamJoinRel.containsSeekableInput(call.rel(0))) { + return false; + } + return BeamJoinRel.getBoundednessOfRelNode(call.rel(1)) == PCollection.IsBounded.BOUNDED + && BeamJoinRel.getBoundednessOfRelNode(call.rel(2)) == PCollection.IsBounded.BOUNDED; + } + + @Override + public void onMatch(RelOptRuleCall call) { + Join join = (Join) call.rel(0); + BeamCrossJoinRel rel = + new BeamCrossJoinRel( + join.getCluster(), + join.getTraitSet().replace(BeamLogicalConvention.INSTANCE), + convert( + join.getLeft(), + join.getLeft().getTraitSet().replace(BeamLogicalConvention.INSTANCE)), + convert( + join.getRight(), + join.getRight().getTraitSet().replace(BeamLogicalConvention.INSTANCE)), + join.getCondition(), + join.getVariablesSet(), + join.getJoinType()); + call.transformTo(rel); + } +} diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamWindowRule.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamWindowRule.java index c442ae3d1947..ad1d94bbc52f 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamWindowRule.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/rule/BeamWindowRule.java @@ -36,10 +36,12 @@ private BeamWindowRule() { @Override public RelNode convert(RelNode relNode) { Window w = (Window) relNode; + RelNode input = w.getInput(); return new BeamWindowRel( w.getCluster(), w.getTraitSet().replace(BeamLogicalConvention.INSTANCE), - w.getInput(), + // Convert input to BeamLogicalConvention so inputs from CALC_TO_WINDOW can be planned. + convert(input, input.getTraitSet().replace(BeamLogicalConvention.INSTANCE)), w.getConstants(), w.getRowType(), w.groups); diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/schema/BeamTableUtils.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/schema/BeamTableUtils.java index d9dbf881850e..8eb23aef61fe 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/schema/BeamTableUtils.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/schema/BeamTableUtils.java @@ -167,6 +167,8 @@ public static Object autoCastField(Schema.Field field, @Nullable Object rawObj) return Float.valueOf(raw); case DOUBLE: return Double.valueOf(raw); + case DECIMAL: + return new BigDecimal(raw); default: throw new UnsupportedOperationException( String.format("Column type %s is not supported yet!", type)); diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/BeamBuiltinAggregations.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/BeamBuiltinAggregations.java index fa2b6fe4adcd..f79527e4da63 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/BeamBuiltinAggregations.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/BeamBuiltinAggregations.java @@ -526,7 +526,10 @@ public BigDecimal toBigDecimal(Byte record) { static class FloatAvg extends Avg { @Override public @Nullable Float extractOutput(KV accumulator) { - return accumulator.getKey() == 0 ? null : prepareOutput(accumulator).floatValue(); + if (accumulator.getKey() == 0) { + return null; + } + return (float) (accumulator.getValue().doubleValue() / accumulator.getKey()); } @Override @@ -538,7 +541,10 @@ public BigDecimal toBigDecimal(Float record) { static class DoubleAvg extends Avg { @Override public @Nullable Double extractOutput(KV accumulator) { - return accumulator.getKey() == 0 ? null : prepareOutput(accumulator).doubleValue(); + if (accumulator.getKey() == 0) { + return null; + } + return accumulator.getValue().doubleValue() / accumulator.getKey(); } @Override diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/BeamBuiltinAnalyticFunctions.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/BeamBuiltinAnalyticFunctions.java index 413093d416d8..b601c45f23e7 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/BeamBuiltinAnalyticFunctions.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/BeamBuiltinAnalyticFunctions.java @@ -40,11 +40,14 @@ public class BeamBuiltinAnalyticFunctions { // Navigation Functions .put("FIRST_VALUE", typeName -> navigationFirstValue()) .put("LAST_VALUE", typeName -> navigationLastValue()) + // Placeholder entry; BeamWindowRel instantiates NTH_VALUE with the actual offset n. + .put("NTH_VALUE", typeName -> navigationNthValue(1L, false)) // Numbering Functions .put("ROW_NUMBER", typeName -> numberingRowNumber()) .put("DENSE_RANK", typeName -> numberingDenseRank()) .put("RANK", typeName -> numberingRank()) .put("PERCENT_RANK", typeName -> numberingPercentRank()) + .put("CUME_DIST", typeName -> numberingCumeDist()) .build(); public static Combine.CombineFn create(String functionName, Schema.FieldType fieldType) { @@ -66,9 +69,104 @@ public class BeamBuiltinAnalyticFunctions { return new LastValueCombineFn(); } - private static class FirstValueCombineFn extends Combine.CombineFn, T> { + /** + * FIRST_VALUE that optionally skips nulls. With {@code ignoreNulls=true} it returns the first + * non-null value in the frame; otherwise it behaves like {@link #navigationFirstValue()}. + */ + public static Combine.CombineFn navigationFirstValue(boolean ignoreNulls) { + return ignoreNulls ? new FirstValueIgnoreNullsCombineFn() : new FirstValueCombineFn(); + } + + /** + * LAST_VALUE that optionally skips nulls. With {@code ignoreNulls=true} it returns the last + * non-null value in the frame; otherwise it behaves like {@link #navigationLastValue()}. + */ + public static Combine.CombineFn navigationLastValue(boolean ignoreNulls) { + return ignoreNulls ? new LastValueIgnoreNullsCombineFn() : new LastValueCombineFn(); + } + + /** + * NTH_VALUE(expr, n) [IGNORE NULLS]: the value of {@code expr} from the n-th row of the frame + * (frame-absolute, 1-based, counted from the frame start). Returns {@code null} when the frame + * has fewer than n rows. With {@code ignoreNulls=true}, n counts only non-null values. + */ + public static Combine.CombineFn navigationNthValue(long n, boolean ignoreNulls) { + return new NthValueCombineFn<>(n, ignoreNulls); + } + + /** + * Accumulator for FIRST_VALUE / LAST_VALUE that, unlike {@link Optional}, can represent a {@code + * null} value distinct from "no row seen yet". Plain {@code Optional.of(input)} throws an NPE + * when a row's value is null, but FIRST_VALUE / LAST_VALUE (without IGNORE NULLS) must faithfully + * return the first / last row's value even when that value is null. + */ + private static class ValueHolder { + private boolean seen; + private T value; + + void set(T input) { + this.seen = true; + this.value = input; + } + } + + private static class FirstValueCombineFn extends Combine.CombineFn, T> { private FirstValueCombineFn() {} + @Override + public ValueHolder createAccumulator() { + return new ValueHolder<>(); + } + + @Override + public ValueHolder addInput(ValueHolder accumulator, T input) { + if (!accumulator.seen) { + accumulator.set(input); + } + return accumulator; + } + + @Override + public ValueHolder mergeAccumulators(Iterable> accumulators) { + throw new UnsupportedOperationException(); + } + + @Override + public T extractOutput(ValueHolder accumulator) { + return accumulator.seen ? accumulator.value : null; + } + } + + private static class LastValueCombineFn extends Combine.CombineFn, T> { + private LastValueCombineFn() {} + + @Override + public ValueHolder createAccumulator() { + return new ValueHolder<>(); + } + + @Override + public ValueHolder addInput(ValueHolder accumulator, T input) { + accumulator.set(input); + return accumulator; + } + + @Override + public ValueHolder mergeAccumulators(Iterable> accumulators) { + throw new UnsupportedOperationException(); + } + + @Override + public T extractOutput(ValueHolder accumulator) { + return accumulator.seen ? accumulator.value : null; + } + } + + /** FIRST_VALUE(... IGNORE NULLS): the first non-null value in the frame. */ + private static class FirstValueIgnoreNullsCombineFn + extends Combine.CombineFn, T> { + private FirstValueIgnoreNullsCombineFn() {} + @Override public Optional createAccumulator() { return Optional.empty(); @@ -76,11 +174,10 @@ public Optional createAccumulator() { @Override public Optional addInput(Optional accumulator, T input) { - Optional r = accumulator; - if (!accumulator.isPresent()) { - r = Optional.of(input); + if (!accumulator.isPresent() && input != null) { + return Optional.of(input); } - return r; + return accumulator; } @Override @@ -94,8 +191,10 @@ public T extractOutput(Optional accumulator) { } } - private static class LastValueCombineFn extends Combine.CombineFn, T> { - private LastValueCombineFn() {} + /** LAST_VALUE(... IGNORE NULLS): the last non-null value in the frame. */ + private static class LastValueIgnoreNullsCombineFn + extends Combine.CombineFn, T> { + private LastValueIgnoreNullsCombineFn() {} @Override public Optional createAccumulator() { @@ -104,8 +203,10 @@ public Optional createAccumulator() { @Override public Optional addInput(Optional accumulator, T input) { - Optional r = Optional.of(input); - return r; + if (input != null) { + return Optional.of(input); + } + return accumulator; } @Override @@ -119,6 +220,74 @@ public T extractOutput(Optional accumulator) { } } + /** + * NTH_VALUE(expr, n) [IGNORE NULLS]. A position-aware combiner: it receives every frame row's + * value column (via the {@link PositionAwareCombineFn#addInput(Object, Object, Object, Long, + * Long, Long)} overload) and keeps the value seen at the n-th eligible position. Eligibility is + * "every row" by default, or "only non-null values" with IGNORE NULLS. + */ + private static class NthValueCombineFn + extends PositionAwareCombineFn, T> { + + /** Tracks how many eligible rows have been seen and the value captured at position n. */ + private static class NthAccum { + long eligibleSeen = 0L; + boolean captured = false; + T value = null; + } + + private final long n; + private final boolean ignoreNulls; + + private NthValueCombineFn(long n, boolean ignoreNulls) { + this.n = n; + this.ignoreNulls = ignoreNulls; + } + + @Override + public NthAccum createAccumulator() { + return new NthAccum<>(); + } + + @Override + public NthAccum addInput( + NthAccum accumulator, + BigDecimal input, + Long cursorWindow, + Long cursorPartition, + Long countPartition) { + return addInput(accumulator, input, input, cursorWindow, cursorPartition, countPartition); + } + + @Override + @SuppressWarnings("unchecked") + public NthAccum addInput( + NthAccum accumulator, + BigDecimal input, + Object inputValue, + Long cursorWindow, + Long cursorPartition, + Long countPartition) { + if (accumulator.captured) { + return accumulator; + } + if (ignoreNulls && inputValue == null) { + return accumulator; + } + accumulator.eligibleSeen++; + if (accumulator.eligibleSeen == n) { + accumulator.captured = true; + accumulator.value = (T) inputValue; + } + return accumulator; + } + + @Override + public T extractOutput(NthAccum accumulator) { + return accumulator.captured ? accumulator.value : null; + } + } + // Numbering functions public static Combine.CombineFn numberingRowNumber() { return new RowNumberCombineFn(); @@ -136,6 +305,10 @@ public T extractOutput(Optional accumulator) { return new PercentRankCombineFn(); } + public static Combine.CombineFn numberingCumeDist() { + return new CumeDistCombineFn(); + } + public abstract static class PositionAwareCombineFn extends Combine.CombineFn { public abstract AccumT addInput( @@ -145,6 +318,23 @@ public abstract AccumT addInput( Long cursorPartition, Long countPartition); + /** + * Position-aware addInput that, in addition to the ORDER-BY key ({@code input}), receives the + * row's actual VALUE column ({@code inputValue}). Numbering / ranking functions only need the + * ORDER-BY key and ignore {@code inputValue}; navigation functions such as NTH_VALUE need the + * value column at the n-th position within the frame. The default routes to the key-only + * overload so existing combiners are unaffected. + */ + public AccumT addInput( + AccumT accumulator, + InputT input, + Object inputValue, + Long cursorWindow, + Long cursorPartition, + Long countPartition) { + return addInput(accumulator, input, cursorWindow, cursorPartition, countPartition); + } + @Override public AccumT addInput(AccumT mutableAccumulator, InputT input) { throw new UnsupportedOperationException(); @@ -251,6 +441,44 @@ public Long extractOutput(KV accumulator) { } } + /** + * CUME_DIST: the relative rank of the current row, computed as {@code (number of rows preceding + * or peer with the current row) / (total rows in the partition)}. + * + *

    Beam evaluates analytic functions over the per-row frame. With the SQL-standard default + * frame for CUME_DIST (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), the frame handed to + * this combiner already contains exactly the "preceding or peer" rows, so the numerator is simply + * the number of {@code addInput} calls. The denominator is the partition size, which is supplied + * to every {@code addInput} call as {@code countPartition}. + */ + private static class CumeDistCombineFn + extends PositionAwareCombineFn, Double> { + + @Override + public KV createAccumulator() { + return KV.of(0L, 0L); + } + + @Override + public KV addInput( + KV accumulator, + BigDecimal input, + Long cursorPosition, + Long cursorPartition, + Long countPartition) { + return KV.of(accumulator.getKey() + 1L, countPartition); + } + + @Override + public Double extractOutput(KV accumulator) { + long total = accumulator.getValue(); + if (total <= 0L) { + return 0.0; + } + return accumulator.getKey().doubleValue() / (double) total; + } + } + private static class PercentRankCombineFn extends PositionAwareCombineFn, KV>, Double> { diff --git a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/agg/VarianceFn.java b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/agg/VarianceFn.java index 906bac7add52..86f76a28eb7a 100644 --- a/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/agg/VarianceFn.java +++ b/sdks/java/extensions/sql/src/main/java/org/apache/beam/sdk/extensions/sql/impl/transform/agg/VarianceFn.java @@ -30,6 +30,7 @@ import org.apache.beam.sdk.transforms.Combine; import org.apache.beam.sdk.transforms.SerializableFunction; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.runtime.SqlFunctions; +import org.checkerframework.checker.nullness.qual.Nullable; /** * {@link Combine.CombineFn} for Variance on {@link Number} types. @@ -68,7 +69,8 @@ @SuppressWarnings({ "rawtypes" // TODO(https://github.com/apache/beam/issues/20447) }) -public class VarianceFn extends Combine.CombineFn { +public class VarianceFn + extends Combine.CombineFn { static final MathContext MATH_CTX = new MathContext(10, RoundingMode.HALF_UP); @@ -76,9 +78,6 @@ public class VarianceFn extends Combine.CombineFn decimalConverter; @@ -146,9 +145,12 @@ public Coder getAccumulatorCoder( } @Override - public T extractOutput(VarianceAccumulator accumulator) { + public @Nullable T extractOutput(VarianceAccumulator accumulator) { BigDecimal result = getVariance(accumulator); - if (result != null && isStddev) { + if (result == null) { + return null; + } + if (isStddev) { double doubleVal = result.doubleValue(); if (doubleVal < 0.0) { doubleVal = 0.0; // Clamp negative variance due to numerical instability @@ -162,14 +164,14 @@ public T extractOutput(VarianceAccumulator accumulator) { return decimalConverter.apply(result); } - private BigDecimal getVariance(VarianceAccumulator variance) { + private @Nullable BigDecimal getVariance(VarianceAccumulator variance) { BigDecimal adjustedCount = this.isSample ? variance.count().subtract(BigDecimal.ONE) : variance.count(); - // Avoid ArithmeticException: Division is undefined when adjustedCount is 0 - if (adjustedCount.compareTo(BigDecimal.ZERO) == 0) { - return BigDecimal.ZERO; + // Sample variance of <= 1 element or population variance of 0 elements is undefined (NULL). + if (adjustedCount.compareTo(BigDecimal.ZERO) <= 0) { + return null; } return variance.variance().divide(adjustedCount, MATH_CTX); diff --git a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/BeamAnalyticFunctionsTest.java b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/BeamAnalyticFunctionsTest.java index d66fdb58d4af..a3719267030e 100644 --- a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/BeamAnalyticFunctionsTest.java +++ b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/BeamAnalyticFunctionsTest.java @@ -588,4 +588,116 @@ public void testPercentRankFunction() throws Exception { pipeline.run(); } + + @Test + public void testCumeDistFunction() throws Exception { + pipeline.enableAbandonedNodeEnforcement(false); + PCollection inputRows = inputData2(); + String sql = "SELECT x, CUME_DIST() over (ORDER BY x ) as agg FROM PCOLLECTION"; + PCollection result = inputRows.apply("sql", SqlTransform.query(sql)); + + Schema overResultSchema = Schema.builder().addInt32Field("x").addDoubleField("agg").build(); + + List overResult = + TestUtils.RowsBuilder.of(overResultSchema) + .addRows( + 1, 1.0 / 7.0, + 2, 3.0 / 7.0, + 2, 3.0 / 7.0, + 5, 4.0 / 7.0, + 8, 5.0 / 7.0, + 10, 7.0 / 7.0, + 10, 7.0 / 7.0) + .getRows(); + + PAssert.that(result).containsInAnyOrder(overResult); + + pipeline.run(); + } + + @Test + public void testCountStarOverWindow() throws Exception { + pipeline.enableAbandonedNodeEnforcement(false); + PCollection inputRows = inputData2(); + String sql = "SELECT x, COUNT(*) over () as agg FROM PCOLLECTION"; + PCollection result = inputRows.apply("sql", SqlTransform.query(sql)); + + Schema overResultSchema = Schema.builder().addInt32Field("x").addInt64Field("agg").build(); + + List overResult = + TestUtils.RowsBuilder.of(overResultSchema) + .addRows( + 1, 7L, + 2, 7L, + 2, 7L, + 5, 7L, + 8, 7L, + 10, 7L, + 10, 7L) + .getRows(); + + PAssert.that(result).containsInAnyOrder(overResult); + + pipeline.run(); + } + + /** + * Table with a TIMESTAMP ordering key. Used to exercise RANGE framing over a temporal ORDER BY + * column (previously a ClassCastException because the order-by value was cast to {@code Number}). + */ + private PCollection inputDataTimestamp() { + Schema schema = Schema.builder().addDateTimeField("ts").addInt32Field("v").build(); + return pipeline + .apply( + Create.of( + TestUtils.rowsBuilderOf(schema) + .addRows( + org.apache.beam.sdk.extensions.sql.utils.DateTimeUtils + .parseTimestampWithUTCTimeZone("2020-01-01 00:00:00"), + 10, + org.apache.beam.sdk.extensions.sql.utils.DateTimeUtils + .parseTimestampWithUTCTimeZone("2020-01-02 00:00:00"), + 20, + org.apache.beam.sdk.extensions.sql.utils.DateTimeUtils + .parseTimestampWithUTCTimeZone("2020-01-02 00:00:00"), + 30, + org.apache.beam.sdk.extensions.sql.utils.DateTimeUtils + .parseTimestampWithUTCTimeZone("2020-01-03 00:00:00"), + 40) + .getRows())) + .setRowSchema(schema); + } + + /** + * RANGE framing over a TIMESTAMP ORDER BY key. A cumulative RANGE sum groups rows that share the + * same ordering value into one peer group: the two rows at 2020-01-02 both see 10+20+30=60. + */ + @Test + public void testOverRangeTimestampOrderKey() throws Exception { + pipeline.enableAbandonedNodeEnforcement(false); + PCollection inputRows = inputDataTimestamp(); + String sql = + "SELECT v, sum(v) over " + + "(" + + "ORDER BY ts " + + "RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW" + + ")" + + " as total FROM PCOLLECTION"; + PCollection result = inputRows.apply("sql", SqlTransform.query(sql)); + + Schema overResultSchema = Schema.builder().addInt32Field("v").addInt32Field("total").build(); + + List overResult = + TestUtils.RowsBuilder.of(overResultSchema) + .addRows( + 10, 10, + 20, 60, + 30, 60, + 40, 100) + .getRows(); + + PAssert.that(result).containsInAnyOrder(overResult); + + pipeline.run(); + } } diff --git a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/BeamSqlDslJoinTest.java b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/BeamSqlDslJoinTest.java index b210bb0258f5..8bae1107b616 100644 --- a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/BeamSqlDslJoinTest.java +++ b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/BeamSqlDslJoinTest.java @@ -146,12 +146,17 @@ public void testException_nonEqualJoin() throws Exception { pipeline.run(); } - @Test(expected = UnsupportedOperationException.class) - public void testException_crossJoin() throws Exception { + @Test + public void testCrossJoin() throws Exception { String sql = "SELECT * " + "FROM ORDER_DETAILS1 o1, ORDER_DETAILS2 o2"; - pipeline.enableAbandonedNodeEnforcement(false); - queryFromOrderTables(sql); + PAssert.that(queryFromOrderTables(sql)) + .containsInAnyOrder( + TestUtils.RowsBuilder.of(RESULT_ROW_TYPE) + .addRows( + 1, 2, 3, 1, 2, 3, 1, 2, 3, 2, 3, 3, 1, 2, 3, 3, 4, 5, 2, 3, 3, 1, 2, 3, 2, 3, 3, + 2, 3, 3, 2, 3, 3, 3, 4, 5, 3, 4, 5, 1, 2, 3, 3, 4, 5, 2, 3, 3, 3, 4, 5, 3, 4, 5) + .getRows()); pipeline.run(); } diff --git a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamCoGBKJoinRelBoundedVsBoundedTest.java b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamCoGBKJoinRelBoundedVsBoundedTest.java index 9a0977e9018c..27c9e14d8748 100644 --- a/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamCoGBKJoinRelBoundedVsBoundedTest.java +++ b/sdks/java/extensions/sql/src/test/java/org/apache/beam/sdk/extensions/sql/impl/rel/BeamCoGBKJoinRelBoundedVsBoundedTest.java @@ -356,7 +356,7 @@ public void testException_join_condition1() throws Exception { } @Test - public void testException_join_condition2() throws Exception { + public void testJoinWithEquiAndNonEquiCondition() throws Exception { String sql = "SELECT * " + "FROM ORDER_DETAILS1 o1" @@ -364,9 +364,21 @@ public void testException_join_condition2() throws Exception { + " on " + " o1.order_id = o2.site_id AND o1.price > o2.site_id"; - thrown.expect(UnsupportedOperationException.class); - thrown.expectMessage(StringContains.containsString("Non equi-join")); - compilePipeline(sql, pipeline); + PCollection rows = compilePipeline(sql, pipeline); + PAssert.that(rows) + .containsInAnyOrder( + TestUtils.RowsBuilder.of( + Schema.builder() + .addField("order_id", Schema.FieldType.INT32) + .addField("site_id", Schema.FieldType.INT32) + .addField("price", Schema.FieldType.INT32) + .addField("order_id0", Schema.FieldType.INT32) + .addField("site_id0", Schema.FieldType.INT32) + .addField("price0", Schema.FieldType.INT32) + .build()) + .addRows(2, 3, 3, 1, 2, 3) + .addRows(3, 4, 5, 2, 3, 3) + .getRows()); pipeline.run(); } @@ -402,12 +414,26 @@ public void testException_join_condition4() throws Exception { pipeline.run(); } - @Test(expected = UnsupportedOperationException.class) - public void testException_crossJoin() throws Exception { + @Test + public void testCrossJoin() throws Exception { String sql = "SELECT * " + "FROM ORDER_DETAILS1 o1, ORDER_DETAILS2 o2"; - pipeline.enableAbandonedNodeEnforcement(false); - compilePipeline(sql, pipeline); + PCollection rows = compilePipeline(sql, pipeline); + PAssert.that(rows) + .containsInAnyOrder( + TestUtils.RowsBuilder.of( + Schema.builder() + .addField("order_id", Schema.FieldType.INT32) + .addField("site_id", Schema.FieldType.INT32) + .addField("price", Schema.FieldType.INT32) + .addField("order_id0", Schema.FieldType.INT32) + .addField("site_id0", Schema.FieldType.INT32) + .addField("price0", Schema.FieldType.INT32) + .build()) + .addRows( + 1, 2, 3, 1, 2, 3, 1, 2, 3, 2, 3, 3, 1, 2, 3, 3, 4, 5, 2, 3, 3, 1, 2, 3, 2, 3, 3, + 2, 3, 3, 2, 3, 3, 3, 4, 5, 3, 4, 5, 1, 2, 3, 3, 4, 5, 2, 3, 3, 3, 4, 5, 3, 4, 5) + .getRows()); pipeline.run(); } } diff --git a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/QueryReader.java b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/QueryReader.java index f6bb0d500920..14a951d76cbf 100644 --- a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/QueryReader.java +++ b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/QueryReader.java @@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets; import java.util.Set; +import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.avatica.util.Quoting; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.sql.SqlNode; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.sql.parser.SqlParseException; import org.apache.beam.vendor.calcite.v1_40_0.org.apache.calcite.sql.parser.SqlParser; @@ -50,7 +51,8 @@ public static String readQuery(String queryFileName) throws Exception { * @throws SqlParseException */ public static Set getQueryIdentifiers(String queryString) throws SqlParseException { - SqlParser parser = SqlParser.create(queryString); + SqlParser parser = + SqlParser.create(queryString, SqlParser.config().withQuoting(Quoting.BACK_TICK)); SqlNode parsedQuery = parser.parseQuery(); SqlTransformRunner.SqlIdentifierVisitor sqlVisitor = new SqlTransformRunner.SqlIdentifierVisitor(); diff --git a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TableSchemaJSONLoader.java b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TableSchemaJSONLoader.java index 0d683c6b66da..cd5f8ad79883 100644 --- a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TableSchemaJSONLoader.java +++ b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TableSchemaJSONLoader.java @@ -78,9 +78,7 @@ public static String parseTableSchema(String tableName) throws Exception { // Use long type to represent int, prevent overflow schemaStringBuilder.append("bigint"); } else if (value.contains("decimal")) { - // Currently Beam SQL doesn't handle "decimal" type properly, use "double" to replace it - // for now. - schemaStringBuilder.append("double"); + schemaStringBuilder.append("decimal"); } else { // Currently Beam SQL doesn't handle "date" type properly, use "varchar" replace it for // now. diff --git a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsOptions.java b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsOptions.java index 93cbc94f30a0..8394acd70dbe 100644 --- a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsOptions.java +++ b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsOptions.java @@ -36,8 +36,9 @@ public interface TpcdsOptions extends ApplicationNameOptions, GcpOptions, BeamSq void setDataSize(String dataSize); // Set the return type to be String since reading from the command line (user input will be like - // "1,2,55" which represent TPC-DS query1, query3, query55) - @Description("The queries numbers, read user input as string, numbers separated by commas") + // "1,2,55" which represent TPC-DS query1, query3, query55, or "supported" / "all") + @Description( + "The queries numbers, read user input as string, numbers separated by commas, or 'supported' / 'all'") String getQueries(); void setQueries(String queries); diff --git a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsParametersReader.java b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsParametersReader.java index 60072ea916c0..a2a451b554e8 100644 --- a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsParametersReader.java +++ b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsParametersReader.java @@ -30,13 +30,22 @@ public class TpcdsParametersReader { /** The data sizes that have been supported. */ private static final Set supportedDataSizes = - Stream.of("1G", "1GB", "10G", "10GB", "100G", "100GB") + Stream.of("10M", "10MB", "1G", "1GB", "10G", "10GB", "100G", "100GB") .collect(Collectors.toCollection(HashSet::new)); private static final String QUERY_PREFIX = "query"; public static final List ALL_QUERY_NAMES = getAllQueryNames(); + public static final List SUPPORTED_QUERY_NAMES = + Stream.of( + "1", "3", "4", "7", "9", "10", "11", "12", "15", "17", "19", "20", "21", "26", "31", + "32", "33", "34", "35", "38", "40", "42", "43", "45", "46", "49", "50", "52", "53", + "55", "56", "59", "60", "61", "63", "65", "66", "68", "69", "71", "73", "74", "75", + "76", "79", "82", "84", "87", "88", "89", "92", "93", "96", "97", "99") + .map(s -> QUERY_PREFIX + s) + .collect(Collectors.toList()); + /** * Get and check dataSize entered by user. This dataSize has to have been supported. * @@ -55,9 +64,10 @@ public static String getAndCheckDataSize(TpcdsOptions tpcdsOptions) throws Excep } /** - * Get and check queries entered by user. This has to be a string of numbers separated by commas - * or "all" which means run all 99 queries. All query numbers have to be between 1 and 99. Some - * queries (14, 23, 24 and 39) may contain suffixes "a" or "b", e.g. "14a" and "14b". + * Get and check queries entered by user. This has to be a string of numbers separated by commas, + * "supported" which means run all supported queries, or "all" which means run all 99 queries. All + * query numbers have to be between 1 and 99. Some queries (14, 23, 24 and 39) may contain + * suffixes "a" or "b", e.g. "14a" and "14b". * * @param tpcdsOptions TpcdsOptions object constructed from user input * @return An array of query names, for example "1,2,14b" will be output as @@ -67,6 +77,8 @@ public static String[] getAndCheckQueryNames(TpcdsOptions tpcdsOptions) { if (tpcdsOptions.getQueries().equalsIgnoreCase("all")) { // All TPC-DS queries need to be executed. return ALL_QUERY_NAMES.toArray(new String[0]); + } else if (tpcdsOptions.getQueries().equalsIgnoreCase("supported")) { + return SUPPORTED_QUERY_NAMES.toArray(new String[0]); } else { List queries = new ArrayList<>(); Arrays.stream(tpcdsOptions.getQueries().split("[\\s,]+", -1)) diff --git a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsSchemas.java b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsSchemas.java index 763e8164dee0..196e27e039c7 100644 --- a/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsSchemas.java +++ b/sdks/java/testing/tpcds/src/main/java/org/apache/beam/sdk/tpcds/TpcdsSchemas.java @@ -58,8 +58,8 @@ public static Map getTpcdsSchemas() throws Exception { Schema.FieldType fieldType; if (type.equals("bigint")) { fieldType = Schema.FieldType.INT64; - } else if (type.equals("double")) { - fieldType = Schema.FieldType.DOUBLE; + } else if (type.equals("decimal")) { + fieldType = Schema.FieldType.DECIMAL; } else { fieldType = Schema.FieldType.STRING; } @@ -235,8 +235,8 @@ public static Schema getWebSiteSchema() { .addNullableField("cc_state", Schema.FieldType.STRING) .addNullableField("cc_zip", Schema.FieldType.STRING) .addNullableField("cc_country", Schema.FieldType.STRING) - .addNullableField("cc_gmt_offset", Schema.FieldType.DOUBLE) - .addNullableField("cc_tax_percentage", Schema.FieldType.DOUBLE) + .addNullableField("cc_gmt_offset", Schema.FieldType.DECIMAL) + .addNullableField("cc_tax_percentage", Schema.FieldType.DECIMAL) .build(); private static final Schema catalogPageSchema = @@ -272,15 +272,15 @@ public static Schema getWebSiteSchema() { .addNullableField("cr_reason_sk", Schema.FieldType.INT64) .addField("cr_order_number", Schema.FieldType.INT64) .addNullableField("cr_return_quantity", Schema.FieldType.INT64) - .addNullableField("cr_return_amount", Schema.FieldType.DOUBLE) - .addNullableField("cr_return_tax", Schema.FieldType.DOUBLE) - .addNullableField("cr_return_amt_inc_tax", Schema.FieldType.DOUBLE) - .addNullableField("cr_fee", Schema.FieldType.DOUBLE) - .addNullableField("cr_return_ship_cost", Schema.FieldType.DOUBLE) - .addNullableField("cr_refunded_cash", Schema.FieldType.DOUBLE) - .addNullableField("cr_reversed_charge", Schema.FieldType.DOUBLE) - .addNullableField("cr_store_credit", Schema.FieldType.DOUBLE) - .addNullableField("cr_net_loss", Schema.FieldType.DOUBLE) + .addNullableField("cr_return_amount", Schema.FieldType.DECIMAL) + .addNullableField("cr_return_tax", Schema.FieldType.DECIMAL) + .addNullableField("cr_return_amt_inc_tax", Schema.FieldType.DECIMAL) + .addNullableField("cr_fee", Schema.FieldType.DECIMAL) + .addNullableField("cr_return_ship_cost", Schema.FieldType.DECIMAL) + .addNullableField("cr_refunded_cash", Schema.FieldType.DECIMAL) + .addNullableField("cr_reversed_charge", Schema.FieldType.DECIMAL) + .addNullableField("cr_store_credit", Schema.FieldType.DECIMAL) + .addNullableField("cr_net_loss", Schema.FieldType.DECIMAL) .build(); private static final Schema catalogSalesSchema = @@ -303,22 +303,22 @@ public static Schema getWebSiteSchema() { .addField("cs_item_sk", Schema.FieldType.INT64) .addNullableField("cs_promo_sk", Schema.FieldType.INT64) .addField("cs_order_number", Schema.FieldType.INT64) - .addNullableField("cs_quantity", Schema.FieldType.DOUBLE) - .addNullableField("cs_wholesale_cost", Schema.FieldType.DOUBLE) - .addNullableField("cs_list_price", Schema.FieldType.DOUBLE) - .addNullableField("cs_sales_price", Schema.FieldType.DOUBLE) - .addNullableField("cs_ext_discount_amt", Schema.FieldType.DOUBLE) - .addNullableField("cs_ext_sales_price", Schema.FieldType.DOUBLE) - .addNullableField("cs_ext_wholesale_cost", Schema.FieldType.DOUBLE) - .addNullableField("cs_ext_list_price", Schema.FieldType.DOUBLE) - .addNullableField("cs_ext_tax", Schema.FieldType.DOUBLE) - .addNullableField("cs_coupon_amt", Schema.FieldType.DOUBLE) - .addNullableField("cs_ext_ship_cost", Schema.FieldType.DOUBLE) - .addNullableField("cs_net_paid", Schema.FieldType.DOUBLE) - .addNullableField("cs_net_paid_inc_tax", Schema.FieldType.DOUBLE) - .addNullableField("cs_net_paid_inc_ship", Schema.FieldType.DOUBLE) - .addNullableField("cs_net_paid_inc_ship_tax", Schema.FieldType.DOUBLE) - .addNullableField("cs_net_profit", Schema.FieldType.DOUBLE) + .addNullableField("cs_quantity", Schema.FieldType.INT64) + .addNullableField("cs_wholesale_cost", Schema.FieldType.DECIMAL) + .addNullableField("cs_list_price", Schema.FieldType.DECIMAL) + .addNullableField("cs_sales_price", Schema.FieldType.DECIMAL) + .addNullableField("cs_ext_discount_amt", Schema.FieldType.DECIMAL) + .addNullableField("cs_ext_sales_price", Schema.FieldType.DECIMAL) + .addNullableField("cs_ext_wholesale_cost", Schema.FieldType.DECIMAL) + .addNullableField("cs_ext_list_price", Schema.FieldType.DECIMAL) + .addNullableField("cs_ext_tax", Schema.FieldType.DECIMAL) + .addNullableField("cs_coupon_amt", Schema.FieldType.DECIMAL) + .addNullableField("cs_ext_ship_cost", Schema.FieldType.DECIMAL) + .addNullableField("cs_net_paid", Schema.FieldType.DECIMAL) + .addNullableField("cs_net_paid_inc_tax", Schema.FieldType.DECIMAL) + .addNullableField("cs_net_paid_inc_ship", Schema.FieldType.DECIMAL) + .addNullableField("cs_net_paid_inc_ship_tax", Schema.FieldType.DECIMAL) + .addNullableField("cs_net_profit", Schema.FieldType.DECIMAL) .build(); private static final Schema customerSchema = @@ -356,7 +356,7 @@ public static Schema getWebSiteSchema() { .addNullableField("ca_state", Schema.FieldType.STRING) .addNullableField("ca_zip", Schema.FieldType.STRING) .addNullableField("ca_country", Schema.FieldType.STRING) - .addNullableField("ca_gmt_offset", Schema.FieldType.DOUBLE) + .addNullableField("ca_gmt_offset", Schema.FieldType.DECIMAL) .addNullableField("ca_location_type", Schema.FieldType.STRING) .build(); @@ -436,8 +436,8 @@ public static Schema getWebSiteSchema() { .addNullableField("i_rec_start_date", Schema.FieldType.STRING) .addNullableField("i_rec_end_date", Schema.FieldType.STRING) .addNullableField("i_item_desc", Schema.FieldType.STRING) - .addNullableField("i_current_price", Schema.FieldType.DOUBLE) - .addNullableField("i_wholesale_cost", Schema.FieldType.DOUBLE) + .addNullableField("i_current_price", Schema.FieldType.DECIMAL) + .addNullableField("i_wholesale_cost", Schema.FieldType.DECIMAL) .addNullableField("i_brand_id", Schema.FieldType.INT64) .addNullableField("i_brand", Schema.FieldType.STRING) .addNullableField("i_class_id", Schema.FieldType.INT64) @@ -462,7 +462,7 @@ public static Schema getWebSiteSchema() { .addNullableField("p_start_date_sk", Schema.FieldType.INT64) .addNullableField("p_end_date_sk", Schema.FieldType.INT64) .addNullableField("p_item_sk", Schema.FieldType.INT64) - .addNullableField("p_cost", Schema.FieldType.DOUBLE) + .addNullableField("p_cost", Schema.FieldType.DECIMAL) .addNullableField("p_response_target", Schema.FieldType.INT64) .addNullableField("p_promo_name", Schema.FieldType.STRING) .addNullableField("p_channel_dmail", Schema.FieldType.STRING) @@ -524,8 +524,8 @@ public static Schema getWebSiteSchema() { .addNullableField("s_state", Schema.FieldType.STRING) .addNullableField("s_zip", Schema.FieldType.STRING) .addNullableField("s_country", Schema.FieldType.STRING) - .addNullableField("s_gmt_offset", Schema.FieldType.DOUBLE) - .addNullableField("s_tax_percentage", Schema.FieldType.DOUBLE) + .addNullableField("s_gmt_offset", Schema.FieldType.DECIMAL) + .addNullableField("s_tax_percentage", Schema.FieldType.DECIMAL) .build(); private static final Schema storeReturnsSchema = @@ -541,15 +541,15 @@ public static Schema getWebSiteSchema() { .addNullableField("sr_reason_sk", Schema.FieldType.INT64) .addField("sr_ticket_number", Schema.FieldType.INT64) .addNullableField("sr_return_quantity", Schema.FieldType.INT64) - .addNullableField("sr_return_amt", Schema.FieldType.DOUBLE) - .addNullableField("sr_return_tax", Schema.FieldType.DOUBLE) - .addNullableField("sr_return_amt_inc_tax", Schema.FieldType.DOUBLE) - .addNullableField("sr_fee", Schema.FieldType.DOUBLE) - .addNullableField("sr_return_ship_cost", Schema.FieldType.DOUBLE) - .addNullableField("sr_refunded_cash", Schema.FieldType.DOUBLE) - .addNullableField("sr_reversed_charge", Schema.FieldType.DOUBLE) - .addNullableField("sr_store_credit", Schema.FieldType.DOUBLE) - .addNullableField("sr_net_loss", Schema.FieldType.DOUBLE) + .addNullableField("sr_return_amt", Schema.FieldType.DECIMAL) + .addNullableField("sr_return_tax", Schema.FieldType.DECIMAL) + .addNullableField("sr_return_amt_inc_tax", Schema.FieldType.DECIMAL) + .addNullableField("sr_fee", Schema.FieldType.DECIMAL) + .addNullableField("sr_return_ship_cost", Schema.FieldType.DECIMAL) + .addNullableField("sr_refunded_cash", Schema.FieldType.DECIMAL) + .addNullableField("sr_reversed_charge", Schema.FieldType.DECIMAL) + .addNullableField("sr_store_credit", Schema.FieldType.DECIMAL) + .addNullableField("sr_net_loss", Schema.FieldType.DECIMAL) .build(); private static final Schema storeSalesSchema = @@ -565,18 +565,18 @@ public static Schema getWebSiteSchema() { .addNullableField("ss_promo_sk", Schema.FieldType.INT64) .addField("ss_ticket_number", Schema.FieldType.INT64) .addNullableField("ss_quantity", Schema.FieldType.INT64) - .addNullableField("ss_wholesale_cost", Schema.FieldType.DOUBLE) - .addNullableField("ss_list_price", Schema.FieldType.DOUBLE) - .addNullableField("ss_sales_price", Schema.FieldType.DOUBLE) - .addNullableField("ss_ext_discount_amt", Schema.FieldType.DOUBLE) - .addNullableField("ss_ext_sales_price", Schema.FieldType.DOUBLE) - .addNullableField("ss_ext_wholesale_cost", Schema.FieldType.DOUBLE) - .addNullableField("ss_ext_list_price", Schema.FieldType.DOUBLE) - .addNullableField("ss_ext_tax", Schema.FieldType.DOUBLE) - .addNullableField("ss_coupon_amt", Schema.FieldType.DOUBLE) - .addNullableField("ss_net_paid", Schema.FieldType.DOUBLE) - .addNullableField("ss_net_paid_inc_tax", Schema.FieldType.DOUBLE) - .addNullableField("ss_net_profit", Schema.FieldType.DOUBLE) + .addNullableField("ss_wholesale_cost", Schema.FieldType.DECIMAL) + .addNullableField("ss_list_price", Schema.FieldType.DECIMAL) + .addNullableField("ss_sales_price", Schema.FieldType.DECIMAL) + .addNullableField("ss_ext_discount_amt", Schema.FieldType.DECIMAL) + .addNullableField("ss_ext_sales_price", Schema.FieldType.DECIMAL) + .addNullableField("ss_ext_wholesale_cost", Schema.FieldType.DECIMAL) + .addNullableField("ss_ext_list_price", Schema.FieldType.DECIMAL) + .addNullableField("ss_ext_tax", Schema.FieldType.DECIMAL) + .addNullableField("ss_coupon_amt", Schema.FieldType.DECIMAL) + .addNullableField("ss_net_paid", Schema.FieldType.DECIMAL) + .addNullableField("ss_net_paid_inc_tax", Schema.FieldType.DECIMAL) + .addNullableField("ss_net_profit", Schema.FieldType.DECIMAL) .build(); private static final Schema timeDimSchema = @@ -608,7 +608,7 @@ public static Schema getWebSiteSchema() { .addNullableField("w_state", Schema.FieldType.STRING) .addNullableField("w_zip", Schema.FieldType.STRING) .addNullableField("w_country", Schema.FieldType.STRING) - .addNullableField("w_gmt_offset", Schema.FieldType.DOUBLE) + .addNullableField("w_gmt_offset", Schema.FieldType.DECIMAL) .build(); private static final Schema webPageSchema = @@ -646,15 +646,15 @@ public static Schema getWebSiteSchema() { .addNullableField("wr_reason_sk", Schema.FieldType.INT64) .addField("wr_order_number", Schema.FieldType.INT64) .addNullableField("wr_return_quantity", Schema.FieldType.INT64) - .addNullableField("wr_return_amt", Schema.FieldType.DOUBLE) - .addNullableField("wr_return_tax", Schema.FieldType.DOUBLE) - .addNullableField("wr_return_amt_inc_tax", Schema.FieldType.DOUBLE) - .addNullableField("wr_fee", Schema.FieldType.DOUBLE) - .addNullableField("wr_return_ship_cost", Schema.FieldType.DOUBLE) - .addNullableField("wr_refunded_cash", Schema.FieldType.DOUBLE) - .addNullableField("wr_reversed_charge", Schema.FieldType.DOUBLE) - .addNullableField("wr_account_credit", Schema.FieldType.DOUBLE) - .addNullableField("wr_net_loss", Schema.FieldType.DOUBLE) + .addNullableField("wr_return_amt", Schema.FieldType.DECIMAL) + .addNullableField("wr_return_tax", Schema.FieldType.DECIMAL) + .addNullableField("wr_return_amt_inc_tax", Schema.FieldType.DECIMAL) + .addNullableField("wr_fee", Schema.FieldType.DECIMAL) + .addNullableField("wr_return_ship_cost", Schema.FieldType.DECIMAL) + .addNullableField("wr_refunded_cash", Schema.FieldType.DECIMAL) + .addNullableField("wr_reversed_charge", Schema.FieldType.DECIMAL) + .addNullableField("wr_account_credit", Schema.FieldType.DECIMAL) + .addNullableField("wr_net_loss", Schema.FieldType.DECIMAL) .build(); private static final Schema webSalesSchema = @@ -678,21 +678,21 @@ public static Schema getWebSiteSchema() { .addNullableField("ws_promo_sk", Schema.FieldType.INT32) .addField("ws_order_number", Schema.FieldType.INT64) .addNullableField("ws_quantity", Schema.FieldType.INT32) - .addNullableField("ws_wholesale_cost", Schema.FieldType.DOUBLE) - .addNullableField("ws_list_price", Schema.FieldType.DOUBLE) - .addNullableField("ws_sales_price", Schema.FieldType.DOUBLE) - .addNullableField("ws_ext_discount_amt", Schema.FieldType.DOUBLE) - .addNullableField("ws_ext_sales_price", Schema.FieldType.DOUBLE) - .addNullableField("ws_ext_wholesale_cost", Schema.FieldType.DOUBLE) - .addNullableField("ws_ext_list_price", Schema.FieldType.DOUBLE) - .addNullableField("ws_ext_tax", Schema.FieldType.DOUBLE) - .addNullableField("ws_coupon_amt", Schema.FieldType.DOUBLE) - .addNullableField("ws_ext_ship_cost", Schema.FieldType.DOUBLE) - .addNullableField("ws_net_paid", Schema.FieldType.DOUBLE) - .addNullableField("ws_net_paid_inc_tax", Schema.FieldType.DOUBLE) - .addNullableField("ws_net_paid_inc_ship", Schema.FieldType.DOUBLE) - .addNullableField("ws_net_paid_inc_ship_tax", Schema.FieldType.DOUBLE) - .addNullableField("ws_net_profit", Schema.FieldType.DOUBLE) + .addNullableField("ws_wholesale_cost", Schema.FieldType.DECIMAL) + .addNullableField("ws_list_price", Schema.FieldType.DECIMAL) + .addNullableField("ws_sales_price", Schema.FieldType.DECIMAL) + .addNullableField("ws_ext_discount_amt", Schema.FieldType.DECIMAL) + .addNullableField("ws_ext_sales_price", Schema.FieldType.DECIMAL) + .addNullableField("ws_ext_wholesale_cost", Schema.FieldType.DECIMAL) + .addNullableField("ws_ext_list_price", Schema.FieldType.DECIMAL) + .addNullableField("ws_ext_tax", Schema.FieldType.DECIMAL) + .addNullableField("ws_coupon_amt", Schema.FieldType.DECIMAL) + .addNullableField("ws_ext_ship_cost", Schema.FieldType.DECIMAL) + .addNullableField("ws_net_paid", Schema.FieldType.DECIMAL) + .addNullableField("ws_net_paid_inc_tax", Schema.FieldType.DECIMAL) + .addNullableField("ws_net_paid_inc_ship", Schema.FieldType.DECIMAL) + .addNullableField("ws_net_paid_inc_ship_tax", Schema.FieldType.DECIMAL) + .addNullableField("ws_net_profit", Schema.FieldType.DECIMAL) .build(); private static final Schema webSiteSchema = @@ -721,7 +721,7 @@ public static Schema getWebSiteSchema() { .addNullableField("web_state", Schema.FieldType.STRING) .addNullableField("web_zip", Schema.FieldType.STRING) .addNullableField("web_country", Schema.FieldType.STRING) - .addNullableField("web_gmt_offset", Schema.FieldType.DOUBLE) - .addNullableField("web_tax_percentage", Schema.FieldType.DOUBLE) + .addNullableField("web_gmt_offset", Schema.FieldType.DECIMAL) + .addNullableField("web_tax_percentage", Schema.FieldType.DECIMAL) .build(); } diff --git a/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TableSchemaJSONLoaderTest.java b/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TableSchemaJSONLoaderTest.java index 3eb84d32c57c..1fd0422c8146 100644 --- a/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TableSchemaJSONLoaderTest.java +++ b/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TableSchemaJSONLoaderTest.java @@ -42,15 +42,15 @@ public void testStoreReturnsTable() throws Exception { + "sr_reason_sk bigint," + "sr_ticket_number bigint," + "sr_return_quantity bigint," - + "sr_return_amt double," - + "sr_return_tax double," - + "sr_return_amt_inc_tax double," - + "sr_fee double," - + "sr_return_ship_cost double," - + "sr_refunded_cash double," - + "sr_reversed_charge double," - + "sr_store_credit double," - + "sr_net_loss double"; + + "sr_return_amt decimal," + + "sr_return_tax decimal," + + "sr_return_amt_inc_tax decimal," + + "sr_fee decimal," + + "sr_return_ship_cost decimal," + + "sr_refunded_cash decimal," + + "sr_reversed_charge decimal," + + "sr_store_credit decimal," + + "sr_net_loss decimal"; assertEquals(expected, storeReturnsSchemaString); } @@ -63,8 +63,8 @@ public void testItemTable() throws Exception { + "i_rec_start_date varchar," + "i_rec_end_date varchar," + "i_item_desc varchar," - + "i_current_price double," - + "i_wholesale_cost double," + + "i_current_price decimal," + + "i_wholesale_cost decimal," + "i_brand_id bigint," + "i_brand varchar," + "i_class_id bigint," @@ -135,7 +135,7 @@ public void testWarehouseTable() throws Exception { + "w_state varchar," + "w_zip varchar," + "w_country varchar," - + "w_gmt_offset double"; + + "w_gmt_offset decimal"; assertEquals(expected, warehouseSchemaString); } diff --git a/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TpcdsParametersReaderTest.java b/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TpcdsParametersReaderTest.java index 9b9c352541ee..f49e4e1d1d9a 100644 --- a/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TpcdsParametersReaderTest.java +++ b/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TpcdsParametersReaderTest.java @@ -64,6 +64,15 @@ public void testGetAndCheckAllQueries() throws Exception { Assert.assertArrayEquals(expected.toArray(new String[0]), queryNames); } + @Test + public void testGetAndCheckSupportedQueries() throws Exception { + TpcdsOptions tpcdsOptionsSupported = PipelineOptionsFactory.as(TpcdsOptions.class); + tpcdsOptionsSupported.setQueries("supported"); + String[] queryNames = TpcdsParametersReader.getAndCheckQueryNames(tpcdsOptionsSupported); + List expected = TpcdsParametersReader.SUPPORTED_QUERY_NAMES; + Assert.assertArrayEquals(expected.toArray(new String[0]), queryNames); + } + @Test public void testGetAndCheckSpecifiedQueries() throws Exception { String[] queryNames = TpcdsParametersReader.getAndCheckQueryNames(tpcdsOptions); diff --git a/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TpcdsSchemasTest.java b/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TpcdsSchemasTest.java index 9f2397cf945c..fdf9fea95aa2 100644 --- a/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TpcdsSchemasTest.java +++ b/sdks/java/testing/tpcds/src/test/java/org/apache/beam/sdk/tpcds/TpcdsSchemasTest.java @@ -68,8 +68,8 @@ public void testCallCenterSchema() { .addNullableField("cc_state", Schema.FieldType.STRING) .addNullableField("cc_zip", Schema.FieldType.STRING) .addNullableField("cc_country", Schema.FieldType.STRING) - .addNullableField("cc_gmt_offset", Schema.FieldType.DOUBLE) - .addNullableField("cc_tax_percentage", Schema.FieldType.DOUBLE) + .addNullableField("cc_gmt_offset", Schema.FieldType.DECIMAL) + .addNullableField("cc_tax_percentage", Schema.FieldType.DECIMAL) .build(); assertNotEquals(schemaMap.get("call_center"), callCenterSchema); @@ -111,7 +111,7 @@ public void testCustomerAddressSchemaNullable() { .addNullableField("ca_state", Schema.FieldType.STRING) .addNullableField("ca_zip", Schema.FieldType.STRING) .addNullableField("ca_country", Schema.FieldType.STRING) - .addNullableField("ca_gmt_offset", Schema.FieldType.DOUBLE) + .addNullableField("ca_gmt_offset", Schema.FieldType.DECIMAL) .addNullableField("ca_location_type", Schema.FieldType.STRING) .build(); diff --git a/sdks/python/apache_beam/testing/benchmarks/tpcds/README.md b/sdks/python/apache_beam/testing/benchmarks/tpcds/README.md new file mode 100644 index 000000000000..e77f50c3b57c --- /dev/null +++ b/sdks/python/apache_beam/testing/benchmarks/tpcds/README.md @@ -0,0 +1,57 @@ + + +# Apache Beam Python TPC-DS Benchmark & Evaluation Suite + +This package provides a Python TPC-DS benchmark and correctness suite that uses: +- **Cross-language Beam SQL Expansion Service** (`:sdks:java:extensions:sql:expansion-service:shadowJar`) via `apache_beam.transforms.sql.SqlTransform` +- **Prism Runner** via `PrismRunner` +- **Java TPC-DS schemas and queries** copied into `tpcds/build` via Gradle (`copyTpcdsResources`) +- **GCS TPC-DS datasets** (`gs://beam-tpcds/datasets/text/10MB`, `1GB`, `10GB`, `100GB`) + +## Running via Gradle + +The Gradle tasks in `:sdks:python:apache_beam:testing:benchmarks:tpcds` automatically copy the Java TPC-DS schemas and queries into `tpcds/build` and build the Java SQL expansion service shadow jar before running Python. + +### 1. Run the Supported TPC-DS Queries on the 10MB Dataset + +```bash +./gradlew :sdks:python:apache_beam:testing:benchmarks:tpcds:run \ + -Ptpcds.args="--queries=supported --data_size=10MB" +``` + +### 2. Run the 13 Canonical Multi-Table Queries + +```bash +./gradlew :sdks:python:apache_beam:testing:benchmarks:tpcds:run \ + -Ptpcds.args="--queries=canonical13 --data_size=10MB" +``` + +### 3. Run Specific Queries on 1GB GCS Data + +```bash +./gradlew :sdks:python:apache_beam:testing:benchmarks:tpcds:run \ + -Ptpcds.args="--queries=3,7,42,43,52,55,96 --data_size=1GB --results_directory=/tmp/tpcds_results" +``` + +### 4. Run the Pytest Evaluation Suite + +```bash +./gradlew :sdks:python:apache_beam:testing:benchmarks:tpcds:pytest +``` diff --git a/sdks/python/apache_beam/testing/benchmarks/tpcds/__init__.py b/sdks/python/apache_beam/testing/benchmarks/tpcds/__init__.py new file mode 100644 index 000000000000..a0174df802d1 --- /dev/null +++ b/sdks/python/apache_beam/testing/benchmarks/tpcds/__init__.py @@ -0,0 +1,18 @@ +# +# 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. +# + +"""TPC-DS benchmark and evaluation suite for Apache Beam Python SQL.""" diff --git a/sdks/python/apache_beam/testing/benchmarks/tpcds/build.gradle b/sdks/python/apache_beam/testing/benchmarks/tpcds/build.gradle new file mode 100644 index 000000000000..8dac81f94495 --- /dev/null +++ b/sdks/python/apache_beam/testing/benchmarks/tpcds/build.gradle @@ -0,0 +1,84 @@ +/* + * 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. + */ + +apply plugin: org.apache.beam.gradle.BeamModulePlugin +applyPythonNature() + +description = "Apache Beam :: SDKs :: Python :: Benchmarks :: TPC-DS" + +def tpcdsArgsProperty = "tpcds.args" + +def getTpcdsArgs = { + def tpcdsArgsStr = project.findProperty(tpcdsArgsProperty) ?: "" + def tpcdsArgsList = new ArrayList() + if (tpcdsArgsStr.trim()) { + Collections.addAll(tpcdsArgsList, tpcdsArgsStr.trim().split("\\s+")) + } + return tpcdsArgsList +} + +def copyTpcdsResources = tasks.register("copyTpcdsResources", Copy) { + from "${project.rootDir}/sdks/java/testing/tpcds/src/main/resources" + into "${project.buildDir}" +} + +tasks.register("run", Exec) { + dependsOn installGcpTest + dependsOn copyTpcdsResources + dependsOn ":sdks:java:extensions:sql:expansion-service:shadowJar" + + setWorkingDir "${project.rootDir}/sdks/python" + doFirst { + def argsList = getTpcdsArgs() + commandLine 'sh', '-c', ". ${envdir}/bin/activate && python -m apache_beam.testing.benchmarks.tpcds.tpcds_launcher " + String.join(" ", argsList) + } +} + +tasks.register("checkAllQueries", Exec) { + description = "Runs the full 103-query TPC-DS suite on PrismRunner with 10MB dataset and verifies against KNOWN_FAILING_QUERIES." + dependsOn installGcpTest + dependsOn copyTpcdsResources + dependsOn ":sdks:java:extensions:sql:expansion-service:shadowJar" + + setWorkingDir "${project.rootDir}/sdks/python" + doFirst { + def argsList = getTpcdsArgs() + if (!argsList.any { it.startsWith("--queries") }) { + argsList.add("--queries=all") + } + if (!argsList.any { it.startsWith("--check_regression") }) { + argsList.add("--check_regression") + } + commandLine 'sh', '-c', ". ${envdir}/bin/activate && python -m apache_beam.testing.benchmarks.tpcds.tpcds_launcher " + String.join(" ", argsList) + } +} + +tasks.register("pytest", Exec) { + dependsOn installGcpTest + dependsOn copyTpcdsResources + dependsOn ":sdks:java:extensions:sql:expansion-service:shadowJar" + + setWorkingDir "${project.rootDir}/sdks/python" + doFirst { + commandLine 'sh', '-c', ". ${envdir}/bin/activate && python -m pytest -v apache_beam/testing/benchmarks/tpcds/tpcds_test.py" + } +} + +tasks.named("test") { + dependsOn "pytest" +} diff --git a/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_launcher.py b/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_launcher.py new file mode 100644 index 000000000000..280226cd00e2 --- /dev/null +++ b/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_launcher.py @@ -0,0 +1,460 @@ +# +# 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. +# + +"""TPC-DS launcher for Apache Beam Python using cross-language SqlTransform and PrismRunner. + +Example usage via Gradle: + ./gradlew :sdks:python:apache_beam:testing:benchmarks:tpcds:run \\ + -Ptpcds.args="--queries=supported --data_size=10MB" +""" + +import argparse +import contextlib +import dataclasses +import datetime +import glob +import json +import logging +import os +import tempfile +import time +from typing import Any +from typing import Dict +from typing import Iterator +from typing import List +from typing import Optional +from typing import Sequence +from typing import Set + +import apache_beam as beam +from apache_beam.options.pipeline_options import PipelineOptions +from apache_beam.portability.api import schema_pb2 +from apache_beam.testing.benchmarks.tpcds.tpcds_queries import KNOWN_FAILING_QUERIES +from apache_beam.testing.benchmarks.tpcds.tpcds_queries import PLANNER_TIMEOUT_QUERIES +from apache_beam.testing.benchmarks.tpcds.tpcds_queries import get_query_tables +from apache_beam.testing.benchmarks.tpcds.tpcds_queries import parse_query_selection +from apache_beam.testing.benchmarks.tpcds.tpcds_queries import read_query +from apache_beam.testing.benchmarks.tpcds.tpcds_schemas import get_all_table_names +from apache_beam.testing.benchmarks.tpcds.tpcds_schemas import get_table_row_class +from apache_beam.testing.benchmarks.tpcds.tpcds_schemas import parse_csv_line +from apache_beam.transforms.sql import SqlTransform +from apache_beam.typehints import schemas as beam_schemas +from apache_beam.utils import subprocess_server + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DATA_DIRECTORY = "gs://beam-tpcds/datasets/text" +SUPPORTED_DATA_SIZES: Set[str] = { + "10M", + "10MB", + "1G", + "1GB", + "10G", + "10GB", + "100G", + "100GB", +} + + +def check_and_normalize_data_size(data_size: str) -> str: + """Validates data_size against SUPPORTED_DATA_SIZES and normalizes '10M' -> '10MB', etc.""" + cleaned = data_size.strip().upper() + if cleaned not in SUPPORTED_DATA_SIZES: + raise ValueError( + f"The data size {data_size!r} has not been supported. " + f"Supported sizes: {sorted(SUPPORTED_DATA_SIZES)}") + if cleaned.endswith(("M", "G")): + return f"{cleaned}B" + return cleaned + + +# TODO(https://github.com/apache/beam/issues/40698) remove this contextmanager once Python +# Row supports arbitrary field name +@contextlib.contextmanager +def _sql_schema_field_sanitizer() -> Iterator[None]: + """Temporarily allows NamedTuple schema decoding for SQL aliases like EXPR$n or >120 days.""" + orig_fn = beam_schemas.SchemaTranslation.named_tuple_from_schema + + def _sanitized_named_tuple_from_schema(self, schema): + if any(not f.name.isidentifier() for f in schema.fields): + copied = schema_pb2.Schema() + copied.CopyFrom(schema) + for field in copied.fields: + if not field.name.isidentifier(): + sanitized = "".join( + ch if (ch.isalnum() or ch == "_") else "_" for ch in field.name) + if not sanitized or sanitized[0].isdigit(): + sanitized = f"col_{sanitized}" + field.name = sanitized + schema = copied + return orig_fn(self, schema) + + beam_schemas.SchemaTranslation.named_tuple_from_schema = ( + _sanitized_named_tuple_from_schema) + try: + yield + finally: + beam_schemas.SchemaTranslation.named_tuple_from_schema = orig_fn + + +@dataclasses.dataclass +class TpcdsQueryResult: + query_name: str + job_name: str + data_size: str + dialect: str + is_successful: bool + status: str + start_time: str + end_time: str + elapsed_sec: float + row_count: int = 0 + error_message: str = "" + + +def build_table_pcollections( + pipeline: beam.Pipeline, + table_names: Sequence[str], + data_directory: str = DEFAULT_DATA_DIRECTORY, + data_size: str = "10MB") -> Dict[str, beam.PCollection]: + """Creates schema-typed input PCollections from TPC-DS .dat files.""" + normalized_size = check_and_normalize_data_size(data_size) + tables: Dict[str, beam.PCollection] = {} + base_dir = data_directory.rstrip("/") + for table_name in table_names: + row_cls = get_table_row_class(table_name) + file_pattern = f"{base_dir}/{normalized_size}/{table_name}.dat" + tables[table_name] = ( + pipeline + | f"Read_{table_name}" >> beam.io.ReadFromText(file_pattern) + | f"Parse_{table_name}" >> beam.FlatMap( + lambda line, t=table_name: + [r for r in [parse_csv_line(t, line)] + if r is not None]).with_output_types(row_cls)) + return tables + + +def run_single_query( + query_name: str, + query_sql: str, + pipeline_args: Sequence[str], + data_directory: str = DEFAULT_DATA_DIRECTORY, + data_size: str = "10MB", + results_directory: Optional[str] = None, + skip_planner_timeouts: bool = False) -> TpcdsQueryResult: + """Executes a single TPC-DS query via SqlTransform.""" + normalized_size = check_and_normalize_data_size(data_size) + start_dt = datetime.datetime.now(datetime.timezone.utc) + t0 = time.monotonic() + job_name = f"{query_name}_{int(time.time() * 1000)}" + if skip_planner_timeouts and query_name in PLANNER_TIMEOUT_QUERIES: + err_msg = KNOWN_FAILING_QUERIES[query_name] + end_dt = datetime.datetime.now(datetime.timezone.utc) + return TpcdsQueryResult( + query_name=query_name, + job_name=job_name, + data_size=normalized_size, + dialect="Calcite", + is_successful=False, + status="Failed (PLANNING)", + start_time=start_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), + end_time=end_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), + elapsed_sec=0.0, + error_message=err_msg, + ) + table_names = get_query_tables(query_sql) + + opts_list = list(pipeline_args) + if not any(a.startswith("--runner") for a in opts_list): + opts_list.append("--runner=PrismRunner") + + options = PipelineOptions(opts_list) + + with tempfile.TemporaryDirectory(prefix=f"tpcds_{query_name}_") as tmp_dir: + if results_directory: + out_dir = os.path.join(results_directory, normalized_size) + os.makedirs(out_dir, exist_ok=True) + out_prefix = os.path.join(out_dir, query_name) + else: + out_prefix = os.path.join(tmp_dir, "out") + stage = "PLANNING" + try: + with _sql_schema_field_sanitizer(): + with beam.Pipeline(options=options) as p: + tables = build_table_pcollections( + p, + table_names=table_names, + data_directory=data_directory, + data_size=normalized_size, + ) + sql_input = tables if len(tables) > 1 else next(iter(tables.values())) + sql_out = sql_input | f"Sql_{query_name}" >> SqlTransform(query_sql) + _ = ( + sql_out + | f"Format_{query_name}" >> beam.Map(str) + | f"Write_{query_name}" >> beam.io.WriteToText( + out_prefix, file_name_suffix=".txt", num_shards=1)) + stage = "RUNTIME" + + row_count = 0 + for fpath in sorted(glob.glob(f"{out_prefix}*.txt")): + with open(fpath, "r", encoding="utf-8") as f: + row_count += sum(1 for line in f if line.strip()) + + elapsed = time.monotonic() - t0 + end_dt = datetime.datetime.now(datetime.timezone.utc) + + is_successful = row_count > 0 + if not is_successful: + _LOGGER.warning( + "Output records counter for job %r is %d", job_name, row_count) + + return TpcdsQueryResult( + query_name=query_name, + job_name=job_name, + data_size=normalized_size, + dialect="Calcite", + is_successful=is_successful, + status="Successful" if is_successful else "Failed (EMPTY_RESULT)", + start_time=start_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), + end_time=end_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), + elapsed_sec=round(elapsed, 3), + row_count=row_count, + error_message="" if is_successful else "Returned 0 rows", + ) + except Exception as exc: # pylint: disable=broad-except + elapsed = time.monotonic() - t0 + end_dt = datetime.datetime.now(datetime.timezone.utc) + err_str = str(exc) + first_line = err_str.splitlines()[0] if err_str else repr(exc) + for line in err_str.splitlines(): + if "UnsupportedOperationException" in line or "CannotPlanException" in line: + first_line = line.strip() + break + _LOGGER.error("%s failed at %s: %s", query_name, stage, first_line) + return TpcdsQueryResult( + query_name=query_name, + job_name=job_name, + data_size=normalized_size, + dialect="Calcite", + is_successful=False, + status=f"Failed ({stage})", + start_time=start_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), + end_time=end_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), + elapsed_sec=round(elapsed, 3), + error_message=first_line, + ) + + +def format_summary_table(results: Sequence[TpcdsQueryResult]) -> str: + """Formats the TPC-DS execution summary table.""" + headers = [ + "Query Name", + "Data Size", + "Dialect", + "Status", + "Rows", + "Elapsed(s)", + "Details / Notes", + ] + rows = [] + for r in results: + note = f"Returned {r.row_count} row(s)" if r.is_successful else r.error_message + if len(note) > 85: + note = note[:82] + "..." + rows.append([ + r.query_name, + r.data_size, + r.dialect, + r.status, + str(r.row_count) if r.is_successful else "-", + f"{r.elapsed_sec:.2f}", + note, + ]) + + widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + widths[i] = max(widths[i], len(cell)) + + sep = "+" + "+".join("-" * (w + 2) for w in widths) + "+" + header_line = "|" + "|".join( + f" {h.ljust(widths[i])} " for i, h in enumerate(headers)) + "|" + lines = ["\nTPC-DS Query Execution Summary:", sep, header_line, sep] + for row in rows: + lines.append( + "|" + "|".join(f" {c.ljust(widths[i])} " + for i, c in enumerate(row)) + "|") + lines.append(sep) + return "\n".join(lines) + + +def main(argv: Optional[Sequence[str]] = None) -> List[TpcdsQueryResult]: + parser = argparse.ArgumentParser( + description="Run Apache Beam Python TPC-DS benchmark suite.") + parser.add_argument( + "--queries", + default="supported", + help= + "Comma-separated query numbers (e.g. '3,7,96'), 'canonical13', 'supported', or 'all'.", + ) + parser.add_argument( + "--data_directory", + default=DEFAULT_DATA_DIRECTORY, + help= + "Path or GCS URI containing TPC-DS text data files (/

  • .dat).", + ) + parser.add_argument( + "--data_size", + default="10MB", + help= + "Dataset scale factor ('10M', '10MB', '1G', '1GB', '10G', '10GB', '100G', '100GB').", + ) + parser.add_argument( + "--queries_directory", + default=None, + help="Optional path to directory containing query*.sql files.", + ) + parser.add_argument( + "--results_directory", + default=None, + help="Optional directory to write query results.", + ) + parser.add_argument( + "--summary_json", + default=None, + help="Optional file path to write JSON execution summary.", + ) + parser.add_argument( + "--check_regression", + default=False, + action=argparse.BooleanOptionalAction, + help="Verify query outcomes against KNOWN_FAILING_QUERIES. Queries not in " + "KNOWN_FAILING_QUERIES must succeed; queries in KNOWN_FAILING_QUERIES must " + "fail with an error matching their expected error substring.", + ) + + known_args, pipeline_args = parser.parse_known_args(argv) + # Fail early if tpcds/build/schemas has not been populated by Gradle. + get_all_table_names() + normalized_size = check_and_normalize_data_size(known_args.data_size) + query_names = parse_query_selection(known_args.queries) + + all_results: List[TpcdsQueryResult] = [] + with subprocess_server.SubprocessServer.cache_subprocesses(): + for qname in query_names: + query_sql = read_query(qname, known_args.queries_directory) + res = run_single_query( + query_name=qname, + query_sql=query_sql, + pipeline_args=pipeline_args, + data_directory=known_args.data_directory, + data_size=normalized_size, + results_directory=known_args.results_directory, + skip_planner_timeouts=known_args.check_regression, + ) + all_results.append(res) + + print(format_summary_table(all_results)) + + if known_args.summary_json: + os.makedirs( + os.path.dirname(os.path.abspath(known_args.summary_json)), + exist_ok=True) + with open(known_args.summary_json, "w", encoding="utf-8") as f: + json.dump( + [dataclasses.asdict(r) for r in all_results], + f, + indent=2, + default=str, + ) + + if known_args.check_regression: + verify_no_regressions(all_results) + + return all_results + + +def verify_no_regressions( + results: Sequence[TpcdsQueryResult], + known_failing: Optional[Dict[str, str]] = None) -> None: + """Verifies that query results match SUPPORTED_QUERIES and KNOWN_FAILING_QUERIES. + + Raises RuntimeError if: + - A query in SUPPORTED_QUERIES fails, or + - A query in KNOWN_FAILING_QUERIES fails with an unexpected error message, or + - A query is unclassified (in neither SUPPORTED_QUERIES nor KNOWN_FAILING_QUERIES). + + Queries in KNOWN_FAILING_QUERIES that succeed are tolerated with a warning. + """ + expected_map = KNOWN_FAILING_QUERIES if known_failing is None else known_failing + supported_set = set(parse_query_selection("supported")) + regressions: List[str] = [] + unexpected_passes: List[str] = [] + expected_failures_matched = 0 + passed_queries = 0 + + for r in results: + expected_err = expected_map.get(r.query_name) + if expected_err is not None: + if r.is_successful: + warn_msg = ( + f"[UNEXPECTED PASS] {r.query_name} is listed in " + f"KNOWN_FAILING_QUERIES ({expected_err!r}) but succeeded! " + f"Consider moving it to SUPPORTED_QUERIES if non-flaky.") + _LOGGER.warning(warn_msg) + unexpected_passes.append(warn_msg) + elif expected_err not in r.error_message: + regressions.append( + f"[ERROR MISMATCH] {r.query_name} failed with " + f"unexpected error: {r.error_message!r} (expected substring: {expected_err!r})" + ) + else: + expected_failures_matched += 1 + elif r.query_name in supported_set: + if not r.is_successful: + regressions.append( + f"[REGRESSION] {r.query_name} is in SUPPORTED_QUERIES " + f"but failed with: {r.error_message}") + else: + passed_queries += 1 + else: + regressions.append( + f"[UNCLASSIFIED] {r.query_name} is in neither SUPPORTED_QUERIES " + f"nor KNOWN_FAILING_QUERIES (status={r.status}, error={r.error_message!r})" + ) + + _LOGGER.info( + "TPC-DS Regression Check Summary: %d passed as expected, " + "%d known failures matched expected error, " + "%d unexpected pass warning(s), " + "%d regression(s).", + passed_queries, + expected_failures_matched, + len(unexpected_passes), + len(regressions)) + if regressions: + for msg in regressions: + _LOGGER.error(" - %s", msg) + raise RuntimeError( + f"TPC-DS regression check failed with {len(regressions)} issue(s):\n" + + "\n".join(regressions)) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + main() diff --git a/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_queries.py b/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_queries.py new file mode 100644 index 000000000000..e7d45fc57d48 --- /dev/null +++ b/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_queries.py @@ -0,0 +1,195 @@ +# +# 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. +# + +"""TPC-DS SQL query loader and table identifier extraction.""" + +import os +import re +from typing import Dict +from typing import List +from typing import Optional +from typing import Set + +from apache_beam.testing.benchmarks.tpcds.tpcds_schemas import TPCDS_BUILD_DIR +from apache_beam.testing.benchmarks.tpcds.tpcds_schemas import get_all_table_names + +TPCDS_QUERIES_DIR = os.path.join(TPCDS_BUILD_DIR, "queries") + +# The 13 canonical multi-table TPC-DS star/snowflake aggregate queries. +CANONICAL_13_QUERIES: List[str] = [ + "3", "7", "13", "15", "19", "26", "42", "43", "48", "52", "55", "91", "96" +] + +# The 55 TPC-DS queries supported by Beam Calcite SQL (returning > 0 rows). +# Together with KNOWN_FAILING_QUERIES (48 queries), this partitions all 103 TPC-DS queries. +# yapf: disable +SUPPORTED_QUERIES: List[str] = [ + "1", "3", "4", "7", "9", "10", "11", "12", "15", "17", + "19", "20", "21", "26", "31", "32", "33", "34", "35", "38", + "40", "42", "43", "45", "46", "49", "50", "52", "53", "55", + "56", "59", "60", "61", "63", "65", "66", "68", "69", "71", + "73", "74", "75", "76", "79", "82", "84", "87", "88", "89", + "92", "93", "96", "97", "99", +] +# yapf: enable + +# The 48 known failing TPC-DS queries on Beam Calcite SQL with their expected error substring. +# Used by `--check_regression` and `:sdks:python:apache_beam:testing:benchmarks:tpcds:checkAllQueries` +# to verify that all 55 SUPPORTED_QUERIES succeed on PrismRunner and that the remaining 48 known +# failing queries fail with their expected diagnostic without regressing. +# pylint: disable=line-too-long +KNOWN_FAILING_QUERIES: Dict[str, str] = { + "query2": "UnsupportedOperationException: Only support column reference or struct field access in conjunction clause", + "query5": "Error while applying rule BeamBasicAggregationRule", + "query6": "VolcanoPlanner timeout (correlated subquery + SINGLE_VALUE join permutation search)", + "query8": "UnsupportedOperationException: Only support column reference or struct field access in conjunction clause", + "query13": "UnsupportedOperationException: Operator OR is not supported in join condition", + "query14a": "Error while applying rule BeamBasicAggregationRule", + "query14b": "UnsupportedOperationException: Aggregator [SINGLE_VALUE] is not supported", + "query16": "UnsupportedOperationException: Does not support COUNT DISTINCT", + "query18": "Error while applying rule BeamBasicAggregationRule", + "query22": "Error while applying rule BeamBasicAggregationRule", + "query23a": "UnsupportedOperationException: Aggregator [SINGLE_VALUE] is not supported", + "query23b": "UnsupportedOperationException: Aggregator [SINGLE_VALUE] is not supported", + "query24a": "VolcanoPlanner timeout (>12-table join permutation search)", + "query24b": "VolcanoPlanner timeout (>12-table join permutation search)", + "query25": "Returned 0 rows", + "query27": "Error while applying rule BeamBasicAggregationRule", + "query28": "ParseException: Unable to parse query", + "query29": "Returned 0 rows", + "query30": "UnsupportedOperationException: Cross join with a residual (non-equi) condition is not supported yet.", + "query36": "Error while applying rule BeamBasicAggregationRule", + "query37": "Returned 0 rows", + "query39a": "Returned 0 rows", + "query39b": "Returned 0 rows", + "query41": "ParseException: Unable to parse query", + "query44": "UnsupportedOperationException: Aggregator [SINGLE_VALUE] is not supported", + "query47": "IllegalArgumentException: DECIMAL NOT NULL is not nullable", + "query48": "UnsupportedOperationException: Operator OR is not supported in join condition", + "query51": "IllegalArgumentException: DECIMAL NOT NULL is not nullable", + "query54": "UnsupportedOperationException: Aggregator [SINGLE_VALUE] is not supported", + "query57": "Returned 0 rows", + "query58": "UnsupportedOperationException: Aggregator [SINGLE_VALUE] is not supported", + "query62": "Error while applying rule JoinPushExpressionsRule", + "query64": "VolcanoPlanner timeout (36-table join permutation search)", + "query67": "Error while applying rule BeamBasicAggregationRule", + "query70": "Error while applying rule BeamBasicAggregationRule", + "query72": "ParseException: Unable to parse query", + "query77": "Error while applying rule BeamBasicAggregationRule", + "query78": "CalcFn failed to evaluate", + "query80": "Error while applying rule BeamBasicAggregationRule", + "query81": "UnsupportedOperationException: Cross join with a residual (non-equi) condition is not supported yet.", + "query83": "Error decoding input stream with coder", + "query85": "Returned 0 rows", + "query86": "Error while applying rule BeamBasicAggregationRule", + "query90": "CalcFn failed to evaluate", + "query91": "VolcanoPlanner timeout (7-table join permutation search)", + "query94": "Error while applying rule JoinPushExpressionsRule", + "query95": "Error while applying rule JoinPushExpressionsRule", + "query98": "IllegalArgumentException: DECIMAL NOT NULL is not nullable", +} +# pylint: enable=line-too-long + +# Subset of KNOWN_FAILING_QUERIES where Calcite's VolcanoPlanner explores exponential join +# permutations (7 to 36 table joins) before failing with CannotPlanException. +PLANNER_TIMEOUT_QUERIES: Set[str] = { + "query6", "query24a", "query24b", "query64", "query91" +} + + +def _require_queries_dir(queries_dir: Optional[str] = None) -> str: + target_dir = queries_dir or TPCDS_QUERIES_DIR + if not os.path.isdir(target_dir): + raise RuntimeError( + f"TPC-DS queries directory not found at {target_dir}. " + "Running TPC-DS as a standalone apache-beam module is not supported; " + "run via Gradle (:sdks:python:apache_beam:testing:benchmarks:tpcds:copyTpcdsResources) " + "to populate tpcds/build.") + return target_dir + + +def get_all_query_names() -> List[str]: + """Returns all 103 TPC-DS query names (query1 .. query99 including a/b variants).""" + names: List[str] = [] + split_queries = {14, 23, 24, 39} + for i in range(1, 100): + if i in split_queries: + names.append(f"query{i}a") + names.append(f"query{i}b") + else: + names.append(f"query{i}") + return names + + +def normalize_query_name(q: str) -> str: + """Normalizes '3', '03', 'q03', 'query3' -> 'query3'.""" + cleaned = q.strip().lower() + m = re.match(r"^(?:query|q)?0*(\d+[ab]?)$", cleaned) + if m: + return f"query{m.group(1)}" + return cleaned + + +def parse_query_selection(queries_arg: str) -> List[str]: + """Parses a --queries argument ('canonical13', 'supported', 'all', or comma-separated list).""" + cleaned = queries_arg.strip().lower() + if cleaned in ("canonical13", "canonical"): + return [normalize_query_name(q) for q in CANONICAL_13_QUERIES] + if cleaned == "supported": + return [normalize_query_name(q) for q in SUPPORTED_QUERIES] + if cleaned == "all": + return get_all_query_names() + all_valid = set(get_all_query_names()) + result = [] + for part in re.split(r"[\s,]+", queries_arg.strip()): + if not part: + continue + qname = normalize_query_name(part) + if qname not in all_valid: + raise ValueError(f"Unsupported TPC-DS query name: {part!r} ({qname})") + result.append(qname) + return result + + +def read_query(query_name: str, queries_dir: Optional[str] = None) -> str: + """Reads a TPC-DS SQL query by name (e.g. 'query3' or '3') from tpcds/build/queries.""" + qname = normalize_query_name(query_name) + target_dir = _require_queries_dir(queries_dir) + candidate = os.path.join(target_dir, f"{qname}.sql") + if not os.path.isfile(candidate): + raise RuntimeError( + f"Could not find SQL file for {qname} at {candidate}. " + "Run via Gradle (:sdks:python:apache_beam:testing:benchmarks:tpcds:copyTpcdsResources)." + ) + with open(candidate, "r", encoding="utf-8") as f: + return _strip_sql_comments(f.read()) + + +def _strip_sql_comments(sql: str) -> str: + lines = [] + for line in sql.splitlines(): + if line.lstrip().startswith("--"): + continue + lines.append(line) + return "\n".join(lines).strip() + + +def get_query_tables(query_sql: str) -> List[str]: + """Extracts the TPC-DS table names referenced in the given SQL query.""" + tokens: Set[str] = set( + re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", query_sql.lower())) + return [t for t in get_all_table_names() if t.lower() in tokens] diff --git a/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_schemas.py b/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_schemas.py new file mode 100644 index 000000000000..40831972a8e1 --- /dev/null +++ b/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_schemas.py @@ -0,0 +1,141 @@ +# +# 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. +# + +"""TPC-DS table schemas and row converters for Beam Python SQL.""" + +import decimal +import json +import os +import typing +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing import Type + +from apache_beam import coders + +TPCDS_BUILD_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "build") +TPCDS_SCHEMAS_DIR = os.path.join(TPCDS_BUILD_DIR, "schemas") + +_SCHEMA_CACHE: Dict[str, List[Tuple[str, str]]] = {} +_ROW_CLASS_CACHE: Dict[str, Type[Tuple[Any, ...]]] = {} + + +def _require_schemas_dir() -> str: + if not os.path.isdir(TPCDS_SCHEMAS_DIR): + raise RuntimeError( + f"TPC-DS schema directory not found at {TPCDS_SCHEMAS_DIR}. " + "Running TPC-DS as a standalone apache-beam module is not supported; " + "run via Gradle (:sdks:python:apache_beam:testing:benchmarks:tpcds:copyTpcdsResources) " + "to populate tpcds/build.") + return TPCDS_SCHEMAS_DIR + + +def get_raw_table_schema(table_name: str) -> List[Tuple[str, str]]: + """Returns the list of (column_name, raw_type) for a TPC-DS table from tpcds/build/schemas.""" + if table_name in _SCHEMA_CACHE: + return list(_SCHEMA_CACHE[table_name]) + + schemas_dir = _require_schemas_dir() + json_path = os.path.join(schemas_dir, f"{table_name}.json") + if not os.path.isfile(json_path): + raise RuntimeError( + f"TPC-DS schema file for table {table_name!r} not found at {json_path}. " + "Run via Gradle (:sdks:python:apache_beam:testing:benchmarks:tpcds:copyTpcdsResources)." + ) + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + cols = [(col["name"], col["type"].lower()) for col in data["schema"]] + _SCHEMA_CACHE[table_name] = cols + return list(cols) + + +def get_all_table_names() -> List[str]: + """Returns sorted list of all TPC-DS table names from tpcds/build/schemas.""" + schemas_dir = _require_schemas_dir() + names = sorted( + os.path.splitext(fname)[0] for fname in os.listdir(schemas_dir) + if fname.endswith(".json")) + if not names: + raise RuntimeError(f"No TPC-DS schema JSON files found in {schemas_dir}.") + return names + + +def _python_field_type(raw_type: str) -> Any: + """Maps a raw TPC-DS JSON schema type to a nullable Python type hint for RowCoder.""" + t = raw_type.lower() + if t in ("identifier", "integer"): + return Optional[int] + elif "decimal" in t: + return Optional[decimal.Decimal] + else: + return Optional[str] + + +def get_table_row_class(table_name: str) -> Type[Tuple[Any, ...]]: + """Returns a NamedTuple class registered with RowCoder for the given table.""" + if table_name in _ROW_CLASS_CACHE: + return _ROW_CLASS_CACHE[table_name] + + raw_cols = get_raw_table_schema(table_name) + fields = [(col_name, _python_field_type(col_type)) + for col_name, col_type in raw_cols] + type_name = f"Tpcds_{table_name}" + row_cls = typing.NamedTuple(type_name, fields) + coders.registry.register_coder(row_cls, coders.RowCoder) + _ROW_CLASS_CACHE[table_name] = row_cls + return row_cls + + +def coerce_value(val: Any, raw_type: str) -> Any: + """Coerces a Python or string value to match the target column type.""" + if val is None or val == "": + return None + t = raw_type.lower() + if t in ("identifier", "integer"): + return int(val) + elif "decimal" in t: + if isinstance(val, decimal.Decimal): + return val + if isinstance(val, float): + return decimal.Decimal(f"{val:.2f}") + return decimal.Decimal(str(val)) + else: + return str(val) + + +def parse_csv_line(table_name: str, + line: str, + delimiter: str = "|") -> Optional[Any]: + """Parses a TPC-DS .dat pipe-delimited line into a typed NamedTuple row.""" + stripped = line.rstrip("\r\n") + if not stripped: + return None + raw_cols = get_raw_table_schema(table_name) + row_cls = get_table_row_class(table_name) + parts = stripped.split(delimiter) + # TPC-DS .dat files have a trailing '|' delimiter + if len(parts) == len(raw_cols) + 1 and parts[-1] == "": + parts = parts[:-1] + args = [] + for idx, (_, col_type) in enumerate(raw_cols): + raw_val = parts[idx] if idx < len(parts) else "" + args.append(coerce_value(raw_val, col_type)) + return row_cls(*args) diff --git a/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_test.py b/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_test.py new file mode 100644 index 000000000000..b19da0f62681 --- /dev/null +++ b/sdks/python/apache_beam/testing/benchmarks/tpcds/tpcds_test.py @@ -0,0 +1,78 @@ +# +# 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. +# + +"""Tests for the Apache Beam Python TPC-DS suite with cross-language SQL and PrismRunner.""" + +import logging +import unittest + +from apache_beam.testing.benchmarks.tpcds import tpcds_launcher +from apache_beam.testing.benchmarks.tpcds import tpcds_queries +from apache_beam.testing.benchmarks.tpcds import tpcds_schemas + + +class TpcdsUnitTest(unittest.TestCase): + """Unit tests for TPC-DS benchmark suite.""" + def test_schema_coercion(self): + self.assertEqual(tpcds_schemas.coerce_value("42", "integer"), 42) + self.assertEqual(tpcds_schemas.coerce_value("123", "identifier"), 123) + self.assertEqual( + str(tpcds_schemas.coerce_value("19.99", "decimal(7,2)")), "19.99") + self.assertEqual(tpcds_schemas.coerce_value("abc", "varchar(20)"), "abc") + self.assertIsNone(tpcds_schemas.coerce_value("", "integer")) + self.assertIsNone(tpcds_schemas.coerce_value(None, "decimal(7,2)")) + + def test_known_failing_queries_map_valid(self): + all_queries = set(tpcds_queries.get_all_query_names()) + supported_queries = set(tpcds_queries.parse_query_selection("supported")) + failing_queries = set(tpcds_queries.KNOWN_FAILING_QUERIES.keys()) + for qname, expected_err in tpcds_queries.KNOWN_FAILING_QUERIES.items(): + self.assertIn(qname, all_queries) + self.assertTrue(len(expected_err) > 0) + self.assertTrue( + tpcds_queries.PLANNER_TIMEOUT_QUERIES.issubset(failing_queries)) + self.assertEqual(supported_queries & failing_queries, set()) + self.assertEqual(supported_queries | failing_queries, all_queries) + # 55 supported + 48 known failing = 103 total queries + self.assertEqual(len(supported_queries), 55) + self.assertEqual(len(failing_queries), 48) + + def test_query_selection_and_normalization(self): + qnames = tpcds_queries.parse_query_selection("canonical13") + self.assertEqual(len(qnames), 13) + self.assertEqual(tpcds_queries.normalize_query_name("03"), "query3") + self.assertEqual(tpcds_queries.normalize_query_name("q14a"), "query14a") + self.assertEqual( + tpcds_queries.parse_query_selection("3, 7, query96"), + ["query3", "query7", "query96"]) + with self.assertRaises(ValueError): + tpcds_queries.parse_query_selection("query999") + + def test_supported_data_sizes(self): + self.assertEqual( + tpcds_launcher.check_and_normalize_data_size("10M"), "10MB") + self.assertEqual( + tpcds_launcher.check_and_normalize_data_size("10MB"), "10MB") + self.assertEqual(tpcds_launcher.check_and_normalize_data_size("1G"), "1GB") + self.assertEqual(tpcds_launcher.check_and_normalize_data_size("1GB"), "1GB") + with self.assertRaises(ValueError): + tpcds_launcher.check_and_normalize_data_size("5G") + + +if __name__ == "__main__": + logging.getLogger().setLevel(logging.INFO) + unittest.main() diff --git a/settings.gradle.kts b/settings.gradle.kts index b8e7b855b999..b45c3f9ffa4c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -311,6 +311,7 @@ include(":sdks:java:transform-service:controller-container") include(":sdks:python") include(":sdks:python:apache_beam:testing:load_tests") include(":sdks:python:apache_beam:testing:benchmarks:nexmark") +include(":sdks:python:apache_beam:testing:benchmarks:tpcds") include(":sdks:python:container") include(":sdks:python:container:py310") include(":sdks:python:container:py311") From 9f5f83e03d71ee2ec067bd5e3b0186a91c51036b Mon Sep 17 00:00:00 2001 From: Bruno Volpato Date: Thu, 22 Oct 2026 12:54:08 -0400 Subject: [PATCH 090/108] [Python] Keep BigQuery load jobs distinct for table aliases (#40001) * [Python] Keep BigQuery load jobs distinct for table aliases * Document BigQuery destination-alias fix * [Python] Disable gRPC fork support before tox test startup --- CHANGES.md | 1 + .../apache_beam/io/gcp/bigquery_file_loads.py | 11 +-- .../io/gcp/bigquery_file_loads_test.py | 80 +++++++++++++++++++ sdks/python/tox.ini | 3 + 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 4e2fffb89380..1f57af6cdb70 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -105,6 +105,7 @@ ## Bugfixes +* (Python) Fixed BigQuery `FILE_LOADS` silently omitting files when dynamic destinations use different spellings of the same table ([#40001](https://github.com/apache/beam/pull/40001)). * (Python) DaskRunner now preserves outputs emitted by `DoFn.finish_bundle()` instead of silently dropping them ([#40519](https://github.com/apache/beam/issues/40519)). * (Python) BigQuery streaming inserts now reset the retry budget and backoff for each batch instead of sharing them across a bundle ([#39995](https://github.com/apache/beam/pull/39995)). * (Python) State write failures reported by portable runners now fail the bundle instead of silently losing state updates ([#39992](https://github.com/apache/beam/pull/39992)). diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py index 79940d109f07..a812467ef18f 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py @@ -758,14 +758,11 @@ def process( if table_reference.projectId is None: table_reference.projectId = vp.RuntimeValueProvider.get_value( 'project', str, '') or self.project - # Load jobs for a single destination are always triggered from the same - # worker. This means that we can generate a deterministic numbered job id, - # and not need to worry. + # Use the same destination identity as file grouping. Equivalent table + # references can form separate groups with different files but identical + # partition numbers, so normalizing the reference would collide job IDs. destination_hash = _bq_uuid( - '%s:%s.%s' % ( - table_reference.projectId, - table_reference.datasetId, - table_reference.tableId)) + bigquery_tools.get_hashable_destination(destination)) job_name = '%s_%s_pane%s_partition%s' % ( load_job_name_prefix, destination_hash, pane_info.index, partition_key) _LOGGER.info('Load job has %s files. Job name is %s.', len(files), job_name) diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py b/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py index 7ccda7ecad8d..91ecb0d700de 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads_test.py @@ -408,6 +408,86 @@ def test_partition_files_dofn_size_split(self): class TestBigQueryFileLoads(_TestCaseWithTempDirCleanUp): + @parameterized.expand([ + ('append', BigQueryDisposition.WRITE_APPEND), + ('truncate', BigQueryDisposition.WRITE_TRUNCATE), + ]) + def test_equivalent_destinations_keep_distinct_load_jobs( + self, unused_name, write_disposition): + destinations = [ + 'project:dataset.table', 'dataset.table', 'project.dataset.table' + ] + files = ['gs://bucket/file%d' % i for i in range(len(destinations))] + jobs = {} + + def insert_job(request, upload=None): + job = request.job + job_id = job.jobReference.jobId + if job_id in jobs: + raise HttpError({'status': '409'}, + ('Already Exists: Job project:US.' + job_id).encode(), + '') + job.status = bigquery_api.JobStatus(state='DONE') + jobs[job_id] = job + return job + + client = mock.Mock() + client.jobs.Insert.side_effect = insert_job + client.jobs.Get.side_effect = lambda request: jobs[request.jobId] + schema = mock.Mock(return_value=_ELEMENTS_SCHEMA) + parameters = mock.Mock(return_value={'timePartitioning': {'type': 'DAY'}}) + loader = bqfl.TriggerLoadJobs( + project='project', + schema=schema, + temporary_tables=True, + additional_bq_parameters=parameters, + test_client=client) + loader.start_bundle() + + # File grouping keeps these equivalent destination strings separate, so + # each group has a partition zero containing different source files. + partitions = [(destination, (0, [path])) + for destination, path in zip(destinations, files)] + for partition in partitions: + list(loader.process(partition, 'load', pane_info=mock.Mock(index=0))) + loaded = [result.value for result in loader.finish_bundle()] + first_ids = [job.jobId for _, job in loaded] + self.assertEqual( + 'load_29b51c1b5b03c4804123fd20083029da_pane0_partition0', first_ids[0]) + + # Replaying the same partitions must resolve to the same jobs, without + # treating a different destination group's files as an already-run job. + loader.start_bundle() + for partition in partitions: + list(loader.process(partition, 'load', pane_info=mock.Mock(index=0))) + replayed = [result.value for result in loader.finish_bundle()] + self.assertEqual(first_ids, [job.jobId for _, job in replayed]) + self.assertEqual([call(dest) for dest in destinations] * 2, + schema.call_args_list) + self.assertEqual([call(dest) for dest in destinations] * 2, + parameters.call_args_list) + + copier = bqfl.TriggerCopyJobs( + project='project', + write_disposition=write_disposition, + test_client=client) + copier.setup() + copier.start_bundle() + copier.process(replayed, 'copy') + list(copier.finish_bundle()) + + copy_jobs = [job for job in jobs.values() if job.configuration.copy] + copied_files = [ + path for job in copy_jobs + for path in jobs[job.configuration.copy.sourceTable.tableId]. + configuration.load.sourceUris + ] + self.assertCountEqual(files, copied_files) + self.assertEqual(3, len(set(first_ids))) + self.assertEqual( + [write_disposition, 'WRITE_APPEND', 'WRITE_APPEND'], + [job.configuration.copy.writeDisposition for job in copy_jobs]) + def test_trigger_load_jobs_with_empty_files(self): destination = "project:dataset.table" empty_files = [] diff --git a/sdks/python/tox.ini b/sdks/python/tox.ini index 7a822cab7593..b579f91fd2eb 100644 --- a/sdks/python/tox.ini +++ b/sdks/python/tox.ini @@ -46,6 +46,9 @@ allowlist_externals = chmod setenv = RUN_SKIPPED_PY3_TESTS=0 + # Set before importing gRPC; test-class setup is too late. + # TODO(https://github.com/grpc/grpc/issues/37710): Remove once fixed. + GRPC_ENABLE_FORK_SUPPORT=0 # Use an isolated tmp dir for tests that get slowed down by scanning /tmp. TMPDIR={envtmpdir} # Silence warning about ignoring PYTHONPATH. From 6e5fdbf17fbe236e86996c109f9986c24ea8970c Mon Sep 17 00:00:00 2001 From: Ahmed Abualsaud <65791736+ahmedabu98@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:41:27 -0700 Subject: [PATCH 091/108] Temporarily skip Iceberg cdc tests for managed service (#40731) --- ...IO_Iceberg_Managed_Integration_Tests_Dataflow.json | 2 +- sdks/java/io/iceberg/build.gradle | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/trigger_files/IO_Iceberg_Managed_Integration_Tests_Dataflow.json b/.github/trigger_files/IO_Iceberg_Managed_Integration_Tests_Dataflow.json index 9a4fbff0c0b7..0ca37f8c8e27 100644 --- a/.github/trigger_files/IO_Iceberg_Managed_Integration_Tests_Dataflow.json +++ b/.github/trigger_files/IO_Iceberg_Managed_Integration_Tests_Dataflow.json @@ -1,5 +1,5 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run.", - "modification": 2, + "modification": 1, "https://github.com/apache/beam/pull/39990": "removing dead code from FnApiDoFnRunner" } diff --git a/sdks/java/io/iceberg/build.gradle b/sdks/java/io/iceberg/build.gradle index 9c9e58edaac0..3f949c5cecb2 100644 --- a/sdks/java/io/iceberg/build.gradle +++ b/sdks/java/io/iceberg/build.gradle @@ -251,10 +251,13 @@ task dataflowIntegrationTest(type: Test) { includeTestsMatching 'org.apache.beam.sdk.io.iceberg.catalog.BigQueryMetastoreCatalogIT.testStreamToPartitionedDynamicDestinations' // CDC sink. The native-ValueKind round trip tests run on legacy worker. // The change-type-column tests run on Runner v2. - includeTestsMatching 'org.apache.beam.sdk.io.iceberg.catalog.LakehouseCatalogCdcWriteIT.changelogOfSinkWrittenTableRoundTripsThroughTheSource' - includeTestsMatching 'org.apache.beam.sdk.io.iceberg.catalog.LakehouseCatalogCdcWriteIT.changelogOfSinkWrittenTableRoundTripsThroughTheSourceWithChangeTypeColumn' - includeTestsMatching 'org.apache.beam.sdk.io.iceberg.cdc.sink.DeltaLakeToIcebergCdcIT.changeFeedAppliesToIcebergTable' - includeTestsMatching 'org.apache.beam.sdk.io.iceberg.cdc.sink.DeltaLakeToIcebergCdcIT.changeFeedAppliesToIcebergTableWithChangeTypeColumn' + // TODO(https://github.com/apache/beam/issues/40732): unskip these tests when managed service upgrades to 2.77 + if (!project.hasProperty('enableManagedTransforms')) { + includeTestsMatching 'org.apache.beam.sdk.io.iceberg.catalog.LakehouseCatalogCdcWriteIT.changelogOfSinkWrittenTableRoundTripsThroughTheSource' + includeTestsMatching 'org.apache.beam.sdk.io.iceberg.catalog.LakehouseCatalogCdcWriteIT.changelogOfSinkWrittenTableRoundTripsThroughTheSourceWithChangeTypeColumn' + includeTestsMatching 'org.apache.beam.sdk.io.iceberg.cdc.sink.DeltaLakeToIcebergCdcIT.changeFeedAppliesToIcebergTable' + includeTestsMatching 'org.apache.beam.sdk.io.iceberg.cdc.sink.DeltaLakeToIcebergCdcIT.changeFeedAppliesToIcebergTableWithChangeTypeColumn' + } } doLast { From 94b84b6adcb106b95afb50123645ae52a8d81d7a Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Thu, 22 Oct 2026 17:16:33 -0400 Subject: [PATCH 092/108] [Java] Use GcsUtil V2 by default (#40713) * Use GcsUtil V2 by default, add disable_gcsutil_v2 fallback GcsUtil now uses GcsUtilV2 (java-storage) by default. The disable_gcsutil_v2 experiment falls back to GcsUtilV1 (gcsio). use_gcsutil_v2 is still accepted but does nothing. * Fallback to GcsUtil V1 when use_grpc_for_gcs is set * Select GcsUtil version explicitly in ParquetIOLT * Edited CHANGES.md * Fix GcsUtilIT.testRewriteMultiPart to keep the V1 experiment * Trigger Java PostCommits for GcsUtil V2 default * Preserve kmsKeyName in GcsUtil V2 getObject. Fix test. --- .../trigger_files/beam_PostCommit_Java.json | 2 +- .../beam_PostCommit_Java_DataflowV1.json | 2 +- .../beam_PostCommit_Java_DataflowV2.json | 2 +- ...eam_PostCommit_Java_Examples_Dataflow.json | 4 ++ ..._PostCommit_Java_Examples_Dataflow_V2.json | 2 +- .../beam_PostCommit_Java_Examples_Direct.json | 4 ++ .../beam_PostCommit_Java_Hadoop_Versions.json | 2 +- ..._PostCommit_Java_IO_Performance_Tests.json | 2 +- CHANGES.md | 1 + .../beam/it/gcp/storage/ParquetIOLT.java | 40 ++++++++------ .../extensions/gcp/options/GcsOptions.java | 19 +++---- .../beam/sdk/extensions/gcp/util/GcsUtil.java | 40 +++++++++++--- .../sdk/extensions/gcp/util/GcsUtilV1.java | 2 +- .../sdk/extensions/gcp/util/GcsUtilIT.java | 5 +- .../sdk/extensions/gcp/util/GcsUtilTest.java | 54 +++++++++++++++++-- .../extensions/gcp/util/GcsUtilV1Test.java | 23 ++++++-- 16 files changed, 157 insertions(+), 47 deletions(-) create mode 100644 .github/trigger_files/beam_PostCommit_Java_Examples_Dataflow.json create mode 100644 .github/trigger_files/beam_PostCommit_Java_Examples_Direct.json diff --git a/.github/trigger_files/beam_PostCommit_Java.json b/.github/trigger_files/beam_PostCommit_Java.json index de320ac8499c..b2a8beaf3775 100644 --- a/.github/trigger_files/beam_PostCommit_Java.json +++ b/.github/trigger_files/beam_PostCommit_Java.json @@ -1,5 +1,5 @@ { "https://github.com/apache/beam/pull/39893": "Fix nullness in BigQueryIO", "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 6 + "modification": 7 } diff --git a/.github/trigger_files/beam_PostCommit_Java_DataflowV1.json b/.github/trigger_files/beam_PostCommit_Java_DataflowV1.json index c403293a8561..dc9b909a926c 100644 --- a/.github/trigger_files/beam_PostCommit_Java_DataflowV1.json +++ b/.github/trigger_files/beam_PostCommit_Java_DataflowV1.json @@ -4,6 +4,6 @@ "https://github.com/apache/beam/pull/34902": "Introducing OutputBuilder", "https://github.com/apache/beam/pull/35177": "Introducing WindowedValueReceiver to runners", "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 9, + "modification": 10, "https://github.com/apache/beam/pull/35159": "moving WindowedValue and making an interface" } diff --git a/.github/trigger_files/beam_PostCommit_Java_DataflowV2.json b/.github/trigger_files/beam_PostCommit_Java_DataflowV2.json index cf92923c2d23..98161d37e42d 100644 --- a/.github/trigger_files/beam_PostCommit_Java_DataflowV2.json +++ b/.github/trigger_files/beam_PostCommit_Java_DataflowV2.json @@ -1,6 +1,6 @@ { "https://github.com/apache/beam/pull/39893": "Fix nullness in BigQueryIO", - "modification": 9, + "modification": 10, "https://github.com/apache/beam/pull/35159": "moving WindowedValue and making an interface", "https://github.com/apache/beam/pull/39990": "removing dead code from FnApiDoFnRunner" } diff --git a/.github/trigger_files/beam_PostCommit_Java_Examples_Dataflow.json b/.github/trigger_files/beam_PostCommit_Java_Examples_Dataflow.json new file mode 100644 index 000000000000..e3d6056a5de9 --- /dev/null +++ b/.github/trigger_files/beam_PostCommit_Java_Examples_Dataflow.json @@ -0,0 +1,4 @@ +{ + "comment": "Modify this file in a trivial way to cause this test suite to run", + "modification": 1 +} diff --git a/.github/trigger_files/beam_PostCommit_Java_Examples_Dataflow_V2.json b/.github/trigger_files/beam_PostCommit_Java_Examples_Dataflow_V2.json index 2bbe49ea6083..5544cd2ef706 100644 --- a/.github/trigger_files/beam_PostCommit_Java_Examples_Dataflow_V2.json +++ b/.github/trigger_files/beam_PostCommit_Java_Examples_Dataflow_V2.json @@ -2,6 +2,6 @@ "https://github.com/apache/beam/pull/36138": "Cleanly separating v1 worker and v2 sdk harness container image handling", "https://github.com/apache/beam/pull/34902": "Introducing OutputBuilder", "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 2, + "modification": 3, "https://github.com/apache/beam/pull/39990": "removing dead code from FnApiDoFnRunner" } diff --git a/.github/trigger_files/beam_PostCommit_Java_Examples_Direct.json b/.github/trigger_files/beam_PostCommit_Java_Examples_Direct.json new file mode 100644 index 000000000000..e3d6056a5de9 --- /dev/null +++ b/.github/trigger_files/beam_PostCommit_Java_Examples_Direct.json @@ -0,0 +1,4 @@ +{ + "comment": "Modify this file in a trivial way to cause this test suite to run", + "modification": 1 +} diff --git a/.github/trigger_files/beam_PostCommit_Java_Hadoop_Versions.json b/.github/trigger_files/beam_PostCommit_Java_Hadoop_Versions.json index f1ba03a243ee..455144f02a35 100644 --- a/.github/trigger_files/beam_PostCommit_Java_Hadoop_Versions.json +++ b/.github/trigger_files/beam_PostCommit_Java_Hadoop_Versions.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 5 + "modification": 6 } diff --git a/.github/trigger_files/beam_PostCommit_Java_IO_Performance_Tests.json b/.github/trigger_files/beam_PostCommit_Java_IO_Performance_Tests.json index 12baa399f316..b2fec41d7e1b 100644 --- a/.github/trigger_files/beam_PostCommit_Java_IO_Performance_Tests.json +++ b/.github/trigger_files/beam_PostCommit_Java_IO_Performance_Tests.json @@ -1,4 +1,4 @@ { "comment": "Modify this file in a trivial way to cause this test suite to run", - "modification": 4, + "modification": 5, } diff --git a/CHANGES.md b/CHANGES.md index 1f57af6cdb70..77f03f3da76c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -84,6 +84,7 @@ * (Go) Added `schema.RegisterLogicalTypeConversion` and `schema.RegisterLogicalTypeCoder` to register portable logical types in one call, and Go types for the standard `beam:logical_type:date:v1` and `beam:logical_type:micros_instant:v1` logical types, so schemas from other SDKs with these types can be used in cross-language pipelines ([#39684](https://github.com/apache/beam/issues/39684)). * (Python, Java, Go) Added `--dataflow_container_base_repository` (Python, Go) and `--dataflowContainerBaseRepository` (Java) to set the repository (e.g. `docker.example.com/my-prefix/cloud-dataflow/v1beta3`) from which the Dataflow runner pulls its default SDK container images, including the default images of cross-language Apache Beam environments. Defaults to `gcr.io/cloud-dataflow/v1beta3` ([#40569](https://github.com/apache/beam/issues/40569)). * (Go) GCS and Dataflow clients now support non-default universes such as Trusted Partner Cloud (TPC), using the universe domain from `GOOGLE_CLOUD_UNIVERSE_DOMAIN` or the default credentials. Added a `--gcs_endpoint` pipeline option to override the GCS endpoint ([#40569](https://github.com/apache/beam/issues/40569)). +* (Java) `GcsUtil` now uses the java-storage client (GcsUtil V2) for GCS operations by default instead of gcsio (GcsUtil V1). To go back to V1, pass `--experiments=disable_gcsutil_v2`. Pipelines with the `use_grpc_for_gcs` experiment keep using V1. The `use_gcsutil_v2` experiment is no longer needed and is now ignored ([#40712](https://github.com/apache/beam/issues/40712)). ## Breaking Changes diff --git a/it/google-cloud-platform/src/test/java/org/apache/beam/it/gcp/storage/ParquetIOLT.java b/it/google-cloud-platform/src/test/java/org/apache/beam/it/gcp/storage/ParquetIOLT.java index 52b12372dcd6..b8838a6ba246 100644 --- a/it/google-cloud-platform/src/test/java/org/apache/beam/it/gcp/storage/ParquetIOLT.java +++ b/it/google-cloud-platform/src/test/java/org/apache/beam/it/gcp/storage/ParquetIOLT.java @@ -132,15 +132,17 @@ *

    GcsUtil v1 or v2

    * *

    {@code useGcsUtilV2} routes GCS access through the java-storage client and defaults to off, - * i.e. to the gcsio based GcsUtilV1 that every pipeline uses today: + * i.e. to the gcsio based GcsUtilV1: * *

      * -Dconfiguration='{"preset":"f100_s16","useGcsUtilV2":true}'
      * 
    * - *

    On Dataflow this is sent as the {@code use_gcsutil_v2} experiment, for the other runners it is - * set on the pipeline options directly. Either way {@code GcsUtil} logs the version it selected at - * INFO, which is what to grep for to confirm a run really used the intended client. + *

    The version is always selected explicitly, with the {@code use_gcsutil_v2} or the {@code + * disable_gcsutil_v2} experiment, so a run does not depend on which one {@code GcsUtil} defaults + * to. On Dataflow the experiment is sent as a job parameter, for the other runners it is set on the + * pipeline options directly. {@code GcsUtil} logs the version it selected at INFO, which is what to + * grep for to confirm a run really used the intended client. * *

    The number of requests a write costs is decided by the upload chunk size, which {@code * gcsUploadBufferSizeBytes} pins when a run needs to sweep it or to rule it out as a variable: @@ -207,6 +209,9 @@ public final class ParquetIOLT extends GcsIOLoadTestBase { /** Experiment that routes GCS access through GcsUtilV2, i.e. the java-storage client. */ private static final String GCS_UTIL_V2_EXPERIMENT = "use_gcsutil_v2"; + /** Experiment that falls back to GcsUtilV1, i.e. the gcsio client. */ + private static final String GCS_UTIL_DISABLE_V2_EXPERIMENT = "disable_gcsutil_v2"; + /** Pipeline option that pins the upload chunk size, see {@code GcsOptions}. */ private static final String GCS_UPLOAD_BUFFER_SIZE_OPTION = "gcsUploadBufferSizeBytes"; @@ -467,10 +472,8 @@ private PipelineLauncher.LaunchConfig launchConfig(String jobName, TestPipeline pipeline.getOptions().as(GcsOptions.class).setGcsPerformanceMetrics(true); // Same reason: for a non-Dataflow runner the experiments parameter below never reaches the // pipeline, so the experiment has to be added to the options directly. - if (configuration.useGcsUtilV2) { - ExperimentalOptions.addExperiment( - pipeline.getOptions().as(ExperimentalOptions.class), GCS_UTIL_V2_EXPERIMENT); - } + ExperimentalOptions.addExperiment( + pipeline.getOptions().as(ExperimentalOptions.class), gcsUtilExperiment()); // Left unset the two clients pick different chunk sizes, which shows up as a difference in the // number of write requests. if (configuration.gcsUploadBufferSizeBytes > 0) { @@ -539,12 +542,18 @@ private PipelineLauncher.LaunchConfig launchConfig(String jobName, TestPipeline private static String dataflowExperiments() { List experiments = new ArrayList<>(); experiments.add(configuration.useRunnerV2 ? RUNNER_V2_EXPERIMENT : LEGACY_WORKER_EXPERIMENT); - if (configuration.useGcsUtilV2) { - experiments.add(GCS_UTIL_V2_EXPERIMENT); - } + experiments.add(gcsUtilExperiment()); return String.join(",", experiments); } + /** + * The experiment that selects the GcsUtil version under test. Both versions are named explicitly, + * so that a run is not affected by which one {@code GcsUtil} picks by default. + */ + private static String gcsUtilExperiment() { + return configuration.useGcsUtilV2 ? GCS_UTIL_V2_EXPERIMENT : GCS_UTIL_DISABLE_V2_EXPERIMENT; + } + /** * Resolves {@code -Dconfiguration} into a {@link Configuration}. * @@ -892,11 +901,12 @@ static class Configuration extends SyntheticSourceOptions { @JsonProperty public boolean useRunnerV2 = true; /** - * {@code true} routes GCS access through GcsUtilV2, the java-storage client, instead of the - * default GcsUtilV1. Applies to every runner: on Dataflow it is sent as the {@code - * use_gcsutil_v2} experiment, elsewhere it is set on the pipeline options directly. + * {@code true} routes GCS access through GcsUtilV2, the java-storage client, {@code false} + * through GcsUtilV1, the gcsio client. Applies to every runner: on Dataflow it is sent as the + * {@code use_gcsutil_v2} or {@code disable_gcsutil_v2} experiment, elsewhere it is set on the + * pipeline options directly. * - *

    Which one a run actually used is logged by {@code GcsUtil} at INFO. + *

    {@code GcsUtil} logs the version a run actually used at INFO. */ @JsonProperty public boolean useGcsUtilV2 = false; diff --git a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java index b5246a87d2ba..88ebf73dfab7 100644 --- a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java +++ b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/options/GcsOptions.java @@ -174,15 +174,15 @@ public GoogleCloudStorageReadOptions create(PipelineOptions options) { /** * Batching limit for rewrite ops which will copy data. * - * @deprecated Only applicable to GcsUtil V1. Not applicable to GcsUtil V2 (enabled with the - * {@code use_gcsutil_v2} experiment), which does not batch rewrites; use {@link - * #getGcsMaxConcurrentRewrites} to tune copy/move concurrency there instead. + * @deprecated Only applicable to GcsUtil V1 (selected with the {@code disable_gcsutil_v2} + * experiment). Not applicable to GcsUtil V2 (the default), which does not batch rewrites; use + * {@link #getGcsMaxConcurrentRewrites} to tune copy/move concurrency there instead. */ @Deprecated @Description( - "Batching limit for rewrite ops which will copy data. Deprecated: NOT applicable to GcsUtil" - + " V2 (enabled with the use_gcsutil_v2 experiment); use gcsMaxConcurrentRewrites" - + " instead.") + "Batching limit for rewrite ops which will copy data. Deprecated: only applicable to" + + " GcsUtil V1 (selected with the disable_gcsutil_v2 experiment), NOT to GcsUtil V2 (the" + + " default); use gcsMaxConcurrentRewrites instead.") @Nullable Integer getGcsRewriteDataOpBatchLimit(); /** @@ -195,12 +195,13 @@ public GoogleCloudStorageReadOptions create(PipelineOptions options) { * Maximum number of files rewritten concurrently when copying or moving files. If unset, a * default of 128 is used. * - *

    Only applicable to GcsUtil V2, which is enabled with the {@code use_gcsutil_v2} experiment. + *

    Only applicable to GcsUtil V2, which is the default. Ignored when falling back to GcsUtil V1 + * with the {@code disable_gcsutil_v2} experiment. */ @Description( "Maximum number of files rewritten concurrently when copying or moving files in GCS." - + " If unset, defaults to 128. Only applicable to GcsUtil V2 (enabled with the" - + " use_gcsutil_v2 experiment).") + + " If unset, defaults to 128. Only applicable to GcsUtil V2 (the default); ignored" + + " with the disable_gcsutil_v2 experiment.") @Nullable Integer getGcsMaxConcurrentRewrites(); void setGcsMaxConcurrentRewrites(@Nullable Integer maxConcurrentRewrites); diff --git a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtil.java b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtil.java index 382ac6856bf4..25ca962711d0 100644 --- a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtil.java +++ b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtil.java @@ -127,16 +127,41 @@ public static boolean isWildcard(GcsPath spec) { return GcsPath.isWildcard(spec); } + /** + * Experiment that falls back to GcsUtil V1 (gcsio). V2 (java-storage) is the default, and this + * experiment is the escape hatch should V2 misbehave for a pipeline. + */ + public static final String DISABLE_GCSUTIL_V2_EXPERIMENT = "disable_gcsutil_v2"; + + /** + * Experiment that used to opt into GcsUtil V2. V2 is now the default, so this is a no-op kept so + * that existing pipelines that still pass it keep working. {@link #DISABLE_GCSUTIL_V2_EXPERIMENT} + * takes precedence if both are set. + */ + public static final String USE_GCSUTIL_V2_EXPERIMENT = "use_gcsutil_v2"; + + /** + * Experiment that makes GcsUtil V1 talk to GCS over gRPC. Only V1 (gcsio) supports it, so it also + * falls back to V1, the same as {@link #DISABLE_GCSUTIL_V2_EXPERIMENT}. + */ + public static final String USE_GRPC_FOR_GCS_EXPERIMENT = "use_grpc_for_gcs"; + GcsUtil(PipelineOptions options) { this.delegate = new GcsUtilV1.GcsUtilFactory().create(options); - if (ExperimentalOptions.hasExperiment(options, "use_gcsutil_v2")) { + @Nullable String v1Experiment = null; + if (ExperimentalOptions.hasExperiment(options, DISABLE_GCSUTIL_V2_EXPERIMENT)) { + v1Experiment = DISABLE_GCSUTIL_V2_EXPERIMENT; + } else if (ExperimentalOptions.hasExperiment(options, USE_GRPC_FOR_GCS_EXPERIMENT)) { + v1Experiment = USE_GRPC_FOR_GCS_EXPERIMENT; + } + if (v1Experiment != null) { + this.delegateV2 = null; + LOG.info( + "Using GcsUtilV1 (gcsio) for GCS operations because the {} experiment is set.", + v1Experiment); + } else { this.delegateV2 = new GcsUtilV2.GcsUtilFactory().create(options); - // INFO only for V2, which is opt-in. V1 is still the default for every pipeline, - // so logging it at INFO would be noise. LOG.info("Using GcsUtilV2 (java-storage) for GCS operations."); - } else { - this.delegateV2 = null; - LOG.debug("Using GcsUtilV1 (gcsio) for GCS operations."); } } @@ -633,7 +658,8 @@ private static StorageObject toStorageObject(Blob blob) { .setContentEncoding(blob.getContentEncoding()) .setMd5Hash(blob.getMd5()) .setCrc32c(blob.getCrc32c()) - .setEtag(blob.getEtag()); + .setEtag(blob.getEtag()) + .setKmsKeyName(blob.getKmsKeyName()); Long size = blob.getSize(); if (size != null) { storageObject.setSize(BigInteger.valueOf(size)); diff --git a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV1.java b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV1.java index 56de6a477aac..bb5570df79c8 100644 --- a/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV1.java +++ b/sdks/java/extensions/google-cloud-platform-core/src/main/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV1.java @@ -171,7 +171,7 @@ public GcsUtilV1 create(PipelineOptions options) { storageBuilder.build(), storageBuilder.getHttpRequestInitializer(), gcsOptions.getExecutorService(), - hasExperiment(options, "use_grpc_for_gcs"), + hasExperiment(options, GcsUtil.USE_GRPC_FOR_GCS_EXPERIMENT), gcsOptions.getGcpCredential(), gcsOptions.getGcsUploadBufferSizeBytes(), gcsOptions.getGcsRewriteDataOpBatchLimit(), diff --git a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilIT.java b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilIT.java index a6ae12024a10..adb694677851 100644 --- a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilIT.java +++ b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilIT.java @@ -109,7 +109,7 @@ public class GcsUtilIT { @Parameters(name = "{0}") public static Iterable data() { - return Arrays.asList("use_gcsutil_v1", "use_gcsutil_v2"); + return Arrays.asList("disable_gcsutil_v2", "use_gcsutil_v2"); } @Parameter public String experiment; @@ -1342,6 +1342,9 @@ public void testRewriteMultiPart() throws IOException { TestPipelineOptions options = TestPipeline.testingPipelineOptions().as(TestPipelineOptions.class); + // These options are built from scratch, so the experiment set in setUp() has to be carried + // over, otherwise GcsUtil picks its default (V2) rather than the V1 this test exercises. + options.as(ExperimentalOptions.class).setExperiments(Collections.singletonList(experiment)); // Using a KMS key is necessary to trigger multi-part rewrites (bucket is created // with a bucket default key). assertNotNull(options.getTempRoot()); diff --git a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilTest.java b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilTest.java index 12d89dec705f..2501313304e1 100644 --- a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilTest.java +++ b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilTest.java @@ -43,6 +43,7 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -52,6 +53,7 @@ import org.apache.beam.sdk.extensions.gcp.util.GcsUtil.StorageObjectOrIOException; import org.apache.beam.sdk.extensions.gcp.util.gcsfs.GcsPath; import org.apache.beam.sdk.io.fs.MoveOptions.StandardMoveOptions; +import org.apache.beam.sdk.options.ExperimentalOptions; import org.apache.beam.sdk.options.PipelineOptionsFactory; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; import org.junit.Test; @@ -117,7 +119,7 @@ public void testMultipleThreadsCanCompleteOutOfOrderWithDefaultThreadPool() thro } // The tests below cover the routing that the GcsUtil facade performs for the legacy typed - // methods once the use_gcsutil_v2 experiment installs a GcsUtilV2 delegate. Both delegates are + // methods when a GcsUtilV2 delegate is installed (the default). Both delegates are // mocked, so they assert both that V2 is used and that V1 is left alone. private GcsUtilV1 mockDelegate; @@ -333,6 +335,7 @@ public void testGetObjectIsRoutedToV2AndKeepsAllFields() throws IOException { when(blob.getMd5()).thenReturn("md5=="); when(blob.getCrc32c()).thenReturn("crc=="); when(blob.getEtag()).thenReturn("etag"); + when(blob.getKmsKeyName()).thenReturn("projects/p/locations/l/keyRings/r/cryptoKeys/k"); when(blob.getUpdateTimeOffsetDateTime()) .thenReturn(Instant.ofEpochMilli(1234L).atOffset(ZoneOffset.UTC)); when(blob.getCreateTimeOffsetDateTime()) @@ -351,6 +354,7 @@ public void testGetObjectIsRoutedToV2AndKeepsAllFields() throws IOException { assertEquals("md5==", object.getMd5Hash()); assertEquals("crc==", object.getCrc32c()); assertEquals("etag", object.getEtag()); + assertEquals("projects/p/locations/l/keyRings/r/cryptoKeys/k", object.getKmsKeyName()); assertEquals(1234L, object.getUpdated().getValue()); assertEquals(1000L, object.getTimeCreated().getValue()); Mockito.verifyNoMoreInteractions(mockDelegate); @@ -439,10 +443,54 @@ public void testGetBucketWithoutSoftDeletePolicyForV2() throws IOException { assertNull(converted.getStorageClass()); } - /** Without the use_gcsutil_v2 experiment, the V2-only methods fail rather than fall back. */ + private static GcsOptions gcsOptionsWithExperiments(String... experiments) { + GcsOptions options = gcsOptionsWithTestCredential(); + options.as(ExperimentalOptions.class).setExperiments(Arrays.asList(experiments)); + return options; + } + + /** GcsUtil V2 is used by default. */ @Test - public void testV2OnlyMethodsFailWithoutV2() { + public void testUsesV2ByDefault() { GcsUtil gcsUtil = gcsOptionsWithTestCredential().getGcsUtil(); + assertNotNull(gcsUtil.delegateV2); + } + + /** The legacy opt-in experiment is still accepted and keeps V2. */ + @Test + public void testUsesV2WithLegacyV2Experiment() { + GcsUtil gcsUtil = gcsOptionsWithExperiments(GcsUtil.USE_GCSUTIL_V2_EXPERIMENT).getGcsUtil(); + assertNotNull(gcsUtil.delegateV2); + } + + /** The disable_gcsutil_v2 experiment falls back to V1, even when use_gcsutil_v2 is also set. */ + @Test + public void testFallsBackToV1WithDisableV2Experiment() { + assertNull( + gcsOptionsWithExperiments(GcsUtil.DISABLE_GCSUTIL_V2_EXPERIMENT).getGcsUtil().delegateV2); + assertNull( + gcsOptionsWithExperiments( + GcsUtil.DISABLE_GCSUTIL_V2_EXPERIMENT, GcsUtil.USE_GCSUTIL_V2_EXPERIMENT) + .getGcsUtil() + .delegateV2); + } + + /** gRPC is only supported by V1, so the use_grpc_for_gcs experiment falls back to V1. */ + @Test + public void testFallsBackToV1WithGrpcExperiment() { + assertNull( + gcsOptionsWithExperiments(GcsUtil.USE_GRPC_FOR_GCS_EXPERIMENT).getGcsUtil().delegateV2); + assertNull( + gcsOptionsWithExperiments( + GcsUtil.USE_GRPC_FOR_GCS_EXPERIMENT, GcsUtil.USE_GCSUTIL_V2_EXPERIMENT) + .getGcsUtil() + .delegateV2); + } + + /** With the disable_gcsutil_v2 experiment, the V2-only methods fail rather than fall back. */ + @Test + public void testV2OnlyMethodsFailWithoutV2() { + GcsUtil gcsUtil = gcsOptionsWithExperiments(GcsUtil.DISABLE_GCSUTIL_V2_EXPERIMENT).getGcsUtil(); assertNull(gcsUtil.delegateV2); GcsUtilV1 v1 = Mockito.mock(GcsUtilV1.class); gcsUtil.delegate = v1; diff --git a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV1Test.java b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV1Test.java index 72d554da7528..2295ecdd1cfa 100644 --- a/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV1Test.java +++ b/sdks/java/extensions/google-cloud-platform-core/src/test/java/org/apache/beam/sdk/extensions/gcp/util/GcsUtilV1Test.java @@ -105,6 +105,7 @@ import org.apache.beam.sdk.io.fs.MoveOptions.StandardMoveOptions; import org.apache.beam.sdk.metrics.MetricName; import org.apache.beam.sdk.metrics.MetricsEnvironment; +import org.apache.beam.sdk.options.ExperimentalOptions; import org.apache.beam.sdk.options.PipelineOptions; import org.apache.beam.sdk.options.PipelineOptionsFactory; import org.apache.beam.sdk.util.FastNanoClockAndSleeper; @@ -142,8 +143,20 @@ public void tearDown() { MetricsEnvironment.setCurrentContainer(null); } - private static GcsOptions gcsOptionsWithTestCredential() { + /** + * These tests exercise {@link GcsUtilV1}, which is no longer the default, so every {@link + * GcsUtil} they build must opt into it with the {@code disable_gcsutil_v2} experiment. + */ + private static GcsOptions gcsOptionsUsingV1() { GcsOptions pipelineOptions = PipelineOptionsFactory.as(GcsOptions.class); + pipelineOptions + .as(ExperimentalOptions.class) + .setExperiments(Collections.singletonList(GcsUtil.DISABLE_GCSUTIL_V2_EXPERIMENT)); + return pipelineOptions; + } + + private static GcsOptions gcsOptionsWithTestCredential() { + GcsOptions pipelineOptions = gcsOptionsUsingV1(); pipelineOptions.setGcpCredential(new TestCredential()); return pipelineOptions; } @@ -181,7 +194,7 @@ public void testCreationWithExplicitGoogleCloudStorageReadOptions() throws Excep .setFastFailOnNotFoundEnabled(false) .build(); - GcsOptions pipelineOptions = PipelineOptionsFactory.as(GcsOptions.class); + GcsOptions pipelineOptions = gcsOptionsUsingV1(); pipelineOptions.setGoogleCloudStorageReadOptions(readOptions); GcsUtil gcsUtil = pipelineOptions.getGcsUtil(); @@ -1746,7 +1759,7 @@ public void testCreateWithException() throws IOException { private void testWriteMetrics(boolean enabled) throws IOException { // arrange - GcsOptions gcsOptions = PipelineOptionsFactory.create().as(GcsOptions.class); + GcsOptions gcsOptions = gcsOptionsUsingV1(); gcsOptions.setEnableBucketWriteMetricCounter(enabled); gcsOptions.setGcsWriteCounterPrefix("test_counter"); GcsUtilMock gcsUtil = GcsUtilMock.createMockWithMockStorage(gcsOptions, null); @@ -1774,7 +1787,7 @@ private void testWriteMetrics(boolean enabled) throws IOException { private void testReadMetrics(boolean enabled, GoogleCloudStorageReadOptions readOptions) throws IOException { // arrange - GcsOptions gcsOptions = PipelineOptionsFactory.create().as(GcsOptions.class); + GcsOptions gcsOptions = gcsOptionsUsingV1(); gcsOptions.setEnableBucketReadMetricCounter(enabled); gcsOptions.setGcsReadCounterPrefix("test_counter"); if (readOptions != null) { @@ -1833,7 +1846,7 @@ public void testReadMetricsAreNotCollectedWhenNotEnabledOpenWithOptions() throws @Test public void testGcsEndpoint() throws IOException { - GcsOptions pipelineOptions = PipelineOptionsFactory.as(GcsOptions.class); + GcsOptions pipelineOptions = gcsOptionsUsingV1(); pipelineOptions.setGcsEndpoint("http://localhost:4443/storage/v1/"); GcsUtil gcsUtil = pipelineOptions.getGcsUtil(); From 1fcf426e67870c54eb972e1753c9cd0eee0ee4e3 Mon Sep 17 00:00:00 2001 From: Danny McCormick Date: Thu, 22 Oct 2026 21:28:39 +0000 Subject: [PATCH 093/108] Upgrade allowed version of pillow (#40708) --- sdks/python/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/python/setup.py b/sdks/python/setup.py index 3279282fc6b9..10bb4b951919 100644 --- a/sdks/python/setup.py +++ b/sdks/python/setup.py @@ -438,7 +438,7 @@ def get_portability_package_data(): 'numpy>=1.14.3,<2.5.0', # Update pyproject.toml as well. 'objsize>=0.6.1,<0.8.0', 'packaging>=22.0', - 'pillow>=12.1.1,<13', + 'pillow>=12.1.1,<14', 'pymongo>=3.8.0,<5.0.0', # ADK / OpenTelemetry need proto-plus>=1.26.1 (protobuf>=5); that # floor is declared on ml_adk_dependency only so core installs stay From e333810ea3a1723b74b0adcd2006bfc5d3bb9306 Mon Sep 17 00:00:00 2001 From: Divyansh Yadav Date: Fri, 23 Oct 2026 03:04:42 +0530 Subject: [PATCH 094/108] Only feed total memory readings to the ModelManager resource estimator (#40646) --- .../apache_beam/ml/inference/model_manager.py | 9 +++----- .../ml/inference/model_manager_test.py | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/sdks/python/apache_beam/ml/inference/model_manager.py b/sdks/python/apache_beam/ml/inference/model_manager.py index bc16feb6f60f..79a96a360fa0 100644 --- a/sdks/python/apache_beam/ml/inference/model_manager.py +++ b/sdks/python/apache_beam/ml/inference/model_manager.py @@ -757,13 +757,10 @@ def _spawn_new_model( try: with self._cv: self.logging_info("Loading Model: %s (Unknown: %s)", tag, is_unknown) - baseline_snap, _, _ = self._monitor.get_stats() + # No estimator observation here: the estimator is fit on total memory + # readings, which release_model records along with the full snapshot + # of loaded models. A load delta would contradict those readings. instance = loader_func() - _, peak_during_load, _ = self._monitor.get_stats() - - snapshot = {tag: 1} - self._estimator.add_observation( - snapshot, peak_during_load - baseline_snap) if not is_unknown: self._pending_reservations = max( diff --git a/sdks/python/apache_beam/ml/inference/model_manager_test.py b/sdks/python/apache_beam/ml/inference/model_manager_test.py index d1f2b35bc1d9..6b94164baf88 100644 --- a/sdks/python/apache_beam/ml/inference/model_manager_test.py +++ b/sdks/python/apache_beam/ml/inference/model_manager_test.py @@ -266,6 +266,29 @@ def test_model_manager_concurrent_execution(self): self.manager.release_model(model_b, inst_b) self.assertEqual(self.manager._total_active_jobs, 0) + def test_model_manager_observations_use_total_memory(self): + """ + Test that the estimator only receives total memory readings for the + full snapshot of loaded models, not the memory delta of a single load. + """ + model_a = "model_a" + model_b = "model_b" + + self.manager._estimator.set_initial_estimate(model_a, 1000.0) + self.manager._estimator.set_initial_estimate(model_b, 2000.0) + + inst_a = self.manager.acquire_model( + model_a, lambda: MockModel(model_a, 1000.0, self.mock_monitor)) + inst_b = self.manager.acquire_model( + model_b, lambda: MockModel(model_b, 2000.0, self.mock_monitor)) + self.manager.release_model(model_b, inst_b) + self.manager.release_model(model_a, inst_a) + + history = self.manager._estimator.history + snapshot_key = ((model_a, 1), (model_b, 1)) + self.assertEqual(list(history.keys()), [snapshot_key]) + self.assertEqual(list(history[snapshot_key]), [3000.0, 3000.0]) + def test_model_manager_concurrent_mixed_workload_convergence(self): """ Simulates a production environment with multiple model types running From d593d389e0b57a43d79ca816c00982519cfe05ad Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Thu, 22 Oct 2026 18:40:58 -0400 Subject: [PATCH 095/108] [Python] Support custom BigQuery endpoints and universe domains (#40675) --- .../apache_beam/io/gcp/bigquery_tools.py | 97 +++++++++++-- .../apache_beam/io/gcp/bigquery_tools_test.py | 128 ++++++++++++++++++ .../apache_beam/options/pipeline_options.py | 33 +++++ .../options/pipeline_options_test.py | 67 +++++++++ 4 files changed, 317 insertions(+), 8 deletions(-) diff --git a/sdks/python/apache_beam/io/gcp/bigquery_tools.py b/sdks/python/apache_beam/io/gcp/bigquery_tools.py index d1412d0f855e..ea3aa1bbbd40 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_tools.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_tools.py @@ -33,6 +33,7 @@ import io import json import logging +import os import re import sys import time @@ -60,6 +61,7 @@ from apache_beam.metrics import monitoring_infos from apache_beam.metrics.metric import Metrics from apache_beam.options import value_provider +from apache_beam.options.pipeline_options import BigQueryOptions from apache_beam.options.pipeline_options import GoogleCloudOptions from apache_beam.options.pipeline_options import PipelineOptions from apache_beam.transforms import DoFn @@ -123,6 +125,77 @@ # For CI: temp dataset of this name are automatically deleted _TEMP_DATASET_PREFIX = 'beam_temp_dataset_' +_DEFAULT_UNIVERSE_DOMAIN = 'googleapis.com' +_UNIVERSE_DOMAIN_ENV = 'GOOGLE_CLOUD_UNIVERSE_DOMAIN' +_BIGQUERY_SERVICE_PATH = '/bigquery/v2' + + +def _get_bigquery_endpoint( + pipeline_options: Optional[PipelineOptions] = None) -> Optional[str]: + """Returns the BigQuery root URL set via ``--bigquery_endpoint``, or None. + + The given pipeline options are checked first. If they do not set the + endpoint, the options of the running pipeline are checked; these are + available on workers, where BigQuery clients are often created without + access to the pipeline options. + + The returned URL always has a scheme and never has a trailing slash or the + ``/bigquery/v2`` service path. + """ + endpoint = None + if pipeline_options is not None: + endpoint = pipeline_options.view_as(BigQueryOptions).big_query_endpoint + if not endpoint: + endpoint = value_provider.RuntimeValueProvider.get_value( + 'big_query_endpoint', str, None) + if not endpoint: + return None + endpoint = endpoint.strip().rstrip('/') + if endpoint.endswith(_BIGQUERY_SERVICE_PATH): + endpoint = endpoint[:-len(_BIGQUERY_SERVICE_PATH)] + if '://' not in endpoint: + endpoint = 'https://' + endpoint + return endpoint + + +def get_bigquery_api_url( + pipeline_options: Optional[PipelineOptions] = None) -> str: + """Returns the base URL of the BigQuery v2 REST API. + + The URL is resolved in the following order: + + 1. ``--bigquery_endpoint``, if set. + 2. ``https://bigquery.``, if the ``GOOGLE_CLOUD_UNIVERSE_DOMAIN`` + environment variable is set to a non-default universe domain. This + matches how the google-cloud-bigquery client library resolves its + endpoint. + 3. The default public URL. + """ + endpoint = _get_bigquery_endpoint(pipeline_options) + if endpoint is None: + universe_domain = ( + os.environ.get(_UNIVERSE_DOMAIN_ENV, '').strip() or + _DEFAULT_UNIVERSE_DOMAIN) + if universe_domain == _DEFAULT_UNIVERSE_DOMAIN: + return bigquery.BigqueryV2.BASE_URL + endpoint = 'https://bigquery.%s' % universe_domain + return endpoint + _BIGQUERY_SERVICE_PATH + '/' + + +def get_bigquery_client_options( + pipeline_options: Optional[PipelineOptions] = None) -> Optional[dict]: + """Returns ``client_options`` for a ``google.cloud.bigquery.Client``. + + Returns None when ``--bigquery_endpoint`` is not set, so that the client + library resolves the endpoint itself (it honors + ``GOOGLE_CLOUD_UNIVERSE_DOMAIN``). + """ + endpoint = _get_bigquery_endpoint(pipeline_options) + if endpoint is None: + return None + return {'api_endpoint': endpoint} + + # TODO(https://github.com/apache/beam/issues/25946): Add support for # more Beam portable schema types as Python types BIGQUERY_TYPE_TO_PYTHON_TYPE = { @@ -510,6 +583,7 @@ def __init__( pipeline_options=None): self.quota_project_id = ( quota_project_id or _quota_project_id_from_options(pipeline_options)) + self._pipeline_options = pipeline_options self.client = client or BigQueryWrapper._bigquery_client( pipeline_options or PipelineOptions(), quota_project_id=self.quota_project_id) @@ -556,7 +630,8 @@ def gcp_bq_client(self): """ if self._gcp_bq_client is None: self._gcp_bq_client = BigQueryWrapper._gcp_bigquery_client( - quota_project_id=self.quota_project_id) + quota_project_id=self.quota_project_id, + pipeline_options=self._pipeline_options) return self._gcp_bq_client @property @@ -1597,6 +1672,7 @@ def _bigquery_client( # Use explicit quota_project_id if provided, otherwise get from options quota_project_id = quota_project_id or _quota_project_id_from_options( pipeline_options) + url = get_bigquery_api_url(pipeline_options) http = get_new_http() if quota_project_id: if credentials is None: @@ -1612,11 +1688,12 @@ def _bigquery_client( http = _HttpWithHeaders( http, {'x-goog-user-project': quota_project_id}, hosts=[ - urllib.parse.urlsplit(url).hostname for url in ( - bigquery.BigqueryV2.BASE_URL, + urllib.parse.urlsplit(u).hostname for u in ( + url, bigquery.BigqueryV2.BASE_URL, bigquery.BigqueryV2.MTLS_BASE_URL) ]) return bigquery.BigqueryV2( + url=url, http=http, credentials=credentials, response_encoding='utf8', @@ -1625,17 +1702,21 @@ def _bigquery_client( }) @staticmethod - def _gcp_bigquery_client(quota_project_id: str = None): + def _gcp_bigquery_client( + quota_project_id: str = None, + pipeline_options: Optional[PipelineOptions] = None): """Create a google-cloud-bigquery Client with optional quota project. The client applies the quota project to the credentials it resolves - itself, so no credentials are derived here. + itself, so no credentials are derived here. If ``--bigquery_endpoint`` is + set, it is used as the API endpoint; otherwise the client library resolves + the endpoint itself, honoring ``GOOGLE_CLOUD_UNIVERSE_DOMAIN``. """ - client_options = None + client_options = get_bigquery_client_options(pipeline_options) or {} if quota_project_id: - client_options = {'quota_project_id': quota_project_id} + client_options['quota_project_id'] = quota_project_id return gcp_bigquery.Client( - client_options=client_options, + client_options=client_options or None, client_info=ClientInfo( user_agent="apache-beam-%s" % apache_beam.__version__)) diff --git a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py index 6df9f6c5c143..be5441c263b9 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_tools_test.py @@ -53,6 +53,9 @@ from apache_beam.io.gcp.internal.clients import bigquery from apache_beam.metrics import monitoring_infos from apache_beam.metrics.execution import MetricsEnvironment +from apache_beam.options.pipeline_options import BigQueryOptions +from apache_beam.options.pipeline_options import PipelineOptions +from apache_beam.options.value_provider import RuntimeValueProvider from apache_beam.options.value_provider import StaticValueProvider from apache_beam.typehints.row_type import RowTypeConstraint from apache_beam.utils.timestamp import Timestamp @@ -241,6 +244,131 @@ def test_calling_with_all_arguments(self): self.assertEqual(parsed_ref.tableId, tableId) +@unittest.skipIf(HttpError is None, 'GCP dependencies are not installed') +class TestBigQueryEndpoint(unittest.TestCase): + def setUp(self): + self._saved_runtime_options = RuntimeValueProvider.runtime_options + self._saved_experiments = RuntimeValueProvider.experiments + RuntimeValueProvider.runtime_options = None + env_patcher = mock.patch.dict(os.environ) + env_patcher.start() + self.addCleanup(env_patcher.stop) + os.environ.pop('GOOGLE_CLOUD_UNIVERSE_DOMAIN', None) + + def tearDown(self): + RuntimeValueProvider.runtime_options = self._saved_runtime_options + RuntimeValueProvider.experiments = self._saved_experiments + + def test_default_endpoint(self): + options = PipelineOptions([]) + self.assertIsNone(options.view_as(BigQueryOptions).bigquery_endpoint) + self.assertEqual( + bigquery_tools.get_bigquery_api_url(options), + 'https://bigquery.googleapis.com/bigquery/v2/') + self.assertIsNone(bigquery_tools.get_bigquery_client_options(options)) + + def test_default_universe_domain_uses_default_endpoint(self): + os.environ['GOOGLE_CLOUD_UNIVERSE_DOMAIN'] = 'googleapis.com' + self.assertEqual( + bigquery_tools.get_bigquery_api_url(PipelineOptions([])), + 'https://bigquery.googleapis.com/bigquery/v2/') + + def test_universe_domain_from_env(self): + os.environ['GOOGLE_CLOUD_UNIVERSE_DOMAIN'] = 'example-tpc.goog' + options = PipelineOptions([]) + self.assertEqual( + bigquery_tools.get_bigquery_api_url(options), + 'https://bigquery.example-tpc.goog/bigquery/v2/') + # google-cloud-bigquery resolves the universe domain by itself. + self.assertIsNone(bigquery_tools.get_bigquery_client_options(options)) + + @parameterized.expand([ + ('https://bq.example.com', ), + ('https://bq.example.com/', ), + ('https://bq.example.com/bigquery/v2', ), + ('https://bq.example.com/bigquery/v2/', ), + ('bq.example.com', ), + ]) + def test_explicit_endpoint(self, endpoint): + options = PipelineOptions(['--bigquery_endpoint', endpoint]) + self.assertEqual( + options.view_as(BigQueryOptions).bigquery_endpoint, endpoint) + self.assertEqual( + bigquery_tools.get_bigquery_api_url(options), + 'https://bq.example.com/bigquery/v2/') + self.assertEqual( + bigquery_tools.get_bigquery_client_options(options), + {'api_endpoint': 'https://bq.example.com'}) + + def test_explicit_endpoint_takes_precedence_over_universe_domain(self): + os.environ['GOOGLE_CLOUD_UNIVERSE_DOMAIN'] = 'example-tpc.goog' + options = PipelineOptions(['--bigquery_endpoint=http://localhost:9050']) + self.assertEqual( + bigquery_tools.get_bigquery_api_url(options), + 'http://localhost:9050/bigquery/v2/') + self.assertEqual( + bigquery_tools.get_bigquery_client_options(options), + {'api_endpoint': 'http://localhost:9050'}) + + def test_endpoint_from_runtime_options(self): + # On workers, clients may be created without the pipeline options. + RuntimeValueProvider.set_runtime_options( + {'big_query_endpoint': 'https://bq.example.com'}) + self.assertEqual( + bigquery_tools.get_bigquery_api_url(PipelineOptions([])), + 'https://bq.example.com/bigquery/v2/') + self.assertEqual( + bigquery_tools.get_bigquery_client_options(None), + {'api_endpoint': 'https://bq.example.com'}) + + def test_endpoint_attribute_alias_setter(self): + bq_options = PipelineOptions([]).view_as(BigQueryOptions) + bq_options.bigquery_endpoint = 'https://bq.example.com' + self.assertEqual( + bigquery_tools.get_bigquery_api_url(bq_options), + 'https://bq.example.com/bigquery/v2/') + + @mock.patch.object(bigquery_tools.auth, 'get_service_credentials') + def test_bigquery_client_uses_endpoint(self, unused_mock_credentials): + options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com']) + client = bigquery_tools.BigQueryWrapper._bigquery_client(options) + self.assertEqual(client.url, 'https://bq.example.com/bigquery/v2/') + + @mock.patch.object(bigquery_tools.auth, 'get_service_credentials') + @mock.patch.object(bigquery_tools.gcp_bigquery, 'Client') + def test_wrapper_applies_endpoint_to_both_clients( + self, mock_gcp_client, unused_mock_credentials): + options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com']) + wrapper = bigquery_tools.BigQueryWrapper(pipeline_options=options) + self.assertEqual(wrapper.client.url, 'https://bq.example.com/bigquery/v2/') + # The google-cloud-bigquery client is created on first use. + wrapper.gcp_bq_client # pylint: disable=pointless-statement + self.assertEqual( + mock_gcp_client.call_args.kwargs['client_options'], + {'api_endpoint': 'https://bq.example.com'}) + + @mock.patch.object(bigquery_tools.gcp_bigquery, 'Client') + def test_gcp_client_merges_endpoint_and_quota_project(self, mock_gcp_client): + options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com']) + bigquery_tools.BigQueryWrapper._gcp_bigquery_client( + quota_project_id='billing', pipeline_options=options) + self.assertEqual( + mock_gcp_client.call_args.kwargs['client_options'], { + 'api_endpoint': 'https://bq.example.com', + 'quota_project_id': 'billing' + }) + + @mock.patch.object(bigquery_tools.auth, 'get_service_credentials') + def test_quota_project_header_sent_to_custom_endpoint(self, mock_credentials): + options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com']) + bigquery_tools.BigQueryWrapper._bigquery_client( + options, quota_project_id='billing') + # apitools wraps the given http with credentials.authorize(). + http = mock_credentials.return_value.authorize.call_args.args[0] + self.assertIsInstance(http, bigquery_tools._HttpWithHeaders) + self.assertIn('bq.example.com', http._hosts) + + @unittest.skipIf(HttpError is None, 'GCP dependencies are not installed') class TestBigQueryWrapper(unittest.TestCase): def test_delete_non_existing_dataset(self): diff --git a/sdks/python/apache_beam/options/pipeline_options.py b/sdks/python/apache_beam/options/pipeline_options.py index da3aae4e6a17..5b73b22476b4 100644 --- a/sdks/python/apache_beam/options/pipeline_options.py +++ b/sdks/python/apache_beam/options/pipeline_options.py @@ -1388,6 +1388,39 @@ def get_cloud_profiler_service_name(self): return None +class BigQueryOptions(PipelineOptions): + """BigQuery-specific options, mirroring Java's ``BigQueryOptions``. + + Option names (``dest``) are chosen so that they map to the Java option names + when pipeline options are passed to Java, e.g. to cross-language BigQuery + transforms: Java converts ``big_query_endpoint`` to ``bigQueryEndpoint``. + """ + + # Alternative attribute names, mapped to the option names they stand for. + _ATTRIBUTE_ALIASES = {'bigquery_endpoint': 'big_query_endpoint'} + + @classmethod + def _add_argparse_args(cls, parser): + parser.add_argument( + '--bigquery_endpoint', + '--big_query_endpoint', + dest='big_query_endpoint', + default=None, + help=( + 'The root URL for the BigQuery API, e.g. ' + '"https://bigquery.googleapis.com". If not set, the endpoint is ' + 'derived from the GOOGLE_CLOUD_UNIVERSE_DOMAIN environment ' + 'variable when present, and otherwise the default public URL is ' + 'used. Also passed to Java cross-language BigQuery transforms as ' + 'bigQueryEndpoint.')) + + def __getattr__(self, name): + return super().__getattr__(self._ATTRIBUTE_ALIASES.get(name, name)) + + def __setattr__(self, name, value): + super().__setattr__(self._ATTRIBUTE_ALIASES.get(name, name), value) + + class AzureOptions(PipelineOptions): """Azure Blob Storage options.""" @classmethod diff --git a/sdks/python/apache_beam/options/pipeline_options_test.py b/sdks/python/apache_beam/options/pipeline_options_test.py index 2814995b067b..94083a55c6f4 100644 --- a/sdks/python/apache_beam/options/pipeline_options_test.py +++ b/sdks/python/apache_beam/options/pipeline_options_test.py @@ -29,6 +29,7 @@ from parameterized import parameterized from apache_beam.options.pipeline_options import AwsOptions +from apache_beam.options.pipeline_options import BigQueryOptions from apache_beam.options.pipeline_options import CrossLanguageOptions from apache_beam.options.pipeline_options import DebugOptions from apache_beam.options.pipeline_options import GoogleCloudOptions @@ -1269,6 +1270,72 @@ def test_options_are_registered_once(self): self.assertIn('s3_api_version', all_options) +def _java_option_name(python_option_name): + """Converts a Python option name the way Java's PipelineOptionsTranslation + does (Guava CaseFormat.LOWER_UNDERSCORE to LOWER_CAMEL).""" + first, *rest = python_option_name.lower().split('_') + return first + ''.join(part.capitalize() for part in rest) + + +class BigQueryOptionsTest(unittest.TestCase): + # Python option name (dest) -> property name in Java's + # org.apache.beam.sdk.io.gcp.bigquery.BigQueryOptions. Options are passed to + # Java (e.g. cross-language BigQuery transforms) as + # beam:option::v1, and Java derives the property name from . + JAVA_OPTION_NAMES = { + 'big_query_endpoint': 'bigQueryEndpoint', + } + + def test_option_names_map_to_java_option_names(self): + parser = _BeamArgumentParser() + BigQueryOptions._add_argparse_args(parser) + dests = {action.dest for action in parser._actions if action.dest != 'help'} + # A new option must be added to JAVA_OPTION_NAMES, so that its name is + # checked against Java. + self.assertEqual(dests, set(self.JAVA_OPTION_NAMES)) + for dest, java_name in self.JAVA_OPTION_NAMES.items(): + self.assertEqual(_java_option_name(dest), java_name) + + def test_options_serialized_with_java_compatible_names(self): + options = PipelineOptions(['--bigquery_endpoint=https://bq.example.com']) + fields = options.to_runner_api().fields + self.assertEqual( + fields['beam:option:big_query_endpoint:v1'].string_value, + 'https://bq.example.com') + self.assertNotIn('beam:option:bigquery_endpoint:v1', fields) + + @parameterized.expand([ + ('--bigquery_endpoint', ), + ('--big_query_endpoint', ), + ]) + def test_endpoint_flags(self, flag): + options = PipelineOptions([flag, 'https://bq.example.com']) + self.assertEqual( + options.get_all_options()['big_query_endpoint'], + 'https://bq.example.com') + bq_options = options.view_as(BigQueryOptions) + self.assertEqual(bq_options.big_query_endpoint, 'https://bq.example.com') + self.assertEqual(bq_options.bigquery_endpoint, 'https://bq.example.com') + + def test_endpoint_default(self): + bq_options = PipelineOptions([]).view_as(BigQueryOptions) + self.assertIsNone(bq_options.big_query_endpoint) + self.assertIsNone(bq_options.bigquery_endpoint) + + def test_endpoint_attribute_alias_setter(self): + options = PipelineOptions([]) + options.view_as( + BigQueryOptions).bigquery_endpoint = 'https://bq.example.com' + self.assertEqual( + options.view_as(BigQueryOptions).big_query_endpoint, + 'https://bq.example.com') + self.assertNotIn('bigquery_endpoint', options.get_all_options()) + + def test_unknown_attribute_still_raises(self): + with self.assertRaises(AttributeError): + getattr(PipelineOptions([]).view_as(BigQueryOptions), 'not_an_option') + + if __name__ == '__main__': logging.getLogger().setLevel(logging.INFO) unittest.main() From d4ba44da40b4a638fda3d92ae87caab26b92da31 Mon Sep 17 00:00:00 2001 From: Shunping Huang Date: Thu, 22 Oct 2026 18:43:47 -0400 Subject: [PATCH 096/108] [Java] Honor GOOGLE_CLOUD_UNIVERSE_DOMAIN for the Dataflow endpoint (#40672) --- CHANGES.md | 1 + .../options/DataflowPipelineDebugOptions.java | 17 ++- .../options/DataflowPipelineOptions.java | 3 +- .../dataflow/util/DataflowTransport.java | 52 ++++++--- .../dataflow/util/DataflowTransportTest.java | 109 ++++++++++++++++++ 5 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/util/DataflowTransportTest.java diff --git a/CHANGES.md b/CHANGES.md index 77f03f3da76c..39d25b568a02 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -93,6 +93,7 @@ * (Java) JmsIO migrated to `jakarta.jms` (JMS 3.1) and ActiveMQ 6.2.5. User code implementing `JmsIO.MessageMapper`, `valueMapper`, `topicNameMapper`, or providing a `ConnectionFactory` must update imports from `javax.jms.*` to `jakarta.jms.*`. The module now requires Java 17 at runtime ([#38729](https://github.com/apache/beam/issues/38729)). +* (Java) Dataflow runner: `--apiRootUrl` no longer defaults to `https://dataflow.googleapis.com/`. When neither `--apiRootUrl` nor an absolute `--dataflowEndpoint` is set, the Google API client library resolves the Dataflow endpoint and honors `GOOGLE_CLOUD_UNIVERSE_DOMAIN`, so Dataflow jobs can be submitted in non-default universes. Code that reads `DataflowPipelineDebugOptions.getApiRootUrl()` should handle a `null` value ([#40569](https://github.com/apache/beam/issues/40569)). * (Python) `Regex.find_all` with `group=Regex.ALL` now returns every group, as `(match, group1, group2, ...)`, instead of only group 1, and with `outputEmpty=False` it drops a match only when the whole match is empty, matching the `group=0` behavior ([#40399](https://github.com/apache/beam/pull/40399)). * (Python) `Regex.all_matches` now returns every group of the regex, instead of failing with a `TypeError` when the regex has no groups and dropping nested or unmatched trailing groups ([#40562](https://github.com/apache/beam/issues/40562)). * (Python) `--dataflow_endpoint` now defaults to `''` instead of `https://dataflow.googleapis.com`. When unset, the Google Cloud client library resolves the Dataflow endpoint and honors `GOOGLE_CLOUD_UNIVERSE_DOMAIN`, so Dataflow jobs can be submitted in non-default universes. Code that reads `GoogleCloudOptions.dataflow_endpoint` should handle an empty value ([#40569](https://github.com/apache/beam/issues/40569)). diff --git a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineDebugOptions.java b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineDebugOptions.java index 7a5284151b9b..caa27c546eaf 100644 --- a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineDebugOptions.java +++ b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineDebugOptions.java @@ -31,6 +31,7 @@ import org.apache.beam.sdk.options.MemoryMonitorOptions; import org.apache.beam.sdk.options.PipelineOptions; import org.apache.beam.sdk.util.InstanceBuilder; +import org.checkerframework.checker.nullness.qual.Nullable; /** * Internal. Options used to control execution of the Dataflow SDK for debugging and testing @@ -47,13 +48,20 @@ public interface DataflowPipelineDebugOptions * The root URL for the Dataflow API. {@code dataflowEndpoint} can override this value if it * contains an absolute URL, otherwise {@code apiRootUrl} will be combined with {@code * dataflowEndpoint} to generate the full URL to communicate with the Dataflow API. + * + *

    If not set, the root URL is resolved by the Google API client library, which honors {@code + * GOOGLE_CLOUD_UNIVERSE_DOMAIN} and otherwise uses the default public URL ({@value + * Dataflow#DEFAULT_ROOT_URL}). */ @Description( "The root URL for the Dataflow API. dataflowEndpoint can override this " + "value if it contains an absolute URL, otherwise apiRootUrl will be combined with " - + "dataflowEndpoint to generate the full URL to communicate with the Dataflow API.") - @Default.String(Dataflow.DEFAULT_ROOT_URL) - String getApiRootUrl(); + + "dataflowEndpoint to generate the full URL to communicate with the Dataflow API. " + + "If not set, the root URL is resolved by the Google API client library, which " + + "honors GOOGLE_CLOUD_UNIVERSE_DOMAIN and otherwise uses the default public URL (" + + Dataflow.DEFAULT_ROOT_URL + + ").") + @Nullable String getApiRootUrl(); void setApiRootUrl(String value); @@ -64,7 +72,8 @@ public interface DataflowPipelineDebugOptions * SDK version was released. * *

    If the string contains "://", then this is treated as a URL, otherwise {@link - * #getApiRootUrl()} is used as the root URL. + * #getApiRootUrl()} is used as the root URL (or, if that is not set, the root URL resolved by the + * Google API client library). */ @Description( "The URL for the Dataflow API. If the string contains \"://\", this" diff --git a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineOptions.java b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineOptions.java index 57f927d73073..02ae3bc762b8 100644 --- a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineOptions.java +++ b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/options/DataflowPipelineOptions.java @@ -161,7 +161,8 @@ public interface DataflowPipelineOptions * SDK version was released. * *

    If the string contains "://", then this is treated as a URL, otherwise {@link - * #getApiRootUrl()} is used as the root URL. + * #getApiRootUrl()} is used as the root URL (or, if that is not set, the root URL resolved by the + * Google API client library). */ @Description( "The URL for the Dataflow API. If the string contains \"://\", this" diff --git a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/util/DataflowTransport.java b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/util/DataflowTransport.java index ad579333ebf4..a93c3dec25d6 100644 --- a/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/util/DataflowTransport.java +++ b/runners/google-cloud-dataflow-java/src/main/java/org/apache/beam/runners/dataflow/util/DataflowTransport.java @@ -30,7 +30,9 @@ import org.apache.beam.runners.dataflow.options.DataflowPipelineOptions; import org.apache.beam.sdk.extensions.gcp.auth.NullCredentialInitializer; import org.apache.beam.sdk.extensions.gcp.util.RetryHttpRequestInitializer; +import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; +import org.checkerframework.checker.nullness.qual.Nullable; /** Helpers for cloud communication. */ public class DataflowTransport { @@ -59,28 +61,42 @@ private static ApiComponents apiComponentsFromUrl(String urlString) { } } - /** Returns a Google Cloud Dataflow client builder. */ + /** + * Returns a Google Cloud Dataflow client builder. + * + *

    The root URL and service path are only set on the builder when the user configured them via + * {@code dataflowEndpoint} and/or {@code apiRootUrl}. Otherwise, the endpoint is resolved by the + * Google API client library, which honors {@code GOOGLE_CLOUD_UNIVERSE_DOMAIN} and otherwise uses + * the default public URL. Note that calling {@link Dataflow.Builder#setRootUrl} (even with the + * default URL) disables this resolution. + */ public static Dataflow.Builder newDataflowClient(DataflowPipelineOptions options) { + Dataflow.Builder builder = + new Dataflow.Builder( + getTransport(), + getJsonFactory(), + chainHttpRequestInitializer( + options.getGcpCredential(), + // Do not log 404. It clutters the output and is possibly even required by the + // caller. + new RetryHttpRequestInitializer(ImmutableList.of(404)))) + .setApplicationName(options.getAppName()) + .setGoogleClientRequestInitializer(options.getGoogleApiTrace()); + String servicePath = options.getDataflowEndpoint(); - ApiComponents components; - if (servicePath.contains("://")) { - components = apiComponentsFromUrl(servicePath); + @Nullable String apiRootUrl = options.getApiRootUrl(); + if (!Strings.isNullOrEmpty(servicePath) && servicePath.contains("://")) { + ApiComponents components = apiComponentsFromUrl(servicePath); + builder.setRootUrl(components.rootUrl).setServicePath(components.servicePath); } else { - components = new ApiComponents(options.getApiRootUrl(), servicePath); + if (apiRootUrl != null && !apiRootUrl.isEmpty()) { + builder.setRootUrl(apiRootUrl); + } + if (!Strings.isNullOrEmpty(servicePath)) { + builder.setServicePath(servicePath); + } } - - return new Dataflow.Builder( - getTransport(), - getJsonFactory(), - chainHttpRequestInitializer( - options.getGcpCredential(), - // Do not log 404. It clutters the output and is possibly even required by the - // caller. - new RetryHttpRequestInitializer(ImmutableList.of(404)))) - .setApplicationName(options.getAppName()) - .setRootUrl(components.rootUrl) - .setServicePath(components.servicePath) - .setGoogleClientRequestInitializer(options.getGoogleApiTrace()); + return builder; } private static HttpRequestInitializer chainHttpRequestInitializer( diff --git a/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/util/DataflowTransportTest.java b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/util/DataflowTransportTest.java new file mode 100644 index 000000000000..1cb34430a212 --- /dev/null +++ b/runners/google-cloud-dataflow-java/src/test/java/org/apache/beam/runners/dataflow/util/DataflowTransportTest.java @@ -0,0 +1,109 @@ +/* + * 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 org.apache.beam.runners.dataflow.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assume.assumeTrue; + +import com.google.api.services.dataflow.Dataflow; +import org.apache.beam.runners.dataflow.options.DataflowPipelineOptions; +import org.apache.beam.sdk.extensions.gcp.auth.TestCredential; +import org.apache.beam.sdk.options.PipelineOptionsFactory; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link DataflowTransport}. */ +@RunWith(JUnit4.class) +public class DataflowTransportTest { + + private static final String UNIVERSE_DOMAIN = "example-universe.goog"; + + private DataflowPipelineOptions options; + + @Before + public void setUp() { + options = PipelineOptionsFactory.create().as(DataflowPipelineOptions.class); + options.setGcpCredential(new TestCredential()); + } + + @Test + public void testApiRootUrlIsUnsetByDefault() { + assertNull(options.getApiRootUrl()); + assertEquals("", options.getDataflowEndpoint()); + } + + @Test + public void testDefaultEndpoint() { + // The default endpoint is resolved by the client library, which honors + // GOOGLE_CLOUD_UNIVERSE_DOMAIN. Only assert the public default if it is not set. + assumeTrue(System.getenv("GOOGLE_CLOUD_UNIVERSE_DOMAIN") == null); + Dataflow client = DataflowTransport.newDataflowClient(options).build(); + assertEquals(Dataflow.DEFAULT_BASE_URL, client.getBaseUrl()); + } + + @Test + public void testDefaultEndpointHonorsUniverseDomain() { + Dataflow client = + DataflowTransport.newDataflowClient(options).setUniverseDomain(UNIVERSE_DOMAIN).build(); + assertEquals("https://dataflow." + UNIVERSE_DOMAIN + "/", client.getBaseUrl()); + } + + @Test + public void testRelativeDataflowEndpointHonorsUniverseDomain() { + options.setDataflowEndpoint("v0neverExisted"); + Dataflow client = + DataflowTransport.newDataflowClient(options).setUniverseDomain(UNIVERSE_DOMAIN).build(); + assertEquals("https://dataflow." + UNIVERSE_DOMAIN + "/v0neverExisted/", client.getBaseUrl()); + } + + @Test + public void testAbsoluteDataflowEndpointOverridesUniverseDomain() { + options.setDataflowEndpoint("https://custom-dataflow.example.com:8443/some/path/"); + Dataflow client = + DataflowTransport.newDataflowClient(options).setUniverseDomain(UNIVERSE_DOMAIN).build(); + assertEquals("https://custom-dataflow.example.com:8443/", client.getRootUrl()); + assertEquals("some/path/", client.getServicePath()); + } + + @Test + public void testApiRootUrlOverridesUniverseDomain() { + options.setApiRootUrl("https://custom-dataflow.example.com/"); + Dataflow client = + DataflowTransport.newDataflowClient(options).setUniverseDomain(UNIVERSE_DOMAIN).build(); + assertEquals("https://custom-dataflow.example.com/", client.getBaseUrl()); + } + + @Test + public void testApiRootUrlWithRelativeDataflowEndpoint() { + options.setApiRootUrl("https://custom-dataflow.example.com/"); + options.setDataflowEndpoint("v0neverExisted"); + Dataflow client = DataflowTransport.newDataflowClient(options).build(); + assertEquals("https://custom-dataflow.example.com/v0neverExisted/", client.getBaseUrl()); + } + + @Test + public void testExplicitDefaultApiRootUrlIsNotOverridden() { + options.setApiRootUrl(Dataflow.DEFAULT_ROOT_URL); + Dataflow client = + DataflowTransport.newDataflowClient(options).setUniverseDomain(UNIVERSE_DOMAIN).build(); + assertEquals(Dataflow.DEFAULT_ROOT_URL, client.getBaseUrl()); + } +} From c8b5acf67ad54b051c7146b4e626dbcd305b5bf0 Mon Sep 17 00:00:00 2001 From: "RuiLong J." Date: Thu, 22 Oct 2026 15:47:18 -0700 Subject: [PATCH 097/108] Scope RunInference model_identifier annotation to BeamML_RunInference (#40671) * Scope RunInference model_identifier annotation to BeamML_RunInference * Address review: drop VertexModelMonitoringV2 model_identifier shadow, add TODO(#40715) for threshold transforms --- sdks/python/apache_beam/ml/inference/base.py | 86 ++++++++++--------- .../apache_beam/ml/inference/base_test.py | 19 ++++ .../vertex_ai_model_monitoring_v2.py | 1 - .../vertex_ai_model_monitoring_v2_test.py | 5 +- 4 files changed, 67 insertions(+), 44 deletions(-) diff --git a/sdks/python/apache_beam/ml/inference/base.py b/sdks/python/apache_beam/ml/inference/base.py index 4c2d9cb4c80e..a417bb9560fd 100644 --- a/sdks/python/apache_beam/ml/inference/base.py +++ b/sdks/python/apache_beam/ml/inference/base.py @@ -1607,7 +1607,6 @@ def annotations(self): 'model_handler_type': ( f'{self._model_handler.__class__.__module__}' f'.{self._model_handler.__class__.__qualname__}'), - 'model_identifier': self._model_tag, **extra, **super().annotations() } @@ -1715,47 +1714,54 @@ def expand( ) if self._model_metadata_pcoll else None).with_resource_hints( **resource_hints) - if self._with_exception_handling: - # On timeouts, report back to the central model metadata - # that the model is invalid - model_tag = self._model_tag - share_across_processes = self._model_handler.share_model_across_processes( - ) - timeout = self._timeout - - def failure_callback(exception: Exception, element: Any): - if type(exception) is not TimeoutError: - return - model_metadata = load_model_status(model_tag, share_across_processes) - model_metadata.try_mark_current_model_invalid(timeout) - logging.warning( - "Inference failed with a timeout, marking the current " + - "model for garbage collection") - - callback = None - if (self._timeout is not None and - self._model_handler.should_garbage_collect_on_timeout()): - callback = failure_callback - results, bad_inference = ( - batched_elements_pcoll - | 'BeamML_RunInference' >> - run_inference_pardo.with_exception_handling( - exc_class=self._exc_class, - use_subprocess=self._use_subprocess, - threshold=self._threshold, - timeout = self._timeout, - on_failure_callback=callback)) - else: - results = ( - batched_elements_pcoll - | 'BeamML_RunInference' >> run_inference_pardo) + # Attach model_identifier only to the inference step so that runners can + # associate it with the ParDo that actually loads and runs the model. + # TODO(https://github.com/apache/beam/issues/40715): When + # with_exception_handling(threshold<1) is used, the threshold-checking + # transforms added by _ExceptionHandlingWrapper also inherit this + # annotation. Refactor to annotate only the inner inference ParDo. + with batched_elements_pcoll.pipeline.transform_annotations( + model_identifier=self._model_tag): + if self._with_exception_handling: + # On timeouts, report back to the central model metadata + # that the model is invalid + model_tag = self._model_tag + share_across_processes = ( + self._model_handler.share_model_across_processes()) + timeout = self._timeout + + def failure_callback(exception: Exception, element: Any): + if type(exception) is not TimeoutError: + return + model_metadata = load_model_status(model_tag, share_across_processes) + model_metadata.try_mark_current_model_invalid(timeout) + logging.warning( + "Inference failed with a timeout, marking the current " + + "model for garbage collection") + + callback = None + if (self._timeout is not None and + self._model_handler.should_garbage_collect_on_timeout()): + callback = failure_callback + results, bad_inference = ( + batched_elements_pcoll + | 'BeamML_RunInference' >> + run_inference_pardo.with_exception_handling( + exc_class=self._exc_class, + use_subprocess=self._use_subprocess, + threshold=self._threshold, + timeout = self._timeout, + on_failure_callback=callback)) + else: + results = ( + batched_elements_pcoll + | 'BeamML_RunInference' >> run_inference_pardo) if self._monitoring_transform is not None: - with results.pipeline.transform_annotations(model_identifier=''): - _ = ( - results - | 'BeamML_RunInference_MonitoringOutlet' >> - self._monitoring_transform) + _ = ( + results + | + 'BeamML_RunInference_MonitoringOutlet' >> self._monitoring_transform) results, bad_postprocessed = self._apply_fns( results, postprocess_fns, 'BeamML_RunInference_Postprocess') diff --git a/sdks/python/apache_beam/ml/inference/base_test.py b/sdks/python/apache_beam/ml/inference/base_test.py index 5d6c5bd517c7..dbf81035cf76 100644 --- a/sdks/python/apache_beam/ml/inference/base_test.py +++ b/sdks/python/apache_beam/ml/inference/base_test.py @@ -2219,6 +2219,25 @@ def test_run_inference_watch_file_pattern_keyword_arg_side_input_label(self): FakeModelHandler(), watch_model_pattern='fake/path/*') assert side_input_str in str(result_pcoll.producer.side_inputs[0]) + def test_run_inference_only_attaches_model_identifier_to_inference_step(self): + pipeline = beam.Pipeline() + from apache_beam.ml.inference.utils import WatchFilePattern + file_pattern_side_input = ( + pipeline + | 'WatchFilePattern' >> WatchFilePattern(file_pattern='fake/path/*')) + pcoll = pipeline | 'start' >> beam.Create([1, 2, 3]) + _ = pcoll | base.RunInference( + FakeModelHandler(), + model_metadata_pcoll=file_pattern_side_input, + model_identifier='my_custom_model_id') + proto = pipeline.to_runner_api() + transforms_with_model_id = [ + t.unique_name for t in proto.components.transforms.values() + if t.annotations.get('model_identifier') == b'my_custom_model_id' + ] + self.assertEqual( + transforms_with_model_id, ['RunInference/BeamML_RunInference']) + def test_model_status_provides_valid_tags(self): ms = base._ModelStatus(False) diff --git a/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py index 0bd15db515b8..e00a62e32381 100644 --- a/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py +++ b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2.py @@ -347,7 +347,6 @@ def __init__( def annotations(self) -> dict[str, Any]: return { - 'model_identifier': '', **super().annotations(), } diff --git a/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_test.py b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_test.py index 383e632d8416..93f2c97f6575 100644 --- a/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_test.py +++ b/sdks/python/apache_beam/ml/inference/vertex_ai_model_monitoring_v2_test.py @@ -542,8 +542,7 @@ def test_annotations_shadow_model_identifier(self): bigquery_table=self.bq_table, ) annotations = transform.annotations() - self.assertIn("model_identifier", annotations) - self.assertEqual(annotations["model_identifier"], "") + self.assertNotIn("model_identifier", annotations) def test_run_inference_monitoring_outlet_shadows_model_identifier(self): class DummyMonitoring(beam.PTransform): @@ -572,7 +571,7 @@ def load_model(self): ] self.assertTrue(len(outlet_transforms) > 0) for t in outlet_transforms: - self.assertEqual(t.annotations.get("model_identifier"), b"") + self.assertFalse(t.annotations.get("model_identifier")) @mock.patch( "apache_beam.ml.inference.vertex_ai_model_monitoring_v2.WriteToBigQuery") From e4c2f8c71a344ba2e177904607c588499e3ac673 Mon Sep 17 00:00:00 2001 From: Khaled Hammouda Date: Thu, 22 Oct 2026 19:49:32 -0400 Subject: [PATCH 098/108] Honor allowMissingRequiredFields when building Storage Write API messages (#40204) --- CHANGES.md | 1 + .../bigquery/TableRowToStorageApiProto.java | 20 +- .../TableRowToStorageApiProtoTest.java | 179 ++++++++++++++++++ 3 files changed, 198 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 39d25b568a02..9d3f6fbac241 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -135,6 +135,7 @@ * (Python) Setting `max_batch_weight` on a RunInference `ModelHandler` no longer fails with a `TypeError`: `BatchElements` now supports `max_batch_weight`, which limits the total `element_size_fn` of a batch in addition to its number of elements ([#40644](https://github.com/apache/beam/issues/40644)). * (Python) `BatchElements` now honors `element_size_fn` when `max_batch_duration_secs` is set, and no longer emits an empty batch before an element that exceeds the batch size on its own ([#40644](https://github.com/apache/beam/issues/40644)). * (Python) `KeyedModelHandler` and `MaybeKeyedModelHandler` now apply the `element_size_fn` and `batch_length_fn` of the wrapped `ModelHandler` to examples, instead of to `(key, example)` pairs ([#40644](https://github.com/apache/beam/issues/40644)). +* (Java) BigQuery Storage Write API `TableRow` conversion now honors `allowMissingRequiredFields` when building protocol-buffer messages, so callers that pass `true` with required descriptors get partial messages instead of a conversion-time `UninitializedMessageException` ([#40189](https://github.com/apache/beam/issues/40189)). * Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). ## Security Fixes diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowToStorageApiProto.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowToStorageApiProto.java index 687796ed5b47..d06c3d764c4f 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowToStorageApiProto.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowToStorageApiProto.java @@ -848,6 +848,13 @@ public static Descriptor wrapDescriptorProto(DescriptorProto descriptorProto) return Iterables.getOnlyElement(fileDescriptor.getMessageTypes()); } + /** + * Given a BigQuery row map, returns a protocol-buffer message for the BigQuery Storage API. + * + *

    When {@code allowMissingRequiredFields} is true, this method returns a partial + * protocol-buffer message if required fields are absent. When it is false, required-field + * validation and its conversion errors remain unchanged. + */ public static @Nullable DynamicMessage messageFromMap( SchemaInformation schemaInformation, @Nullable Descriptor descriptor, @@ -1042,7 +1049,8 @@ public static Descriptor wrapDescriptorProto(DescriptorProto descriptorProto) return null; } try { - return Preconditions.checkArgumentNotNull(builder).build(); + DynamicMessage.Builder nonNullBuilder = Preconditions.checkArgumentNotNull(builder); + return allowMissingRequiredFields ? nonNullBuilder.buildPartial() : nonNullBuilder.build(); } catch (Exception e) { throw new SchemaDoesntMatchException( "Couldn't convert schema for " + schemaInformation.getFullName(), e); @@ -1053,6 +1061,10 @@ public static Descriptor wrapDescriptorProto(DescriptorProto descriptorProto) * Forwards {@code changeSequenceNum} to {@link #messageFromTableRow(SchemaInformation, * Descriptor, TableRow, boolean, boolean, TableRow, String, String, ErrorCollector)} via {@link * Long#toHexString}. + * + *

    When {@code allowMissingRequiredFields} is true, this method returns a partial + * protocol-buffer message if required fields are absent. When it is false, required-field + * validation and its conversion errors remain unchanged. */ public static @Nullable DynamicMessage messageFromTableRow( SchemaInformation schemaInformation, @@ -1080,6 +1092,10 @@ public static Descriptor wrapDescriptorProto(DescriptorProto descriptorProto) /** * Given a BigQuery TableRow, returns a protocol-buffer message that can be used to write data * using the BigQuery Storage API. + * + *

    When {@code allowMissingRequiredFields} is true, this method returns a partial + * protocol-buffer message if required fields are absent. When it is false, required-field + * validation and its conversion errors remain unchanged. */ public static @Nullable DynamicMessage messageFromTableRow( SchemaInformation schemaInformation, @@ -1202,7 +1218,7 @@ public static Descriptor wrapDescriptorProto(DescriptorProto descriptorProto) } try { - return builder.build(); + return allowMissingRequiredFields ? builder.buildPartial() : builder.build(); } catch (Exception e) { throw new SchemaDoesntMatchException( "Could convert schema for " + schemaInformation.getFullName(), e); diff --git a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowToStorageApiProtoTest.java b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowToStorageApiProtoTest.java index 90735bae1261..51c185fc7b9e 100644 --- a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowToStorageApiProtoTest.java +++ b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowToStorageApiProtoTest.java @@ -19,6 +19,7 @@ import static org.apache.beam.sdk.io.gcp.bigquery.BigQueryUtils.TIMESTAMP_FORMATTER; import static org.apache.beam.sdk.io.gcp.bigquery.TableRowToStorageApiProto.TYPE_MAP_PROTO_CONVERTERS; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -1903,6 +1904,184 @@ public void testNullRepeatedDescriptorFromTableSchema() throws Exception { assertTrue(repeatednof2.isEmpty()); } + @Test + public void testAllowMissingRequiredFieldsMatchesStrictBuildForInitializedMessage() + throws Exception { + TableSchema tableSchema = requiredStringTableSchema(); + SchemaInformation schemaInformation = SchemaInformation.fromTableSchema(tableSchema); + Descriptor optionalDescriptor = + TableRowToStorageApiProto.getDescriptorFromTableSchema(tableSchema, false, false); + TableRow tableRow = + new TableRow().set("requiredvalue", "present").set("unknownvalue", "preserved"); + TableRow strictUnknownFields = new TableRow(); + TableRow allowMissingUnknownFields = new TableRow(); + + DynamicMessage strictMessage = + TableRowToStorageApiProto.messageFromTableRow( + schemaInformation, + optionalDescriptor, + tableRow, + true, + false, + strictUnknownFields, + null, + -1, + TableRowToStorageApiProto.ErrorCollector.DONT_COLLECT); + DynamicMessage allowMissingMessage = + TableRowToStorageApiProto.messageFromTableRow( + schemaInformation, + optionalDescriptor, + tableRow, + true, + true, + allowMissingUnknownFields, + null, + -1, + TableRowToStorageApiProto.ErrorCollector.DONT_COLLECT); + + assertArrayEquals(strictMessage.toByteArray(), allowMissingMessage.toByteArray()); + assertEquals(strictUnknownFields, allowMissingUnknownFields); + assertEquals(new TableRow().set("unknownvalue", "preserved"), strictUnknownFields); + assertTrue(strictMessage.isInitialized()); + assertTrue(allowMissingMessage.isInitialized()); + } + + @Test + public void testStrictDescriptorRejectsMissingRequiredFieldWhenNotAllowed() throws Exception { + TableSchema tableSchema = requiredStringTableSchema(); + SchemaInformation schemaInformation = SchemaInformation.fromTableSchema(tableSchema); + Descriptor strictDescriptor = + TableRowToStorageApiProto.getDescriptorFromTableSchema(tableSchema, true, false); + + try { + TableRowToStorageApiProto.messageFromTableRow( + schemaInformation, + strictDescriptor, + new TableRow().setF(Collections.emptyList()), + false, + false, + null, + null, + -1, + TableRowToStorageApiProto.ErrorCollector.DONT_COLLECT); + fail("Expected strict descriptor validation to reject the missing field"); + } catch (TableRowToStorageApiProto.SchemaDoesntMatchException e) { + assertEquals( + "Could convert schema for . Exception: " + + "com.google.protobuf.UninitializedMessageException: Message missing required " + + "fields: requiredvalue", + e.getMessage()); + } + } + + @Test + public void testNestedStrictDescriptorPreservesExactFailureChain() throws Exception { + TableSchema tableSchema = + new TableSchema() + .setFields( + ImmutableList.of( + new TableFieldSchema() + .setName("nested") + .setType("STRUCT") + .setFields( + ImmutableList.of( + new TableFieldSchema() + .setName("requiredvalue") + .setType("STRING") + .setMode("REQUIRED"))))); + SchemaInformation schemaInformation = SchemaInformation.fromTableSchema(tableSchema); + Descriptor strictDescriptor = + TableRowToStorageApiProto.getDescriptorFromTableSchema(tableSchema, true, false); + + try { + TableRowToStorageApiProto.messageFromTableRow( + schemaInformation, + strictDescriptor, + new TableRow().set("nested", new TableRow()), + false, + false, + null, + null, + -1, + TableRowToStorageApiProto.ErrorCollector.DONT_COLLECT); + fail("Expected nested strict descriptor validation to reject the missing field"); + } catch (TableRowToStorageApiProto.SchemaDoesntMatchException e) { + assertEquals( + "Problem converting field nested expected type: STRUCT. Exception: " + + "org.apache.beam.sdk.io.gcp.bigquery.TableRowToStorageApiProto$" + + "SchemaMissingRequiredFieldException: Missing required fields: " + + "[nested.requiredvalue]", + e.getMessage()); + Throwable cause = e; + while (cause != null + && !(cause instanceof TableRowToStorageApiProto.SchemaMissingRequiredFieldException)) { + cause = cause.getCause(); + } + assertNotNull(cause); + assertEquals( + Collections.singleton("nested.requiredvalue"), + ((TableRowToStorageApiProto.SchemaMissingRequiredFieldException) cause) + .getMissingFields()); + } + } + + @Test + public void testAllowMissingRequiredFieldsReturnsPartialMessageFromMap() throws Exception { + TableSchema tableSchema = requiredStringTableSchema(); + SchemaInformation schemaInformation = SchemaInformation.fromTableSchema(tableSchema); + Descriptor strictDescriptor = + TableRowToStorageApiProto.getDescriptorFromTableSchema(tableSchema, true, false); + + DynamicMessage message = + TableRowToStorageApiProto.messageFromTableRow( + schemaInformation, + strictDescriptor, + new TableRow(), + false, + true, + null, + null, + -1, + TableRowToStorageApiProto.ErrorCollector.DONT_COLLECT); + + assertFalse(message.isInitialized()); + assertTrue(message.findInitializationErrors().contains("requiredvalue")); + } + + @Test + public void testAllowMissingRequiredFieldsReturnsPartialMessageFromPositionalRow() + throws Exception { + TableSchema tableSchema = requiredStringTableSchema(); + SchemaInformation schemaInformation = SchemaInformation.fromTableSchema(tableSchema); + Descriptor strictDescriptor = + TableRowToStorageApiProto.getDescriptorFromTableSchema(tableSchema, true, false); + + DynamicMessage message = + TableRowToStorageApiProto.messageFromTableRow( + schemaInformation, + strictDescriptor, + new TableRow().setF(Collections.emptyList()), + false, + true, + null, + null, + -1, + TableRowToStorageApiProto.ErrorCollector.DONT_COLLECT); + + assertFalse(message.isInitialized()); + assertTrue(message.findInitializationErrors().contains("requiredvalue")); + } + + private static TableSchema requiredStringTableSchema() { + return new TableSchema() + .setFields( + ImmutableList.of( + new TableFieldSchema() + .setName("requiredvalue") + .setType("STRING") + .setMode("REQUIRED"))); + } + @Test public void testIntegerTypeConversion() throws DescriptorValidationException { String intFieldName = "int_field"; From 932dbe68f7f7961ca0bb9221815b8f42040e87c0 Mon Sep 17 00:00:00 2001 From: Danish Amjad Date: Fri, 23 Oct 2026 02:30:13 +0200 Subject: [PATCH 099/108] [Java][BigQueryIO] Fix BigQueryIO streaming insert silently dropping transient network errors during response body read (#40420) --- .../io/gcp/bigquery/BigQueryServicesImpl.java | 50 +++++++ .../bigquery/BigQueryServicesImplTest.java | 141 ++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java index a921be3700ec..81ba6ce3ca72 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImpl.java @@ -99,6 +99,8 @@ import io.grpc.Status.Code; import io.grpc.protobuf.ProtoUtils; import java.io.IOException; +import java.net.SocketException; +import java.net.SocketTimeoutException; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; @@ -1081,6 +1083,27 @@ public List call() throws Exception { } catch (IOException e) { GoogleJsonError.ErrorInfo errorInfo = getErrorInfo(e); if (errorInfo == null) { + String networkErrorReason = transientNetworkErrorReason(e); + if (networkErrorReason != null) { + // Route all rows in this batch through InsertRetryPolicy so the outer insertAll + // loop handles retries consistently via the pipeline's configured policy. + serviceCallMetric.call(networkErrorReason); + result.updateFailedRpcMetrics(start, start, BigQuerySinkMetrics.UNKNOWN); + LOG.warn( + "BigQuery insertAll transient network error, routing rows to retry policy", e); + List syntheticErrors = new ArrayList<>(); + for (int i = 0; i < rows.size(); i++) { + syntheticErrors.add( + new TableDataInsertAllResponse.InsertErrors() + .setIndex((long) i) + .setErrors( + Collections.singletonList( + new ErrorProto() + .setReason(networkErrorReason) + .setMessage(Strings.nullToEmpty(e.getMessage()))))); + } + return syntheticErrors; + } serviceCallMetric.call(ServiceCallMetric.CANONICAL_STATUS_UNKNOWN); result.updateFailedRpcMetrics(start, start, BigQuerySinkMetrics.UNKNOWN); throw e; @@ -1481,6 +1504,33 @@ public long insertAll( successfulRows); } + /** + * Returns a BigQuery-style error reason string for transient network errors that occur outside + * the scope of {@link com.google.api.client.http.HttpRequest#execute} (e.g. during chunked + * response body reading), or {@code null} if the exception is not a transient network error. + */ + static @Nullable String transientNetworkErrorReason(IOException e) { + Throwable cause = e; + while (cause != null) { + if (cause instanceof SocketTimeoutException) { + return "socketTimeout"; + } + if (cause instanceof SocketException) { + String msg = cause.getMessage(); + if (msg != null) { + if (msg.contains("Connection reset")) { + return "connectionReset"; + } + if (msg.contains("Broken pipe")) { + return "brokenPipe"; + } + } + } + cause = cause.getCause(); + } + return null; + } + protected static GoogleJsonError.@Nullable ErrorInfo getErrorInfo(IOException e) { if (!(e instanceof GoogleJsonResponseException)) { return null; diff --git a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java index 642d35b43905..f9ca23fe2272 100644 --- a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java +++ b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryServicesImplTest.java @@ -84,6 +84,8 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.SocketException; +import java.net.SocketTimeoutException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -822,6 +824,145 @@ public void testInsertQuotaExceededRetry() throws Exception { verifyWriteMetricWasSet("project", "dataset", "table", "quotaexceeded", 1); } + @Test + public void testTransientNetworkErrorReason_socketTimeout() { + assertEquals( + "socketTimeout", + DatasetServiceImpl.transientNetworkErrorReason( + new SocketTimeoutException("Read timed out"))); + } + + @Test + public void testTransientNetworkErrorReason_wrappedSocketTimeout() { + assertEquals( + "socketTimeout", + DatasetServiceImpl.transientNetworkErrorReason( + new IOException("wrapper", new SocketTimeoutException("Read timed out")))); + } + + @Test + public void testTransientNetworkErrorReason_connectionReset() { + assertEquals( + "connectionReset", + DatasetServiceImpl.transientNetworkErrorReason(new SocketException("Connection reset"))); + } + + @Test + public void testTransientNetworkErrorReason_brokenPipe() { + assertEquals( + "brokenPipe", + DatasetServiceImpl.transientNetworkErrorReason(new SocketException("Broken pipe"))); + } + + @Test + public void testTransientNetworkErrorReason_nonTransient() { + assertNull(DatasetServiceImpl.transientNetworkErrorReason(new IOException("Some other error"))); + } + + /** + * Tests that {@link DatasetServiceImpl#insertAll} retries when a SocketTimeoutException occurs + * during response body reading (outside RetryHttpRequestInitializer scope). + */ + @Test + public void testInsertSocketTimeoutRetry() throws Exception { + TableReference ref = + new TableReference().setProjectId("project").setDatasetId("dataset").setTableId("table"); + List> rows = new ArrayList<>(); + rows.add(wrapValue(new TableRow())); + + // First response: 200 OK but content stream throws SocketTimeoutException during body read, + // simulating a timeout mid-chunked-response. Second response succeeds. + setupMockResponses( + response -> { + when(response.getStatusCode()).thenReturn(200); + when(response.getContentType()).thenReturn(Json.MEDIA_TYPE); + when(response.getContent()) + .thenReturn( + new InputStream() { + @Override + public int read() throws IOException { + throw new SocketTimeoutException("Read timed out"); + } + }); + }, + response -> { + when(response.getStatusCode()).thenReturn(200); + when(response.getContentType()).thenReturn(Json.MEDIA_TYPE); + when(response.getContent()).thenReturn(toStream(new TableDataInsertAllResponse())); + }); + + DatasetServiceImpl dataService = + new DatasetServiceImpl(bigquery, PipelineOptionsFactory.create()); + dataService.insertAll( + ref, + rows, + null, + BackOffAdapter.toGcpBackOff(TEST_BACKOFF.backoff()), + TEST_BACKOFF, + new MockSleeper(), + InsertRetryPolicy.alwaysRetry(), + null, + null, + false, + false, + false, + null); + + verifyAllResponsesAreRead(); + expectedLogs.verifyWarn( + "BigQuery insertAll transient network error, routing rows to retry policy"); + } + + /** + * Tests that {@link DatasetServiceImpl#insertAll} throws IOException when SocketTimeoutException + * retries are exhausted via the outer insertAll backoff. + */ + @Test + public void testInsertSocketTimeoutExhausted() throws Exception { + TableReference ref = + new TableReference().setProjectId("project").setDatasetId("dataset").setTableId("table"); + List> rows = new ArrayList<>(); + rows.add(wrapValue(new TableRow())); + + // Single response that times out during body read. With maxRetries(0) on the outer backoff + // there is no retry; the synthetic timeout errors are collected and IOException is thrown. + setupMockResponses( + response -> { + when(response.getStatusCode()).thenReturn(200); + when(response.getContentType()).thenReturn(Json.MEDIA_TYPE); + when(response.getContent()) + .thenReturn( + new InputStream() { + @Override + public int read() throws IOException { + throw new SocketTimeoutException("Read timed out"); + } + }); + }); + + DatasetServiceImpl dataService = + new DatasetServiceImpl(bigquery, PipelineOptionsFactory.create()); + // Rows are routed through InsertRetryPolicy as synthetic "timeout" errors; when the outer + // backoff is exhausted insertAll throws IOException("Insert failed: ..."). + assertThrows( + IOException.class, + () -> + dataService.insertAll( + ref, + rows, + null, + BackOffAdapter.toGcpBackOff(FluentBackoff.DEFAULT.withMaxRetries(0).backoff()), + FluentBackoff.DEFAULT.withMaxRetries(0), + new MockSleeper(), + InsertRetryPolicy.alwaysRetry(), + null, + null, + false, + false, + false, + null)); + } + /** Tests that {@link DatasetServiceImpl#insertAll} can stop quotaExceeded retry attempts. */ @Test public void testInsertStoppedRetry() throws Exception { From 6848f8454daa33e11a002c753d049e8a7384e65f Mon Sep 17 00:00:00 2001 From: ddebowczyk92 Date: Fri, 23 Oct 2026 02:35:57 +0200 Subject: [PATCH 100/108] Handle null split initialization results in Flink lazy source enumerator (#40417) --- .../LazyFlinkSourceSplitEnumerator.java | 9 +++++- .../FlinkSourceSplitEnumeratorTest.java | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/LazyFlinkSourceSplitEnumerator.java b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/LazyFlinkSourceSplitEnumerator.java index 95d789178c0a..8ec469abbd2e 100644 --- a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/LazyFlinkSourceSplitEnumerator.java +++ b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/LazyFlinkSourceSplitEnumerator.java @@ -102,7 +102,14 @@ private void initializeSplits() { this::splitBeamSource, (sourceSplits, error) -> { if (error != null) { - throw new RuntimeException("Failed to start source enumerator.", error); + throw new RuntimeException( + "Failed to initialize splits for bounded source " + beamSource.getClass().getName(), + error); + } + if (sourceSplits == null) { + throw new IllegalStateException( + "Split initialization returned null without an error for bounded source " + + beamSource.getClass().getName()); } pendingSplits.addAll(sourceSplits); splitsInitialized = true; diff --git a/runners/flink/src/test/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/FlinkSourceSplitEnumeratorTest.java b/runners/flink/src/test/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/FlinkSourceSplitEnumeratorTest.java index c4d220d22138..17a14c317bf8 100644 --- a/runners/flink/src/test/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/FlinkSourceSplitEnumeratorTest.java +++ b/runners/flink/src/test/java/org/apache/beam/runners/flink/translation/wrappers/streaming/io/source/FlinkSourceSplitEnumeratorTest.java @@ -30,6 +30,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; import org.apache.beam.runners.core.construction.SerializablePipelineOptions; import org.apache.beam.runners.flink.FlinkPipelineOptions; import org.apache.beam.runners.flink.translation.utils.SerdeUtils; @@ -43,9 +44,11 @@ import org.apache.beam.sdk.options.PipelineOptions; import org.apache.beam.sdk.values.KV; import org.apache.flink.api.connector.source.SplitEnumerator; +import org.apache.flink.api.connector.source.SplitEnumeratorContext; import org.apache.flink.connector.testutils.source.reader.TestingSplitEnumeratorContext; import org.apache.flink.core.io.SimpleVersionedSerializer; import org.junit.Test; +import org.mockito.Mockito; /** Unit tests for the Flink source split enumerators. */ public class FlinkSourceSplitEnumeratorTest { @@ -54,6 +57,31 @@ public class FlinkSourceSplitEnumeratorTest { private static final int SOURCE_PARALLELISM = 2; private static final int REQUESTED_SPLITS = 4; + @Test + @SuppressWarnings("unchecked") + public void testLazyInitializationRejectsNullResultWithoutError() throws Exception { + SplitEnumeratorContext> context = + Mockito.mock(SplitEnumeratorContext.class); + Mockito.doAnswer( + invocation -> { + BiConsumer>, Throwable> callback = + invocation.getArgument(1); + callback.accept(null, null); + return null; + }) + .when(context) + .callAsync(Mockito.any(), Mockito.any()); + try (LazyFlinkSourceSplitEnumerator enumerator = + new LazyFlinkSourceSplitEnumerator<>( + context, + TestEstimatedSizeBoundedSource.create(1L, 1), + FlinkPipelineOptions.defaults(), + 1)) { + IllegalStateException thrown = assertThrows(IllegalStateException.class, enumerator::start); + assertTrue(thrown.getMessage().contains("returned null without an error")); + } + } + @Test public void testSelectsAssignmentModeFromEstimatedSizeAndConfiguration() throws Exception { assertEquals(0L, (long) FlinkPipelineOptions.defaults().getSourceStaticSplitThresholdMb()); From 1ea317548e8dfd8d6eb669cd20bfc9ac5f29c687 Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:55:18 -0400 Subject: [PATCH 101/108] include WRITE_TRUNCATE_DATA as a write disposition in java impl --- .../org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java | 4 ++-- .../sdk/io/gcp/bigquery/UpdateSchemaDestination.java | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java index 2f1d6b702691..5d461ba9b08f 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java @@ -3042,7 +3042,7 @@ public enum WriteDisposition { * Specifies that write should replace a table, but not the table schema. * *

    The replacement may occur in multiple steps - for instance by first removing the - * existing table, then creating a replacement, then filling it in. This is not an atomic + * existing data, replacing the data with a write collection. This is not an atomic * operation, and external programs may see the table in any of these intermediate steps. * *

    Note: This write disposition is only supported for the FILE_LOADS write method. @@ -3984,7 +3984,7 @@ && getStorageApiTriggeringFrequency(bqOptions) != null) { getBigLakeConfiguration() == null, "bigLakeConfiguration is only supported when using STORAGE_WRITE_API or STORAGE_API_AT_LEAST_ONCE."); } else { - WriteDisposition disposition = getWriteDisposition() + WriteDisposition disposition = getWriteDisposition(); if (disposition == WriteDisposition.WRITE_TRUNCATE || disposition == WriteDisposition.WRITE_TRUNCATE_DATA) { LOG.error("The Storage API sink does not support the "+disposition.toString()+" write disposition."); } diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java index 7f801ef38824..d7b658b45ff1 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java @@ -257,9 +257,9 @@ public void finishBundle(FinishBundleContext context) throws Exception { if (!loadConfig .getWriteDisposition() .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE.toString()) - && !loadConfig + && (!loadConfig .getWriteDisposition() - .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE_DATA.toString() + .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE_DATA.toString()) && !loadConfig .getWriteDisposition() .equals(BigQueryIO.Write.WriteDisposition.WRITE_APPEND.toString())) { @@ -286,6 +286,11 @@ public void finishBundle(FinishBundleContext context) throws Exception { return null; } loadConfig.setSchema(schema); + if (!loadConfig + .getWriteDisposition() + .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE_DATA.toString()){ + loadConfig.setSchema(destinationSchema); + } if (timePartitioning != null) { loadConfig.setTimePartitioning(timePartitioning); } From 8acf928f70a1f6a4e7becedfe0ebe42ecac4bebb Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:01:43 -0400 Subject: [PATCH 102/108] add WRITE_TRUNCATE_DATA write_disposition to the bigquery python sdk --- sdks/python/apache_beam/io/gcp/bigquery.py | 5 +++++ .../clients/bigquery/bigquery_v2_messages.py | 15 +++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/sdks/python/apache_beam/io/gcp/bigquery.py b/sdks/python/apache_beam/io/gcp/bigquery.py index f3d64bfcfa6c..b07437855195 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery.py +++ b/sdks/python/apache_beam/io/gcp/bigquery.py @@ -590,6 +590,7 @@ class BigQueryDisposition(object): CREATE_NEVER = 'CREATE_NEVER' CREATE_IF_NEEDED = 'CREATE_IF_NEEDED' + WRITE_TRUNCATE_DATA = 'WRITE_TRUNCATE_DATA' WRITE_TRUNCATE = 'WRITE_TRUNCATE' WRITE_APPEND = 'WRITE_APPEND' WRITE_EMPTY = 'WRITE_EMPTY' @@ -606,6 +607,7 @@ def validate_create(disposition): @staticmethod def validate_write(disposition): values = ( + BigQueryDisposition.WRITE_TRUNCATE_DATA, BigQueryDisposition.WRITE_TRUNCATE, BigQueryDisposition.WRITE_APPEND, BigQueryDisposition.WRITE_EMPTY) @@ -1568,6 +1570,7 @@ def __init__( - BigQueryDisposition.CREATE_NEVER: fail the write if does not exist. write_disposition: A string describing what happens if the table has already some data. Possible values are: + - BigQueryDisposition.WRITE_TRUNCATE_DATA: delete table data but preserve table metadata. - BigQueryDisposition.WRITE_TRUNCATE: delete existing rows. - BigQueryDisposition.WRITE_APPEND: add to existing rows. - BigQueryDisposition.WRITE_EMPTY: fail the write if table not empty. @@ -1612,6 +1615,7 @@ def __init__( self.create_disposition = create_disposition self.write_disposition = write_disposition if write_disposition in (BigQueryDisposition.WRITE_EMPTY, + BigQueryDisposition.WRITE_TRUNCATE_DATA, BigQueryDisposition.WRITE_TRUNCATE): raise ValueError( 'Write disposition %s is not supported for' @@ -2183,6 +2187,7 @@ def __init__( write_disposition (BigQueryDisposition): A string describing what happens if the table has already some data. Possible values are: + * :attr:`BigQueryDisposition.WRITE_TRUNCATE_DATA`: delete table data but preserve table metadata. * :attr:`BigQueryDisposition.WRITE_TRUNCATE`: delete existing rows. * :attr:`BigQueryDisposition.WRITE_APPEND`: add to existing rows. * :attr:`BigQueryDisposition.WRITE_EMPTY`: fail the write if table not diff --git a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py index 3392bdaf9592..8b9fa0f883f4 100644 --- a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py +++ b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py @@ -5440,12 +5440,15 @@ class JobConfigurationTableCopy(_messages.Message): destination table already exists. The following values are supported: * WRITE_TRUNCATE: If the table already exists, BigQuery overwrites the table data and uses the schema and table constraints from the source - table. * WRITE_APPEND: If the table already exists, BigQuery appends the - data to the table. * WRITE_EMPTY: If the table already exists and - contains data, a 'duplicate' error is returned in the job result. The - default value is WRITE_EMPTY. Each action is atomic and only occurs if - BigQuery is able to complete the job successfully. Creation, truncation - and append actions occur as one atomic update upon job completion. + table. * WRITE_TRUNCATE_DATA: If the table already exists, BigQuery + overwrites the table data, but does not use schema and table constraints + from the source table.* WRITE_APPEND: If the table already exists, + BigQuery appends the data to the table. * WRITE_EMPTY: If the table + already exists and contains data, a 'duplicate' error is returned in + the job result. The default value is WRITE_EMPTY. Each action is atomic + and only occurs if BigQuery is able to complete the job successfully. + Creation, truncation and append actions occur as one atomic update + upon job completion. """ class OperationTypeValueValuesEnum(_messages.Enum): From 88f0966af4ecd5bd697bf2872a683e1f3b8e7afc Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:01:43 -0400 Subject: [PATCH 103/108] changes.md --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 9d3f6fbac241..cea23c3cfdb8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -66,7 +66,7 @@ * Support for X source added (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). * (Java) FirestoreIO: added `RpcQosOptions.Builder.withRampupThrottlingDisabled()`, mirroring the existing DatastoreIO option, to opt out of the 500/50/5 write ramp-up ([#27170](https://github.com/apache/beam/issues/27170)). -* Added support for attributing BigQuery API quota and billing to a specific GCP project (quota project): `quota_project_id` parameter in `ReadFromBigQuery` or `--quota_project_id` pipeline option, honored by the Python BigQuery IO transforms (`WriteToBigQuery` with `FILE_LOADS` or `STREAMING_INSERTS`; not yet `STORAGE_WRITE_API`, which is Java-backed), `--bigQueryQuotaProjectId` pipeline option (Java), and `bigqueryio.WithQuotaProject` read/query option (Go) ([#37431](https://github.com/apache/beam/issues/37431)). +* (Python) The BigQuery IO writer now allows the `WRITE_TRUNCATE_DATA` write_disposition* Added support for attributing BigQuery API quota and billing to a specific GCP project (quota project): `quota_project_id` parameter in `ReadFromBigQuery` or `--quota_project_id` pipeline option, honored by the Python BigQuery IO transforms (`WriteToBigQuery` with `FILE_LOADS` or `STREAMING_INSERTS`; not yet `STORAGE_WRITE_API`, which is Java-backed), `--bigQueryQuotaProjectId` pipeline option (Java), and `bigqueryio.WithQuotaProject` read/query option (Go) ([#37431](https://github.com/apache/beam/issues/37431)). ## New Features / Improvements From c276cc5bab45af57efec5538076a45683110a9c3 Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:04:54 -0400 Subject: [PATCH 104/108] revert changes to bigquery_v2_messages --- .../clients/bigquery/bigquery_v2_messages.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py index 8b9fa0f883f4..3392bdaf9592 100644 --- a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py +++ b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py @@ -5440,15 +5440,12 @@ class JobConfigurationTableCopy(_messages.Message): destination table already exists. The following values are supported: * WRITE_TRUNCATE: If the table already exists, BigQuery overwrites the table data and uses the schema and table constraints from the source - table. * WRITE_TRUNCATE_DATA: If the table already exists, BigQuery - overwrites the table data, but does not use schema and table constraints - from the source table.* WRITE_APPEND: If the table already exists, - BigQuery appends the data to the table. * WRITE_EMPTY: If the table - already exists and contains data, a 'duplicate' error is returned in - the job result. The default value is WRITE_EMPTY. Each action is atomic - and only occurs if BigQuery is able to complete the job successfully. - Creation, truncation and append actions occur as one atomic update - upon job completion. + table. * WRITE_APPEND: If the table already exists, BigQuery appends the + data to the table. * WRITE_EMPTY: If the table already exists and + contains data, a 'duplicate' error is returned in the job result. The + default value is WRITE_EMPTY. Each action is atomic and only occurs if + BigQuery is able to complete the job successfully. Creation, truncation + and append actions occur as one atomic update upon job completion. """ class OperationTypeValueValuesEnum(_messages.Enum): From 6e55f625bdcfb78263743a2c85ef1a78ce126967 Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:08:03 -0400 Subject: [PATCH 105/108] trim doc line length --- sdks/python/apache_beam/io/gcp/bigquery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/python/apache_beam/io/gcp/bigquery.py b/sdks/python/apache_beam/io/gcp/bigquery.py index b07437855195..a87261fc8b43 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery.py +++ b/sdks/python/apache_beam/io/gcp/bigquery.py @@ -1570,7 +1570,7 @@ def __init__( - BigQueryDisposition.CREATE_NEVER: fail the write if does not exist. write_disposition: A string describing what happens if the table has already some data. Possible values are: - - BigQueryDisposition.WRITE_TRUNCATE_DATA: delete table data but preserve table metadata. + - BigQueryDisposition.WRITE_TRUNCATE_DATA: delete rows, keep schema. - BigQueryDisposition.WRITE_TRUNCATE: delete existing rows. - BigQueryDisposition.WRITE_APPEND: add to existing rows. - BigQueryDisposition.WRITE_EMPTY: fail the write if table not empty. From a8030f0d1ce51624ef20d426602309305a7784bb Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:05:29 -0400 Subject: [PATCH 106/108] Update CHANGES.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index cea23c3cfdb8..25a3d61841cb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -66,7 +66,11 @@ * Support for X source added (Java/Python) ([#X](https://github.com/apache/beam/issues/X)). * (Java) FirestoreIO: added `RpcQosOptions.Builder.withRampupThrottlingDisabled()`, mirroring the existing DatastoreIO option, to opt out of the 500/50/5 write ramp-up ([#27170](https://github.com/apache/beam/issues/27170)). +<<<<<<< HEAD * (Python) The BigQuery IO writer now allows the `WRITE_TRUNCATE_DATA` write_disposition* Added support for attributing BigQuery API quota and billing to a specific GCP project (quota project): `quota_project_id` parameter in `ReadFromBigQuery` or `--quota_project_id` pipeline option, honored by the Python BigQuery IO transforms (`WriteToBigQuery` with `FILE_LOADS` or `STREAMING_INSERTS`; not yet `STORAGE_WRITE_API`, which is Java-backed), `--bigQueryQuotaProjectId` pipeline option (Java), and `bigqueryio.WithQuotaProject` read/query option (Go) ([#37431](https://github.com/apache/beam/issues/37431)). +======= +* (Python) The BigQuery IO writer now allows the `WRITE_TRUNCATE_DATA` write_disposition ([#40622](https://github.com/apache/beam/pull/40622)). +>>>>>>> d233c0c51f6 (Update CHANGES.md) ## New Features / Improvements From 62b9965b0ee78217a531f34eacf115aae7acb7a6 Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:42:45 -0400 Subject: [PATCH 107/108] add WRITE_TRUNCATE_DATA support to bigqueryIO in java --- .../beam/sdk/io/gcp/bigquery/BigQueryIO.java | 19 +++++++++++++++-- .../gcp/bigquery/UpdateSchemaDestination.java | 3 +++ .../providers/BigQueryWriteConfiguration.java | 3 ++- .../apache_beam/io/gcp/bigquery_file_loads.py | 21 ++++++++++++------- .../clients/bigquery/bigquery_v2_messages.py | 15 +++++++------ 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java index 07fa1a320e92..94050be8ca20 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java @@ -3248,6 +3248,17 @@ public enum WriteDisposition { */ WRITE_TRUNCATE, + /** + * Specifies that write should replace a table, but not the table schema. + * + *

    The replacement may occur in multiple steps - for instance by first removing the + * existing table, then creating a replacement, then filling it in. This is not an atomic + * operation, and external programs may see the table in any of these intermediate steps. + * + *

    Note: This write disposition is only supported for the FILE_LOADS write method. + */ + WRITE_TRUNCATE_DATA, + /** Specifies that rows may be appended to an existing table. */ WRITE_APPEND, @@ -4183,8 +4194,9 @@ && getStorageApiTriggeringFrequency(bqOptions) != null) { getBigLakeConfiguration() == null, "bigLakeConfiguration is only supported when using STORAGE_WRITE_API or STORAGE_API_AT_LEAST_ONCE."); } else { - if (getWriteDisposition() == WriteDisposition.WRITE_TRUNCATE) { - LOG.error("The Storage API sink does not support the WRITE_TRUNCATE write disposition."); + WriteDisposition disposition = getWriteDisposition() + if (disposition == WriteDisposition.WRITE_TRUNCATE || disposition == WriteDisposition.WRITE_TRUNCATE_DATA) { + LOG.error("The Storage API sink does not support the "+disposition.toString()+" write disposition."); } Map bigLakeConfiguration = getBigLakeConfiguration(); if (bigLakeConfiguration != null) { @@ -4439,6 +4451,9 @@ private WriteResult continueExpandTyped( checkArgument( getWriteDisposition() != WriteDisposition.WRITE_TRUNCATE, "WriteDisposition.WRITE_TRUNCATE is not supported for an unbounded PCollection."); + checkArgument( + getWriteDisposition() != WriteDisposition.WRITE_TRUNCATE_DATA, + "WriteDisposition.WRITE_TRUNCATE_DATA is not supported for an unbounded PCollection."); InsertRetryPolicy retryPolicy = MoreObjects.firstNonNull(getFailedInsertRetryPolicy(), InsertRetryPolicy.alwaysRetry()); diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java index 536d318f0213..7f801ef38824 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java @@ -257,6 +257,9 @@ public void finishBundle(FinishBundleContext context) throws Exception { if (!loadConfig .getWriteDisposition() .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE.toString()) + && !loadConfig + .getWriteDisposition() + .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE_DATA.toString() && !loadConfig .getWriteDisposition() .equals(BigQueryIO.Write.WriteDisposition.WRITE_APPEND.toString())) { diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/providers/BigQueryWriteConfiguration.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/providers/BigQueryWriteConfiguration.java index ec59db14501b..ec498c66aacc 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/providers/BigQueryWriteConfiguration.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/providers/BigQueryWriteConfiguration.java @@ -138,7 +138,8 @@ public static Builder builder() { @SchemaFieldDescription( "Specifies the action that occurs if the destination table already exists. " + "The following values are supported: " - + "WRITE_TRUNCATE (overwrites the table data), " + + "WRITE_TRUNCATE (overwrites the table data and schema), " + + "WRITE_TRUNCATE_DATA (overwrites the table data), " + "WRITE_APPEND (append the data to the table), " + "WRITE_EMPTY (job must fail if the table is not empty).") @Pure diff --git a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py index a812467ef18f..8c7737c54704 100644 --- a/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py +++ b/sdks/python/apache_beam/io/gcp/bigquery_file_loads.py @@ -419,7 +419,11 @@ def process(self, element, schema_mod_job_name_prefix): # When writing to normal tables WRITE_TRUNCATE will overwrite the schema but # when writing to a partition, care needs to be taken to update the schema # even on WRITE_TRUNCATE. - if (self._write_disposition not in ('WRITE_TRUNCATE', 'WRITE_APPEND') or + if (self._write_disposition not in ( + 'WRITE_TRUNCATE', + 'WRITE_TRUNCATE_DATA', + 'WRITE_APPEND' + ) or not additional_parameters or not additional_parameters.get("schemaUpdateOptions")): # No need to modify schema of destination table @@ -619,11 +623,12 @@ def _determine_write_disposition(self, copy_to_reference) -> tuple[bool, str]: Determines the write disposition for a BigQuery copy job, based on destination. - When the write_disposition for a job is WRITE_TRUNCATE, multiple copy jobs - to the same destination can interfere with each other, truncate data, and - write to the BigQuery table repeatedly. To prevent this, the first copy job - runs with the user's specified write_disposition, but subsequent jobs must - always use WRITE_APPEND. This ensures that subsequent copy jobs do not + When the write_disposition for a job is WRITE_TRUNCATE or + WRITE_TRUNCATE_DATA, multiple copy jobs to the same destination can + interfere with each other, truncate data, and write to the BigQuery + table repeatedly. To prevent this, the first copy job runs with the + user's specified write_disposition, but subsequent jobs must always + use WRITE_APPEND. This ensures that subsequent copy jobs do not clear out data appended by previous jobs. Args: @@ -1242,7 +1247,9 @@ def _load_data( quota_project_id=self.quota_project_id), schema_mod_job_name_pcv)) - if self.write_disposition in ('WRITE_EMPTY', 'WRITE_TRUNCATE'): + if self.write_disposition in ( + 'WRITE_EMPTY', 'WRITE_TRUNCATE', 'WRITE_TRUNCATE_DATA' + ): # All loads going to the same table must be processed together so that # the truncation happens only once. See # https://github.com/apache/beam/issues/24535. diff --git a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py index 3392bdaf9592..8b9fa0f883f4 100644 --- a/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py +++ b/sdks/python/apache_beam/io/gcp/internal/clients/bigquery/bigquery_v2_messages.py @@ -5440,12 +5440,15 @@ class JobConfigurationTableCopy(_messages.Message): destination table already exists. The following values are supported: * WRITE_TRUNCATE: If the table already exists, BigQuery overwrites the table data and uses the schema and table constraints from the source - table. * WRITE_APPEND: If the table already exists, BigQuery appends the - data to the table. * WRITE_EMPTY: If the table already exists and - contains data, a 'duplicate' error is returned in the job result. The - default value is WRITE_EMPTY. Each action is atomic and only occurs if - BigQuery is able to complete the job successfully. Creation, truncation - and append actions occur as one atomic update upon job completion. + table. * WRITE_TRUNCATE_DATA: If the table already exists, BigQuery + overwrites the table data, but does not use schema and table constraints + from the source table.* WRITE_APPEND: If the table already exists, + BigQuery appends the data to the table. * WRITE_EMPTY: If the table + already exists and contains data, a 'duplicate' error is returned in + the job result. The default value is WRITE_EMPTY. Each action is atomic + and only occurs if BigQuery is able to complete the job successfully. + Creation, truncation and append actions occur as one atomic update + upon job completion. """ class OperationTypeValueValuesEnum(_messages.Enum): From 55d8daeaf168e38612caf2e75f841fb7e604421a Mon Sep 17 00:00:00 2001 From: Gary White Jr <7660110+GaryPWhite@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:55:18 -0400 Subject: [PATCH 108/108] include WRITE_TRUNCATE_DATA as a write disposition in java impl --- .../org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java | 4 ++-- .../sdk/io/gcp/bigquery/UpdateSchemaDestination.java | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java index 94050be8ca20..667285ce6e8b 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryIO.java @@ -3252,7 +3252,7 @@ public enum WriteDisposition { * Specifies that write should replace a table, but not the table schema. * *

    The replacement may occur in multiple steps - for instance by first removing the - * existing table, then creating a replacement, then filling it in. This is not an atomic + * existing data, replacing the data with a write collection. This is not an atomic * operation, and external programs may see the table in any of these intermediate steps. * *

    Note: This write disposition is only supported for the FILE_LOADS write method. @@ -4194,7 +4194,7 @@ && getStorageApiTriggeringFrequency(bqOptions) != null) { getBigLakeConfiguration() == null, "bigLakeConfiguration is only supported when using STORAGE_WRITE_API or STORAGE_API_AT_LEAST_ONCE."); } else { - WriteDisposition disposition = getWriteDisposition() + WriteDisposition disposition = getWriteDisposition(); if (disposition == WriteDisposition.WRITE_TRUNCATE || disposition == WriteDisposition.WRITE_TRUNCATE_DATA) { LOG.error("The Storage API sink does not support the "+disposition.toString()+" write disposition."); } diff --git a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java index 7f801ef38824..d7b658b45ff1 100644 --- a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java +++ b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/bigquery/UpdateSchemaDestination.java @@ -257,9 +257,9 @@ public void finishBundle(FinishBundleContext context) throws Exception { if (!loadConfig .getWriteDisposition() .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE.toString()) - && !loadConfig + && (!loadConfig .getWriteDisposition() - .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE_DATA.toString() + .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE_DATA.toString()) && !loadConfig .getWriteDisposition() .equals(BigQueryIO.Write.WriteDisposition.WRITE_APPEND.toString())) { @@ -286,6 +286,11 @@ public void finishBundle(FinishBundleContext context) throws Exception { return null; } loadConfig.setSchema(schema); + if (!loadConfig + .getWriteDisposition() + .equals(BigQueryIO.Write.WriteDisposition.WRITE_TRUNCATE_DATA.toString()){ + loadConfig.setSchema(destinationSchema); + } if (timePartitioning != null) { loadConfig.setTimePartitioning(timePartitioning); }