-
Notifications
You must be signed in to change notification settings - Fork 19
BqUtils: support a BigQuery quota project override + consolidate client construction #706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
95f3ccf
aaddb79
e066e24
9617bbb
131e971
a1912a4
4d5fadb
ee77f88
2eb82b0
90b8802
7f39220
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,22 @@ | ||
| import datetime | ||
| import itertools | ||
| import os | ||
| import re | ||
| from typing import Iterable, Optional, Tuple, Union | ||
| import time | ||
| from typing import Iterable, Optional, Sequence, Tuple, Union | ||
|
|
||
| import google.api_core.retry | ||
| import google.cloud.bigquery as bigquery | ||
| from google.api_core.client_options import ClientOptions | ||
| from google.api_core.exceptions import NotFound | ||
| from google.cloud.bigquery._helpers import _record_field_to_json | ||
| from google.cloud.bigquery.job import _AsyncJob | ||
| from google.cloud.bigquery.job import LoadJob, _AsyncJob | ||
| from google.cloud.bigquery.table import RowIterator | ||
|
|
||
| from gigl.common import GcsUri, LocalUri, Uri | ||
| from gigl.common.logger import Logger | ||
| from gigl.common.utils.retry import retry | ||
| from gigl.env.constants import GIGL_BIGQUERY_QUOTA_PROJECT_ENV_KEY | ||
| from gigl.src.common.constants.time import DEFAULT_DATE_FORMAT | ||
| from gigl.src.common.utils.time import convert_days_to_ms, current_datetime | ||
|
|
||
|
|
@@ -58,9 +62,44 @@ def _load_file_to_bq_with_retry( | |
|
|
||
|
|
||
| class BqUtils: | ||
| def __init__(self, project: Optional[str] = None) -> None: | ||
| logger.info(f"BqUtils initialized with project: {project}") | ||
| self.__bq_client = bigquery.Client(project=project) | ||
| def __init__( | ||
| self, | ||
| project: Optional[str] = None, | ||
| quota_project_id: Optional[str] = None, | ||
| ) -> None: | ||
| """Initializes a BqUtils instance wrapping a single BigQuery client. | ||
|
|
||
| The quota / billing project for the underlying client is resolved as: | ||
| the explicit ``quota_project_id`` argument if provided, else the | ||
| ``GIGL_BIGQUERY_QUOTA_PROJECT`` environment variable if set to a | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. qq -- how would Realistically, if we don't expect users to use the environment-supported path here, should we only support explicitly provided quota_project_ids for now, and have the fallback directly be the no override? |
||
| non-empty value, else no override (google-auth's default resolution). | ||
|
|
||
| The override scopes only this BigQuery client; other Google clients in | ||
| the process are unaffected. | ||
|
|
||
| Args: | ||
| project (Optional[str]): The GCP project the client acts on | ||
| (default project for datasets, tables, and jobs). | ||
| quota_project_id (Optional[str]): The GCP project billed for | ||
| BigQuery quota / usage for this client. | ||
| """ | ||
| # An empty string (from either the argument or the env var) is treated | ||
| # as "unset" so callers can opt out with an empty value. | ||
| resolved_quota_project_id = quota_project_id or os.environ.get( | ||
| GIGL_BIGQUERY_QUOTA_PROJECT_ENV_KEY | ||
| ) | ||
| client_options = ( | ||
| ClientOptions(quota_project_id=resolved_quota_project_id) | ||
| if resolved_quota_project_id | ||
| else None | ||
| ) | ||
| logger.info( | ||
| f"BqUtils initialized with project: {project}, " | ||
| f"quota project: {resolved_quota_project_id}" | ||
| ) | ||
| self.__bq_client = bigquery.Client( | ||
| project=project, client_options=client_options | ||
| ) | ||
|
|
||
| def create_bq_dataset(self, dataset_id, exists_ok=True) -> None: | ||
| dataset = bigquery.Dataset(dataset_id) | ||
|
|
@@ -412,6 +451,53 @@ def fetch_bq_table_schema(self, bq_table: str) -> dict[str, bigquery.SchemaField | |
| schema_dict = {field.name: field for field in bq_schema} | ||
| return schema_dict | ||
|
|
||
| def load_files_to_bq( | ||
| self, | ||
| source_uris: Union[str, Sequence[str]], | ||
| bq_path: str, | ||
| job_config: bigquery.LoadJobConfig, | ||
| should_run_async: bool = False, | ||
| ) -> LoadJob: | ||
| """Loads one or more GCS files into a BigQuery table with a single load job. | ||
|
|
||
| ``source_uris`` may contain a trailing wildcard (e.g. | ||
| ``gs://bucket/folder/*.avro``) to load every matching file. | ||
|
|
||
| Args: | ||
| source_uris (Union[str, Sequence[str]]): GCS URI(s), optionally with | ||
| a ``*`` wildcard, of the file(s) to load. | ||
| bq_path (str): Destination table path in ``project.dataset.table`` | ||
| format. | ||
| job_config (bigquery.LoadJobConfig): The load job configuration | ||
| (source format, write disposition, schema, ...). | ||
| should_run_async (bool): If True, return immediately after the load | ||
| job is created without waiting for completion. Defaults to False. | ||
|
|
||
| Returns: | ||
| LoadJob: The BigQuery load job. Completed if | ||
| ``should_run_async=False``; possibly still running if | ||
| ``should_run_async=True``. | ||
| """ | ||
| # Timing is measured around the load job here (rather than at the call | ||
| # site) so all callers get consistent load-duration logging. | ||
| start = time.perf_counter() | ||
| load_job = self.__bq_client.load_table_from_uri( | ||
| source_uris=source_uris, | ||
| destination=bq_path, | ||
| job_config=job_config, | ||
| ) | ||
| if should_run_async: | ||
| logger.info( | ||
| f"Started load job {load_job.job_id} for {bq_path}, running asynchronously." | ||
| ) | ||
| else: | ||
| load_job.result() # Waits for the job to complete. | ||
| logger.info( | ||
| f"Loaded {load_job.output_rows:,} rows into {bq_path} in " | ||
| f"{time.perf_counter() - start:.2f} seconds." | ||
| ) | ||
| return load_job | ||
|
|
||
| def load_file_to_bq( | ||
| self, | ||
| source_path: Uri, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import uuid | ||
|
|
||
| import apache_beam as beam | ||
| from absl.testing import absltest | ||
| from apache_beam.options.pipeline_options import GoogleCloudOptions, PipelineOptions | ||
| from apache_beam.testing.util import assert_that, equal_to | ||
| from google.api_core.exceptions import NotFound | ||
|
|
||
| from gigl.common.beam.sharded_read import ( | ||
| BigQueryShardedReadConfig, | ||
| ShardedExportRead, | ||
| ) | ||
| from gigl.env.pipelines_config import get_resource_config | ||
| from gigl.src.common.utils.bq import BqUtils | ||
| from tests.test_assets.test_case import TestCase | ||
|
|
||
| NUM_ROWS = 20 | ||
|
|
||
|
|
||
| class ShardedReadIntegrationTest(TestCase): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, does adding this test belong in this PR? This seems a bit large of a test provided the change we've made to the sharded read file is to just use |
||
| """Integration tests for ShardedExportRead against real BigQuery.""" | ||
|
|
||
| def setUp(self): | ||
| resource_config = get_resource_config() | ||
| test_unique_name = f"gigl_sharded_read_test_{uuid.uuid4().hex}" | ||
|
|
||
| self.bq_utils = BqUtils(project=resource_config.project) | ||
| self.bq_project = resource_config.project | ||
| self.bq_dataset = resource_config.temp_assets_bq_dataset_name | ||
| self.temp_location = ( | ||
| f"{resource_config.temp_assets_regional_bucket_path}/" | ||
| f"sharded_read_test/{test_unique_name}" | ||
| ) | ||
| self.table_path = self.bq_utils.join_path( | ||
| self.bq_project, | ||
| self.bq_dataset, | ||
| test_unique_name, | ||
| ) | ||
|
|
||
| table_path = self.bq_utils.format_bq_path(self.table_path) | ||
| create_query = f""" | ||
| CREATE OR REPLACE TABLE `{table_path}` AS | ||
| SELECT | ||
| id as user_id, | ||
| CONCAT('test_name_', CAST(id AS STRING)) as name | ||
| FROM UNNEST(GENERATE_ARRAY(0, {NUM_ROWS - 1})) as id | ||
| """ | ||
| self.bq_utils.run_query(query=create_query, labels={}) | ||
|
|
||
| def tearDown(self): | ||
| self.bq_utils.delete_bq_table_if_exist( | ||
| bq_table_path=self.table_path, not_found_ok=True | ||
| ) | ||
|
|
||
| def test_reads_all_rows_across_shards(self): | ||
| sharded_read_config = BigQueryShardedReadConfig( | ||
| shard_key="user_id", | ||
| project_id=self.bq_project, | ||
| temp_dataset_name=self.bq_dataset, | ||
| num_shards=2, | ||
| ) | ||
|
|
||
| # Construction validates the shard key against the real table schema. | ||
| sharded_read = ShardedExportRead( | ||
| table_name=self.table_path, | ||
| sharded_read_info=sharded_read_config, | ||
| ) | ||
|
|
||
| # ReadFromBigQuery's EXPORT method stages query results in GCS, so the | ||
| # DirectRunner pipeline needs a project and temp_location to run against. | ||
| options = PipelineOptions() | ||
| google_cloud_options = options.view_as(GoogleCloudOptions) | ||
| google_cloud_options.project = self.bq_project | ||
| google_cloud_options.temp_location = self.temp_location | ||
|
|
||
| # ShardedExportRead flattens every shard, so the read yields one dict per | ||
| # row with the table's column names as keys. We assert that all NUM_ROWS | ||
| # rows are recovered across the shards. | ||
| expected_rows = [ | ||
| {"user_id": i, "name": f"test_name_{i}"} for i in range(NUM_ROWS) | ||
| ] | ||
| with beam.Pipeline(options=options) as pipeline: | ||
| rows = pipeline | sharded_read | ||
| assert_that(rows, equal_to(expected_rows)) | ||
|
|
||
| def test_raises_when_shard_key_is_not_a_column(self): | ||
| sharded_read_config = BigQueryShardedReadConfig( | ||
| shard_key="not_a_column", | ||
| project_id=self.bq_project, | ||
| temp_dataset_name=self.bq_dataset, | ||
| num_shards=2, | ||
| ) | ||
|
|
||
| with self.assertRaises(ValueError): | ||
| ShardedExportRead( | ||
| table_name=self.table_path, | ||
| sharded_read_info=sharded_read_config, | ||
| ) | ||
|
|
||
| def test_raises_when_table_does_not_exist(self): | ||
| missing_table_path = self.bq_utils.join_path( | ||
| self.bq_project, | ||
| self.bq_dataset, | ||
| f"gigl_sharded_read_missing_{uuid.uuid4().hex}", | ||
| ) | ||
| sharded_read_config = BigQueryShardedReadConfig( | ||
| shard_key="user_id", | ||
| project_id=self.bq_project, | ||
| temp_dataset_name=self.bq_dataset, | ||
| num_shards=2, | ||
| ) | ||
|
|
||
| with self.assertRaises(NotFound): | ||
| ShardedExportRead( | ||
| table_name=missing_table_path, | ||
| sharded_read_info=sharded_read_config, | ||
| ) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| absltest.main() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the reason we need this change in this PR? It seems independent to the quota project override goal of the other changes here.