From 3889477e216771947250b7a8fc632b6c301e923b Mon Sep 17 00:00:00 2001 From: HansMarcus01 Date: Tue, 18 Aug 2026 23:26:35 -0600 Subject: [PATCH 1/2] [GSoC-273] Fixing the github action Unmanaged Service Account Keys (#39177) * Fixing the execution errors of the GitHub action, and adding the report for service accounts that are not yet found in keys.yaml * Feat: Enables the creation of reports for non-administered users and their roles within the GCP environment, eliminates redundant code, and updates the documentation. * Fix: Clean up the code and correct the brute-force approach * Fixing the action that handles assigning permissions to users by adjusting the correct version of hashicorp/terraform and avoiding the permissions error * Delete sdks/python/apache_beam/io/gcp/TestPubSubCOntext.md * Delete sdks/python/apache_beam/examples/report.md * Fix: correctly scheduling the action to run on Monday mornings and ensuring that unmanaged keys are reported for both authorized and unauthorized service accounts. * Replaced generic 'SECURITY ALERT' strings with specific '[IAC_DRIFT_IAM_USER]' and '[IAC_DRIFT_SA_KEY]' tags in the enforcement scripts. This change allows for better filtering of notifications, reducing alert fatigue, and clearly distinguishing between routine infrastructure drift and actual critical security vulnerabilities. * Add the constant declarations and change the issue titles to clarify the reported problem. --- .github/workflows/README.md | 1 - ...beam_Infrastructure_AuditUnmanagedKeys.yml | 76 ----------------- .../beam_Infrastructure_PolicyEnforcer.yml | 13 +-- infra/enforcement/README.md | 18 ++-- infra/enforcement/account_keys.py | 76 +++++++++++------ infra/enforcement/iam.py | 83 ++++++++++++++----- infra/enforcement/sending.py | 57 ++++++++++--- 7 files changed, 173 insertions(+), 151 deletions(-) delete mode 100644 .github/workflows/beam_Infrastructure_AuditUnmanagedKeys.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 41287872ee25..3ff1aa5a10b4 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -556,4 +556,3 @@ PostCommit Jobs run in a schedule against master branch and generally do not get | [ Modify the GCP User Roles according to the infra/users.yml file ](https://github.com/apache/beam/actions/workflows/beam_Infrastructure_UsersPermissions.yml) | N/A | [![.github/workflows/beam_Infrastructure_UsersPermissions.yml](https://github.com/apache/beam/actions/workflows/beam_Infrastructure_UsersPermissions.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_Infrastructure_UsersPermissions.yml?query=event%3Aschedule) | | [ Service Account Keys Management ](https://github.com/apache/beam/actions/workflows/beam_Infrastructure_ServiceAccountKeys.yml) | N/A | [![.github/workflows/beam_Infrastructure_ServiceAccountKeys.yml](https://github.com/apache/beam/actions/workflows/beam_Infrastructure_ServiceAccountKeys.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_Infrastructure_ServiceAccountKeys.yml?query=event%3Aschedule) | | [ Upgrade GCP Libraries BOM ](https://github.com/apache/beam/actions/workflows/beam_Upgrade_GCP_BOM.yml) | N/A | [![.github/workflows/beam_Upgrade_GCP_BOM.yml](https://github.com/apache/beam/actions/workflows/beam_Upgrade_GCP_BOM.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_Upgrade_GCP_BOM.yml?query=event%3Aschedule) | -| [ Unmanaged Service Accounts Keys Audit ](https://github.com/apache/beam/actions/workflows/beam_Infrastructure_AuditUnmanagedKeys.yml) | N/A | [![.github/workflows/beam_Infrastructure_AuditUnmanagedKeys.yml](https://github.com/apache/beam/actions/workflows/beam_Infrastructure_AuditUnmanagedKeys.yml/badge.svg?event=schedule)](https://github.com/apache/beam/actions/workflows/beam_Infrastructure_AuditUnmanagedKeys.yml?query=event%3Aschedule) | diff --git a/.github/workflows/beam_Infrastructure_AuditUnmanagedKeys.yml b/.github/workflows/beam_Infrastructure_AuditUnmanagedKeys.yml deleted file mode 100644 index 37d6ecbd362c..000000000000 --- a/.github/workflows/beam_Infrastructure_AuditUnmanagedKeys.yml +++ /dev/null @@ -1,76 +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. - -# This workflow works with the GCP security log analyzer to -# generate weekly security reports and initialize log sinks - -name: Unmanaged Service Accounts Keys Audit - -on: - workflow_dispatch: - schedule: - # Every day at 00:00 UTC - - cron: '0 0 * * *' - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: read - issues: write - id-token: write - -jobs: - beam_UnmanagedKeysAudit: - name: Audit Unmanaged Service Account Keys - runs-on: [self-hosted, ubuntu-24.04, main] - timeout-minutes: 30 - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: true - - - name: Setup gcloud - uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db - - - name: Setup Python - uses: actions/setup-python@v7 - with: - python-version: '3.13' - - - name: Install Python dependencies - working-directory: ./infra/enforcement - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run Unmanaged Service Account Keys Audit - working-directory: ./infra/enforcement - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - SMTP_SERVER: smtp.gmail.com - SMTP_PORT: 465 - EMAIL_ADDRESS: ${{ secrets.ISSUE_REPORT_SENDER_EMAIL_ADDRESS }} - EMAIL_PASSWORD: ${{ secrets.ISSUE_REPORT_SENDER_EMAIL_PASSWORD }} - EMAIL_RECIPIENT: "dev@beam.apache.org" - run: python account_keys.py --action announce - - - - diff --git a/.github/workflows/beam_Infrastructure_PolicyEnforcer.yml b/.github/workflows/beam_Infrastructure_PolicyEnforcer.yml index 52f851d2cdaa..730d45cf992f 100644 --- a/.github/workflows/beam_Infrastructure_PolicyEnforcer.yml +++ b/.github/workflows/beam_Infrastructure_PolicyEnforcer.yml @@ -35,6 +35,7 @@ concurrency: permissions: contents: read issues: write + id-token: write jobs: beam_Infrastructure_PolicyEnforcer: @@ -45,21 +46,21 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - + - name: Setup Python uses: actions/setup-python@v7 with: python-version: '3.13' - + - name: Install Python dependencies working-directory: ./infra/enforcement run: | python -m pip install --upgrade pip pip install -r requirements.txt - + - name: Setup gcloud uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db - + - name: Run IAM Policy Enforcement working-directory: ./infra/enforcement env: @@ -70,7 +71,7 @@ jobs: EMAIL_ADDRESS: ${{ secrets.ISSUE_REPORT_SENDER_EMAIL_ADDRESS }} EMAIL_PASSWORD: ${{ secrets.ISSUE_REPORT_SENDER_EMAIL_PASSWORD }} EMAIL_RECIPIENT: "dev@beam.apache.org" - run: python iam.py --action print + run: python iam.py --action announce - name: Run Account Keys Policy Enforcement working-directory: ./infra/enforcement @@ -82,4 +83,4 @@ jobs: EMAIL_ADDRESS: ${{ secrets.ISSUE_REPORT_SENDER_EMAIL_ADDRESS }} EMAIL_PASSWORD: ${{ secrets.ISSUE_REPORT_SENDER_EMAIL_PASSWORD }} EMAIL_RECIPIENT: "dev@beam.apache.org" - run: python account_keys.py --action print + run: python account_keys.py --action announce diff --git a/infra/enforcement/README.md b/infra/enforcement/README.md index 6d883f7e6806..b92e5f7e1802 100644 --- a/infra/enforcement/README.md +++ b/infra/enforcement/README.md @@ -134,16 +134,18 @@ The enforcement tools are integrated with GitHub Actions to provide automated co ### Workflow Configuration -The repository includes workflows for different security domains: -- **IAM Policy Enforcer** (`.github/workflows/beam_Infrastructure_PolicyEnforcer.yml`): Runs weekly on Mondays at 9:00 AM UTC. -- **Unmanaged Keys Audit** (`.github/workflows/beam_Infrastructure_AuditUnmanagedKeys.yml`): Runs daily at 00:00 UTC. It manages the continuous execution of the `account_keys.py` script to swiftly detect rogue service account keys generated outside the official rotation system. -- **Manual trigger**: Can be triggered manually via `workflow_dispatch` -- **Actions**: Runs both IAM and Account Keys enforcement with the `announce` action +The enforcement tools are consolidated into a single daily workflow (`.github/workflows/beam_Infrastructure_PolicyEnforcer.yml`) that runs automatically at 00:00 UTC. + +This unified workflow executes both security domains sequentially: +- **IAM Policy Enforcement:** Validates user bindings against the defined policies. +- **Unmanaged Keys Audit:** Detects rogue service account keys generated outside the official rotation system. **Note**: -- The email service is configured to use gmail -- The recipient email is set to `dev@beam.apache.org` for Apache Beam project notifications -- The `GITHUB_TOKEN` is automatically provided by GitHub Actions and doesn't need to be configured manually +- **Manual trigger**: The workflow can also be triggered manually via `workflow_dispatch`. +- **Actions**: It executes the respective Python scripts using the `announce` action. +- The email service is configured to use gmail. +- The recipient email is set to `dev@beam.apache.org` for Apache Beam project notifications. +- The `GITHUB_TOKEN` is automatically provided by GitHub Actions and doesn't need to be configured manually. ## Account Keys diff --git a/infra/enforcement/account_keys.py b/infra/enforcement/account_keys.py index 56ccbf654b03..a1248e3ee09c 100644 --- a/infra/enforcement/account_keys.py +++ b/infra/enforcement/account_keys.py @@ -19,6 +19,7 @@ import yaml import argparse import os +from datetime import datetime, timezone from typing import List, Dict, TypedDict, Optional from google.cloud import secretmanager from google.cloud import iam_admin_v1 @@ -26,6 +27,7 @@ from sending import SendingClient SECRET_MANAGER_LABEL = "beam-infra-secret-manager" +IAC_DRIFT_SA_KEY = "IAC_DRIFT_SA_KEY" class AuthorizedUser(TypedDict): email: str @@ -312,22 +314,24 @@ def check_compliance(self) -> List[str]: # Check that all service accounts that exist are declared for service_account in live_service_accounts: + if self._denormalize_account_email(service_account) not in [account["account_id"] for account in file_service_accounts]: msg = f"Service account '{service_account}' is not declared in the service account keys file." compliance_issues.append(msg) self.logger.warning(msg) - else: - iam_keys = self._get_user_managed_keys_from_iam(service_account) - if iam_keys: - secret_name = f"{self._denormalize_account_email(service_account)}-key" - legal_keys = [] - if secret_name in managed_secrets: - legal_keys = self._get_verified_keys_from_secret_manager(secret_name) - unmanaged_keys = set(iam_keys) - set(legal_keys) - for unmanaged_key in unmanaged_keys: - msg = f"SECURITY ALERT: Unmanaged key '{unmanaged_key}' detected on account '{service_account}'. This key was created outside of Beam's service account management system. " - compliance_issues.append(msg) - self.logger.warning(msg) + + iam_keys = self._get_user_managed_keys_from_iam(service_account) + + if iam_keys: + secret_name = f"{self._denormalize_account_email(service_account)}-key" + legal_keys = [] + if secret_name in managed_secrets: + legal_keys = self._get_verified_keys_from_secret_manager(secret_name) + unmanaged_keys = set(iam_keys) - set(legal_keys) + for unmanaged_key in unmanaged_keys: + msg = f"IAC_DRIFT_SA_KEY: Unmanaged key '{unmanaged_key}' detected on account '{service_account}'. This key was created outside of Beam's service account management system. " + compliance_issues.append(msg) + self.logger.warning(msg) extracted_secrets = [f"{self._denormalize_account_email(account['account_id'])}-key" for account in file_service_accounts] @@ -376,13 +380,14 @@ def create_announcement(self, recipient: str) -> None: self.logger.info("No compliance issues found, no announcement will be created.") return - unmanaged_keys_issues = [issue for issue in diff if "SECURITY ALERT" in issue] - general_issues = [issue for issue in diff if "SECURITY ALERT" not in issue] + unmanaged_keys_issues = [issue for issue in diff if IAC_DRIFT_SA_KEY in issue] + general_issues = [issue for issue in diff if IAC_DRIFT_SA_KEY not in issue] if general_issues: self.logger.info(f"Found {len(general_issues)} general compliance issues. Triggering announcement...") - title = f"Account Keys Compliance Issue Detected" - body = f"Account keys for project {self.project_id} are not compliant with the defined policies on {self.service_account_keys_file}\n\n" + title = f"[{IAC_DRIFT_SA_KEY}] Action Required: Unauthorized Service Accounts Detected" + body = f"Unauthorized Service Accounts Report\n\n" + body += f"Account keys for project {self.project_id} are not compliant with the defined policies on {self.service_account_keys_file}\n\n" for issue in general_issues: body += f"- {issue}\n" @@ -406,23 +411,44 @@ def print_announcement(self, recipient: str) -> None: """ if not self.sending_client: raise ValueError("SendingClient is required for printing announcements") - + diff = self.check_compliance() if not diff: self.logger.info("No compliance issues found, no announcement will be printed.") return - title = f"Account Keys Compliance Issue Detected" - body = f"Account keys for project {self.project_id} are not compliant with the defined policies on {self.service_account_keys_file}\n\n" - for issue in diff: - body += f"- {issue}\n" + unmanaged_keys_issues = [issue for issue in diff if IAC_DRIFT_SA_KEY in issue] + general_issues = [issue for issue in diff if IAC_DRIFT_SA_KEY not in issue] - announcement = f"Dear team,\n\nThis is an automated notification about compliance issues detected in the Account Keys policy for project {self.project_id}.\n\n" - announcement += f"We found {len(diff)} compliance issue(s) that need your attention.\n" - announcement += f"\nPlease check the GitHub issue for detailed information and take appropriate action to resolve these compliance violations." + if general_issues: + self.logger.info("Printing general compliance announcement...") + title = f"[IAC_DRIFT_SA_KEY] Action Required: Unauthorized Service Accounts Detected" + body = f"Unauthorized Service Accounts Report\n\n" + body += f"Account keys for project {self.project_id} are not compliant with the defined policies on {self.service_account_keys_file}\n\n" + for issue in general_issues: + body += f"- {issue}\n" - self.sending_client.print_announcement(title, body, recipient, announcement) + announcement = f"Dear team,\n\nThis is an automated notification about compliance issues detected in the Account Keys policy for project {self.project_id}.\n\n" + announcement += f"We found {len(general_issues)} compliance issue(s) that need your attention.\n" + announcement += f"\nPlease check the GitHub issue for detailed information and take appropriate action to resolve these compliance violations." + + self.sending_client.print_announcement(title, body, recipient, announcement) + + if unmanaged_keys_issues: + self.logger.info("Printing security dashboard update for unmanaged keys...") + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + print("\n" + "="*60) + print("SIMULATING GITHUB SECURITY ISSUE CREATION/UPDATE") + print("="*60) + print(f"Title: [{IAC_DRIFT_SA_KEY}] Action Required: Unmanaged Service Account Keys Detected\n") + print(f"Body:\n### Unmanaged Keys Audit Report ({timestamp})") + print(f"The following unauthorized or unmanaged keys were detected in `{self.project_id}`:\n") + for issue in unmanaged_keys_issues: + print(f"- {issue}") + print("\n*Please investigate and revoke these keys if they are not part of the official rotation system.*\n") + print("### History\n
\nClick to expand\n\n[... Previous reports would be collapsed here ...]\n
") + print("="*60 + "\n") def generate_compliance(self) -> None: """ diff --git a/infra/enforcement/iam.py b/infra/enforcement/iam.py index c4c65c7c679c..14dbf455a256 100644 --- a/infra/enforcement/iam.py +++ b/infra/enforcement/iam.py @@ -21,10 +21,12 @@ import yaml from google.api_core import exceptions from google.cloud import resourcemanager_v3 -from typing import Optional, List, Dict, Tuple +from typing import Optional, List, Dict from sending import SendingClient +from datetime import datetime, timezone CONFIG_FILE = "config.yml" +IAC_DRIFT_IAM_USER = "IAC_DRIFT_IAM_USER" class IAMPolicyComplianceChecker: @@ -214,7 +216,7 @@ def check_compliance(self) -> List[str]: self.logger.info(error_msg) raise RuntimeError(error_msg) - differences = [] + differences = [] all_emails = set(current_users.keys()) | set(existing_users.keys()) @@ -223,7 +225,7 @@ def check_compliance(self) -> List[str]: existing_user = existing_users.get(email) if current_user and not existing_user: - differences.append(f"User {email} not found in existing policy.") + differences.append(f"IAC_DRIFT_IAM_USER: Unauthorized user '{email}' detected in GCP but not found in existing policy.") elif not current_user and existing_user: differences.append(f"User {email} found in policy file but not in GCP.") elif current_user and existing_user: @@ -247,51 +249,86 @@ def create_announcement(self, recipient: str) -> None: """ if not self.sending_client: raise ValueError("SendingClient is required for creating announcements") - diff = self.check_compliance() if not diff: self.logger.info("No compliance issues found, no announcement will be created.") return - title = f"IAM Policy Non-Compliance Detected" - body = f"IAM policy for project {self.project_id} is not compliant with the defined policies on {self.users_file}\n\n" - for issue in diff: - body += f"- {issue}\n" + iam_drift_issues = [issue for issue in diff if IAC_DRIFT_IAM_USER in issue] + general_issues = [issue for issue in diff if IAC_DRIFT_IAM_USER not in issue] + + if general_issues: + self.logger.info(f"Found {len(general_issues)} general IAM compliance issues. Triggering announcement...") + title = f"IAM Policy Non-Compliance Detected" + body = f"IAM policy for project {self.project_id} is not compliant with the defined policies on {self.users_file}\n\n" + for issue in general_issues: + body += f"- {issue}\n" + + announcement = f"Dear team,\n\nThis is an automated notification about compliance issues detected in the IAM policy for project {self.project_id}.\n\n" + announcement += f"We found {len(general_issues)} compliance issue(s) that need your attention.\n" + announcement += f"\nPlease check the GitHub issue for detailed information and take appropriate action to resolve these compliance violations." + + self.sending_client.create_announcement(title, body, recipient, announcement) - announcement = f"Dear team,\n\nThis is an automated notification about compliance issues detected in the IAM policy for project {self.project_id}.\n\n" - announcement += f"We found {len(diff)} compliance issue(s) that need your attention.\n" - announcement += f"\nPlease check the GitHub issue for detailed information and take appropriate action to resolve these compliance violations." + if iam_drift_issues: + self.logger.info(f"Found {len(iam_drift_issues)} critical IAM security alerts. Dispatching to GitHub security issue...") + title = f"[{IAC_DRIFT_IAM_USER}] Action Required: Unauthorized IAM Users Detected" + body = f"Critical security violations detected in IAM policies for project {self.project_id}:\n\n" + for issue in iam_drift_issues: + body += f"- {issue}\n" - self.sending_client.create_announcement(title, body, recipient, announcement) + announcement = f"URGENT: Dear team,\n\nThis is an automated security alert regarding unauthorized IAM access in project {self.project_id}.\n\n" + announcement += f"We found {len(iam_drift_issues)} critical security alert(s) that require IMMEDIATE attention.\n" + announcement += f"\nPlease check the GitHub issue for detailed information and revoke unauthorized access immediately." + + self.sending_client.create_announcement(title, body, recipient, announcement) def print_announcement(self, recipient: str) -> None: """ Prints announcement details instead of sending them (for testing purposes). - + Args: recipient (str): The email address of the announcement recipient. """ if not self.sending_client: raise ValueError("SendingClient is required for printing announcements") - + diff = self.check_compliance() if not diff: self.logger.info("No compliance issues found, no announcement will be printed.") return - title = f"IAM Policy Non-Compliance Detected" - body = f"IAM policy for project {self.project_id} is not compliant with the defined policies on {self.users_file}\n\n" - for issue in diff: - body += f"- {issue}\n" + iam_drift_issues = [issue for issue in diff if IAC_DRIFT_IAM_USER in issue] + general_issues = [issue for issue in diff if IAC_DRIFT_IAM_USER not in issue] - announcement = f"Dear team,\n\nThis is an automated notification about compliance issues detected in the IAM policy for project {self.project_id}.\n\n" - announcement += f"We found {len(diff)} compliance issue(s) that need your attention.\n" - announcement += f"\nPlease check the GitHub issue for detailed information and take appropriate action to resolve these compliance violations." + if general_issues: + self.logger.info(f"Found {len(general_issues)} general IAM compliance issues. Printing announcement...") + title = f"IAM Policy Non-Compliance Detected" + body = f"IAM policy for project {self.project_id} is not compliant with the defined policies on {self.users_file}\n\n" + for issue in general_issues: + body += f"- {issue}\n" + + announcement = f"Dear team,\n\nThis is an automated notification about compliance issues detected in the IAM policy for project {self.project_id}.\n\n" + announcement += f"We found {len(general_issues)} compliance issue(s) that need your attention.\n" + announcement += f"\nPlease check the GitHub issue for detailed information and take appropriate action to resolve these compliance violations." + + self.sending_client.print_announcement(title, body, recipient, announcement) + + if iam_drift_issues: + self.logger.info("Printing security dashboard update for IAM vulnerabilities...") + title = f"[{IAC_DRIFT_IAM_USER}] Action Required: Unauthorized IAM Users Detected" + body = f"Critical security violations detected in IAM policies for project {self.project_id}:\n\n" + for issue in iam_drift_issues: + body += f"- {issue}\n" + + announcement = f"URGENT: Dear team,\n\nThis is an automated security alert regarding unauthorized IAM access in project {self.project_id}.\n\n" + announcement += f"We found {len(iam_drift_issues)} critical security alert(s) that require IMMEDIATE attention.\n" + announcement += f"\nPlease check the GitHub issue for detailed information and revoke unauthorized access immediately." + + self.sending_client.print_announcement(title, body, recipient, announcement) - self.sending_client.print_announcement(title, body, recipient, announcement) - def generate_compliance(self) -> None: """ Modifies the users file to match the current IAM policy. diff --git a/infra/enforcement/sending.py b/infra/enforcement/sending.py index bd9787b6ce87..9d24a816fbc3 100644 --- a/infra/enforcement/sending.py +++ b/infra/enforcement/sending.py @@ -18,6 +18,7 @@ import smtplib, ssl from typing import List, Optional from dataclasses import dataclass +from datetime import datetime, timezone @dataclass class GitHubIssue: @@ -229,7 +230,7 @@ def report_unmanaged_keys(self, project_id: str, compilance_issues: List[str]) - issue_title = "[SECURITY] Action Required: Unmanaged Service Account Keys Detected" #markdown body - timestamp = __import__("datetime").datetime.now(__import__("datetime").timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") new_report = f"### Unmanaged Keys Audit Report ({timestamp})\n" new_report += f"The following unauthorized or unmanaged keys were detected in `{project_id}`:\n\n" @@ -248,10 +249,13 @@ def report_unmanaged_keys(self, project_id: str, compilance_issues: List[str]) - if history_marker in old_body: # If history already exists, append the new report to it - headed = old_body.split(history_marker) + headed = old_body.split(history_marker, 1) last_report = headed[0].strip() old_history = headed[1].replace("", "").strip() + if old_history.endswith(""): + old_history = old_history[:-10].rstrip() + combined_history = f"{last_report}\n\n---\n\n{old_history}" else: combined_history = old_body.strip() @@ -294,18 +298,40 @@ def create_announcement(self, title: str, body: str, recipient: str, announcemen """ open_issues = self._get_open_issues(title) open_issues.sort(key=lambda x: x.updated_at, reverse=True) + + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + new_report = f"### Compliance Audit Report ({timestamp})\n{body}" + if open_issues: - self.logger.info(f"Issue with title '{title}' already exists: #{open_issues[0].number}") - announcement += f"\n\nRelated GitHub Issue: {open_issues[0].html_url}" + target_issue = open_issues[0] + self.logger.info(f"Issue with title '{title}' already exists: #{target_issue.number}") + announcement += f"\n\nRelated GitHub Issue: {target_issue.html_url}" + + old_body = target_issue.body or "" + history_marker = "### History\n
\nClick to expand\n\n" - if open_issues[0].body != body: - self.logger.info(f"Updating body of issue #{open_issues[0].number}") - self.update_issue_body(open_issues[0].number, body) + if history_marker in old_body: + # If history already exists, append the new report to it + headed = old_body.split(history_marker, 1) + last_report = headed[0].strip() + old_history = headed[1].rstrip() + + if old_history.endswith("
"): + old_history = old_history[:-10].rstrip() + + combined_history = f"{last_report}\n\n---\n\n{old_history}" else: - self.logger.info(f"No changes detected for issue #{open_issues[0].number}") + # First time updating, turn the entire old body into history + combined_history = old_body.strip() + + final_body = f"{new_report}\n\n{history_marker}{combined_history}\n" + + self.logger.info(f"Appending report and archiving history to existing issue #{target_issue.number}") + self.update_issue_body(target_issue.number, final_body) self._send_email(title, announcement, recipient) else: - new_issue = self.create_issue(title, body) + self.logger.info(f"Creating new compliance issue for: {title}") + new_issue = self.create_issue(title, new_report) announcement += f"\n\nRelated GitHub Issue: {new_issue.html_url}" self._send_email(title, announcement, recipient) @@ -319,6 +345,13 @@ def print_announcement(self, title: str, body: str, recipient: str, announcement print(f"Recipient: {recipient}") print(f"Announcement: {announcement}") - print("\nSimulating GitHub issue creation...") - print(f"Title: {title}") - print(f"Body: {body}") + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + print("\n" + "="*60) + print("SIMULATING GITHUB GENERAL ISSUE CREATION/UPDATE") + print("="*60) + print(f"Title: {title}\n") + print(f"Body:\n### Compliance Audit Report ({timestamp})") + print(body) + print("### History\n
\nClick to expand\n\n[... Previous reports would be collapsed here ...]\n
") + print("="*60 + "\n") \ No newline at end of file From 6356a3c3d28e700c4557dce13ffb228ad793a393 Mon Sep 17 00:00:00 2001 From: Arun Pandian Date: Wed, 19 Aug 2026 01:18:06 -0700 Subject: [PATCH 2/2] [Dataflow Streaming] Commit size validation for multi key commits (#39473) --- .../worker/StreamingModeExecutionContext.java | 49 +- .../BoundedQueueExecutorWorkHandle.java | 8 +- .../worker/streaming/ExecutableWork.java | 8 + .../MultiKeyCommitValidationException.java | 28 + .../dataflow/worker/streaming/Work.java | 20 + .../worker/util/BoundedQueueExecutor.java | 11 +- .../worker/util/KeyGroupWorkQueue.java | 12 +- .../processing/StreamingWorkScheduler.java | 21 +- .../failures/WorkFailureProcessor.java | 11 + .../worker/StreamingDataflowWorkerTest.java | 535 +++++++++++++++++- .../StreamingModeExecutionContextTest.java | 44 +- .../worker/util/KeyGroupWorkQueueTest.java | 21 + .../failures/WorkFailureProcessorTest.java | 23 + 13 files changed, 743 insertions(+), 48 deletions(-) create mode 100644 runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/MultiKeyCommitValidationException.java diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingModeExecutionContext.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingModeExecutionContext.java index 365ebbdc1f9d..7e9c3eca13f9 100644 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingModeExecutionContext.java +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/StreamingModeExecutionContext.java @@ -55,6 +55,7 @@ import org.apache.beam.runners.dataflow.worker.streaming.ExecutableWork; import org.apache.beam.runners.dataflow.worker.streaming.FailedWorkHandler; import org.apache.beam.runners.dataflow.worker.streaming.KeyCommitTooLargeException; +import org.apache.beam.runners.dataflow.worker.streaming.MultiKeyCommitValidationException; 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.streaming.config.StreamingGlobalConfig; @@ -193,7 +194,6 @@ public interface KeyTransitionListener { private @Nullable KeyTransitionListener keyTransitionListener; private @Nullable FailedWorkHandler onFailedWorkHandler; - private List executedWorks = Collections.emptyList(); private List outputBuilders = Collections.emptyList(); // Map> @@ -319,7 +319,6 @@ public byte[] getCurrentRecordOffset() { public void reset() { // these lists and maps are returned to callers after processing // don't clear and reuse, instead reset the reference. - this.executedWorks = Collections.emptyList(); this.outputBuilders = Collections.emptyList(); this.finalizationCallbacks = Collections.emptyMap(); // Work from prior bundles might have a reference to the old workBatchFailed. @@ -353,7 +352,6 @@ public void start( FailedWorkHandler onFailedWorkHandler) throws CoderException { reset(); - this.executedWorks = new ArrayList<>(); this.outputBuilders = new ArrayList<>(); this.finalizationCallbacks = new HashMap<>(); this.keyCoder = keyCoder; @@ -578,11 +576,13 @@ public void setActiveReader(UnboundedReader reader) { /** Invalidate the state and reader caches for this computation and key. */ public void invalidateCache() { - for (Work w : executedWorks) { - WindmillComputationKey compKey = - WindmillComputationKey.create(computationId, w.getShardedKey()); - readerCache.invalidateReader(compKey); - stateCache.invalidate(w.getShardedKey()); + if (budgetHandle != null) { + for (Work w : budgetHandle.getWorkBatch()) { + WindmillComputationKey compKey = + WindmillComputationKey.create(computationId, w.getShardedKey()); + readerCache.invalidateReader(compKey); + stateCache.invalidate(w.getShardedKey()); + } } if (activeReader != null) { try { @@ -718,6 +718,23 @@ private void validateCommitRequestSize() { return; } + // If this is a multi-key work item, then we need to retry all of the individual work items + // without merging so that we can identify large commits to truncate. + // TODO: Can we request truncation without retrying if the first commit exceed the limits? + BoundedQueueExecutorWorkHandle handle = checkNotNull(budgetHandle); + List currentBatch = handle.getWorkBatch(); + checkState(!currentBatch.isEmpty()); + if (currentBatch.size() > 1) { + LOG.warn( + "Windmill Commit limit exceeded on a multi key bundle. Retrying without batching. Batch size: {}", + currentBatch.size()); + for (Work w : currentBatch) { + w.setMultiKeyBatchingDisabled(true); + } + throw new MultiKeyCommitValidationException( + "Commit size validation failed for batch. Retrying individually."); + } + KeyCommitTooLargeException e = KeyCommitTooLargeException.causedBy( systemName, byteLimit, commitRequest, key, hotKeyLoggingEnabled); @@ -731,11 +748,6 @@ private void validateCommitRequestSize() { buildWorkItemTruncationRequestBuilder(currentWork, estimatedCommitSize); currentBuilder.clear(); currentBuilder.mergeFrom(truncationBuilder.build()); - - // TODO: throw and retry when truncation is not on a single key bundle. - checkState( - !multiKeyBundleOptions.multiKeyBundleEnabled(), - "Commit truncation not implemented for multikey bundles"); } private Windmill.WorkItemCommitRequest.Builder buildWorkItemTruncationRequestBuilder( @@ -774,7 +786,9 @@ public boolean advance() throws CoderException { throw new WorkItemCancelledException(activeWork.getWorkItem().getShardingKey()); } - if (activeWork.getKeyGroup().equals(Work.KeyGroup.DEFAULT) || shouldStopBatching()) { + if (activeWork.getKeyGroup().equals(Work.KeyGroup.DEFAULT) + || activeWork.isMultiKeyBatchingDisabled() + || shouldStopBatching()) { return false; } @@ -797,7 +811,6 @@ public boolean advance() throws CoderException { } private boolean shouldStopBatching() { - // TODO: stop batching if the previous work item requested truncation if (workItemsPolled >= multiKeyBundleOptions.maxKeyGroupBatchSize()) { return true; } @@ -821,7 +834,6 @@ private void startForNewKey(Work newWork) throws CoderException { this.outputBuilder = createOutputBuilder(newWork); this.outputBuilders.add(this.outputBuilder); newWork.setOnFailureListener(this.workBatchFailed); - this.executedWorks.add(newWork); logHotKeyIfDetected(newWork, this.key); @@ -862,11 +874,6 @@ public List getWorkItemCommits() { return commits; } - // Returns list of Work that was executed in the bundle - public List getExecutedWorks() { - return executedWorks; - } - // Returns finalization callbacks recorded during the bundle execution public Map> getFinalizationCallbacks() { return finalizationCallbacks; diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/BoundedQueueExecutorWorkHandle.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/BoundedQueueExecutorWorkHandle.java index 20661aae0a04..d7a61562bc58 100644 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/BoundedQueueExecutorWorkHandle.java +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/BoundedQueueExecutorWorkHandle.java @@ -17,13 +17,15 @@ */ package org.apache.beam.runners.dataflow.worker.streaming; -import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList; +import java.util.List; /** * A handle to use when requesting pulling more work from @BoundedQueueExecutor * via @BoundedQueueExecutor.pollWork */ public interface BoundedQueueExecutorWorkHandle { - // Returns all work that are tracked by the handle - ImmutableList getWorkBatch(); + // Returns all work that are tracked by the handle. + // Returned list cannot be modified. Copying the list is fine. + // Don't keep reference to the returned list after the processing exits the harness threads. + List getWorkBatch(); } diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/ExecutableWork.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/ExecutableWork.java index 7748a554f0fc..4a992e872a4c 100644 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/ExecutableWork.java +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/ExecutableWork.java @@ -82,4 +82,12 @@ public String getComputationId() { public Work.KeyGroup getKeyGroup() { return work().getKeyGroup(); } + + /** + * Returns true if multi-key batching is disabled for this work item (e.g. after a prior batch + * commit size validation failure). + */ + public boolean isMultiKeyBatchingDisabled() { + return work().isMultiKeyBatchingDisabled(); + } } diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/MultiKeyCommitValidationException.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/MultiKeyCommitValidationException.java new file mode 100644 index 000000000000..f147d380d073 --- /dev/null +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/streaming/MultiKeyCommitValidationException.java @@ -0,0 +1,28 @@ +/* + * 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.streaming; + +/** + * Thrown when a multi-key bundle exceeds commit size limits, triggering unbatching and local retry + * of individual work items. + */ +public final class MultiKeyCommitValidationException extends RuntimeException { + public MultiKeyCommitValidationException(String message) { + super(message); + } +} 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 4541a1c313a2..2acee9410fa3 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 @@ -83,6 +83,10 @@ public final class Work implements RefreshableWork { private final long serializedWorkItemSize; private volatile TimedState currentState; private volatile boolean isFailed; + // If true, this work item will not be batched with other work items in a multi-key bundle. + // This is used to isolate work items that failed validation (e.g. commit size limit exceeded) + // so they can be retried individually and potentially truncated. + private volatile boolean disableMultiKeyBatching = false; private volatile String processingThreadName = ""; private final AtomicReference<@Nullable AtomicBoolean> onFailureListener = new AtomicReference<>(null); @@ -399,6 +403,22 @@ public boolean isFailed() { return isFailed; } + /** + * Sets whether multi-key batching should be disabled for this work item. When true, this work + * item will not be batched with other work items upon local retry. + */ + public void setMultiKeyBatchingDisabled(boolean disableMultiKeyBatching) { + this.disableMultiKeyBatching = disableMultiKeyBatching; + } + + /** + * Returns true if multi-key batching is disabled for this work item (e.g. after a prior batch + * commit size validation failure). + */ + public boolean isMultiKeyBatchingDisabled() { + return disableMultiKeyBatching; + } + boolean isStuckCommittingAt(Instant stuckCommitDeadline) { return currentState.state() == Work.State.COMMITTING && currentState.startTime().isBefore(stuckCommitDeadline); 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 2dd0f971168e..046d8cae9f9d 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 @@ -22,6 +22,7 @@ import static org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.LinkedBlockingQueue; @@ -36,7 +37,6 @@ import org.apache.beam.runners.dataflow.worker.streaming.Work; 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.Preconditions; -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.Monitor; import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.util.concurrent.Monitor.Guard; import org.checkerframework.checker.nullness.qual.Nullable; @@ -306,8 +306,13 @@ public synchronized boolean isClosed() { } @Override - public synchronized ImmutableList getWorkBatch() { - return ImmutableList.copyOf(workBatch); + /* + * Returns an unmodifiable view over the underlying list. + * It is unsafe to use the returned list with concurrent calls to mutating methods + * like merge/close + */ + public synchronized List getWorkBatch() { + return Collections.unmodifiableList(workBatch); } @VisibleForTesting 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 index d151157ec68f..dd409616ab9d 100644 --- 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 @@ -20,6 +20,7 @@ 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; @@ -67,9 +68,14 @@ static class Node { @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 (task instanceof QueuedWork) { + if (!isMultiKeyBatchingDisabled(task)) { this.computationId = ((QueuedWork) task).getWork().getComputationId(); this.keyGroup = ((QueuedWork) task).getWork().getKeyGroup(); } else { @@ -193,6 +199,10 @@ private void unlinkNode(Node node) { if (firstNode == keyGroupWorkList.tail) { return null; } + + // MultiKeyBatchingDisabled items should not be in keyGroupWorkList + checkState(!Node.isMultiKeyBatchingDisabled(firstNode.task)); + unlinkNode(firstNode); return (QueuedWork) firstNode.task; diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/StreamingWorkScheduler.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/StreamingWorkScheduler.java index 63cfad5a9a6f..958cd62f5eb3 100644 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/StreamingWorkScheduler.java +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/StreamingWorkScheduler.java @@ -242,7 +242,6 @@ private void processWork( long processingStartTimeNanos = System.nanoTime(); StageInfo stageInfo = getStageInfo(computationState); - @Nullable List workBatch = null; try { if (work.isFailed()) { throw new WorkItemCancelledException(workItem.getShardingKey()); @@ -251,7 +250,7 @@ private void processWork( // Execute the user code for the Work batch. ExecuteWorkResult executeWorkResult = executeWork(work, stageInfo, computationState, handle, keyTransitionListener); - workBatch = executeWorkResult.workBatch(); + List workBatch = handle.getWorkBatch(); List workItemCommits = executeWorkResult.workItemCommits(); commitFinalizer.cacheCommitFinalizers(executeWorkResult.finalizationCallbacks()); @@ -264,7 +263,7 @@ private void processWork( handleProcessWorkFailure( computationState, handle.getWorkBatch(), computationId, systemName, work, t); } finally { - List processedWorkBatch = workBatch != null ? workBatch : ImmutableList.of(work); + List processedWorkBatch = handle.getWorkBatch(); // Update total processing time counters. Updating in finally clause ensures that // work items causing exceptions are also accounted in time spent. recordProcessingTime(stageInfo, processedWorkBatch, processingStartTimeNanos); @@ -328,7 +327,6 @@ private ExecuteWorkResult executeWork( computationWorkExecutor.executeWork( work, workExecutor, handle, keyTransitionListener, onFailedWorkHandler); - List workBatch; List workItemCommits; Map> finalizationCallbacks; long stateBytesRead; @@ -338,9 +336,6 @@ private ExecuteWorkResult executeWork( } context.flushState(); - // Retrieve executed works, work item commits, and accumulated callbacks from execution - // context - workBatch = context.getExecutedWorks(); workItemCommits = context.getWorkItemCommits(); finalizationCallbacks = context.getFinalizationCallbacks(); stateBytesRead = context.getStateBytesRead(); @@ -351,8 +346,7 @@ private ExecuteWorkResult executeWork( computationState.releaseComputationWorkExecutor(computationWorkExecutor); computationWorkExecutor = null; - return ExecuteWorkResult.create( - workBatch, workItemCommits, finalizationCallbacks, stateBytesRead); + return ExecuteWorkResult.create(workItemCommits, finalizationCallbacks, stateBytesRead); } catch (Throwable t) { if (computationWorkExecutor != null) { // If processing failed due to a thrown exception, close the executionState. Do not @@ -419,10 +413,6 @@ private void commitMultiKeyWorkBatch( } for (int i = 0; i < workBatch.size(); i++) { Windmill.WorkItemCommitRequest commit = workItemCommits.get(i); - // TODO: Retry on commit truncations - checkState( - !commit.getExceedsMaxWorkItemCommitBytes(), - "Commit truncation with multikey bundles not implemented"); Work w = workBatch.get(i); multiKeyBuilder.addRequests( commit @@ -523,16 +513,13 @@ private KeyTransitionListener createKeyTransitionListener() { @AutoValue abstract static class ExecuteWorkResult { static ExecuteWorkResult create( - List workBatch, List workItemCommits, Map> finalizationCallbacks, long stateBytesRead) { return new AutoValue_StreamingWorkScheduler_ExecuteWorkResult( - workBatch, workItemCommits, finalizationCallbacks, stateBytesRead); + workItemCommits, finalizationCallbacks, stateBytesRead); } - abstract List workBatch(); - abstract List workItemCommits(); // Map> diff --git a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/failures/WorkFailureProcessor.java b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/failures/WorkFailureProcessor.java index d23c870178e0..b635bde7e08a 100644 --- a/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/failures/WorkFailureProcessor.java +++ b/runners/google-cloud-dataflow-java/worker/src/main/java/org/apache/beam/runners/dataflow/worker/windmill/work/processing/failures/WorkFailureProcessor.java @@ -25,6 +25,7 @@ import org.apache.beam.runners.dataflow.worker.status.LastExceptionDataProvider; import org.apache.beam.runners.dataflow.worker.streaming.ExecutableWork; import org.apache.beam.runners.dataflow.worker.streaming.FailedWorkHandler; +import org.apache.beam.runners.dataflow.worker.streaming.MultiKeyCommitValidationException; import org.apache.beam.runners.dataflow.worker.streaming.Work; import org.apache.beam.runners.dataflow.worker.util.BoundedQueueExecutor; import org.apache.beam.sdk.annotations.Internal; @@ -162,6 +163,16 @@ private RetryEvaluation evaluateRetry( @Nullable final Throwable cause = t.getCause(); Throwable parsedException = (t instanceof UserCodeException && cause != null) ? cause : t; + if (parsedException instanceof MultiKeyCommitValidationException) { + LOG.info( + "Execution of work for computation '{}' on sharding key '{}' for work token '{}' exceeded commit size limits. " + + "Work will be retried locally in smaller batches.", + computationId, + work.getWorkItem().getShardingKey(), + work.getWorkItem().getWorkToken()); + return RetryEvaluation.RETRY_LOCALLY; + } + LastExceptionDataProvider.reportException(parsedException); LOG.debug("Failed work: {}", work); Duration elapsedTimeSinceStart = new Duration(work.getStartTime(), clock.get()); 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 c48b30ecf640..d8063ae66d44 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 @@ -151,9 +151,11 @@ import org.apache.beam.sdk.coders.VarIntCoder; import org.apache.beam.sdk.extensions.gcp.util.Transport; import org.apache.beam.sdk.options.PipelineOptionsFactory; +import org.apache.beam.sdk.state.BagState; 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.testing.ExpectedLogs; import org.apache.beam.sdk.transforms.DoFn; import org.apache.beam.sdk.transforms.DoFnSchemaInformation; import org.apache.beam.sdk.transforms.windowing.AfterPane; @@ -301,6 +303,11 @@ public Long get() { }; @Rule public transient Timeout globalTimeout = Timeout.seconds(600); + + @Rule + public ExpectedLogs expectedStreamingModeExecutionContextLogs = + ExpectedLogs.none(StreamingModeExecutionContext.class); + @Rule public BlockingFn blockingFn = new BlockingFn(); @Rule public TestRule restoreMDC = new RestoreDataflowLoggingMDC(); @Rule public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); @@ -346,6 +353,8 @@ private Iterable buildCounters() { @Before public void setUp() { + FixedSizeBagCommitFn.SEEN_ELEMENTS.set(0); + LargeBagCommitFn.SEEN_ELEMENTS.set(0); server.clearCommitsReceived(); streamingCounters = StreamingCounters.create(); } @@ -4884,6 +4893,479 @@ public void testSkipInputElementsWithDecodingExceptions() throws Exception { "12345", commit.getOutputMessages(0).getBundles(0).getMessages(0).getData().toStringUtf8()); } + // TODO: Add similar tests with productions after changing WindmillSink to flush in finishKey. + + @Test + public void testMultiKeyCommit_batchCommitSizeExceededUnBatchSucceeds() throws Exception { + if (!streamingEngine) { + return; + } + KvCoder kvCoder = KvCoder.of(StringUtf8Coder.of(), StringUtf8Coder.of()); + + List instructions = + Arrays.asList( + makeSourceInstruction(kvCoder), + makeDoFnInstruction(new FixedSizeBagCommitFn(500), 0, kvCoder), + makeSinkInstruction(kvCoder, 1)); + + StreamingDataflowWorker worker = + makeWorker( + defaultWorkerParams( + "--experiments=unstable_enable_multi_key_bundle,windmill_max_key_group_batch_time_ms=50000", + "--numberOfWorkerHarnessThreads=1") + .setLocalRetryTimeoutMs(100) + .setInstructions(instructions) + .setStreamingGlobalConfig( + StreamingGlobalConfig.builder() + .setOperationalLimits( + OperationalLimits.builder().setMaxWorkItemCommitBytes(1000).build()) + .build()) + .build()); + worker.start(); + + String batchInputText = + "work {" + + " computation_id: \"" + + DEFAULT_COMPUTATION_ID + + "\"" + + " input_data_watermark: 0" + + " work {" + + " key: \"key1\"" + + " sharding_key: 1" + + " work_token: 1" + + " cache_token: 1" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data1\"" + + " }" + + " }" + + " }" + + " work {" + + " key: \"key2\"" + + " sharding_key: 2" + + " work_token: 2" + + " cache_token: 2" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data2\"" + + " }" + + " }" + + " }" + + " work {" + + " key: \"key3\"" + + " sharding_key: 3" + + " work_token: 3" + + " cache_token: 3" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data3\"" + + " }" + + " }" + + " }" + + "}"; + Windmill.GetWorkResponse batchInput = + buildInput( + batchInputText, + CoderUtils.encodeToByteArray( + CollectionCoder.of(IntervalWindow.getCoder()), + Collections.singletonList(DEFAULT_WINDOW))); + + server.whenGetWorkCalled().thenReturn(batchInput); + + Map result = server.waitForAndGetCommits(3); + + assertEquals(3, result.size()); + assertTrue(result.containsKey(1L)); + assertTrue(result.containsKey(2L)); + assertTrue(result.containsKey(3L)); + for (Windmill.WorkItemCommitRequest commitRequest : result.values()) { + assertFalse(commitRequest.getExceedsMaxWorkItemCommitBytes()); + } + + List multiKeyCommits = + server.getMultiKeyCommitsReceived(); + assertEquals(3, multiKeyCommits.size()); + assertEquals(1, multiKeyCommits.get(0).getRequestsCount()); + assertEquals(1, multiKeyCommits.get(1).getRequestsCount()); + assertEquals(1, multiKeyCommits.get(2).getRequestsCount()); + // 2 in initial batch (item 1 succeeds with 500 bytes, item 2 fails after accumulating 1000 + // bytes) + 3 unbatched retries + assertEquals(5, FixedSizeBagCommitFn.SEEN_ELEMENTS.get()); + expectedStreamingModeExecutionContextLogs.verifyWarn( + "Windmill Commit limit exceeded on a multi key bundle"); + + worker.stop(); + } + + @Test + public void testMultiKeyCommit_batchCommitSizeExceededUnBatchTruncates() throws Exception { + if (!streamingEngine) { + return; + } + KvCoder kvCoder = KvCoder.of(StringUtf8Coder.of(), StringUtf8Coder.of()); + + List instructions = + Arrays.asList( + makeSourceInstruction(kvCoder), + makeDoFnInstruction(new FixedSizeBagCommitFn(500), 0, kvCoder), + makeSinkInstruction(kvCoder, 1)); + + StreamingDataflowWorker worker = + makeWorker( + defaultWorkerParams( + "--experiments=unstable_enable_multi_key_bundle,windmill_max_key_group_batch_time_ms=50000", + "--numberOfWorkerHarnessThreads=1") + .setLocalRetryTimeoutMs(100) + .setInstructions(instructions) + .setStreamingGlobalConfig( + StreamingGlobalConfig.builder() + .setOperationalLimits( + // All workitems exceed commit limits + OperationalLimits.builder().setMaxWorkItemCommitBytes(400).build()) + .build()) + .build()); + worker.start(); + + String batchInputText = + "work {" + + " computation_id: \"" + + DEFAULT_COMPUTATION_ID + + "\"" + + " input_data_watermark: 0" + + " work {" + + " key: \"key1\"" + + " sharding_key: 1" + + " work_token: 1" + + " cache_token: 1" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data1\"" + + " }" + + " }" + + " }" + + " work {" + + " key: \"key2\"" + + " sharding_key: 2" + + " work_token: 2" + + " cache_token: 2" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data2\"" + + " }" + + " }" + + " }" + + " work {" + + " key: \"key3\"" + + " sharding_key: 3" + + " work_token: 3" + + " cache_token: 3" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data3\"" + + " }" + + " }" + + " }" + + "}"; + Windmill.GetWorkResponse batchInput = + buildInput( + batchInputText, + CoderUtils.encodeToByteArray( + CollectionCoder.of(IntervalWindow.getCoder()), + Collections.singletonList(DEFAULT_WINDOW))); + + server.whenGetWorkCalled().thenReturn(batchInput); + + Map result = server.waitForAndGetCommits(3); + + assertEquals(3, result.size()); + assertTrue(result.containsKey(1L)); + assertTrue(result.containsKey(2L)); + assertTrue(result.containsKey(3L)); + for (Windmill.WorkItemCommitRequest commitRequest : result.values()) { + assertTrue(commitRequest.getExceedsMaxWorkItemCommitBytes()); + } + + List multiKeyCommits = + server.getMultiKeyCommitsReceived(); + assertEquals(3, multiKeyCommits.size()); + assertEquals(1, multiKeyCommits.get(0).getRequestsCount()); + assertEquals(1, multiKeyCommits.get(1).getRequestsCount()); + assertEquals(1, multiKeyCommits.get(2).getRequestsCount()); + // 1 in initial batch (fails after first item's bag write exceeds limit) + 3 unbatched retries + assertEquals(4, FixedSizeBagCommitFn.SEEN_ELEMENTS.get()); + expectedStreamingModeExecutionContextLogs.verifyWarn( + "Windmill Commit limit exceeded on a multi key bundle"); + + worker.stop(); + } + + @Test + public void testMultiKeyCommit_batchCommitSizeExceededUnBatchFirstItemTruncates() + throws Exception { + if (!streamingEngine) { + return; + } + KvCoder kvCoder = KvCoder.of(StringUtf8Coder.of(), StringUtf8Coder.of()); + + List instructions = + Arrays.asList( + makeSourceInstruction(kvCoder), + makeDoFnInstruction(new LargeBagCommitFn(), 0, kvCoder), + makeSinkInstruction(kvCoder, 1)); + + StreamingDataflowWorker worker = + makeWorker( + defaultWorkerParams( + "--experiments=unstable_enable_multi_key_bundle,windmill_max_key_group_batch_time_ms=50000", + "--numberOfWorkerHarnessThreads=1") + .setLocalRetryTimeoutMs(100) + .setInstructions(instructions) + .setStreamingGlobalConfig( + StreamingGlobalConfig.builder() + .setOperationalLimits( + OperationalLimits.builder().setMaxWorkItemCommitBytes(1000).build()) + .build()) + .build()); + worker.start(); + + String batchInputText = + "work {" + + " computation_id: \"" + + DEFAULT_COMPUTATION_ID + + "\"" + + " input_data_watermark: 0" + + " work {" + + " key: \"large_key\"" + + " sharding_key: 1" + + " work_token: 1" + + " cache_token: 1" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data1\"" + + " }" + + " }" + + " }" + + " work {" + + " key: \"small_key\"" + + " sharding_key: 2" + + " work_token: 2" + + " cache_token: 2" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data2\"" + + " }" + + " }" + + " }" + + " work {" + + " key: \"small_key\"" + + " sharding_key: 3" + + " work_token: 3" + + " cache_token: 3" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data3\"" + + " }" + + " }" + + " }" + + "}"; + Windmill.GetWorkResponse batchInput = + buildInput( + batchInputText, + CoderUtils.encodeToByteArray( + CollectionCoder.of(IntervalWindow.getCoder()), + Collections.singletonList(DEFAULT_WINDOW))); + + server.whenGetWorkCalled().thenReturn(batchInput); + + Map result = server.waitForAndGetCommits(3); + + assertEquals(3, result.size()); + assertTrue(result.containsKey(1L)); + assertTrue(result.containsKey(2L)); + assertTrue(result.containsKey(3L)); + assertTrue(result.get(1L).getExceedsMaxWorkItemCommitBytes()); + assertFalse(result.get(2L).getExceedsMaxWorkItemCommitBytes()); + assertFalse(result.get(3L).getExceedsMaxWorkItemCommitBytes()); + + List multiKeyCommits = + server.getMultiKeyCommitsReceived(); + assertEquals(3, multiKeyCommits.size()); + assertEquals(1, multiKeyCommits.get(0).getRequestsCount()); + assertEquals(1, multiKeyCommits.get(1).getRequestsCount()); + assertEquals(1, multiKeyCommits.get(2).getRequestsCount()); + // 1 in initial batch (fails after first item's bag write exceeds limit) + 3 unbatched retries + assertEquals(4, LargeBagCommitFn.SEEN_ELEMENTS.get()); + + expectedStreamingModeExecutionContextLogs.verifyWarn( + "Windmill Commit limit exceeded on a multi key bundle"); + + worker.stop(); + } + + @Test + public void testMultiKeyCommit_batchCommitSizeExceededUnBatchSecondItemTruncates() + throws Exception { + if (!streamingEngine) { + return; + } + KvCoder kvCoder = KvCoder.of(StringUtf8Coder.of(), StringUtf8Coder.of()); + + List instructions = + Arrays.asList( + makeSourceInstruction(kvCoder), + makeDoFnInstruction(new LargeBagCommitFn(), 0, kvCoder), + makeSinkInstruction(kvCoder, 1)); + + StreamingDataflowWorker worker = + makeWorker( + defaultWorkerParams( + "--experiments=unstable_enable_multi_key_bundle,windmill_max_key_group_batch_time_ms=50000", + "--numberOfWorkerHarnessThreads=1") + .setLocalRetryTimeoutMs(100) + .setInstructions(instructions) + .setStreamingGlobalConfig( + StreamingGlobalConfig.builder() + .setOperationalLimits( + OperationalLimits.builder().setMaxWorkItemCommitBytes(1000).build()) + .build()) + .build()); + worker.start(); + + String batchInputText = + "work {" + + " computation_id: \"" + + DEFAULT_COMPUTATION_ID + + "\"" + + " input_data_watermark: 0" + + " work {" + + " key: \"small_key\"" + + " sharding_key: 1" + + " work_token: 1" + + " cache_token: 1" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data1\"" + + " }" + + " }" + + " }" + + " work {" + + " key: \"large_key\"" + + " sharding_key: 2" + + " work_token: 2" + + " cache_token: 2" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data2\"" + + " }" + + " }" + + " }" + + " work {" + + " key: \"small_key\"" + + " sharding_key: 3" + + " work_token: 3" + + " cache_token: 3" + + " key_group { high: 0 low: 1 }" + + " message_bundles {" + + " source_computation_id: \"" + + DEFAULT_SOURCE_COMPUTATION_ID + + "\"" + + " messages {" + + " timestamp: 0" + + " data: \"data3\"" + + " }" + + " }" + + " }" + + "}"; + Windmill.GetWorkResponse batchInput = + buildInput( + batchInputText, + CoderUtils.encodeToByteArray( + CollectionCoder.of(IntervalWindow.getCoder()), + Collections.singletonList(DEFAULT_WINDOW))); + + server.whenGetWorkCalled().thenReturn(batchInput); + + Map result = server.waitForAndGetCommits(3); + + assertEquals(3, result.size()); + assertTrue(result.containsKey(1L)); + assertTrue(result.containsKey(2L)); + assertTrue(result.containsKey(3L)); + assertFalse(result.get(1L).getExceedsMaxWorkItemCommitBytes()); + assertTrue(result.get(2L).getExceedsMaxWorkItemCommitBytes()); + assertFalse(result.get(3L).getExceedsMaxWorkItemCommitBytes()); + + List multiKeyCommits = + server.getMultiKeyCommitsReceived(); + assertEquals(3, multiKeyCommits.size()); + assertEquals(1, multiKeyCommits.get(0).getRequestsCount()); + assertEquals(1, multiKeyCommits.get(1).getRequestsCount()); + assertEquals(1, multiKeyCommits.get(2).getRequestsCount()); + // 2 in initial batch (item 1 succeeds, fails after item 2's bag write exceeds limit) + 3 + // unbatched retries + assertEquals(5, LargeBagCommitFn.SEEN_ELEMENTS.get()); + + expectedStreamingModeExecutionContextLogs.verifyWarn( + "Windmill Commit limit exceeded on a multi key bundle"); + + worker.stop(); + } + static class BlockingFn extends DoFn implements TestRule { public static AtomicReference blocker = @@ -4978,7 +5460,6 @@ public static void reset() { } static class LargeCommitFn extends DoFn, KV> { - @ProcessElement public void processElement(ProcessContext c) { if (c.element().getKey().equals("large_key")) { @@ -4993,6 +5474,58 @@ public void processElement(ProcessContext c) { } } + static class LargeBagCommitFn extends DoFn, KV> { + @StateId("bag") + private final StateSpec> bagSpec = StateSpecs.bag(StringUtf8Coder.of()); + + public static AtomicInteger SEEN_ELEMENTS = new AtomicInteger(); + + @ProcessElement + public void processElement(ProcessContext c, @StateId("bag") BagState bag) { + SEEN_ELEMENTS.incrementAndGet(); + if (c.element().getKey().equals("large_key")) { + StringBuilder s = new StringBuilder(); + for (int i = 0; i < 100; ++i) { + s.append("large_commit"); + } + bag.add(s.toString()); + } else { + bag.add(c.element().getValue()); + } + } + } + + static class FixedSizeBagCommitFn extends DoFn, KV> { + @StateId("bag") + private final StateSpec> bagSpec = StateSpecs.bag(StringUtf8Coder.of()); + + private final int size; + public static AtomicInteger SEEN_ELEMENTS = new AtomicInteger(); + private List bundleElements = new ArrayList<>(); + + FixedSizeBagCommitFn(int size) { + this.size = size; + } + + @StartBundle + public void startBundle() { + bundleElements = new ArrayList<>(); + } + + @ProcessElement + public void processElement(ProcessContext c, @StateId("bag") BagState bag) { + SEEN_ELEMENTS.incrementAndGet(); + StringBuilder s = new StringBuilder(); + for (int i = 0; i < size; ++i) { + s.append("a"); + } + bundleElements.add(s.toString()); + for (String elem : bundleElements) { + bag.add(elem); + } + } + } + static class ExceptionCatchingFn extends DoFn, KV> { @ProcessElement diff --git a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/StreamingModeExecutionContextTest.java b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/StreamingModeExecutionContextTest.java index 5aceb0ca9564..53dd96620a55 100644 --- a/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/StreamingModeExecutionContextTest.java +++ b/runners/google-cloud-dataflow-java/worker/src/test/java/org/apache/beam/runners/dataflow/worker/StreamingModeExecutionContextTest.java @@ -78,7 +78,7 @@ import org.apache.beam.runners.dataflow.worker.windmill.state.WindmillStateCache; import org.apache.beam.runners.dataflow.worker.windmill.state.WindmillTagEncodingV1; import org.apache.beam.runners.dataflow.worker.windmill.state.WindmillTagEncodingV2; -import org.apache.beam.runners.dataflow.worker.windmill.work.processing.failures.FailureTracker; +import org.apache.beam.runners.dataflow.worker.windmill.work.processing.failures.StreamingEngineFailureTracker; import org.apache.beam.runners.dataflow.worker.windmill.work.refresh.HeartbeatSender; import org.apache.beam.sdk.Pipeline; import org.apache.beam.sdk.coders.Coder; @@ -164,7 +164,7 @@ private StreamingModeExecutionContext createExecutionContext( /*stepName=*/ "stepName", /*systemName=*/ "systemName", StreamingCounters.create(), - mock(FailureTracker.class), + StreamingEngineFailureTracker.create(10, 10), "sourceBytesProcessCounterName", MultiKeyBundleOptions.fromOptions(options), SideInputStateFetcherFactory.fromOptions(options)); @@ -900,4 +900,44 @@ public void testInternalsPoisonedAfterFlushState() throws Exception { assertThat(e.getMessage(), Matchers.containsString("poisoned")); } } + + @Test + public void testAdvance_stopsWhenCurrentWorkBatchingDisabled() throws Exception { + DataflowWorkerHarnessOptions optionsMultiKey = + PipelineOptionsFactory.as(DataflowWorkerHarnessOptions.class); + optionsMultiKey + .as(ExperimentalOptions.class) + .setExperiments(Arrays.asList("unstable_enable_multi_key_bundle")); + StreamingModeExecutionContext context = + createExecutionContext(optionsMultiKey, globalConfigHandle); + + BoundedQueueExecutor mockExecutor = mock(BoundedQueueExecutor.class); + BoundedQueueExecutorWorkHandle mockHandle = mock(BoundedQueueExecutorWorkHandle.class); + Windmill.Uint128Proto keyGroup = + Windmill.Uint128Proto.newBuilder().setHigh(1).setLow(2).build(); + + Work work1 = + createMockWork( + Windmill.WorkItem.newBuilder() + .setKey(ByteString.copyFromUtf8("key1")) + .setWorkToken(1L) + .setKeyGroup(keyGroup) + .build(), + Watermarks.builder().setInputDataWatermark(Instant.EPOCH).build()); + work1.setMultiKeyBatchingDisabled(true); + + AtomicBoolean transitionListenerCalled = new AtomicBoolean(false); + context.start( + work1, + workExecutor, + mockExecutor, + mockHandle, + null, + (oldWork, newWork) -> transitionListenerCalled.set(true), + FAILING_FAILED_WORK_HANDLER); + + assertFalse(context.advance()); + assertFalse(transitionListenerCalled.get()); + verifyNoInteractions(mockExecutor); + } } 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 index 77fcb0597586..c7be44525502 100644 --- 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 @@ -489,6 +489,27 @@ public void testPollWorkWithKeyGroup() { 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(); 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 741cc35376fc..f1cc33c963f1 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 @@ -30,6 +30,8 @@ import java.util.function.Consumer; import java.util.function.Supplier; import org.apache.beam.runners.dataflow.worker.streaming.ExecutableWork; +import org.apache.beam.runners.dataflow.worker.streaming.FailedWorkHandler; +import org.apache.beam.runners.dataflow.worker.streaming.MultiKeyCommitValidationException; 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; @@ -272,4 +274,25 @@ public void logAndProcessFailureBatch_mixRetryAndAbort() throws Throwable { assertThat(executedWork2).isEmpty(); assertThat(invalidWork).containsExactly(work2.work()); } + + @Test + public void logAndProcessFailureBatch_retriesOnMultiKeyCommitValidationException() + throws Throwable { + CountDownLatch runWork = new CountDownLatch(1); + ExecutableWork work = createWork(ignored -> runWork.countDown()); + FailureTracker failureTracker = streamingEngineFailureReporter(); + WorkFailureProcessor workFailureProcessor = createWorkFailureProcessor(failureTracker); + Set invalidWork = new HashSet<>(); + + workFailureProcessor.logAndProcessFailureBatch( + DEFAULT_COMPUTATION_ID, + DEFAULT_COMPUTATION_ID, + List.of(work), + new MultiKeyCommitValidationException("test"), + (FailedWorkHandler) invalidWork::add); + + runWork.await(); + assertThat(invalidWork).isEmpty(); + assertThat(failureTracker.drainPendingFailuresToReport()).isEmpty(); + } }