-
Notifications
You must be signed in to change notification settings - Fork 147
[DENG-11196] Port socorro import job from spark #9700
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
Draft
BenWu
wants to merge
1
commit into
main
Choose a base branch
from
benwu/socorro-import
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
60 changes: 60 additions & 0 deletions
60
sql/moz-fx-data-shared-prod/telemetry_derived/socorro_crash_v2/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # socorro_crash_v2 | ||
|
|
||
| Daily import of Socorro crash reports from GCS newline JSON into | ||
| `moz-fx-data-shared-prod.telemetry_derived.socorro_crash_v2`, partitioned by `crash_date`. | ||
|
|
||
| `query.py` loads one day of JSON from | ||
| `gs://moz-fx-socorro-prod-prod-telemetry/v1/crash_report/<yyyymmdd>/` and writes | ||
| the corresponding `crash_date` partition. This replaces the Spark/Dataproc job | ||
| that used to do this (`https://github.com/mozilla/telemetry-airflow/blob/main/jobs/socorro_import_crash_data.py`). | ||
|
|
||
| ## How it works | ||
|
|
||
| 1. Load the day's JSON into a temporary table using a schema derived | ||
| from `schema.yaml` at runtime | ||
| 2. Run `transform.sql` to reshape the temp table into the destination table's | ||
| layout and write the `crash_date` partition with `WRITE_TRUNCATE` | ||
|
|
||
| ## Usage | ||
|
|
||
| ```sh | ||
| python3 query.py --date 2026-01-01 | ||
| ``` | ||
|
|
||
| Options: `--date` (required), `--project`, `--source-bucket`, `--source-prefix`, | ||
| `--destination-dataset`, `--destination-table`, `--dry-run`. `--dry-run` prints | ||
| the planned load (source URI, destination partition) without touching BigQuery. | ||
|
|
||
| ## Migration notes | ||
|
|
||
| The original Spark job read GCS JSON, coerced it into a Spark `StructType` | ||
| derived from the JSON (`telemetry_socorro_crash.json`), and wrote | ||
| partitioned parquet to `gs://moz-fx-data-prod-socorro-data/socorro_crash/v2/`. A | ||
| separate `parquet2bigquery` GKE pod then loaded that parquet into this table. | ||
|
|
||
| Moving the job here removes the Spark cluster, the intermediate parquet, and the | ||
| separate load job. The load is a single BigQuery load plus one transform query. | ||
|
|
||
| ### Schema | ||
|
|
||
| `schema.yaml` is a copy of the current production table schema, so the | ||
| migrated job targets the existing table shape without redefining it. The schemas is based on | ||
| Schema is based on https://github.com/mozilla-services/socorro/blob/main/socorro/schemas/telemetry_socorro_crash.json. | ||
|
|
||
| Differences from the upstream schema: | ||
| - `crash_date` (DATE) is the partition column. It is not present in | ||
| the crash report JSON. The old pipeline derived it from the GCS folder name | ||
| (`crash_date=<yyyymmdd>`); here it comes from `--date`. | ||
| - Arrays are stored as `RECORD<list: ARRAY<RECORD<element>>>` rather than | ||
| plain `REPEATED` fields. This is the encoding Spark's parquet writer | ||
| produced, and downstream consumers (for example the symbolication jobs that | ||
| read `json_dump.modules.list`) depend on it. `transform.sql` rewraps the | ||
| following arrays to match: | ||
| - `additional_minidumps` (element: STRING) | ||
| - `addons` (element: STRING) | ||
| - `json_dump.crashing_thread.frames` | ||
| - `json_dump.modules` | ||
| - `json_dump.threads` (and the nested `frames` inside each thread) | ||
| - `memory_report.reports` | ||
|
|
||
| The upstream schema is unlikely to change so it's static instead of dynamically built. |
25 changes: 18 additions & 7 deletions
25
sql/moz-fx-data-shared-prod/telemetry_derived/socorro_crash_v2/metadata.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,21 @@ | ||
| friendly_name: |- | ||
| Socorro Crash | ||
| friendly_name: Socorro Crash | ||
| description: |- | ||
| Crash reports imported via socorro. | ||
| See https://github.com/mozilla/telemetry-airflow/blob/4050697bd2e283864a9013bd48d439eb66f6d581/dags/socorro_import.py#L105 | ||
| Crash reports imported via Socorro. | ||
|
|
||
| Loaded daily from newline JSON crash reports in GCS | ||
| (gs://moz-fx-socorro-prod-prod-telemetry/v1/crash_report) by query.py. This replaces the | ||
| Spark/Dataproc job in telemetry-airflow that wrote parquet loaded by parquet2bigquery. | ||
| owners: | ||
| - srose@mozilla.com | ||
| # Schema is imported externally via the socorro_import Airflow DAG; deeply nested | ||
| # crash fields are not individually documented. | ||
| - bewu@mozilla.com | ||
| labels: | ||
| incremental: true | ||
| owner1: benwu | ||
| bigquery: | ||
| time_partitioning: | ||
| type: day | ||
| field: crash_date | ||
| require_partition_filter: true | ||
| expiration_days: null | ||
| range_partitioning: null | ||
| clustering: null | ||
| require_column_descriptions: false |
176 changes: 176 additions & 0 deletions
176
sql/moz-fx-data-shared-prod/telemetry_derived/socorro_crash_v2/query.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| #!/usr/bin/env python3 | ||
|
|
||
| """Load a day of Socorro crash reports from GCS JSON into socorro_crash_v2.""" | ||
|
|
||
| import os | ||
| import uuid | ||
| from pathlib import Path | ||
|
|
||
| import click | ||
| from google.cloud import bigquery, storage | ||
|
|
||
| from bigquery_etl.schema import Schema | ||
|
|
||
| THIS_DIR = os.path.dirname(__file__) | ||
| DEFAULT_SCHEMA = os.path.join(THIS_DIR, "schema.yaml") | ||
| TRANSFORM_SQL = os.path.join(THIS_DIR, "transform.sql") | ||
|
|
||
|
|
||
| def load_source_schema(): | ||
| """Build the schema to load the raw JSON with. | ||
|
|
||
| This is the table schema minus crash_date, with the wrapped arrays flattened | ||
| back to their natural JSON shape. It is derived from schema.yaml at runtime | ||
| so the load schema and the destination schema never drift apart. | ||
| """ | ||
| # Use the repo's schema loader so !include-field-description and the other | ||
| # include tags in schema.yaml are resolved | ||
| fields = Schema.from_schema_file(Path(DEFAULT_SCHEMA)).schema["fields"] | ||
| return [ | ||
| bigquery.SchemaField.from_api_repr(_unwrap(field)) | ||
| for field in fields | ||
| if field["name"] != "crash_date" | ||
| ] | ||
|
|
||
|
|
||
| def _unwrap(field): | ||
| """Flatten a wrapped array back to the plain array the JSON contains. | ||
|
|
||
| A field stored as RECORD<list: ARRAY<RECORD<element>>> becomes the plain | ||
| REPEATED field the source JSON actually has, recursively. | ||
| """ | ||
| is_wrapped = field["type"] == "RECORD" and [ | ||
| f["name"] for f in field.get("fields", []) | ||
| ] == ["list"] | ||
| if is_wrapped: | ||
| element = field["fields"][0]["fields"][0] | ||
| if element["type"] == "RECORD": | ||
| inner = [_unwrap(f) for f in element.get("fields", [])] | ||
| return { | ||
| "name": field["name"], | ||
| "type": "RECORD", | ||
| "mode": "REPEATED", | ||
| "fields": inner, | ||
| } | ||
| return {"name": field["name"], "type": element["type"], "mode": "REPEATED"} | ||
| if field.get("fields"): | ||
| return {**field, "fields": [_unwrap(f) for f in field["fields"]]} | ||
| return field | ||
|
|
||
|
|
||
| @click.command(help=__doc__) | ||
| @click.option( | ||
| "--date", | ||
| "date", | ||
| type=click.DateTime(formats=["%Y-%m-%d"]), | ||
| required=True, | ||
| help="Partition date to load, e.g. 2019-08-01.", | ||
| ) | ||
| @click.option("--project", default="moz-fx-data-shared-prod") | ||
| @click.option("--source-bucket", default="moz-fx-socorro-prod-prod-telemetry") | ||
| @click.option( | ||
| "--source-prefix", | ||
| default="v1/crash_report", | ||
| help="Source prefix without the date folder.", | ||
| ) | ||
| @click.option("--destination-dataset", default="telemetry_derived") | ||
| @click.option("--destination-table", default="socorro_crash_v2") | ||
| @click.option( | ||
| "--dry-run", | ||
| is_flag=True, | ||
| help=( | ||
| "Smoke test the load: build the load schema, confirm source objects " | ||
| "exist in GCS, load the temp table, and validate the transform against " | ||
| "it without writing the destination partition." | ||
| ), | ||
| ) | ||
| def main( | ||
| date, | ||
| project, | ||
| source_bucket, | ||
| source_prefix, | ||
| destination_dataset, | ||
| destination_table, | ||
| dry_run, | ||
| ): | ||
| """Load one crash_date partition of GCS JSON into BigQuery.""" | ||
| date = date.date() | ||
|
|
||
| date_nodash = date.strftime("%Y%m%d") | ||
| source_prefix_with_date = f"{source_prefix}/{date_nodash}/" | ||
| source_uri = f"gs://{source_bucket}/{source_prefix_with_date}*" | ||
| partition = ".".join( | ||
| [ | ||
| project, | ||
| destination_dataset, | ||
| f"{destination_table}${date_nodash}", | ||
| ] | ||
| ) | ||
| with open(TRANSFORM_SQL) as f: | ||
| transform = f.read() | ||
|
|
||
| client = bigquery.Client(project) | ||
|
|
||
| if dry_run: | ||
| # Build the load schema locally | ||
| schema = load_source_schema() | ||
| print(f"Load schema built: {len(schema)} top-level fields") | ||
|
|
||
| # Confirm the date folder actually holds objects before loading | ||
| storage_client = storage.Client(project) | ||
| blobs = storage_client.list_blobs( | ||
| source_bucket, prefix=source_prefix_with_date, max_results=1 | ||
| ) | ||
| if next(iter(blobs), None) is None: | ||
| raise click.ClickException(f"No source objects found under {source_uri}") | ||
| print(f"Source objects present under {source_uri}") | ||
| else: | ||
| schema = load_source_schema() | ||
|
|
||
| # Load the raw JSON into a temp table using the natural (unwrapped) schema. | ||
| tmp_table = f"tmp.socorro_crash_{uuid.uuid4().hex[:8]}" | ||
| load_result = client.load_table_from_uri( | ||
| source_uri, | ||
| tmp_table, | ||
| location="US", | ||
| job_config=bigquery.LoadJobConfig( | ||
| schema=schema, | ||
| source_format=bigquery.SourceFormat.NEWLINE_DELIMITED_JSON, | ||
| write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE, | ||
| # Ignore fields present in the JSON but absent from the schema. | ||
| ignore_unknown_values=True, | ||
| ), | ||
| ).result() | ||
| print(f"Loaded {load_result.output_rows} rows into {tmp_table}") | ||
|
|
||
| try: | ||
| query = transform.format(source_table=tmp_table, crash_date=date.isoformat()) | ||
|
|
||
| if dry_run: | ||
| # Dry run the transform against the real temp table without writing | ||
| validate_result = client.query( | ||
| query, | ||
| job_config=bigquery.QueryJobConfig(dry_run=True, use_query_cache=False), | ||
| ) | ||
| print( | ||
| f"Transform validates; would scan " | ||
| f"{validate_result.total_bytes_processed} bytes. " | ||
| f"Skipping write to {partition}." | ||
| ) | ||
| return | ||
|
|
||
| # Transform into the destination shape and write the partition. | ||
| query_result = client.query( | ||
| query, | ||
| job_config=bigquery.QueryJobConfig( | ||
| destination=partition, | ||
| write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE, | ||
| ), | ||
| ).result() | ||
| print(f"Wrote {query_result.total_rows} rows into {partition}") | ||
| finally: | ||
| client.delete_table(tmp_table, not_found_ok=True) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
54 changes: 54 additions & 0 deletions
54
sql/moz-fx-data-shared-prod/telemetry_derived/socorro_crash_v2/transform.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| -- Transform raw Socorro crash JSON (loaded into a temp table with natural | ||
| -- arrays) into the socorro_crash_v2 shape: inject the crash_date partition | ||
| -- column and rewrap arrays as RECORD<list: ARRAY<RECORD<element>>> to match the | ||
| -- encoding the old Spark parquet writer produced. | ||
| -- | ||
| -- {source_table} and {crash_date} are filled in by query.py. | ||
| -- | ||
| -- Every scalar column is carried through by name via the * EXCEPT below; only | ||
| -- the seven wrapped arrays and crash_date are rewritten explicitly. | ||
| SELECT | ||
| DATE("{crash_date}") AS crash_date, | ||
| * EXCEPT (additional_minidumps, addons, json_dump, memory_report), | ||
| STRUCT( | ||
| ARRAY(SELECT AS STRUCT element FROM UNNEST(additional_minidumps) AS element) AS list | ||
| ) AS additional_minidumps, | ||
| STRUCT(ARRAY(SELECT AS STRUCT element FROM UNNEST(addons) AS element) AS list) AS addons, | ||
| ( | ||
| SELECT AS STRUCT | ||
| json_dump.* EXCEPT (crashing_thread, modules, threads), | ||
| STRUCT( | ||
| json_dump.crashing_thread.* EXCEPT (frames), | ||
| STRUCT( | ||
| ARRAY( | ||
| SELECT AS STRUCT | ||
| element | ||
| FROM | ||
| UNNEST(json_dump.crashing_thread.frames) AS element | ||
| ) AS list | ||
| ) AS frames | ||
| ) AS crashing_thread, | ||
| STRUCT( | ||
| ARRAY(SELECT AS STRUCT element FROM UNNEST(json_dump.modules) AS element) AS list | ||
| ) AS modules, | ||
| STRUCT( | ||
| ARRAY( | ||
| SELECT AS STRUCT | ||
| thread.* EXCEPT (frames), | ||
| STRUCT( | ||
| ARRAY(SELECT AS STRUCT element FROM UNNEST(thread.frames) AS element) AS list | ||
| ) AS frames | ||
| FROM | ||
| UNNEST(json_dump.threads) AS thread | ||
| ) AS list | ||
| ) AS threads | ||
| ) AS json_dump, | ||
| ( | ||
| SELECT AS STRUCT | ||
| memory_report.* EXCEPT (reports), | ||
| STRUCT( | ||
| ARRAY(SELECT AS STRUCT element FROM UNNEST(memory_report.reports) AS element) AS list | ||
| ) AS reports | ||
| ) AS memory_report | ||
| FROM | ||
| `{source_table}` | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
issue: This
* EXCEPT (...)+ append pattern reorders the output columns relative toschema.yaml.additional_minidumps(pos 9),addons(pos 10),json_dump, andmemory_reportare removed from their positions in the middle of the schema and re-emitted at the very end, while everything after them shifts up. BigQuery matches query results to an existing partitioned table (table$YYYYMMDD) by ordinal position, not by name, and a partition-decorator write cannot alter the table schema. So at the position where the table expectsadditional_minidumps(RECORD) the query now suppliesaddons_checked(BOOLEAN), and the write to the partition will fail with a schema mismatch.Note this won't be caught by
--dry-run: the dry-runQueryJobConfigsets nodestination, so it validates the query in isolation and never compares against the destination table's column order.Emit the columns in
schema.yamlorder — either rewrap each array in place instead ofEXCEPT-and-append, or wrap the whole thing in an outerSELECTthat projects the columns in the table's order.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.
I'm not sure if this is true because we have a lot of ETL with non-matching schema order, but I'll test if I can get access to the source data.