diff --git a/.github/workflows/End2EndTest.yml b/.github/workflows/End2EndTest.yml index 99522be21..8b492a6fc 100644 --- a/.github/workflows/End2EndTest.yml +++ b/.github/workflows/End2EndTest.yml @@ -1,4 +1,4 @@ -name: Snowpipe Java SDK Tests +name: Snowpipe Java SDK on: push: @@ -8,28 +8,85 @@ on: jobs: build: - runs-on: ubuntu-18.04 + name: Build & Test - JDK ${{ matrix.java }}, Cloud ${{ matrix.snowflake_cloud }} + runs-on: ubuntu-20.04 strategy: + fail-fast: false # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast matrix: java: [ 8 ] + snowflake_cloud: [ 'AWS', 'AZURE', 'GCP' ] steps: - name: Checkout Code uses: actions/checkout@v2 - name: Install Java ${{ matrix.java }} - uses: actions/setup-java@v1 + uses: actions/setup-java@v2 with: + distribution: temurin java-version: ${{ matrix.java }} + cache: maven - - name: Decrypt profile.json + - name: Decrypt profile.json for Cloud ${{ matrix.snowflake_cloud }} env: DECRYPTION_PASSPHRASE: ${{ secrets.PROFILE_JSON_DECRYPT_PASSPHRASE }} run: | - ./scripts/decrypt_secret.sh + ./scripts/decrypt_secret.sh ${{ matrix.snowflake_cloud }} - - name: Unit & Integration Test + - name: Unit & Integration Test against Cloud ${{ matrix.snowflake_cloud }} env: JACOCO_COVERAGE: true WHITESOURCE_API_KEY: ${{ secrets.WHITESOURCE_API_KEY }} continue-on-error: false run: | - ./scripts/run_gh_actions.sh \ No newline at end of file + ./scripts/run_gh_actions.sh + + - name: Code Coverage + uses: codecov/codecov-action@v1 + build-windows: + name: Build & Test - Windows, JDK ${{ matrix.java }}, Cloud ${{ matrix.snowflake_cloud }} + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + java: [ 8 ] + snowflake_cloud: [ 'AWS', 'AZURE', 'GCP' ] + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Install Java ${{ matrix.java }} + uses: actions/setup-java@v2 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + - name: Decrypt profile.json for Cloud ${{ matrix.snowflake_cloud }} on Windows Powershell + env: + DECRYPTION_PASSPHRASE: ${{ secrets.PROFILE_JSON_DECRYPT_PASSPHRASE }} + shell: pwsh + run: | + ./scripts/decrypt_secret_windows.ps1 -SnowflakeDeployment '${{ matrix.snowflake_cloud }}' + - name: Unit & Integration Test (Windows) + continue-on-error: false + run: mvn -DghActionsIT verify --batch-mode + build-e2e-jar-test: + name: E2E JAR Test - ${{ matrix.java }}, Cloud ${{ matrix.snowflake_cloud }} + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + java: [ 8 ] + snowflake_cloud: [ 'AWS', 'AZURE', 'GCP' ] + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Install Java ${{ matrix.java }} + uses: actions/setup-java@v2 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + - name: Decrypt profile.json for Cloud ${{ matrix.snowflake_cloud }} + env: + DECRYPTION_PASSPHRASE: ${{ secrets.PROFILE_JSON_DECRYPT_PASSPHRASE }} + run: ./scripts/decrypt_secret.sh ${{ matrix.snowflake_cloud }} + - name: Run E2E JAR Test + run: ./e2e-jar-test/run_e2e_jar_test.sh diff --git a/.github/workflows/GoogleJavaFormat.yml b/.github/workflows/GoogleJavaFormat.yml index f932ec319..4e321b914 100644 --- a/.github/workflows/GoogleJavaFormat.yml +++ b/.github/workflows/GoogleJavaFormat.yml @@ -11,10 +11,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: GoogleJavaFormat - id: googlejavaformat - uses: axel-op/googlejavaformat-action@v3 + - uses: actions/setup-java@v2 with: - skipCommit: true - version: 1.10.0 - args: "-n --set-exit-if-changed" + distribution: 'adopt' + java-version: '11' + - name: Format code + run: ./format.sh + - if: ${{ failure() }} + run: git diff --color=always diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 713a25109..d2a49777e 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -1,14 +1,16 @@ -name: Semgrep +--- +name: Run semgrep checks + on: - pull_request: - branches: - - master + pull_request: + branches: [master] + +permissions: + contents: read + jobs: - semgrep: - runs-on: ubuntu-latest - name: Semgrep - steps: - - uses: actions/checkout@v1 - - name: Semgrep - id: semgrep - uses: returntocorp/semgrep-action@v1 + run-semgrep-reusable-workflow: + uses: snowflakedb/reusable-workflows/.github/workflows/semgrep-v2.yml@main + secrets: + token: ${{ secrets.SEMGREP_APP_TOKEN }} + diff --git a/.github/workflows/semgrep_internal.yml b/.github/workflows/semgrep_internal.yml deleted file mode 100644 index e3f0d8835..000000000 --- a/.github/workflows/semgrep_internal.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Semgrep - -on: - pull_request: {} - -jobs: - semgrep: - name: Scan - runs-on: ubuntu-latest - if: (github.actor != 'dependabot[bot]') - steps: - - uses: actions/checkout@v2 - - uses: returntocorp/semgrep-action@v1 - with: - auditOn: push - publishUrl: https://semgrep.snowflake.com - publishDeployment: 1 - publishToken: ${{ secrets.SEMGREP_APP_TOKEN }} diff --git a/.github/workflows/snyk-issue.yml b/.github/workflows/snyk-issue.yml new file mode 100644 index 000000000..2a3f6226a --- /dev/null +++ b/.github/workflows/snyk-issue.yml @@ -0,0 +1,34 @@ +name: Snyk Issue + +on: + schedule: + - cron: '* */12 * * *' + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: snyk-issue + +jobs: + snyk: + runs-on: ubuntu-latest + steps: + - name: checkout action + uses: actions/checkout@v3 + with: + repository: snowflakedb/whitesource-actions + token: ${{ secrets.WHITESOURCE_ACTION_TOKEN }} + path: whitesource-actions + - name: set-env + run: echo "REPO=$(basename $GITHUB_REPOSITORY)" >> $GITHUB_ENV + - name: Jira Creation + uses: ./whitesource-actions/snyk-issue + with: + snyk_org: ${{ secrets.SNYK_ORG_ID_PUBLIC_REPO }} + snyk_token: ${{ secrets.SNYK_GITHUB_INTEGRATION_TOKEN_PUBLIC_REPO }} + jira_token: ${{ secrets.JIRA_TOKEN_PUBLIC_REPO }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/whitesource.yml b/.github/workflows/snyk-pr.yml similarity index 50% rename from .github/workflows/whitesource.yml rename to .github/workflows/snyk-pr.yml index 860b5f850..4cb65c098 100644 --- a/.github/workflows/whitesource.yml +++ b/.github/workflows/snyk-pr.yml @@ -1,29 +1,37 @@ -name: whitesource +name: Snyk PR on: pull_request: branches: - master + +permissions: + contents: read + issues: write + pull-requests: write + jobs: - whitesource: + snyk: runs-on: ubuntu-latest - if: ${{ github.event.pull_request.user.login == 'whitesource-for-github-com[bot]' }} + if: ${{ github.event.pull_request.user.login == 'sfc-gh-snyk-sca-sa' }} steps: - - name: checkout - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v3 with: ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 - - name: checkout action - uses: actions/checkout@v2 + - name: Checkout Action + uses: actions/checkout@v3 with: repository: snowflakedb/whitesource-actions token: ${{ secrets.WHITESOURCE_ACTION_TOKEN }} path: whitesource-actions - - name: PR - uses: ./whitesource-actions/whitesource-pr + - name: nyk Pull Request Scan Check + uses: ./whitesource-actions/snyk-pr + env: + PR_TITLE: ${{ github.event.pull_request.title }} with: jira_token: ${{ secrets.JIRA_TOKEN_PUBLIC_REPO }} gh_token: ${{ secrets.GITHUB_TOKEN }} - amend: false # true if you want the commit to be amended with the JIRA number + amend: false \ No newline at end of file diff --git a/.github/workflows/whitesource-issue.yml b/.github/workflows/whitesource-issue.yml deleted file mode 100644 index 812a1c741..000000000 --- a/.github/workflows/whitesource-issue.yml +++ /dev/null @@ -1,26 +0,0 @@ - -name: whitesource-issue - -on: - issues: - types: [opened, reopened] - -concurrency: whitesource-issue - -jobs: - whitesource: - runs-on: ubuntu-latest - if: ${{ github.event.issue.user.login == 'whitesource-for-github-com[bot]' }} - steps: - - name: checkout action - uses: actions/checkout@v2 - with: - repository: snowflakedb/whitesource-actions - token: ${{ secrets.WHITESOURCE_ACTION_TOKEN }} - path: whitesource-actions - - - name: Jira Creation - uses: ./whitesource-actions/whitesource-issue - with: - jira_token: ${{ secrets.JIRA_TOKEN_PUBLIC_REPO }} - gh_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ce014b839..7cda7192a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ profile.properties src/main/resources/log4j.properties src/test/resources/log4j.properties testOutput/ +.cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0809b2dac..6d3ea2497 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,4 +2,4 @@ repos: - hooks: - id: secret-scanner repo: git@github.com:snowflakedb/casec_precommit.git - rev: v1.5 + rev: v1.8 diff --git a/.semaphore/project.yml b/.semaphore/project.yml new file mode 100644 index 000000000..0a93cd3b1 --- /dev/null +++ b/.semaphore/project.yml @@ -0,0 +1,47 @@ +# +# Copyright [2019 - 2019] Confluent Inc. +# +# This file is managed by ServiceBot plugin - Semaphore. The content in this file is created using a common +# template and configurations in service.yml. +# Modifications in this file will be overwritten by generated content in the nightly run. +# For more information, please refer to the page: +# https://confluentinc.atlassian.net/wiki/spaces/Foundations/pages/2871296194/Add+SemaphoreCI +apiVersion: v1alpha +kind: Project +metadata: + name: snowflake-ingest-java + description: "" +spec: + visibility: private + repository: + url: git@github.com:confluentinc/snowflake-ingest-java.git + run_on: + - branches + - pull_requests + pipeline_file: .semaphore/semaphore.yml + integration_type: github_app + status: + pipeline_files: + - path: .semaphore/semaphore.yml + level: pipeline + whitelist: + branches: + - master + - main + - /^\d+\.\d+\.x$/ + - /v\d+\.\d+\.\d+\-hotfix\-x/ + - /^gh-readonly-queue.*/ + custom_permissions: true + debug_permissions: + - empty + - default_branch + - non_default_branch + - pull_request + - forked_pull_request + - tag + attach_permissions: + - default_branch + - non_default_branch + - pull_request + - forked_pull_request + - tag \ No newline at end of file diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml new file mode 100644 index 000000000..62876027b --- /dev/null +++ b/.semaphore/semaphore.yml @@ -0,0 +1,96 @@ +# This file is managed by ServiceBot plugin - Semaphore. The content in this file is created using a common +# template and configurations in service.yml. +# Any modifications made to ths file will be overwritten by the generated content in nightly runs. +# For more information, please refer to the page: +# https://confluentinc.atlassian.net/wiki/spaces/Foundations/pages/2871296194/Add+SemaphoreCI +version: v1.0 +name: build-test-release +agent: + machine: + type: s1-prod-ubuntu24-04-amd64-1 + +fail_fast: + cancel: + when: "true" + +execution_time_limit: + hours: 1 + +queue: + - when: "branch != 'master' and branch !~ '[0-9]+\\.[0-9]+\\.x'" + processing: parallel + +global_job_config: + prologue: + commands: + - checkout + - sem-version java 8 + - . cache-maven restore + +blocks: + - name: Test + dependencies: [] + run: + # don't run the tests on non-functional changes... + when: "change_in('/', {exclude: ['/.deployed-versions/', '.github/']})" + task: + jobs: + - name: Test + commands: + - . sem-pint -c + - mvn -U -Dmaven.wagon.http.retryHandler.count=10 --batch-mode --no-transfer-progress clean verify install dependency:analyze validate + - cve-scan + - . cache-maven store + epilogue: + always: + commands: + - . publish-test-results + - artifact push workflow target/test-results + - artifact push workflow target + + - name: Release + dependencies: ["Test"] + run: + when: "branch = 'master' or branch =~ '[0-9]+\\.[0-9]+\\.x' or branch =~ 'v[0-9]+\\.[0-9]+\\.[0-9]+-hotfix-x'" + task: + jobs: + - name: Release + commands: + - ci-tools ci-update-version + - ci-tools ci-push-tag + - mvn -U -Dmaven.wagon.http.retryHandler.count=10 --batch-mode -DaltDeploymentRepository=confluent-codeartifact-internal::default::https://confluent-519856050701.d.codeartifact.us-west-2.amazonaws.com/maven/maven-releases/ -DrepositoryId=confluent-codeartifact-internal deploy -DskipTests + + - name: Snapshot Release + dependencies: [ "Test" ] + run: + when: "branch =~ '.*-test-release$'" + task: + jobs: + - name: Snapshot Release + commands: + - export COMMIT_SHA=$(git rev-parse --short HEAD) + - echo "Commit SHA = ${COMMIT_SHA}" + - export CURRENT_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + - echo "Current version = ${CURRENT_VERSION}" + - mvn versions:set -DnewVersion="${CURRENT_VERSION}-${COMMIT_SHA}-SNAPSHOT" -DgenerateBackupPoms=false + - echo "New version = ${CURRENT_VERSION}-${COMMIT_SHA}-SNAPSHOT" + - mvn -U -Dmaven.wagon.http.retryHandler.count=10 --batch-mode -DaltDeploymentRepository=confluent-codeartifact-internal::default::https://confluent-519856050701.d.codeartifact.us-west-2.amazonaws.com/maven/maven-snapshots/ -DrepositoryId=confluent-codeartifact-internal deploy -DskipTests + +after_pipeline: + task: + agent: + machine: + type: s1-prod-ubuntu20-04-arm64-0 + jobs: + - name: Metrics + commands: + - emit-ci-metrics -p -a test-results + - name: Publish Test Results + commands: + - test-results gen-pipeline-report + - name: SonarQube + commands: + - checkout + - sem-version java 11 + - artifact pull workflow target + - emit-sonarqube-data --run_only_sonar_scan diff --git a/.semgrep.yml b/.semgrep.yml deleted file mode 100644 index cd0425af0..000000000 --- a/.semgrep.yml +++ /dev/null @@ -1,425 +0,0 @@ -rules: -- id: java.lang.security.audit.bad-hexa-conversion.bad-hexa-conversion - metadata: - cwe: 'CWE-704: Incorrect Type Conversion or Cast' - owasp: 'A3: Sensitive Data Exposure' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#BAD_HEXA_CONVERSION - message: | - This mistake weakens the hash value computed since it introduces more collisions. - In this situation, the method Integer.toHexString() should be replaced with String.format(). - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#bad-hexa-conversion - severity: WARNING - languages: [java] - pattern: |- - $X $METHOD(...) { - ... - MessageDigest $MD = ...; - ... - $MD.digest(...); - ... - Integer.toHexString(...); - } -- id: java.lang.security.audit.cbc-padding-oracle.cbc-padding-oracle - message: | - Using CBC with PKCS5Padding is susceptible to padding orcale attacks. A malicious actor - could discern the difference between plaintext with valid or invalid padding. Further, - CBC mode does not include any integrity checks (https://find-sec-bugs.github.io/bugs.htm#CIPHER_INTEGRITY). - Use 'AES/GCM/NoPadding' instead. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#cbc-padding-oracle - metadata: - cwe: 'CWE-696: Incorrect Behavior Order' - owasp: 'A3: Sensitive Data Exposure' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#PADDING_ORACLE - references: - - https://capec.mitre.org/data/definitions/463.html - severity: WARNING - patterns: - - pattern: $CIPHER.getInstance("=~/.*\/CBC\/PKCS5Padding/"); - fix: $CIPHER.getInstance("AES/GCM/NoPadding"); - languages: - - java -- id: java.lang.security.audit.command-injection-formatted-runtime-call.command-injection-formatted-runtime-call - patterns: - - pattern-either: - - pattern: $RUNTIME.exec($X + $Y); - - pattern: $RUNTIME.exec(String.format(...)); - - pattern: $RUNTIME.loadLibrary($X + $Y); - - pattern: $RUNTIME.loadLibrary(String.format(...)); - message: | - A formatted or concatenated string was detected as input to a java.lang.Runtime call. - This is dangerous if a variable is controlled by user input and could result in a - command injection. Ensure your variables are not controlled by users or sufficiently sanitized. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#command-injection-formatted-runtime-call - metadata: - cwe: "CWE-78: Improper Neutralization of Special Elements used in an OS Command\ - \ ('OS Command Injection')" - owasp: 'A1: Injection' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#COMMAND_INJECTION. - severity: WARNING - languages: - - java -- id: java.lang.security.audit.script-engine-injection.script-engine-injection - message: | - Potential code injection when using Script Engine. - Ensure that untrusted data is not being passed to this function or otherwise ensure that proper sandboxing is being performed. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#script-engine-injection - metadata: - cwe: "CWE-94: Improper Control of Generation of Code ('Code Injection')" - owasp: 'A1: Injection' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#SCRIPT_ENGINE_INJECTION - severity: WARNING - languages: [java] - patterns: - - pattern-either: - - pattern-inside: | - class $CLASS { - ... - ScriptEngine $SE; - ... - } - - pattern-inside: | - class $CLASS { - ... - ScriptEngine $SE = ...; - ... - } - - pattern-inside: | - $X $METHOD(...) { - ... - ScriptEngine $SE = ...; - ... - } - - pattern: | - $X $METHOD(...) { - ... - $SE.eval(...); - ... - } - - pattern-not: | - $X $METHOD(...) { - ... - $SE.eval("..."); - ... - } - - pattern-not: | - $X $METHOD(...) { - ... - String $S = "..."; - ... - $SE.eval($S); - ... - } -- id: java.lang.security.audit.weak-ssl-context.weak-ssl-context - metadata: - cwe: 'CWE-326: Inadequate Encryption Strength' - owasp: 'A3: Sensitive Data Exposure' - source_rule_url: https://find-sec-bugs.github.io/bugs.htm#SSL_CONTEXT - references: - - https://tools.ietf.org/html/rfc7568 - - https://tools.ietf.org/id/draft-ietf-tls-oldversions-deprecate-02.html - message: | - An insecure SSL context was detected. TLS versions 1.0, 1.1, and all SSL versions - are considered weak encryption and are deprecated. - Use SSLContext.getInstance("TLSv1.2") or SSLContext.getInstance("TLSv1.3") for the best security. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#weak-ssl-context - severity: WARNING - languages: [java] - patterns: - - pattern-not: SSLContext.getInstance("TLS1.3") - - pattern-not: SSLContext.getInstance("TLS1.2") - - pattern: SSLContext.getInstance("...") -- id: java.lang.security.audit.xml-decoder.xml-decoder - message: | - XMLDecoder should not be used to parse untrusted data. Deserializing user input can lead to arbitrary code execution. - Ensure that only trusted data is being parsed using XMLDecoder. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#xml-decoder - metadata: - cwe: 'CWE-611: Improper Restriction of XML External Entity Reference' - owasp: 'A4: XML External Entities (XXE)' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#XML_DECODER - severity: WARNING - languages: [java] - patterns: - - pattern: | - $X $METHOD(...) { - ... - new XMLDecoder(...); - ... - } - - pattern-not: | - $X $METHOD(...) { - ... - new XMLDecoder("..."); - ... - } - - pattern-not: |- - $X $METHOD(...) { - ... - String $STR = "..."; - ... - new XMLDecoder($STR); - ... - } -- id: java.lang.security.audit.xssrequestwrapper-is-insecure.xssrequestwrapper-is-insecure - metadata: - owasp: 'A7: Cross-Site Scripting (XSS)' - cwe: "CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site\ - \ Scripting')" - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#XSS_REQUEST_WRAPPER - message: | - It looks like you're using an implementation of XSSRequestWrapper from dzone. - (https://www.javacodegeeks.com/2012/07/anti-cross-site-scripting-xss-filter.html) - The XSS filtering in this code is not secure and can be bypassed by malicious actors. - It is recommended to use a stack that automatically escapes in your view or templates - instead of filtering yourself. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#xssrequestwrapper-is-insecure - severity: WARNING - patterns: - - pattern-either: - - pattern: | - class XSSRequestWrapper extends HttpServletRequestWrapper { - ... - } - - pattern: |- - $P = $X.compile("", $X.CASE_INSENSITIVE); - $V = $P.matcher(...).replaceAll(""); - languages: - - java -- id: java.lang.security.audit.cookie-missing-httponly.cookie-missing-httponly - metadata: - cwe: "CWE-1004: Sensitive Cookie Without 'HttpOnly' Flag" - owasp: 'A3: Sensitive Data Exposure' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#HTTPONLY_COOKIE - message: | - A cookie was detected without setting the 'HttpOnly' flag. The 'HttpOnly' flag - for cookies instructs the browser to forbid client-side scripts from reading the - cookie. Set the 'HttpOnly' flag by calling 'cookie.setHttpOnly(true);' - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#cookie-missing-httponly - severity: WARNING - languages: [java] - patterns: - - pattern-not-inside: $COOKIE.setValue(""); ... - - pattern-either: - - pattern: $COOKIE.setHttpOnly(false); - - patterns: - - pattern-not-inside: $COOKIE.setHttpOnly(...); ... - - pattern: $RESPONSE.addCookie($COOKIE); -- id: java.lang.security.audit.cookie-missing-samesite.cookie-missing-samesite - metadata: - cwe: 'CWE-352: Cross-Site Request Forgery (CSRF)' - owasp: 'A6: Security Misconfiguration' - references: - - https://stackoverflow.com/questions/42717210/samesite-cookie-in-java-application - message: | - Detected cookie without the SameSite attribute. - Set the SameSite attributed on the cookie. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#cookie-missing-samesite - severity: WARNING - languages: [java] - patterns: - - pattern-not-inside: | - $RETURNTYPE $METHOD(..., HttpServletResponse $RESP, ...) { - ... - $RESP.setHeader("Set-Cookie", "=~/.*SameSite=.*/"); - ... - } - - pattern-either: - - pattern: $RESP.addCookie(...); - - pattern: $RESP.setHeader("Set-Cookie", ...); -- id: java.lang.security.audit.cookie-missing-secure-flag.cookie-missing-secure-flag - metadata: - cwe: "CWE-614: Sensitive Cookie in HTTPS Session Without 'Secure' Attribute" - owasp: 'A3: Sensitive Data Exposure' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#INSECURE_COOKIE - message: | - A cookie was detected without setting the 'secure' flag. The 'secure' flag - for cookies prevents the client from transmitting the cookie over insecure - channels such as HTTP. Set the 'secure' flag by calling '$COOKIE.setSecure(true);' - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#cookie-missing-secure-flag - severity: WARNING - languages: [java] - patterns: - - pattern-not-inside: $COOKIE.setValue(""); ... - - pattern-either: - - pattern: (Cookie $COOKIE).setSecure(false); - - patterns: - - pattern-not-inside: (Cookie $COOKIE).setSecure(...); ... - - pattern: $RESPONSE.addCookie($COOKIE); -- id: java.lang.security.audit.crypto.no-static-initialization-vector.no-static-initialization-vector - message: | - Initialization Vectors (IVs) for block ciphers should be randomly generated - each time they are used. Using a static IV means the same plaintext - encrypts to the same ciphertext every time, weakening the strength - of the encryption. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#no-static-initialization-vector - metadata: - cwe: 'CWE-329: Not Using a Random IV with CBC Mode' - owasp: 'A3: Sensitive Data Exposure' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#STATIC_IV - references: - - https://cwe.mitre.org/data/definitions/329.html - severity: WARNING - languages: [java] - patterns: - - pattern-either: - - pattern: | - byte[] $IV = { - ... - }; - ... - new IvParameterSpec($IV, ...); - - pattern: | - class $CLASS { - byte[] $IV = { - ... - }; - ... - $METHOD(...) { - ... - new IvParameterSpec($IV, ...); - ... - } - } -- id: java.lang.security.audit.crypto.weak-hash.use-of-sha1 - message: | - Use of weak cryptographic primitive SHA1 - Use SHA256 at minimum for signature verification and use PBKDF2 for hashing passwords. - For more informations, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#use-of-sha1 - languages: [java] - severity: WARNING - metadata: - owasp: 'A9: Using Components with Known Vulnerabilities' - cwe: 'CWE-327: Use of a Broken or Risky Cryptographic Algorithm' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#WEAK_MESSAGE_DIGEST_SHA1 - pattern-either: - - pattern: | - MessageDigest $VAR = $MD.getInstance("SHA1"); - - pattern: | - $DU.getSha1Digest().digest(...) -- id: java.lang.security.audit.crypto.weak-hash.use-of-md5 - message: | - Use of weak cryptographic primitive MD5 - Use SHA256 at minimum for signature verification and use PBKDF2 for hashing passwords. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#use-of-md5 - languages: [java] - severity: WARNING - metadata: - owasp: 'A9: Using Components with Known Vulnerabilities' - cwe: 'CWE-327: Use of a Broken or Risky Cryptographic Algorithm' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#WEAK_MESSAGE_DIGEST_MD5 - pattern-either: - - pattern: | - MessageDigest $VAR = $MD.getInstance("MD5"); - - pattern: | - $DU.getMd5Digest().digest(...) -- id: java.lang.security.audit.crypto.no-null-cipher.no-null-cipher - pattern: new NullCipher(...); - metadata: - cwe: 'CWE-327: Use of a Broken or Risky Cryptographic Algorithm' - owasp: 'A3: Sensitive Data Exposure' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#NULL_CIPHER - message: | - NullCipher was detected. This will not encrypt anything; - the cipher text will be the same as the plain text. Use - a valid, secure cipher: Cipher.getInstance("AES/GCM/NoPadding"). - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#no-null-cipher - severity: WARNING - languages: - - java -- id: java.lang.security.audit.crypto.ssl.avoid-implementing-custom-digests.avoid-implementing-custom-digests - metadata: - cwe: 'CWE-327: Use of a Broken or Risky Cryptographic Algorithm' - owasp: 'A3: Sensitive Data Exposure' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#CUSTOM_MESSAGE_DIGEST - references: - - https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#custom-algorithms - message: | - Cryptographic algorithms are notoriously difficult to get right. By implementing - a custom message digest, you risk introducing security issues into your program. - Use one of the many sound message digests already available to you: - MessageDigest sha256Digest = MessageDigest.getInstance("SHA256"); - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#avoid-implementing-custom-digests - severity: WARNING - languages: [java] - pattern: |- - class $CLASS extends MessageDigest { - ... - } -- id: java.lang.security.audit.crypto.ssl.defaulthttpclient-is-deprecated.defaulthttpclient-is-deprecated - metadata: - cwe: 'CWE-326: Inadequate Encryption Strength' - owasp: 'A3: Sensitive Data Exposure' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#DEFAULT_HTTP_CLIENT - message: | - DefaultHttpClient is deprecated. Further, it does not support connections - using TLS1.2, which makes using DefaultHttpClient a security hazard. - Use SystemDefaultHttpClient instead, which supports TLS1.2. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#defaulthttpclient-is-deprecated - severity: WARNING - languages: [java] - pattern: new DefaultHttpClient(...); -- id: java.lang.security.audit.crypto.ssl.insecure-hostname-verifier.insecure-hostname-verifier - message: | - Insecure HostnameVerifier implementation detected. This will accept - any SSL certificate with any hostname, which creates the possibility - for man-in-the-middle attacks. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#insecure-hostname-verifier - metadata: - cwe: 'CWE-295: Improper Certificate Validation' - owasp: 'A6: Security Misconfiguration' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#WEAK_HOSTNAME_VERIFIER - severity: WARNING - languages: [java] - patterns: - - pattern-either: - - pattern: | - class $CLASS implements HostnameVerifier { - ... - public boolean verify(...) { return true; } - } - - pattern: |- - new HostnameVerifier(...){ - public boolean verify(...) { - return true; - } - } -- id: java.lang.security.audit.crypto.ssl.insecure-trust-manager.insecure-trust-manager - metadata: - cwe: 'CWE-295: Improper Certificate Validation' - owasp: 'A3: Sensitive Data Exposure' - source-rule-url: https://find-sec-bugs.github.io/bugs.htm#WEAK_TRUST_MANAGER - references: - - https://stackoverflow.com/questions/2642777/trusting-all-certificates-using-httpclient-over-https - message: | - Detected empty trust manager implementations. This is dangerous because it accepts any - certificate, enabling man-in-the-middle attacks. Consider using a KeyStore - and TrustManagerFactory isntead. - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#insecure-trust-manager - severity: WARNING - languages: [java] - patterns: - - pattern-inside: | - class $CLASS implements X509TrustManager { - ... - } - - pattern-not: public void checkClientTrusted(...) { $SOMETHING; } - - pattern-not: public void checkServerTrusted(...) { $SOMETHING; } - - pattern-either: - - pattern: public void checkClientTrusted(...) {} - - pattern: public void checkServerTrusted(...) {} - - pattern: public X509Certificate[] getAcceptedIssuers(...) { return null; } -- id: java.lang.correctness.eqeq.eqeq - patterns: - - pattern-not-inside: assert $X; - - pattern-not-inside: | - assert $X : $Y; - - pattern-either: - - pattern: $X == $X - - pattern: $X != $X - - pattern-not: 1 == 1 - message: | - '`$X == $X` or `$X != $X` is a useless comparison unless the value compared - is a float or double. To test if `$X` is not-a-number, use `Double.isNaN($X)`.' - For more information, please refer to https://snowflakecomputing.atlassian.net/wiki/spaces/CLO/pages/1127713128/Semgrep+Finding+Remediation#eqeq - languages: [java] - severity: ERROR diff --git a/.whitesource b/.whitesource deleted file mode 100644 index 6b7f0e839..000000000 --- a/.whitesource +++ /dev/null @@ -1,19 +0,0 @@ -{ - "scanSettings": { - "configMode": "AUTO", - "configExternalURL": "", - "projectToken": "", - "baseBranches": [] - }, - "checkRunSettings": { - "vulnerableCheckRunConclusionLevel": "failure", - "displayMode": "diff" - }, - "issueSettings": { - "minSeverityLevel": "LOW" - }, - "remediateSettings": { - "enableRenovate": false, - "commitMessagePrefix": "SNOW-470176" - } -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 000000000..eedd90a7b --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,42 @@ +pipeline { + agent { label 'parallelizable-c7' } + options { timestamps() } + environment { + ingest_sdk_dir = "${WORKSPACE}/snowflake-ingest-java" + ingest_sdk_tag = sh(returnStdout: true, script: "cd $ingest_sdk_dir && git describe --tags").trim() + + } + stages { + stage('TriggerJobs') { + steps { + script { + def valid_db_name_tag = ingest_sdk_tag.split('\\.').join('_') + def deployments = [ + "qa3": { + build job: "SFPerf-Other-Jobs/TPCDS_BDEC_Setup", + parameters: [ + string(name: 'ingest_sdk_github_branch', value: ingest_sdk_tag), + string(name: 'database', value: "BENCHMARK_DB_BDEC_PERFORMANCE_SIGNOFF_${valid_db_name_tag}"), + string(name: 'deployment', value: 'qa3.us-west-2.aws'), + string(name: 'tpcds_scale_factor', value: 'sf1000') + ], + propagate: true + }, + "preprod12": { + build job: "SFPerf-Other-Jobs/TPCDS_BDEC_Setup", + parameters: [ + string(name: 'ingest_sdk_github_branch', value: ingest_sdk_tag), + string(name: 'database', value: "BENCHMARK_DB_BDEC_PERFORMANCE_SIGNOFF_${valid_db_name_tag}"), + string(name: 'deployment', value: 'preprod12.us-west-2.aws'), + string(name: 'tpcds_scale_factor', value: 'sf1000') + ], + propagate: true + } + ] + + parallel deployments + } + } + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 51aecbc53..92015a17d 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,29 @@ Snowflake Ingest Service Java SDK [![image](http://img.shields.io/:license-Apache%202-brightgreen.svg)](http://www.apache.org/licenses/LICENSE-2.0.txt) [![image](https://github.com/snowflakedb/snowflake-ingest-java/workflows/Snowpipe%20Java%20SDK%20Tests/badge.svg)](https://github.com/snowflakedb/snowflake-ingest-java/actions) -[![image](https://codecov.io/gh/snowflakedb/snowflake-ingest-java/branch/master/graph/badge.svg)](https://codecov.io/gh/snowflakedb/snowflake-ingest-java) [![image](https://maven-badges.herokuapp.com/maven-central/net.snowflake/snowflake-ingest-sdk/badge.svg?style=plastic)](https://repo.maven.apache.org/maven2/net/snowflake/snowflake-ingest-sdk/) The Snowflake Ingest Service SDK allows users to ingest files into their Snowflake data warehouse in a programmatic fashion via key-pair -authentication. +authentication. Currently, we support ingestion through the following APIs: +1. [Snowpipe](https://docs.snowflake.com/en/user-guide/data-load-snowpipe-rest-gs.html#client-requirement-java-or-python-sdk) +2. [Snowpipe Streaming](https://docs.snowflake.com/en/user-guide/data-load-snowpipe-streaming-overview) + +# Dependencies + +The Snowflake Ingest Service SDK depends on the following libraries: + +* snowflake-jdbc (3.13.30 to 3.14.5) +* slf4j-api +* com.github.luben:zstd-jni (1.5.0-1) + +These dependencies will be fetched automatically by build systems like Maven or Gradle. If you don't build your project +using a build system, please make sure these dependencies are on the classpath. # Prerequisites +**If your project depends on the Snowflake JDBC driver, as well, please make sure the JDBC driver version is 3.13.30 to 3.14.5. + ## Java 8+ The Snowflake Ingest Service SDK can only be used with Java 8 or higher. @@ -23,12 +37,12 @@ time. Snowflake Authentication for the Ingest Service requires creating a 2048 bit RSA key pair and, registering the public key with Snowflake. For detailed instructions, please visit the relevant [Snowflake -Documentation Page](docs.snowflake.net). +Documentation Page](https://docs.snowflake.com/en/user-guide/key-pair-auth.html). ## Maven (Developers only) -This SDK is developed as a [Maven](maven.apache.org) project. As a -result, you\'ll need to install maven to build the projects and, run +This SDK is developed as a Maven project. As a +result, you'll need to install Maven to build the projects and, run tests. # Adding as a Dependency @@ -52,6 +66,49 @@ dependencies { } ``` +## Jar Versions + +The Snowflake Ingest SDK provides shaded and unshaded versions of its jar. The shaded version bundles the dependencies into its own jar, +whereas the unshaded version declares its dependencies in `pom.xml`, which are fetched as standard transitive dependencies by the build system like Maven or Gradle. +The shaded JAR can help avoid potential dependency conflicts, but the unshaded version provides finer graned control over transitive dependencies. + +## Using with snowflake-jdbc-fips + +For use cases, which need to use `snowflake-jdbc-fips` instead of the default `snowflake-jdbc`, we recommend to take the following steps: + +- Use the unshaded version of the Ingest SDK. +- Exclude these transitive dependencies: + - `net.snowflake:snowflake-jdbc` + - `org.bouncycastle:bcpkix-jdk18on` + - `org.bouncycastle:bcprov-jdk18on` +- Add a dependency on `snowflake-jdbc-fips`. + +See [this test](https://github.com/snowflakedb/snowflake-ingest-java/tree/master/e2e-jar-test/fips) for an example how to use Snowflake Ingest SDK together with Snowflake FIPS JDBC Driver. + +# Example + +## Snowpipe + +Check out `SnowflakeIngestBasicExample.java` + +## Snowpipe Streaming + +Check out `SnowflakeStreamingIngestExample.java`, which performs following operations: +1. Reads a JSON file which contains details regarding Snowflake Account, User, Role and Private Key. Take a look at `profile_streaming.json.example` for more details. + 1. [Here](https://docs.snowflake.com/en/user-guide/key-pair-auth.html#configuring-key-pair-authentication) are the steps required to generate a private key. +2. Creates a `SnowflakeStreamingIngestClient` which can be used to open one or more Streaming Channels pointing to the same or different tables. +3. Creates a `SnowflakeStreamingIngestChannel` against a Database, Schema and Table. + 1. Please note: The database, schema and table is expected to be present before opening the Channel. Example SQL queries to create them: +```sql +create or replace database MY_DATABASE; +create or replace schema MY_SCHEMA; +create or replace table MY_TABLE(c1 number); +``` +4. Inserts 1000 rows into the channel created in 3rd step using the `insertRows` API on the Channel object + 1. `insertRows` API also takes in an optional `offsetToken` String which can be associated to this batch of rows. +5. Calls `getLatestCommittedOffsetToken` on the channel until the appropriate offset is found in Snowflake. +6. Close the channel when the ingestion is done to make sure everything is committed. + # Building From Source If you would like to build this project from source you can run the @@ -62,7 +119,7 @@ mvn install ``` If you would just like to build the jar in the source directory, you can -run +run ``` {.bash} mvn package @@ -72,16 +129,9 @@ However, for general usage, pulling a pre-built jar from maven is recommended. If you would like to run SnowflakeIngestBasicExample.java or SnowflakeStreamingIngestExample.java in the example folder, -you would need to remove the following scope limits in pom.xml +please edit `pom.xml` and change the scope of the dependency `slf4j-simple` from `test` to `runtime` in order to enable +console log output. -
-<!-- Remove provided scope from slf4j-api -->
-<dependency>
-    <groupId>org.slf4j</groupId>
-    <artifactId>slf4j-api</artifactId>
-    <scope>provided</scope>
-</dependency>
-
# Testing (SimpleIngestIT Test) @@ -93,11 +143,13 @@ you would need to remove the following scope limits in pom.xml - Use an unencrypted version(Only for testing) of private key while generating keys(private and public pair) using OpenSSL. - Here is the link for documentation [Key Pair - Generator](https://docs.snowflake.net/manuals/user-guide/python-connector-example.html#using-key-pair-authentication) + Generator](https://docs.snowflake.com/en/user-guide/key-pair-auth.html) -# Google Java Format +# Contributing to this repo -- Download the formatter jar file from https://github.com/google/google-java-format, then run it with -``` -java -jar ~/path-to/google-java-format-1.10.0-all-deps.jar -i $(find . -type f -name "*.java" | grep ".*/src/.*java") -``` +Each PR must pass all required github action merge gates before approval and merge. In addition to those tests, you will need: + +- Formatter: run this script [`./format.sh`](https://github.com/snowflakedb/snowflake-ingest-java/blob/master/format.sh) from root +- CLA: all contributers must sign the Snowflake CLA. This is a one time signature, please provide your email so we can work with you to get this signed after you open a PR. + +Thank you for contributing! We will review and approve PRs as soon as we can. diff --git a/deploy.sh b/deploy.sh index 0df76eedf..d3cd928fc 100755 --- a/deploy.sh +++ b/deploy.sh @@ -84,8 +84,4 @@ mvn ${MVN_OPTIONS[@]} \ -DstagingRepositoryId=$snowflake_repositories \ -DstagingDescription="Automated Release" -rm $OSSRH_DEPLOY_SETTINGS_XML - -#white source -chmod 755 ./scripts/run_whitesource_gh.sh -scripts/run_whitesource_gh.sh +rm $OSSRH_DEPLOY_SETTINGS_XML \ No newline at end of file diff --git a/e2e-jar-test/core/pom.xml b/e2e-jar-test/core/pom.xml new file mode 100644 index 000000000..36d52ae5b --- /dev/null +++ b/e2e-jar-test/core/pom.xml @@ -0,0 +1,38 @@ + + 4.0.0 + + net.snowflake.snowflake-ingest-java-e2e-jar-test + parent + 1.0-SNAPSHOT + + + core + core + jar + + + UTF-8 + 1.8 + 1.8 + + + + + + net.snowflake + snowflake-ingest-sdk + provided + + + + org.slf4j + slf4j-simple + + + + com.fasterxml.jackson.core + jackson-databind + + + diff --git a/e2e-jar-test/core/src/main/java/net/snowflake/IngestTestUtils.java b/e2e-jar-test/core/src/main/java/net/snowflake/IngestTestUtils.java new file mode 100644 index 000000000..e7db8d1c2 --- /dev/null +++ b/e2e-jar-test/core/src/main/java/net/snowflake/IngestTestUtils.java @@ -0,0 +1,200 @@ +package net.snowflake; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Properties; +import java.util.Random; +import java.util.UUID; + +import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClient; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClientFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class IngestTestUtils { + private static final String PROFILE_PATH = "profile.json"; + + private final Connection connection; + + private final String database; + private final String schema; + private final String table; + + private final String testId; + + private static final Logger logger = LoggerFactory.getLogger(IngestTestUtils.class); + + private final SnowflakeStreamingIngestClient client; + + private final SnowflakeStreamingIngestChannel channel; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final Random random = new Random(); + + private final Base64.Decoder base64Decoder = Base64.getDecoder(); + + public IngestTestUtils(String testName) + throws SQLException, + IOException, + ClassNotFoundException, + NoSuchAlgorithmException, + InvalidKeySpecException { + testId = String.format("%s_%s", testName, UUID.randomUUID().toString().replace("-", "_")); + connection = getConnection(); + database = String.format("database_%s", testId); + schema = String.format("schema_%s", testId); + table = String.format("table_%s", testId); + + connection.createStatement().execute(String.format("create database %s", database)); + connection.createStatement().execute(String.format("create schema %s", schema)); + connection.createStatement().execute(String.format("create table %s (c1 int, c2 varchar, c3 binary)", table)); + + client = + SnowflakeStreamingIngestClientFactory.builder("TestClient01") + .setProperties(loadProperties()) + .build(); + + channel = client.openChannel( + OpenChannelRequest.builder(String.format("channel_%s", this.testId)) + .setDBName(database) + .setSchemaName(schema) + .setTableName(table) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .build()); + } + + private Properties loadProperties() throws IOException { + Properties props = new Properties(); + Iterator> propIt = + objectMapper.readTree(new String(Files.readAllBytes(Paths.get(PROFILE_PATH)))).fields(); + while (propIt.hasNext()) { + Map.Entry prop = propIt.next(); + props.put(prop.getKey(), prop.getValue().asText()); + } + return props; + } + + private Connection getConnection() + throws IOException, ClassNotFoundException, SQLException, NoSuchAlgorithmException, InvalidKeySpecException { + Class.forName("net.snowflake.client.jdbc.SnowflakeDriver"); + + Properties loadedProps = loadProperties(); + + byte[] decoded = base64Decoder.decode(loadedProps.getProperty("private_key")); + KeyFactory kf = KeyFactory.getInstance("RSA"); + + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded); + PrivateKey privateKey = kf.generatePrivate(keySpec); + + Properties props = new Properties(); + props.putAll(loadedProps); + props.put("client_session_keep_alive", "true"); + props.put("privateKey", privateKey); + + return DriverManager.getConnection(loadedProps.getProperty("connect_string"), props); + } + + private Map createRow() { + Map row = new HashMap<>(); + + byte[] bytes = new byte[1024]; + random.nextBytes(bytes); + + row.put("c1", random.nextInt()); + row.put("c2", String.valueOf(random.nextInt())); + row.put("c3", bytes); + + return row; + } + + /** + * Given a channel and expected offset, this method waits up to 60 seconds until the last + * committed offset is equal to the passed offset + */ + private void waitForOffset(SnowflakeStreamingIngestChannel channel, String expectedOffset) + throws InterruptedException { + int counter = 0; + String lastCommittedOffset = null; + while (counter < 600) { + String currentOffset = channel.getLatestCommittedOffsetToken(); + if (expectedOffset.equals(currentOffset)) { + return; + } + System.out.printf("Waiting for offset expected=%s actual=%s%n", expectedOffset, currentOffset); + lastCommittedOffset = currentOffset; + counter++; + Thread.sleep(100); + } + throw new RuntimeException( + String.format( + "Timeout exceeded while waiting for offset %s. Last committed offset: %s", + expectedOffset, lastCommittedOffset)); + } + + public void runBasicTest() throws InterruptedException { + // Insert few rows one by one + for (int offset = 2; offset < 1000; offset++) { + offset++; + channel.insertRow(createRow(), String.valueOf(offset)); + } + + // Insert a batch of rows + String offset = "final-offset"; + channel.insertRows( + Arrays.asList(createRow(), createRow(), createRow(), createRow(), createRow()), offset); + + waitForOffset(channel, offset); + } + + public void runLongRunningTest(Duration testDuration) throws InterruptedException { + final Instant testStart = Instant.now(); + int counter = 0; + while(true) { + counter++; + + channel.insertRow(createRow(), String.valueOf(counter)); + + if (!channel.isValid()) { + throw new IllegalStateException("Channel has been invalidated"); + } + Thread.sleep(60000); + + final Duration elapsed = Duration.between(testStart, Instant.now()); + + logger.info("Test loop_nr={} duration={}s/{}s committed_offset={}", counter, elapsed.get(ChronoUnit.SECONDS), testDuration.get(ChronoUnit.SECONDS), channel.getLatestCommittedOffsetToken()); + + if (elapsed.compareTo(testDuration) > 0) { + break; + } + } + waitForOffset(channel, String.valueOf(counter)); + } + + public void close() throws Exception { + connection.close(); + channel.close().get(); + client.close(); + } +} diff --git a/e2e-jar-test/fips/pom.xml b/e2e-jar-test/fips/pom.xml new file mode 100644 index 000000000..039af9b7a --- /dev/null +++ b/e2e-jar-test/fips/pom.xml @@ -0,0 +1,53 @@ + + 4.0.0 + + net.snowflake.snowflake-ingest-java-e2e-jar-test + parent + 1.0-SNAPSHOT + + + fips + jar + fips + + + UTF-8 + 1.8 + 1.8 + + + + + net.snowflake.snowflake-ingest-java-e2e-jar-test + core + + + + net.snowflake + snowflake-ingest-sdk + + + net.snowflake + snowflake-jdbc + + + org.bouncycastle + bcpkix-jdk18on + + + org.bouncycastle + bcprov-jdk18on + + + + + net.snowflake + snowflake-jdbc-fips + + + junit + junit + + + diff --git a/e2e-jar-test/fips/src/test/java/net/snowflake/FipsIngestE2ETest.java b/e2e-jar-test/fips/src/test/java/net/snowflake/FipsIngestE2ETest.java new file mode 100644 index 000000000..c6f9bfe33 --- /dev/null +++ b/e2e-jar-test/fips/src/test/java/net/snowflake/FipsIngestE2ETest.java @@ -0,0 +1,40 @@ +package net.snowflake; + +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +import java.security.Security; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +public class FipsIngestE2ETest { + + private IngestTestUtils ingestTestUtils; + + @Before + public void setUp() throws Exception { + // Add FIPS provider, the SDK does not do this by default + Security.addProvider(new BouncyCastleFipsProvider("C:HYBRID;ENABLE{All};")); + + ingestTestUtils = new IngestTestUtils("fips_ingest"); + } + + @After + public void tearDown() throws Exception { + ingestTestUtils.close(); + } + + @Test + public void basicTest() throws InterruptedException { + ingestTestUtils.runBasicTest(); + } + + @Test + @Ignore("Takes too long to run") + public void longRunningTest() throws InterruptedException { + ingestTestUtils.runLongRunningTest(Duration.of(80, ChronoUnit.MINUTES)); + } +} diff --git a/e2e-jar-test/pom.xml b/e2e-jar-test/pom.xml new file mode 100644 index 000000000..e9fb43592 --- /dev/null +++ b/e2e-jar-test/pom.xml @@ -0,0 +1,65 @@ + + 4.0.0 + + net.snowflake.snowflake-ingest-java-e2e-jar-test + parent + 1.0-SNAPSHOT + pom + + snowflake-ingest-sdk-e2e-test + + + standard + fips + core + + + + + + + net.snowflake.snowflake-ingest-java-e2e-jar-test + core + ${project.version} + + + + net.snowflake + snowflake-ingest-sdk + 2.0.0 + + + + net.snowflake + snowflake-jdbc-fips + 3.13.34 + + + + org.bouncycastle + bc-fips + 1.0.2.4 + + + + org.slf4j + slf4j-simple + 1.7.36 + + + + com.fasterxml.jackson.core + jackson-databind + 2.14.0 + + + + junit + junit + 4.13.2 + test + + + + diff --git a/e2e-jar-test/run_e2e_jar_test.sh b/e2e-jar-test/run_e2e_jar_test.sh new file mode 100755 index 000000000..e5e9b5c55 --- /dev/null +++ b/e2e-jar-test/run_e2e_jar_test.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -euo pipefail + +## This script tests the SDK JARs end-to-end, i.e. not using integration tests from within the project, but from an +## external Maven project, which depends on the SDK deployed into the local maven repository. The following SDK variants are tested: +## 1. Shaded jar +## 2. Unshaded jar +## 3. FIPS-compliant jar, i.e. unshaded jar without snowflake-jdbc and bouncy castle dependencies, but with snowflake-jdbc-fips depedency + +maven_repo_dir=$(mvn help:evaluate -Dexpression=settings.localRepository -q -DforceStdout) +sdk_repo_dir="${maven_repo_dir}/net/snowflake/snowflake-ingest-sdk" + +cp profile.json e2e-jar-test/standard +cp profile.json e2e-jar-test/fips + +################### +# TEST SHADED JAR # +################### + +# Remove the SDK from local maven repository +rm -fr $sdk_repo_dir + +# Prepare pom.xml for shaded JAR +project_version=$(./scripts/get_project_info_from_pom.py pom.xml version) +./scripts/update_project_version.py public_pom.xml $project_version > generated_public_pom.xml + +# Build shaded SDK +mvn clean package -DskipTests=true --batch-mode --show-version + +# Install shaded SDK JARs into local maven repository +mvn install:install-file -Dfile=target/snowflake-ingest-sdk.jar -DpomFile=generated_public_pom.xml + +# Run e2e tests +(cd e2e-jar-test && mvn clean verify -pl standard -am) + +##################### +# TEST UNSHADED JAR # +##################### + +# Remove the SDK from local maven repository +rm -r $sdk_repo_dir + +# Install unshaded SDK into local maven repository +mvn clean install -Dnot-shadeDep -DskipTests=true --batch-mode --show-version + +# Run e2e tests +(cd e2e-jar-test && mvn clean verify -pl standard -am) + +############# +# TEST FIPS # +############# + +# Remove the SDK from local maven repository +rm -r $sdk_repo_dir + +# Install unshaded SDK into local maven repository +mvn clean install -Dnot-shadeDep -DskipTests=true --batch-mode --show-version + +# Run e2e tests on the FIPS module +(cd e2e-jar-test && mvn clean verify -pl fips -am) \ No newline at end of file diff --git a/e2e-jar-test/standard/pom.xml b/e2e-jar-test/standard/pom.xml new file mode 100644 index 000000000..2fdddcddd --- /dev/null +++ b/e2e-jar-test/standard/pom.xml @@ -0,0 +1,34 @@ + + 4.0.0 + + net.snowflake.snowflake-ingest-java-e2e-jar-test + parent + 1.0-SNAPSHOT + + + standard + jar + unshaded + + + UTF-8 + 1.8 + 1.8 + + + + + net.snowflake.snowflake-ingest-java-e2e-jar-test + core + + + net.snowflake + snowflake-ingest-sdk + + + junit + junit + + + diff --git a/e2e-jar-test/standard/src/test/java/net/snowflake/StandardIngestE2ETest.java b/e2e-jar-test/standard/src/test/java/net/snowflake/StandardIngestE2ETest.java new file mode 100644 index 000000000..211c421fc --- /dev/null +++ b/e2e-jar-test/standard/src/test/java/net/snowflake/StandardIngestE2ETest.java @@ -0,0 +1,35 @@ +package net.snowflake; + +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +public class StandardIngestE2ETest { + + private IngestTestUtils ingestTestUtils; + + @Before + public void setUp() throws Exception { + ingestTestUtils = new IngestTestUtils("standard_ingest"); + } + + @After + public void tearDown() throws Exception { + ingestTestUtils.close(); + } + + @Test + public void basicTest() throws InterruptedException { + ingestTestUtils.runBasicTest(); + } + + @Test + @Ignore("Takes too long to run") + public void longRunningTest() throws InterruptedException { + ingestTestUtils.runLongRunningTest(Duration.of(80, ChronoUnit.MINUTES)); + } +} diff --git a/format.sh b/format.sh new file mode 100755 index 000000000..cb58bd995 --- /dev/null +++ b/format.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +DOWNLOAD_URL="https://github.com/google/google-java-format/releases/download/v1.10.0/google-java-format-1.10.0-all-deps.jar" +JAR_FILE="./.cache/google-java-format-1.10.0-all-deps.jar" + +if [ ! -f "${JAR_FILE}" ]; then + mkdir -p "$(dirname "${JAR_FILE}")" + echo "Downloading Google Java format to ${JAR_FILE}" + curl -# -L --fail "${DOWNLOAD_URL}" --output "${JAR_FILE}" +fi + +if ! command -v java > /dev/null; then + echo "Java not installed." + exit 1 +fi +echo "Running Google Java Format" +find ./src -type f -name "*.java" -print0 | xargs -0 java -jar "${JAR_FILE}" --replace --set-exit-if-changed && echo "OK" + +echo "Sorting pom.xml" +mvn com.github.ekryd.sortpom:sortpom-maven-plugin:sort diff --git a/linkage-checker-exclusion-rules.xml b/linkage-checker-exclusion-rules.xml new file mode 100644 index 000000000..0cb2eb38c --- /dev/null +++ b/linkage-checker-exclusion-rules.xml @@ -0,0 +1,161 @@ + + + + + + + + + + Bouncy castle is an optional dependency of nimbus-jose-jwt + + + + + + + + + Google Crypto Tink is an optional dependency of nimbus-jose-jwt + + + + + + + Seems like a false positive, this class does exist on classpath. + + + + + + Seems like a false positive, this class does exist on classpath. + + + + + + + Not used in the SDK + + + + + + + + + Relates to security/SSL pulled in by hadoop-auth, not used + + + + + + We do not need Hadoop authentication + + + + + + + + We don't explicitly exclude any dependency of the JDBC driver. + If some classes are missing, they know what are they doing. + + + + + + + + + + Optional dependency of Snowflake JDBC driver that the SDK doesn't need + + + + + + + + + + + + + + The SDK is not using mapreduce + + + + + + The SDK is not using mapreduce + + + + + + The SDK is not using Hadoop YARN + + + + + + The SDK does not use XZ compression + + + + + + Client library for Apache ZooKeeper, not used in the SDK + + + + + + SSH client, not used in the SDK + + + + + + The SDK is not using Avro + + + + + + Not used, the SDK is using FasterXML Jackson + + + + + + Comes from dnsjava, which is not used in the SDK + + + + + + Not used in the SDK + + + + + + The SDK does not log with log4j + + + + + + The SDK does not use web services + + + + + + The SDK does not use Jetty web server + + diff --git a/pom.xml b/pom.xml index 9a747959f..58cd1f485 100644 --- a/pom.xml +++ b/pom.xml @@ -1,561 +1,1268 @@ - - 4.0.0 + + 4.0.0 - - net.snowflake - snowflake-ingest-sdk - 1.0.1-beta - jar - Snowflake Ingest SDK - Snowflake Ingest SDK - https://www.snowflake.net/ + + io.confluent + snowflake-ingest-sdk + 2.0.0 + jar + Snowflake Ingest SDK + Snowflake Ingest SDK + + + 1.74 + 1.9.13 + 1.15 + 3.2.2 + 1.21 + 2.8.0 + 3.12.0 + 1.2 + 1.10.0 + 2.14.0 + 32.0.1-jre + 3.3.6 + true + 0.8.5 + ${project.build.directory}/dependency-jars + ${project.build.directory}/dependency-list.txt + ${project.build.directory}/generated-licenses-info + ${license.processing.resourcesRoot}/META-INF/third-party-licenses + 1.8 + 1.8 + 2.4.9 + 4.1.94.Final + 9.31 + 3.1 + 1.13.1 + UTF-8 + 3.19.6 + net.snowflake.ingest.internal + 1.7.36 + 1.1.10.4 + 3.14.5 + 0.13.0 + - - - The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - - - - - - Snowflake Support Team - snowflake-java@snowflake.net - Snowflake Computing - https://www.snowflake.net - - - - - scm:git:git://github.com/snowflakedb/snowflake-ingest-java - http://github.com/snowflakedb/snowflake-ingest-java/tree/master - - - - - 1.8 - 1.8 - net.snowflake.ingest.internal - true - 0.8.5 - - - - ${project.artifactId} - - - src/main/resources - true - - - - - org.apache.maven.plugins - maven-surefire-plugin - 2.19.1 - - - **/TestSimpleIngestLocal.java - - - - - maven-assembly-plugin - - - - true - net.snowflake.ingest.SimpleIngestManager - - - - jar-with-dependencies - - - - - org.apache.maven.plugins - maven-compiler-plugin - 2.5.1 - true - - 1.8 - 1.8 - - - - org.apache.maven.plugins - maven-javadoc-plugin - 2.10.4 - - - attach-javadocs - - jar - - - -Xdoclint:none - 8 - - - - - - org.apache.maven.plugins - maven-source-plugin - 3.0.1 - - - attach-sources - - jar - - - - - - org.codehaus.mojo - exec-maven-plugin - - - check-shaded-content - verify - - exec - - - ${basedir}/scripts/check_content.sh - - - - - - - - - - - maven-deploy-plugin - - true - - - - org.jacoco - jacoco-maven-plugin - ${jacoco.version} - - - pre-unit-test - - prepare-agent - - - target/jacoco-ut.exec - - - - post-unit-test - test - - report - - - target/jacoco-ut.exec - target/jacoco-ut - - - - - ${jacoco.skip.instrument} - - - - - - org.apache.maven.plugins - maven-failsafe-plugin - 3.0.0-M5 - - - **/StreamingIngestIT.java - - - - - - - - + + + com.fasterxml.jackson + jackson-bom + ${fasterxml.version} + pom + import + + + com.google.guava + guava + ${guava.version} + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + + com.nimbusds + nimbus-jose-jwt + ${nimbusds.version} + + + commons-codec + commons-codec + ${commonscodec.version} + + + commons-collections + commons-collections + ${commonscollections.version} + + + commons-io + commons-io + ${commonsio.version} + + + commons-logging + commons-logging + ${commonslogging.version} + + + io.netty + netty-buffer + ${netty.version} + + + io.netty + netty-common + ${netty.version} + + + net.minidev + json-smart + ${net.minidev.version} + + + net.snowflake + snowflake-jdbc + ${snowjdbc.version} + + + org.apache.commons + commons-compress + ${commonscompress.version} + + + org.apache.commons + commons-lang3 + ${commonslang3.version} + + + org.apache.commons + commons-text + ${commonstext.version} + + + org.apache.hadoop + hadoop-common + ${hadoop.version} + + + ch.qos.reload4j + reload4j + + + com.github.pjfanning + jersey-json + + + com.jcraft + jsch + + + com.sun.jersey + jersey-core + + + com.sun.jersey + jersey-server + + + com.sun.jersey + jersey-servlet + + + commons-logging + commons-logging + + + dnsjava + dnsjava + + + io.dropwizard.metrics + metrics-core + + + javax.activation + activation + + + javax.servlet + javax.servlet-api + + + javax.servlet.jsp + jsp-api + + + org.apache.avro + avro + + + org.apache.curator + curator-client + + + org.apache.curator + curator-recipes + + + org.apache.hadoop + hadoop-auth + + + org.apache.httpcomponents + httpclient + + + org.apache.kerby + kerb-core + + + org.apache.zookeeper + zookeeper + + + org.eclipse.jetty + jetty-server + + + org.eclipse.jetty + jetty-servlet + + + org.eclipse.jetty + jetty-util + + + org.eclipse.jetty + jetty-webapp + + + org.slf4j + slf4j-log4j12 + + + org.slf4j + slf4j-reload4j + + + + + org.apache.parquet + parquet-column + ${parquet.version} + + + org.apache.parquet + parquet-common + ${parquet.version} + + + org.apache.parquet + parquet-hadoop + ${parquet.version} + + + org.apache.yetus + audience-annotations + ${yetus.version} + + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + + + org.codehaus.jackson + jackson-core-asl + ${codehaus.version} + + + org.codehaus.jackson + jackson-jaxrs + ${codehaus.version} + + + org.codehaus.jackson + jackson-xc + ${codehaus.version} + + + org.codehaus.jettison + jettison + 1.5.4 + + + org.codehaus.woodstox + stax2-api + 4.2.1 + + + org.objenesis + objenesis + ${objenesis.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.xerial.snappy + snappy-java + ${snappy.version} + - - - net.snowflake - snowflake-jdbc - 3.13.14 - + + + junit + junit + 4.13.2 + test + + + net.bytebuddy + byte-buddy + 1.10.19 + test + + + net.bytebuddy + byte-buddy-agent + 1.10.19 + test + + + org.mockito + mockito-core + 3.7.7 + test + + + - - - com.ibm.icu - icu4j - 70.1 - + + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-core + + + + com.fasterxml.jackson.core + jackson-databind + - - - com.nimbusds - nimbus-jose-jwt - 9.9.3 - + + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + + + + com.google.errorprone + error_prone_annotations + + + org.checkerframework + checker-qual + + + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + com.google.guava + guava + - - - com.fasterxml.jackson.core - jackson-core - 2.13.1 - + + + com.nimbusds + nimbus-jose-jwt + + + commons-codec + commons-codec + - - - com.fasterxml.jackson.core - jackson-databind - 2.13.2.1 - + + + io.dropwizard.metrics + metrics-core + 4.1.22 + - - - org.apache.httpcomponents - httpclient - 4.5.13 - - - commons-codec - commons-codec - - - + + + io.dropwizard.metrics + metrics-jmx + 4.1.22 + - - commons-codec - commons-codec - 1.15 - + + + io.dropwizard.metrics + metrics-jvm + 4.1.22 + + + io.netty + netty-common + + + net.bytebuddy + byte-buddy-agent + + + + net.snowflake + snowflake-jdbc + + + org.apache.hadoop + hadoop-common + + + + org.apache.parquet + parquet-column + + + org.apache.parquet + parquet-common + + + org.apache.parquet + parquet-hadoop + + + org.bouncycastle + bcpkix-jdk18on + + + org.bouncycastle + bcprov-jdk18on + + + org.slf4j + slf4j-api + + + com.github.luben + zstd-jni + 1.5.0-1 + runtime + + + com.google.protobuf + protobuf-java + runtime + + + org.xerial.snappy + snappy-java + runtime + - - - org.apache.httpcomponents - httpasyncclient - 4.1.2 - + + + junit + junit + test + + + org.apache.commons + commons-lang3 + test + + + org.hamcrest + hamcrest-core + 1.3 + test + + + org.mockito + mockito-core + test + + + org.powermock + powermock-api-mockito2 + 2.0.2 + test + + + org.powermock + powermock-core + 2.0.2 + test + + + org.powermock + powermock-module-junit4 + 2.0.2 + test + + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + ${project.artifactId} + + + true + src/main/resources + + + ${license.processing.resourcesRoot} + + - - - org.slf4j - slf4j-api - 1.7.21 - provided - + + + + + maven-deploy-plugin + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M3 + + + com.google.cloud.tools + linkage-checker-enforcer-rules + 1.5.13 + + + org.codehaus.mojo + extra-enforcer-rules + 1.3 + + + org.eclipse.aether + aether-util + + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.0.0-M5 + + + org.apache.maven.plugins + maven-surefire-plugin + 2.19.1 + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + ${jacoco.skip.instrument} + + **/*SnowflakeStreamingIngestExample* + **/*SnowflakeIngestBasicExample* + **/*IngestExampleHelper* + + + + + pre-unit-test + + prepare-agent + + + target/jacoco-ut.exec + + + + post-unit-test + + report + + test + + target/jacoco-ut.exec + target/jacoco-ut + + + + + + + + + maven-assembly-plugin + + + + true + net.snowflake.ingest.SimpleIngestManager + + + + jar-with-dependencies + + + + + org.apache.maven.plugins + maven-compiler-plugin + 2.5.1 + true + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.3.0 + + + analyze + + analyze-only + + + true + true + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-best-practices + + enforce + + + + + + META-INF/versions/*/org/bouncycastle/* + + true + true + + + + + + + + + + enforce-maven + + enforce + + + + + 3.5 + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.10.4 + + + attach-javadocs + + jar + + + -Xdoclint:none + 8 + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.0.1 + + + attach-sources + + jar + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.19.1 + + + **/TestSimpleIngestLocal.java + + + + + org.codehaus.mojo + license-maven-plugin + 2.0.1 + + failFast + + + Apache License 2.0 + BSD 2-Clause License + 3-Clause BSD License + The MIT License + EDL 1.0 + The Go license + Bouncy Castle Licence + + test,provided,system + true + + Apache License 2.0 + |The Apache License, Version 2.0 + |The Apache Software License, Version 2.0 + |Apache-2.0 + |Apache License, Version 2.0 + |Apache 2.0 + |Apache License V2.0 + BSD 2-Clause License + |The BSD License + The MIT License|MIT License + + + + + add-third-party + + add-third-party + + package + + + + + + https://www.snowflake.net/ - - - org.slf4j - slf4j-simple - 1.7.21 - test - + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + - - - - javax.xml.bind - jaxb-api - 2.3.1 - + + + Snowflake Support Team + snowflake-java@snowflake.net + Snowflake Computing + https://www.snowflake.net + + + + scm:git:git://github.com/snowflakedb/snowflake-ingest-java + https://github.com/snowflakedb/snowflake-ingest-java/tree/master + - - - junit - junit - 4.13.2 - test - - - org.powermock - powermock-module-junit4 - 2.0.2 - test - - - org.mockito - mockito-core - 3.7.7 - test - - - org.powermock - powermock-api-mockito2 - 2.0.2 - test - - - org.powermock - powermock-core - 2.0.2 - test - + + + checkLinkageErrors + + + + not-shadeDep + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-linkage-checker + + enforce + + verify + + + + true + ${basedir}/linkage-checker-exclusion-rules.xml + WARN + + + + + + + + + + - - - org.apache.arrow - arrow-vector - 4.0.0 - - - org.apache.arrow - arrow-memory-netty - 4.0.0 - runtime - + + - - - io.dropwizard.metrics - metrics-core - 4.1.22 - + + + + + checkShadedContent + + false + + + + + org.codehaus.mojo + exec-maven-plugin + + + check-shaded-content + + exec + + verify + + ${basedir}/scripts/check_content.sh + + + + + + + + + shadeDep + + + !not-shadeDep + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-dependencies + + copy-dependencies + + generate-resources + + ${license.processing.dependencyJarsDir} + false + false + true + + + + - - - io.dropwizard.metrics - metrics-jvm - 4.1.22 - + + + org.apache.maven.plugins + maven-dependency-plugin + 3.6.0 + + runtime + ${license.processing.dependencyListFile} + + + + + list + + generate-resources + + + - - - io.dropwizard.metrics - metrics-jmx - 4.2.3 - - + + + org.apache.maven.plugins + maven-shade-plugin + 3.4.1 + + + + net.snowflake:snowflake-jdbc + org.slf4j:slf4j-api + com.github.luben:zstd-jni + + + + + com.nimbusds + ${shadeBase}.com.nimbusds + + + org.bouncycastle + ${shadeBase}.org.bouncycastle + + + net.jcip + ${shadeBase}.net.jcip + + + net.minidev + ${shadeBase}.net.minidev + + + org.objectweb + ${shadeBase}.org.objectweb + + + com.fasterxml + ${shadeBase}.fasterxml + + + org.apache + ${shadeBase}.apache + + + org.xbill + ${shadeBase}.org.xbill + + + org.xerial + ${shadeBase}.org.xerial + + + io.netty + ${shadeBase}.io.netty + + + com.google + ${shadeBase}.com.google + + + com.github.benmanes + ${shadeBase}.com.github.benmanes + + + + shaded.parquet + ${shadeBase}.shaded.parquet + + + org.codehaus + ${shadeBase}.org.codehaus + + + com.jcraft + ${shadeBase}.com.jcraft + + + org.eclipse + ${shadeBase}.org.eclipse + + + org.checkerframework + ${shadeBase}.org.checkerframework + + + com.codahale + ${shadeBase}.com.codahale + + + com.ctc + ${shadeBase}.com.ctc + + + com.thoughtworks + ${shadeBase}.com.thoughtworks + + + + codegen + ${shadeBase}.codegen + + + javax.annotation + ${shadeBase}.javax.annotation + + + javax.activation + ${shadeBase}.javax.activation + + + io.airlift.compress + ${shadeBase}.io.airlift.compress + + + + + *:* + + assets/org/apache/commons/math3/exception/util/LocalizedFormats_fr.properties + about.html + mozilla/public-suffix-list.txt - - - shadeDep - - - !not-shadeDep - - - - - - - org.apache.maven.plugins - maven-shade-plugin - 3.1.1 - - - - com.nimbusds - - ${shadeBase}.com.nimbusds - - - - net.jcip - ${shadeBase}.net.jcip - - - net.minidev - ${shadeBase}.net.minidev - - - org.objectweb - ${shadeBase}.org.objectweb - - - com.fasterxml - ${shadeBase}.fasterxml - - - org.apache - ${shadeBase}.apache - - - - - *:* - - META-INF/LICENSE* - META-INF/NOTICE* - META-INF/DEPENDENCIES - META-INF/maven/** - META-INF/services/com.fasterxml.* - META-INF/*.xml - META-INF/*.SF - META-INF/*.DSA - META-INF/*.RSA - - - - commons-logging:commons-logging - - org/apache/commons/logging/impl/AvalonLogger.class - - - - org.slf4j:slf4j-simple - - org/slf4j/** - - - - - - - - - - - - package - - shade - - - - - - - - - ossrh-deploy - - - ossrh-deploy - - - - - - org.apache.maven.plugins - maven-gpg-plugin - 1.6 - - - deploy - - sign-and-deploy-file - - - target/${project.artifactId}.jar - ossrh - https://oss.sonatype.org/service/local/staging/deploy/maven2 - generated_public_pom.xml - target/${project.artifactId}-javadoc.jar - target/${project.artifactId}-sources.jar - ${env.GPG_KEY_ID} - ${env.GPG_KEY_PASSPHRASE} - - - - - - - - - ghActionsIT - - - ghActionsIT - - - - - - org.apache.maven.plugins - maven-failsafe-plugin - - - - integration-test - - - - verify_github_actions_it - verify - - verify - - - - - - org.jacoco - jacoco-maven-plugin - ${jacoco.version} - - - pre-unit-test - - prepare-agent - - - target/jacoco-ut.exec - - - - post-unit-test - test - - report - - - target/jacoco-ut.exec - target/jacoco-ut - - - - pre-integration-test - pre-integration-test - - prepare-agent - - - target/jacoco-it.exec - - - - post-integration-test - post-integration-test - - report - - - target/jacoco-it.exec - target/jacoco-it - - - - - ${jacoco.skip.instrument} - - - - - - + META-INF/LICENSE* + META-INF/NOTICE* + META-INF/DEPENDENCIES + META-INF/maven/** + META-INF/services/com.fasterxml.* + META-INF/*.xml + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + google/protobuf/**/*.proto + + + + commons-logging:commons-logging + + org/apache/commons/logging/impl/AvalonLogger.class + + + + org.slf4j:slf4j-simple + + org/slf4j/** + + + + + + + + + + + + shade + + package + + + + + + org.codehaus.mojo + exec-maven-plugin + + + process-third-party-licenses + + exec + + generate-resources + + python3 + + ${project.basedir}/scripts/process_licenses.py + ${license.processing.dependencyListFile} + ${license.processing.dependencyJarsDir} + ${license.processing.targetDir} + + + + + + + + + + ossrh-deploy + + + ossrh-deploy + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + + sign-and-deploy-file + + deploy + + target/${project.artifactId}.jar + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2 + generated_public_pom.xml + target/${project.artifactId}-javadoc.jar + target/${project.artifactId}-sources.jar + ${env.GPG_KEY_ID} + ${env.GPG_KEY_PASSPHRASE} + + + + + + + + + snapshot-deploy + + + snapshot-deploy + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + + sign-and-deploy-file + + deploy + + target/${project.artifactId}.jar + snapshot + https://nexus.int.snowflakecomputing.com/repository/Snapshots/ + generated_public_pom.xml + target/${project.artifactId}-javadoc.jar + target/${project.artifactId}-sources.jar + ${env.GPG_KEY_ID} + ${env.GPG_KEY_PASSPHRASE} + + + + + + + + + ghActionsIT + + + ghActionsIT + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + + + + verify_github_actions_it + + verify + + verify + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + ${jacoco.skip.instrument} + + + + pre-unit-test + + prepare-agent + + + target/jacoco-ut.exec + + + + post-unit-test + + report + + test + + target/jacoco-ut.exec + target/jacoco-ut + + + + pre-integration-test + + prepare-agent + + pre-integration-test + + target/jacoco-it.exec + + + + post-integration-test + + report + + post-integration-test + + target/jacoco-it.exec + target/jacoco-it + + + + + + + + diff --git a/profile.json.example b/profile.json.example index 9f99ad0c1..46dbd42f0 100644 --- a/profile.json.example +++ b/profile.json.example @@ -1,15 +1,15 @@ { "user": "user name", - "url": "https://account_name.snowflakecomputing.com:443" + "url": "https://account_name.snowflakecomputing.com:443", "account": "account_name", "private_key": "PEM Private Key", "port": 443, "host": "account_name.snowflakecomputing.com", "schema": "schema", "scheme": "https", - "database": "database name", + "database": "database_name", "connect_string": "jdbc:snowflake://account_name.snowflakecomputing.com:443", "ssl": "on", - "warehouse": "warehouse name" - "role": "accountadmin" + "warehouse": "warehouse_name", + "role": "role_name" } \ No newline at end of file diff --git a/profile.json.gpg b/profile.json.gpg index 11b20def7..12955621b 100644 Binary files a/profile.json.gpg and b/profile.json.gpg differ diff --git a/profile_azure.json.gpg b/profile_azure.json.gpg new file mode 100644 index 000000000..b388626dc Binary files /dev/null and b/profile_azure.json.gpg differ diff --git a/profile_gcp.json.gpg b/profile_gcp.json.gpg new file mode 100644 index 000000000..cc45b109f Binary files /dev/null and b/profile_gcp.json.gpg differ diff --git a/profile_streaming.json.example b/profile_streaming.json.example new file mode 100644 index 000000000..a94994b4b --- /dev/null +++ b/profile_streaming.json.example @@ -0,0 +1,9 @@ +{ + "user": "user name", + "url": "https://account_name.snowflakecomputing.com:443", // This is required, or it can be constructed from host, scheme and port + "private_key": "PEM Private Key", + "port": 443, + "host": "account_name.snowflakecomputing.com", + "scheme": "https", + "role": "role_name" +} \ No newline at end of file diff --git a/public_pom.xml b/public_pom.xml index 2edbfb272..fa8c7dd4a 100644 --- a/public_pom.xml +++ b/public_pom.xml @@ -34,4 +34,25 @@ scm:git:git://github.com/snowflakedb/snowflake-ingest-java http://github.com/snowflakedb/snowflake-ingest-java/tree/master + + + + net.snowflake + snowflake-jdbc + 3.14.5 + compile + + + org.slf4j + slf4j-api + 1.7.36 + compile + + + com.github.luben + zstd-jni + 1.5.0-1 + runtime + + diff --git a/scripts/check_content.sh b/scripts/check_content.sh index b25f24901..e4d3e2076 100755 --- a/scripts/check_content.sh +++ b/scripts/check_content.sh @@ -1,11 +1,40 @@ #!/bin/bash -e # scripts used to check if all dependency is shaded into snowflake internal path +# Do not add any additional exceptions into this script. +# If this script fails the maven build, re-configure shading relocations in pom.xml. set -o pipefail DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -if jar tvf $DIR/../target/snowflake-ingest-sdk.jar | awk '{print $8}' | grep -v -E "^(net|com)/snowflake" | grep -v -E "(com|net)/\$" | grep -v -E "^META-INF" | grep -v -E "^mozilla" | grep -v mime.types | grep -v project.properties | grep -v -E "javax" | grep -v -E "^org/" | grep -v -E "^com/google" | grep -v -E "^com/sun" | grep -v "log4j.properties" | grep -v "git.properties" | grep -v "io/" | grep -v "codegen/" | grep -v "com/codahale/" | grep -v "com/ibm/" | grep -v "LICENSE"; then - echo "[ERROR] Ingest SDK jar includes class not under the snowflake namespace" + +if jar tvf $DIR/../target/snowflake-ingest-sdk.jar | awk '{print $8}' | \ + # Ignore directories + grep -v -E '/$' | \ + + # Snowflake top-level packages are allowed + grep -v -E "^net/snowflake/ingest" | \ + + # META-INF is allowed in the shaded JAR + grep -v -E "^META-INF" | \ + + # Files in the JAR root that are probably required + grep -v mime.types | \ + grep -v project.properties | \ + grep -v arrow-git.properties | \ + grep -v core-default.xml | \ + grep -v org.apache.hadoop.application-classloader.properties | \ + grep -v common-version-info.properties | \ + grep -v PropertyList-1.0.dtd | \ + grep -v properties.dtd | \ + grep -v parquet.thrift | \ + + # Native zstd libraries are allowed + grep -v -E '^darwin' | \ + grep -v -E '^freebsd' | \ + grep -v -E '^linux' | \ + grep -v -E '^win' ; then + + echo "[ERROR] JDBC jar includes class not under the snowflake namespace" exit 1 fi \ No newline at end of file diff --git a/scripts/decrypt_secret.sh b/scripts/decrypt_secret.sh index 05df864e6..37b923ff2 100755 --- a/scripts/decrypt_secret.sh +++ b/scripts/decrypt_secret.sh @@ -3,5 +3,21 @@ # Decrypt the file # --batch to prevent interactive command --yes to assume "yes" for questions -gpg --quiet --batch --yes --decrypt --passphrase="$DECRYPTION_PASSPHRASE" \ - --output profile.json ./profile.json.gpg \ No newline at end of file +# Input one argument +snowflake_deployment=$1 + +# Convert the input to uppercase +uppercase_deployment=$(echo $snowflake_deployment | tr '[:lower:]' '[:upper:]') + +if [ $uppercase_deployment = 'AWS' ]; then + gpg --quiet --batch --yes --decrypt --passphrase="$DECRYPTION_PASSPHRASE" \ + --output profile.json ./profile.json.gpg +elif [ $uppercase_deployment = 'GCP' ]; then + gpg --quiet --batch --yes --decrypt --passphrase="$DECRYPTION_PASSPHRASE" \ + --output profile.json ./profile_gcp.json.gpg +elif [ $uppercase_deployment = 'AZURE' ]; then + gpg --quiet --batch --yes --decrypt --passphrase="$DECRYPTION_PASSPHRASE" \ + --output profile.json ./profile_azure.json.gpg +else + echo "Invalid deployment option. Please enter 'AWS', 'AZURE', or 'GCP'." +fi \ No newline at end of file diff --git a/scripts/decrypt_secret_windows.ps1 b/scripts/decrypt_secret_windows.ps1 new file mode 100644 index 000000000..e0cd0b589 --- /dev/null +++ b/scripts/decrypt_secret_windows.ps1 @@ -0,0 +1,25 @@ +param( + [string]$SnowflakeDeployment +) + +# Convert the input to uppercase +$UppercaseDeployment = $SnowflakeDeployment.ToUpper() + +# Decrypt the file based on the deployment option +switch ($UppercaseDeployment) { + 'AWS' { + gpg --quiet --batch --yes --decrypt --passphrase="$env:DECRYPTION_PASSPHRASE" ` + --output profile.json ./profile.json.gpg + } + 'GCP' { + gpg --quiet --batch --yes --decrypt --passphrase="$env:DECRYPTION_PASSPHRASE" ` + --output profile.json ./profile_gcp.json.gpg + } + 'AZURE' { + gpg --quiet --batch --yes --decrypt --passphrase="$env:DECRYPTION_PASSPHRASE" ` + --output profile.json ./profile_azure.json.gpg + } + default { + Write-Output "Invalid deployment option. Please enter 'AWS', 'AZURE', or 'GCP'." + } +} diff --git a/scripts/process_licenses.py b/scripts/process_licenses.py new file mode 100644 index 000000000..bb43fbbf0 --- /dev/null +++ b/scripts/process_licenses.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python + +# This script processes licenses of 3rd party dependencies and stores them in the JAR. The rules are: +# 1. Dependencies, which contains a license file should be put into the shaded JAR as-is. +# 2. Dependencies, which do not contain a license file should be mentioned in the file ADDITIONAL_LICENCES, together with the name of its license. +# +# +# The script accepts the following arguments: +# * DEPENDENCY_LIST_FILE_PATH +# * Can be obtained by running mvn dependency:list -DincludeScope=runtime -DoutputFile=target/dependency_list.txt +# * DEPENDENCIES_DIR +# * Directory containging the JAR files of all SDK dependencies. Automatically generated by `mvn clean package` in target/dependency-jars +# * TARGET_DIR +# * Where to save all output, should be target/generated-sources/META-INF/third-party-licenses +# +# +# Useful mvn commands: +# * mvn clean license:add-third-party +# * Generate dependency report; useful to find out licenses for dependencies that don't ship with a license file +# * mvn dependency:list -DincludeScope=runtime -DoutputFile=target/dependency_list.txt +# * Used as input of this script (DEPENDENCY_LIST_FILE_PATH) +import sys +from pathlib import Path +from zipfile import ZipFile + +# License name constants +APACHE_LICENSE = "Apache License 2.0" +BSD_2_CLAUSE_LICENSE = "2-Clause BSD License" +BSD_3_CLAUSE_LICENSE = "3-Clause BSD License" +EDL_10_LICENSE = "EDL 1.0" +MIT_LICENSE = "The MIT License" +GO_LICENSE = "The Go license" +BOUNCY_CASTLE_LICENSE = "Bouncy Castle Licence " + +# The SDK does not need to include licenses of dependencies, which aren't shaded +IGNORED_DEPENDENCIES = {"net.snowflake:snowflake-jdbc", "org.slf4j:slf4j-api"} + +# List of dependencies, which don't ship with a license file. +# Only add a new record here after verifying that the dependency JAR does not contain a license! +ADDITIONAL_LICENSES_MAP = { + "com.google.code.findbugs:jsr305": APACHE_LICENSE, + "io.dropwizard.metrics:metrics-core": APACHE_LICENSE, + "io.dropwizard.metrics:metrics-jmx": APACHE_LICENSE, + "io.dropwizard.metrics:metrics-jvm": APACHE_LICENSE, + "com.google.guava:guava": APACHE_LICENSE, + "com.google.guava:failureaccess": APACHE_LICENSE, + "com.google.guava:listenablefuture": APACHE_LICENSE, + "com.google.errorprone:error_prone_annotations": APACHE_LICENSE, + "com.google.j2objc:j2objc-annotations": APACHE_LICENSE, + "com.nimbusds:nimbus-jose-jwt": APACHE_LICENSE, + "com.github.stephenc.jcip:jcip-annotations": APACHE_LICENSE, + "io.netty:netty-common": APACHE_LICENSE, + "com.google.re2j:re2j": GO_LICENSE, + "com.google.protobuf:protobuf-java": BSD_3_CLAUSE_LICENSE, + "com.google.code.gson:gson": APACHE_LICENSE, + "org.xerial.snappy:snappy-java": APACHE_LICENSE, + "org.apache.parquet:parquet-common": APACHE_LICENSE, + "org.apache.parquet:parquet-format-structures": APACHE_LICENSE, + "com.github.luben:zstd-jni": BSD_2_CLAUSE_LICENSE, + "io.airlift:aircompressor": APACHE_LICENSE, + "org.bouncycastle:bcpkix-jdk18on": BOUNCY_CASTLE_LICENSE, + "org.bouncycastle:bcutil-jdk18on": BOUNCY_CASTLE_LICENSE, + "org.bouncycastle:bcprov-jdk18on": BOUNCY_CASTLE_LICENSE, +} + + +def parse_cmdline_args(): + if len(sys.argv) != 4: + raise Exception("usage: process_licenses.py DEPENDENCY_LIST_FILE_PATH DEPENDENCIES_DIR TARGET_DIR") + dependency_list_file_path = Path(sys.argv[1]).absolute() + dependencies_dir_path = Path(sys.argv[2]).absolute() + target_dir = Path(sys.argv[3]).absolute() + + if not dependency_list_file_path.exists() or not dependency_list_file_path.is_file(): + raise Exception(f"File {dependency_list_file_path} does not exist") + + if not dependencies_dir_path.exists() or not dependencies_dir_path.is_dir(): + raise Exception(f"Directory {dependencies_dir_path} does not exist") + return dependency_list_file_path, dependencies_dir_path, target_dir + + +def main(): + dependency_list_path, dependency_jars_path, target_dir = parse_cmdline_args() + + dependency_count = 0 + dependency_with_license_count = 0 + dependency_without_license_count = 0 + dependency_ignored_count = 0 + + missing_licenses_str = "" + + target_dir.mkdir(parents=True, exist_ok=True) + + with open(dependency_list_path, "r") as dependency_file_handle: + for line in dependency_file_handle.readlines(): + line = line.strip() + if line == "" or line == "The following files have been resolved:": + continue + dependency_count += 1 + + # Line is a string like: "commons-codec:commons-codec:jar:1.15:compile -- module org.apache.commons.codec [auto]" + artifact_details = line.split()[0] + group_id, artifact_id, _, version, scope = artifact_details.split(":") + current_jar = Path(dependency_jars_path, f"{artifact_id}-{version}.jar") + if not current_jar.exists() and current_jar.is_file(): + raise Exception(f"Expected JAR file does not exist: {current_jar}") + current_jar_as_zip = ZipFile(current_jar) + + dependency_lookup_key = f"{group_id}:{artifact_id}" + if dependency_lookup_key in IGNORED_DEPENDENCIES: + dependency_ignored_count += 1 + continue + + license_found = False + for zip_info in current_jar_as_zip.infolist(): + if zip_info.is_dir(): + continue + if zip_info.filename in ("META-INF/LICENSE.txt", "META-INF/LICENSE", "META-INF/LICENSE.md"): + license_found = True + dependency_with_license_count += 1 + # Extract license to the target directory + zip_info.filename = f"LICENSE_{group_id}__{artifact_id}" + current_jar_as_zip.extract(zip_info, target_dir) + break + if "license" in zip_info.filename.lower(): # Log potential license matches + print(f"Potential license match: {current_jar} {zip_info}") + + if not license_found: + print(f"License not found {current_jar}; using value from ADDITIONAL_LICENSES_MAP") + license_name = ADDITIONAL_LICENSES_MAP.get(dependency_lookup_key) + if license_name: + dependency_without_license_count += 1 + missing_licenses_str += f"{dependency_lookup_key}: {license_name}\n" + else: + raise Exception(f"The dependency {dependency_lookup_key} does not ship a license file, but neither is it not defined in ADDITIONAL_LICENSES_MAP") + + with open(Path(target_dir, "ADDITIONAL_LICENCES"), "w") as additional_licenses_handle: + additional_licenses_handle.write(missing_licenses_str) + + if dependency_count < 30: + raise Exception(f"Suspiciously low number of dependency JARs detected in {dependency_jars_path}: {dependency_count}") + print("License generation finished") + print(f"\tTotal dependencies: {dependency_count}") + print(f"\tTotal dependencies (with license): {dependency_with_license_count}") + print(f"\tTotal dependencies (without license): {dependency_without_license_count}") + print(f"\tIgnored dependencies: {dependency_ignored_count}") + +if __name__ == "__main__": + main() diff --git a/scripts/run_gh_actions.sh b/scripts/run_gh_actions.sh index 4ceacbe4b..49895010b 100755 --- a/scripts/run_gh_actions.sh +++ b/scripts/run_gh_actions.sh @@ -1,4 +1,9 @@ -mvn install -DskipTests=true --batch-mode --show-version +#!/bin/bash -e + +set -o pipefail + +# Build and install shaded JAR first. check_content.sh runs here. +mvn install -PcheckShadedContent -DskipTests=true --batch-mode --show-version PARAMS=() PARAMS+=("-DghActionsIT") @@ -13,8 +18,3 @@ rc=$? if [ $rc -ne 0 ] ; then echo Could not perform mvn verify with parameters "${PARAMS[@]}", exit code [$rc]; exit $rc fi - -# run whitesource -echo ${PWD} -chmod 755 ./scripts/run_whitesource_gh.sh -./scripts/run_whitesource_gh.sh \ No newline at end of file diff --git a/scripts/run_whitesource_gh.sh b/scripts/run_whitesource_gh.sh deleted file mode 100644 index 990352f5c..000000000 --- a/scripts/run_whitesource_gh.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash -# -# Run whitesource for components which need versioning - -# SCAN_DIRECTORIES is a comma-separated list (as a string) of file paths which contain all source code and build artifacts for this project -THIS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -CONNECTOR_DIR="$( dirname "${THIS_DIR}")" -SCAN_DIRECTORIES="${CONNECTOR_DIR}" -echo "SCAN_DIRECTORIES:"$SCAN_DIRECTORIES - -# If your PROD_BRANCH is not master, you can define it here based on the need -PROD_BRANCH="master" - -PRODUCT_NAME="snowflake-ingest-java" - -PROJECT_VERSION=${GITHUB_SHA} - -BRANCH_OR_PR_NUMBER="$(echo "${GITHUB_REF}" | awk 'BEGIN { FS = "/" } ; { print $3 }')" - -echo "PROJECT_VERSION:"$PROJECT_VERSION -echo "BRANCH_OR_PR_NUMBER:"$BRANCH_OR_PR_NUMBER - -# GITHUB_EVENT_NAME should either be 'push', or 'pull_request' -if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then - echo "[INFO] Pull Request" - export PROJECT_NAME="PR-${BRANCH_OR_PR_NUMBER}" -elif [[ "${BRANCH_OR_PR_NUMBER}" == "$PROD_BRANCH" ]]; then - echo "[INFO] Production branch" - export PROJECT_NAME="$PROD_BRANCH" -else - echo "[INFO] Non Production branch. Skipping wss..." - export PROJECT_NAME="" -fi - -echo "PROJECT_NAME:"$PROJECT_NAME - -[[ -z "$WHITESOURCE_API_KEY" ]] && echo "[WARNING] No WHITESOURCE_API_KEY is set. No WhiteSource scan will occurr." && exit 0 - -#if [[ -z "${JOB_BASE_NAME}" ]]; then -# echo "[ERROR] No JOB_BASE_NAME is set. Run this on Jenkins" -# exit 0 -#fi - -# Download the latest whitesource unified agent to do the scanning if there is no existing one -curl -LO https://github.com/whitesource/unified-agent-distribution/releases/latest/download/wss-unified-agent.jar -if [[ ! -f "wss-unified-agent.jar" ]]; then - echo "failed to download whitesource unified agent" - # if you want to fail the build when failing to download whitesource scanning agent, please use exit 1 - # exit 1 -fi - -# whitesource will scan the folder and detect the corresponding configuration -# configuration file wss-generated-file.config will be generated under ${SCAN_DIRECTORIES} -# java -jar wss-unified-agent.jar -detect -d ${SCAN_DIRECTORIES} -# SCAN_CONFIG="${SCAN_DIRECTORIES}/wss-generated-file.config" - -# SCAN_CONFIG is the path to your whitesource configuration file -SCAN_CONFIG="scripts/wss-java-maven-agent.config" -echo "SCAN_CONFIG:"$SCAN_CONFIG - -echo "[INFO] Running wss.sh for ${PRODUCT_NAME}-${PROJECT_NAME} under ${SCAN_DIRECTORIES}" - -if [[ "$PROJECT_NAME" == "$PROD_BRANCH" ]]; then - echo "PRODUCTION" - java -jar wss-unified-agent.jar -apiKey ${WHITESOURCE_API_KEY} \ - -c ${SCAN_CONFIG} \ - -project ${PROJECT_NAME} \ - -product ${PRODUCT_NAME} \ - -d ${SCAN_DIRECTORIES} \ - -projectVersion ${PROJECT_VERSION} \ - -offline true - ERR=$? - if [[ "$ERR" != "254" && "$ERR" != "0" ]]; then - echo "failed to run wss for PROJECT_VERSION=${PROJECT_VERSION} in ${PROJECT_VERSION}..." - exit 1 - fi - java -jar wss-unified-agent.jar -apiKey ${WHITESOURCE_API_KEY} \ - -c ${SCAN_CONFIG} \ - -project ${PROJECT_NAME} \ - -product ${PRODUCT_NAME} \ - -projectVersion baseline \ - -requestFiles whitesource/update-request.txt - ERR=$? - if [[ "$ERR" != "254" && "$ERR" != "0" ]]; then - echo "failed to run wss for PROJECT_VERSION=${PROJECT_VERSION} in baseline" - exit 1 - fi - java -jar wss-unified-agent.jar -apiKey ${WHITESOURCE_API_KEY} \ - -c ${SCAN_CONFIG} \ - -project ${PROJECT_NAME} \ - -product ${PRODUCT_NAME} \ - -projectVersion ${PROJECT_VERSION} \ - -requestFiles whitesource/update-request.txt - ERR=$? - if [[ "$ERR" != "254" && "$ERR" != "0" ]]; then - echo "failed to run wss for PROJECT_VERSION=${PROJECT_VERSION} in ${PROJECT_VERSION}" - exit 1 - fi -elif [[ -n "$PROJECT_NAME" ]]; then - echo "PR" - java -jar wss-unified-agent.jar -apiKey ${WHITESOURCE_API_KEY} \ - -c ${SCAN_CONFIG} \ - -project ${PROJECT_NAME} \ - -product ${PRODUCT_NAME} \ - -d ${SCAN_DIRECTORIES} \ - -projectVersion ${PROJECT_VERSION} - ERR=$? - if [[ "$ERR" != "254" && "$ERR" != "0" ]]; then - echo "failed to run wss for PROJECT_VERSION=${PROJECT_VERSION} in ${PROJECT_VERSION}" - exit 1 - fi -fi -exit 0 diff --git a/scripts/update_project_version.py b/scripts/update_project_version.py index 6ed3d47c1..c67d58802 100755 --- a/scripts/update_project_version.py +++ b/scripts/update_project_version.py @@ -1,10 +1,11 @@ -#!/usr/bin/env python +#!/usr/bin/env python2 # -*- coding: utf-8 -*- # # Update project version # # arg1: pom.xml # arg2: new version number +# arg3: optional suffix for project name # import sys import xml.etree.ElementTree as ET @@ -12,6 +13,9 @@ pom_file = sys.argv[1] version = sys.argv[2] +suffix = None +if len(sys.argv) > 3: + suffix = sys.argv[3] def remove_namespace(doc, namespace): """ @@ -42,6 +46,8 @@ def handle_comment(self, data): for e1 in root: if type(e1.tag) is str and e1.tag.endswith('version'): e1.text = version + if suffix and type(e1.tag) is str and e1.tag.endswith('artifactId'): + e1.text = e1.text + '-' + suffix remove_namespace(root, u'http://maven.apache.org/POM/4.0.0') diff --git a/scripts/wss-java-maven-agent.config b/scripts/wss-java-maven-agent.config deleted file mode 100644 index 37c54929a..000000000 --- a/scripts/wss-java-maven-agent.config +++ /dev/null @@ -1,89 +0,0 @@ -############################################################### -# WhiteSource Unified-Agent configuration file -############################################################### -# MAVEN SCAN MODE -############################################################### - -apiKey= -#userKey is required if WhiteSource administrator has enabled "Enforce user level access" option -#userKey= -#requesterEmail=user@provider.com - -projectName= -projectVersion= -projectToken= -#projectTag= key:value - -productName= -productVersion= -productToken= - -#projectPerFolder=true -#projectPerFolderIncludes= -#projectPerFolderExcludes= - -#wss.connectionTimeoutMinutes=60 -wss.url=https://saas.whitesourcesoftware.com/agent - -############ -# Policies # -############ -checkPolicies=true -forceCheckAllDependencies=true -forceUpdate=true -forceUpdate.failBuildOnPolicyViolation=true -#updateInventory=false - -########### -# General # -########### -#offline=false -#updateType=APPEND -#ignoreSourceFiles=true -#scanComment= -#failErrorLevel=ALL -#requireKnownSha1=false - -#generateProjectDetailsJson=true -#generateScanReport=true -#scanReportTimeoutMinutes=10 -#scanReportFilenameFormat= - -#analyzeFrameworks=true -#analyzeFrameworksReference= - -#updateEmptyProject=false - -#log.files.level= -#log.files.maxFileSize= -#log.files.maxFilesCount= -#log.files.path= - -######################################## -# Package Manager Dependency resolvers # -######################################## -resolveAllDependencies=false - -maven.ignoredScopes=test provided -maven.resolveDependencies=true -maven.ignoreSourceFiles=false -maven.aggregateModules=true -maven.ignorePomModules=false -maven.runPreStep=false -maven.ignoreMvnTreeErrors=true -maven.environmentPath= -maven.m2RepositoryPath= -maven.downloadMissingDependencies=false -maven.additionalArguments= -maven.projectNameFromDependencyFile=false - -########################################################################################### -# Includes/Excludes Glob patterns - Please use only one exclude line and one include line # -########################################################################################### -includes=**/*.java **/*.jar - -#Exclude file extensions or specific directories by adding **/*. or **//** -excludes=**/*sources.jar **/*javadoc.jar **/original-snowflake-ingest-sdk.jar - -case.sensitive.glob=false -followSymbolicLinks=true diff --git a/service.yml b/service.yml new file mode 100644 index 000000000..e217c3de9 --- /dev/null +++ b/service.yml @@ -0,0 +1,24 @@ +name: snowflake-ingest-java +lang: java +lang_version: 8 +codeowners: + enable: true +semaphore: + enable: true + pipeline_type: cp + branches: + - master + - main + - /^\d+\.\d+\.x$/ + - /v\d+\.\d+\.\d+\-hotfix\-x/ + - /^gh-readonly-queue.*/ + - /.*-test-release$/ +git: + enable: true +github: + enable: true +code_artifact: + enable: true + package_paths: + - maven-snapshots/maven/io.confluent/snowflake-ingest-sdk + - maven-releases/maven/io.confluent/snowflake-ingest-sdk diff --git a/snapshot_deploy.sh b/snapshot_deploy.sh new file mode 100755 index 000000000..78a9bdc90 --- /dev/null +++ b/snapshot_deploy.sh @@ -0,0 +1,52 @@ +#!/bin/bash -e + +THIS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +export GPG_KEY_ID="Snowflake Computing" +export LDAP_USER="$INTERNAL_NEXUS_USERNAME" +export LDAP_PWD="$INTERNAL_NEXUS_PASSWORD" + +if [ -z "$GPG_KEY_PASSPHRASE" ]; then + echo "[ERROR] GPG passphrase is not specified for $GPG_KEY_ID!" + exit 1 +fi + +if [ -z "$GPG_PRIVATE_KEY" ]; then + echo "[ERROR] GPG private key file is not specified!" + exit 1 +fi + +echo "[INFO] Import PGP Key" +if ! gpg --list-secret-key | grep "$GPG_KEY_ID"; then + gpg --allow-secret-key-import --import "$GPG_PRIVATE_KEY" +fi +# copy the settings.xml template and inject credential information +SNAPSHOT_DEPLOY_SETTINGS_XML="$THIS_DIR/mvn_settings_snapshot_deploy.xml" +MVN_REPOSITORY_ID=snapshot + +cat > $SNAPSHOT_DEPLOY_SETTINGS_XML << SETTINGS.XML + + + + + $MVN_REPOSITORY_ID + $LDAP_USER + $LDAP_PWD + + + +SETTINGS.XML + +MVN_OPTIONS+=( + "--settings" "$SNAPSHOT_DEPLOY_SETTINGS_XML" + "--batch-mode" +) + +echo "[Info] Sign package and deploy to staging area" +project_version=$($THIS_DIR/scripts/get_project_info_from_pom.py $THIS_DIR/pom.xml version) +$THIS_DIR/scripts/update_project_version.py public_pom.xml $project_version > generated_public_pom.xml + +mvn clean deploy ${MVN_OPTIONS[@]} -Dsnapshot-deploy + +rm $SNAPSHOT_DEPLOY_SETTINGS_XML \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..c7eb73003 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,9 @@ +### service-bot sonarqube plugin managed file +sonar.coverage.exclusions=**/test/**/*,**/tests/**/*,**/mock/**/*,**/mocks/**/*,**/*mock*,**/*test* +sonar.coverage.jacoco.xmlReportPaths=**/jacoco.xml +sonar.cpd.exclusions=**/test/**/*,**/tests/**/*,**/mock/**/*,**/mocks/**/*,**/*mock*,**/*test* +sonar.exclusions=**/*.pb.*,**/mk-include/**/* +sonar.java.binaries=. +sonar.language=java +sonar.projectKey=snowflake-ingest-java +sonar.sources=. diff --git a/src/main/java/net/snowflake/ingest/SimpleIngestManager.java b/src/main/java/net/snowflake/ingest/SimpleIngestManager.java index 1064c25ab..3a5fe71ed 100644 --- a/src/main/java/net/snowflake/ingest/SimpleIngestManager.java +++ b/src/main/java/net/snowflake/ingest/SimpleIngestManager.java @@ -15,27 +15,24 @@ import java.security.spec.InvalidKeySpecException; import java.util.Collections; import java.util.List; -import java.util.Optional; +import java.util.Properties; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; -import net.snowflake.ingest.connection.ClientStatusResponse; -import net.snowflake.ingest.connection.ConfigureClientResponse; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.CloseableHttpResponse; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpGet; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpPost; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; import net.snowflake.ingest.connection.HistoryRangeResponse; import net.snowflake.ingest.connection.HistoryResponse; import net.snowflake.ingest.connection.IngestResponse; import net.snowflake.ingest.connection.IngestResponseException; -import net.snowflake.ingest.connection.InsertFilesClientInfo; import net.snowflake.ingest.connection.RequestBuilder; import net.snowflake.ingest.connection.ServiceResponseHandler; import net.snowflake.ingest.utils.BackOffException; import net.snowflake.ingest.utils.HttpUtil; import net.snowflake.ingest.utils.StagedFileWrapper; import net.snowflake.ingest.utils.Utils; -import org.apache.http.HttpResponse; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -197,7 +194,8 @@ public SimpleIngestManager build() { // logger object for this class private static final Logger LOGGER = LoggerFactory.getLogger(SimpleIngestManager.class); // HTTP Client that we use for sending requests to the service - private HttpClient httpClient; + private CloseableHttpClient httpClient; + private boolean httpClientClosed = false; // the account in which the user lives private String account; @@ -388,6 +386,27 @@ public SimpleIngestManager( new RequestBuilder(account, user, keyPair, schemeName, hostName, port, userAgentSuffix); } + /* Another flavor of constructor which supports userAgentSuffix and proxyProperties */ + public SimpleIngestManager( + String account, + String user, + String pipe, + PrivateKey privateKey, + String schemeName, + String hostName, + int port, + String userAgentSuffix, + Properties proxyProperties) + throws NoSuchAlgorithmException, InvalidKeySpecException { + KeyPair keyPair = Utils.createKeyPairFromPrivateKey(privateKey); + // call our initializer method + init(account, user, pipe, keyPair, proxyProperties); + + // make the request builder we'll use to build messages to the service + builder = + new RequestBuilder(account, user, keyPair, schemeName, hostName, port, userAgentSuffix); + } + // ========= Constructors End ========= /** @@ -400,14 +419,43 @@ public SimpleIngestManager( * @param keyPair the KeyPair we'll use to sign JWT tokens */ private void init(String account, String user, String pipe, KeyPair keyPair) { + init(account, user, pipe, keyPair, new Properties()); + } + + /** + * init - Does the basic work of constructing a SimpleIngestManager that is common across all + * constructors + * + * @param account The account into which we're loading + * @param user the user performing this load + * @param pipe the fully qualified name of pipe + * @param keyPair the KeyPair we'll use to sign JWT tokens + * @param proxyProperties proxy properties for HTTP client configuration + */ + private void init( + String account, String user, String pipe, KeyPair keyPair, Properties proxyProperties) { // set up our reference variables this.account = account; this.user = user; this.pipe = pipe; this.keyPair = keyPair; - // make our client for sending requests - httpClient = HttpUtil.getHttpClient(); + // make our client for sending requests with proxy properties support + if (proxyProperties != null && !proxyProperties.isEmpty()) { + LOGGER.debug( + "Creating HTTP client for SimpleIngestManager with proxy properties for account: {}," + + " user: {}", + account, + user); + } else { + LOGGER.debug( + "Creating HTTP client for SimpleIngestManager without proxy properties for account: {}," + + " user: {}", + account, + user); + } + + httpClient = HttpUtil.getHttpClient(account, proxyProperties); // make the request builder we'll use to build messages to the service } @@ -524,53 +572,20 @@ public IngestResponse ingestFiles(List files, UUID requestId) public IngestResponse ingestFiles( List files, UUID requestId, boolean showSkippedFiles) throws URISyntaxException, IOException, IngestResponseException, BackOffException { - return ingestFiles(files, requestId, showSkippedFiles, null /* Client info is null */); - } - - /** - * ingestFiles With Client Info - synchronously sends a request to the ingest service to enqueue - * these files along with clientSequencer and offSetToken. - * - *

OffsetToken will be atomically persisted on server(Snowflake) side along with files if the - * clientSequencer added in this request matches with what Snowflake currently has. - * - *

If clientSequencers doesnt match, 400 response code is sent back and no files will be added. - * - * @param files - list of wrappers around filenames and sizes - * @param requestId - a requestId that we'll use to label - if null, we generate one for the user - * @param showSkippedFiles - a flag which returns the files that were skipped when set to true. - * @param clientInfo - clientSequencer and offsetToken to pass along with files. Can be null. - * @return an insert response from the server - * @throws URISyntaxException - if the provided account name was illegal and caused a URI - * construction failure - * @throws IOException - if we have some other network failure - * @throws IngestResponseException - if snowflake encountered error during ingest - * @throws BackOffException - if we have a 503 response - */ - public IngestResponse ingestFiles( - List files, - UUID requestId, - boolean showSkippedFiles, - InsertFilesClientInfo clientInfo) - throws URISyntaxException, IOException, IngestResponseException, BackOffException { - // the request id we want to send with this payload if (requestId == null || requestId.toString().isEmpty()) { requestId = UUID.randomUUID(); } HttpPost httpPostForIngestFile = - builder.generateInsertRequest( - requestId, pipe, files, showSkippedFiles, Optional.ofNullable(clientInfo)); + builder.generateInsertRequest(requestId, pipe, files, showSkippedFiles); // send the request and get a response.... - HttpResponse response = httpClient.execute(httpPostForIngestFile); - - LOGGER.info( - "Attempting to unmarshall insert response - {}, with clientInfo - {}", - response, - clientInfo); - return ServiceResponseHandler.unmarshallIngestResponse(response, requestId); + try (CloseableHttpResponse response = httpClient.execute(httpPostForIngestFile)) { + LOGGER.info("Attempting to unmarshall insert response - {}", response); + return ServiceResponseHandler.unmarshallIngestResponse( + response, requestId, httpClient, httpPostForIngestFile, builder); + } } /** @@ -597,10 +612,11 @@ public HistoryResponse getHistory(UUID requestId, Integer recentSeconds, String builder.generateHistoryRequest(requestId, pipe, recentSeconds, beginMark); // send the request and get a response... - HttpResponse response = httpClient.execute(httpGetHistory); - - LOGGER.info("Attempting to unmarshall history response - {}", response); - return ServiceResponseHandler.unmarshallHistoryResponse(response, requestId); + try (CloseableHttpResponse response = httpClient.execute(httpGetHistory)) { + LOGGER.info("Attempting to unmarshall history response - {}", response); + return ServiceResponseHandler.unmarshallHistoryResponse( + response, requestId, httpClient, httpGetHistory, builder); + } } /** @@ -626,57 +642,14 @@ public HistoryRangeResponse getHistoryRange( requestId = UUID.randomUUID(); } - HttpResponse response = - httpClient.execute( - builder.generateHistoryRangeRequest( - requestId, pipe, startTimeInclusive, endTimeExclusive)); - - LOGGER.info("Attempting to unmarshall history range response - {}", response); - return ServiceResponseHandler.unmarshallHistoryRangeResponse(response, requestId); - } + HttpGet request = + builder.generateHistoryRangeRequest(requestId, pipe, startTimeInclusive, endTimeExclusive); - /** - * Register a snowpipe client and returns the client sequencer - * - * @param requestId a UUID we use to label the request, if null, one is generated for the user - * @return - * @throws URISyntaxException - if the provided account name was illegal and caused a URI - * construction failure - * @throws IOException - if we have some other network failure - * @throws IngestResponseException - if snowflake encountered error during ingest - * @throws BackOffException - if we have a 503 response - */ - public ConfigureClientResponse configureClient(UUID requestId) - throws URISyntaxException, IOException, IngestResponseException, BackOffException { - if (requestId == null || requestId.toString().isEmpty()) { - requestId = UUID.randomUUID(); - } - HttpResponse response = - httpClient.execute(builder.generateConfigureClientRequest(requestId, pipe)); - LOGGER.info("Attempting to unmarshall configure client response - {}", response); - return ServiceResponseHandler.unmarshallConfigureClientResponse(response, requestId); - } - - /** - * Get client status for snowpipe which contains offset token and client sequencer - * - * @param requestId a UUID we use to label the request, if null, one is generated for the user - * @return - * @throws URISyntaxException - if the provided account name was illegal and caused a URI - * construction failure - * @throws IOException - if we have some other network failure - * @throws IngestResponseException - if snowflake encountered error during ingest - * @throws BackOffException - if we have a 503 response - */ - public ClientStatusResponse getClientStatus(UUID requestId) - throws URISyntaxException, IOException, IngestResponseException, BackOffException { - if (requestId == null || requestId.toString().isEmpty()) { - requestId = UUID.randomUUID(); + try (CloseableHttpResponse response = httpClient.execute(request)) { + LOGGER.info("Attempting to unmarshall history range response - {}", response); + return ServiceResponseHandler.unmarshallHistoryRangeResponse( + response, requestId, httpClient, request, builder); } - HttpResponse response = - httpClient.execute(builder.generateGetClientStatusRequest(requestId, pipe)); - LOGGER.info("Attempting to unmarshall get client status response - {}", response); - return ServiceResponseHandler.unmarshallGetClientStatus(response, requestId); } /** @@ -687,6 +660,7 @@ public ClientStatusResponse getClientStatus(UUID requestId) @Override public void close() { builder.closeResources(); + HttpUtil.shutdownHttpConnectionManagerDaemonThread(); } /* Used for testing */ diff --git a/src/main/java/net/snowflake/ingest/connection/ClientStatusResponse.java b/src/main/java/net/snowflake/ingest/connection/ClientStatusResponse.java deleted file mode 100644 index 5a3cbdc24..000000000 --- a/src/main/java/net/snowflake/ingest/connection/ClientStatusResponse.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2021 Snowflake Computing Inc. All rights reserved. - */ -package net.snowflake.ingest.connection; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -/** ClientStatusResponse - response from a get client status request */ -@JsonIgnoreProperties(ignoreUnknown = true) -public class ClientStatusResponse { - private Long clientSequencer; - - private String offsetToken; - - @Override - public String toString() { - return "IngestResponse{" - + "clientSequencer='" - + clientSequencer - + '\'' - + ", offsetToken='" - + offsetToken - + '\'' - + '}'; - } - - /** - * unique identifier for the client - * - * @return clientSequencer - */ - public Long getClientSequencer() { - return clientSequencer; - } - - /** - * offset number for kafka connector - * - * @return offsetToken - */ - public String getOffsetToken() { - return offsetToken; - } -} diff --git a/src/main/java/net/snowflake/ingest/connection/ConfigureClientResponse.java b/src/main/java/net/snowflake/ingest/connection/ConfigureClientResponse.java deleted file mode 100644 index 144065c22..000000000 --- a/src/main/java/net/snowflake/ingest/connection/ConfigureClientResponse.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2021 Snowflake Computing Inc. All rights reserved. - */ -package net.snowflake.ingest.connection; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -/** ConfigureClientResponse - response from a configure client request */ -@JsonIgnoreProperties(ignoreUnknown = true) -public class ConfigureClientResponse { - private Long clientSequencer; - - @Override - public String toString() { - return "IngestResponse{" + "clientSequencer='" + clientSequencer + '\'' + '}'; - } - - /** - * unique identifier for the client - * - * @return clientSequencer - */ - public Long getClientSequencer() { - return clientSequencer; - } -} diff --git a/src/main/java/net/snowflake/ingest/connection/InsertFilesClientInfo.java b/src/main/java/net/snowflake/ingest/connection/InsertFilesClientInfo.java deleted file mode 100644 index 175f994d0..000000000 --- a/src/main/java/net/snowflake/ingest/connection/InsertFilesClientInfo.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright (c) 2021 Snowflake Computing Inc. All rights reserved. - */ -package net.snowflake.ingest.connection; - -import java.nio.charset.StandardCharsets; - -/** - * Just a wrapper class which is serialised into REST request for insertFiles. - * - *

This is an optional field which can be passed with a required field "files" in the request - * body. - * - *

Here is how the new request could look like - * - *

- * {
- *   "files":[
- *     {
- *       "path":"file1.csv.gz"
- *     },
- *     {
- *       "path":"file2.csv.gz"
- *     }
- *    ],
- *   "clientInfo": {
- *     "clientSequencer": 1,
- *     "offsetToken": "2"
- *    }
- * }
- * 
- */ -public class InsertFilesClientInfo { - // FDB constant - public static final int MAX_ALLOWED_OFFSET_TOKEN_BYTE_SIZE = 100000; - - // client sequencer which the caller thinks it currently has - private final long clientSequencer; - - // offsetToken to atomically commit with files. - private final String offsetToken; - - /** Constructor with both fields as required. */ - public InsertFilesClientInfo(long clientSequencer, String offsetToken) { - if (clientSequencer < 0) { - throw new IllegalArgumentException("ClientSequencer should be non negative."); - } - if (offsetToken == null || offsetToken.isEmpty()) { - throw new IllegalArgumentException("OffsetToken can not be null or empty."); - } - /* Keeping some buffer */ - if (offsetToken.getBytes(StandardCharsets.UTF_8).length - > MAX_ALLOWED_OFFSET_TOKEN_BYTE_SIZE - 1000) { - throw new IllegalArgumentException("OffsetToken size too large."); - } - this.clientSequencer = clientSequencer; - this.offsetToken = offsetToken; - } - - /** Gets client Sequencer associated with this clientInfo record. */ - public long getClientSequencer() { - return clientSequencer; - } - - /** Gets offsetToken associated with this clientInfo record. */ - public String getOffsetToken() { - return offsetToken; - } - - /** Only printing clientSequencer */ - @Override - public String toString() { - return "InsertFilesClientInfo{" + "clientSequencer=" + clientSequencer + '}'; - } -} diff --git a/src/main/java/net/snowflake/ingest/connection/JWTManager.java b/src/main/java/net/snowflake/ingest/connection/JWTManager.java new file mode 100644 index 000000000..691348a34 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/connection/JWTManager.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2012-2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.connection; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import java.security.KeyPair; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import net.snowflake.ingest.utils.Cryptor; + +/** + * This class manages creating and automatically renewing the JWT token + * + * @author obabarinsa + */ +public final class JWTManager extends SecurityManager { + // the token lifetime is 59 minutes + private static final float LIFETIME_IN_MINUTES = 59; + + // the renewal time is 54 minutes + private static final int RENEWAL_INTERVAL_IN_MINUTES = 54; + + private static final String TOKEN_TYPE = "KEYPAIR_JWT"; + + // The public - private key pair we're using to connect to the service + private final transient KeyPair keyPair; + + // the token itself + private final AtomicReference token; + + /** + * Creates a JWTManager entity for a given account, user and KeyPair with a specified time to + * renew the token + * + * @param accountName - the snowflake account name of this user + * @param username - the snowflake username of the current user + * @param keyPair - the public/private key pair we're using to connect + * @param timeTillRenewal - the time measure until we renew the token + * @param unit the unit by which timeTillRenewal is measured + * @param telemetryService reference to the telemetry service + */ + JWTManager( + String accountName, + String username, + KeyPair keyPair, + int timeTillRenewal, + TimeUnit unit, + TelemetryService telemetryService) { + super(accountName, username, telemetryService); + // if any of our arguments are null, throw an exception + if (keyPair == null) { + throw new IllegalArgumentException(); + } + token = new AtomicReference<>(); + + // we have to keep around the keys + this.keyPair = keyPair; + + // generate our first token + refreshToken(); + + // schedule all future renewals + tokenRefresher.scheduleAtFixedRate(this::refreshToken, timeTillRenewal, timeTillRenewal, unit); + } + + /** + * Creates a JWTManager entity for a given account, user and KeyPair with the default time to + * renew (RENEWAL_INTERVAL_IN_MINUTES minutes) + * + * @param accountName - the snowflake account name of this user + * @param username - the snowflake username of the current user + * @param keyPair - the public/private key pair we're using to connect + * @param telemetryService reference to the telemetry service + */ + public JWTManager( + String accountName, String username, KeyPair keyPair, TelemetryService telemetryService) { + this( + accountName, + username, + keyPair, + RENEWAL_INTERVAL_IN_MINUTES, + TimeUnit.MINUTES, + telemetryService); + } + + @Override + public String getToken() { + // if we failed to regenerate the token at some point, throw + if (refreshFailed.get()) { + LOGGER.error("getToken request failed due to token regeneration failure"); + throw new SecurityException(); + } + + return token.get(); + } + + @Override + String getTokenType() { + return TOKEN_TYPE; + } + + /** regenerateToken - Regenerates our Token given our current user, account and keypair */ + @Override + void refreshToken() { + // create our JWT claim builder object + JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder(); + + // get the subject to the fully qualified username + String subject = String.format("%s.%s", account, user); + + // get the issuer + String publicKeyFPInJwt = calculatePublicKeyFp(keyPair); + String issuer = String.format("%s.%s.%s", account, user, publicKeyFPInJwt); + + // iat set to now + Date iat = new Date(System.currentTimeMillis()); + + // expiration in 59 minutes + Date exp = new Date(iat.getTime() + 59 * 60 * 1000); + + // build claim set + JWTClaimsSet claimsSet = + builder.issuer(issuer).subject(subject).issueTime(iat).expirationTime(exp).build(); + LOGGER.debug("Creating new JWT with subject {} and issuer {}...", subject, issuer); + + SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claimsSet); + + JWSSigner signer = new RSASSASigner(this.keyPair.getPrivate()); + + String newToken; + try { + signedJWT.sign(signer); + newToken = signedJWT.serialize(); + } catch (JOSEException e) { + refreshFailed.set(true); + LOGGER.error("Failed to regenerate token! Exception is as follows : {}", e.getMessage()); + throw new SecurityException(); + } + + // atomically update the string + LOGGER.info("Successfully created new JWT"); + token.set(newToken); + + // Refresh the token used in the telemetry service as well + if (telemetryService != null) { + telemetryService.refreshToken(newToken); + } + } + + /** + * Given a keypair + * + * @return the fingerprint of public key + *

The idea is to hash public key's raw bytes using SHA-256 and converts hash into a string + * using Base64 encoding. + */ + private String calculatePublicKeyFp(KeyPair keyPair) { + // get the raw bytes of public key + byte[] publicKeyRawBytes = keyPair.getPublic().getEncoded(); + + // take sha256 on raw bytes and do base64 encode + publicKeyFingerPrint = String.format("SHA256:%s", Cryptor.sha256HashBase64(publicKeyRawBytes)); + return publicKeyFingerPrint; + } + + /** Currently, it only shuts down the instance of ExecutorService. */ + @Override + public void close() { + tokenRefresher.shutdown(); + } +} diff --git a/src/main/java/net/snowflake/ingest/connection/OAuthClient.java b/src/main/java/net/snowflake/ingest/connection/OAuthClient.java new file mode 100644 index 000000000..bdff6c96e --- /dev/null +++ b/src/main/java/net/snowflake/ingest/connection/OAuthClient.java @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.connection; + +import java.util.concurrent.atomic.AtomicReference; + +/** Interface to perform token refresh request from {@link OAuthManager} */ +public interface OAuthClient { + AtomicReference getoAuthCredentialRef(); + + void refreshToken(); +} diff --git a/src/main/java/net/snowflake/ingest/connection/OAuthCredential.java b/src/main/java/net/snowflake/ingest/connection/OAuthCredential.java new file mode 100644 index 000000000..e222478d1 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/connection/OAuthCredential.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.connection; + +import java.util.Base64; + +/** This class hold credentials for OAuth authentication */ +public class OAuthCredential { + private static final String BASIC_AUTH_HEADER_PREFIX = "Basic "; + private final String authHeader; + private final String clientId; + private final String clientSecret; + private String accessToken; + private String refreshToken; + private int expiresIn; + + public OAuthCredential(String clientId, String clientSecret, String refreshToken) { + this.authHeader = + BASIC_AUTH_HEADER_PREFIX + + Base64.getEncoder().encodeToString((clientId + ":" + clientSecret).getBytes()); + this.clientId = clientId; + this.clientSecret = clientSecret; + this.refreshToken = refreshToken; + } + + public String getAuthHeader() { + return authHeader; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void setExpiresIn(int expiresIn) { + this.expiresIn = expiresIn; + } + + public int getExpiresIn() { + return expiresIn; + } +} diff --git a/src/main/java/net/snowflake/ingest/connection/OAuthManager.java b/src/main/java/net/snowflake/ingest/connection/OAuthManager.java new file mode 100644 index 000000000..fefb94299 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/connection/OAuthManager.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2012-2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.connection; + +import java.util.concurrent.TimeUnit; +import net.snowflake.client.jdbc.internal.apache.http.client.utils.URIBuilder; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; + +/** This class manages creating and automatically refresh the OAuth token */ +public final class OAuthManager extends SecurityManager { + private static final double DEFAULT_UPDATE_THRESHOLD_RATIO = 0.8; + + // the endpoint for token request + private static final String TOKEN_REQUEST_ENDPOINT = "/oauth/token-request"; + + // Content type header to specify the encoding + private static final String OAUTH_CONTENT_TYPE_HEADER = "application/x-www-form-urlencoded"; + + // Properties for token refresh request + private static final String TOKEN_TYPE = "OAUTH"; + + // Update threshold, a floating-point value representing the ratio between the expiration time of + // an access token and the time needed to update it. It must be a value greater than 0 and less + // than 1. E.g. An access token with expires_in=600 and update_threshold_ratio=0.8 would be + // updated after 600*0.8 = 480. + private final double updateThresholdRatio; + + private OAuthClient oAuthClient; + + /** + * Creates a OAuthManager entity for a given account, user and OAuthCredential with default time + * to refresh the access token + * + * @param accountName - the snowflake account name of this user + * @param username - the snowflake username of the current user + * @param oAuthCredential - the OAuth credential we're using to connect + * @param baseURIBuilder - the uri builder with common scheme, host and port + * @param telemetryService reference to the telemetry service + */ + OAuthManager( + String accountName, + String username, + OAuthCredential oAuthCredential, + URIBuilder baseURIBuilder, + TelemetryService telemetryService) { + this( + accountName, + username, + oAuthCredential, + baseURIBuilder, + DEFAULT_UPDATE_THRESHOLD_RATIO, + telemetryService); + } + + /** + * Creates a OAuthManager entity for a given account, user and OAuthCredential with a specified + * time to refresh the token + * + * @param accountName - the snowflake account name of this user + * @param username - the snowflake username of the current user + * @param oAuthCredential - the OAuth credential we're using to connect + * @param baseURIBuilder - the uri builder with common scheme, host and port + * @param updateThresholdRatio - the ratio between the expiration time of a token and the time + * needed to refresh it. + * @param telemetryService reference to the telemetry service + */ + OAuthManager( + String accountName, + String username, + OAuthCredential oAuthCredential, + URIBuilder baseURIBuilder, + double updateThresholdRatio, + TelemetryService telemetryService) { + // disable telemetry service until jdbc v3.13.34 is released + super(accountName, username, null); + + // if any of our arguments are null, throw an exception + if (oAuthCredential == null || baseURIBuilder == null) { + throw new IllegalArgumentException(); + } + + // check if update threshold is within (0, 1) + if (updateThresholdRatio <= 0 || updateThresholdRatio >= 1) { + throw new IllegalArgumentException("updateThresholdRatio should fall in (0, 1)"); + } + this.updateThresholdRatio = updateThresholdRatio; + this.oAuthClient = new SnowflakeOAuthClient(accountName, oAuthCredential, baseURIBuilder); + + // generate our first token + refreshToken(); + } + + /** + * Creates a OAuthManager entity for a given account, user and OAuthClient with a specified time + * to refresh the token. Use for testing only. + * + * @param accountName - the snowflake account name of this user + * @param username - the snowflake username of the current user + * @param oAuthClient - the OAuth client to perform token refresh + * @param updateThresholdRatio - the ratio between the expiration time of a token and the time + * needed to refresh it. + */ + public OAuthManager( + String accountName, String username, OAuthClient oAuthClient, double updateThresholdRatio) { + super(accountName, username, null); + + this.updateThresholdRatio = updateThresholdRatio; + this.oAuthClient = oAuthClient; + + refreshToken(); + } + + @Override + String getToken() { + if (refreshFailed.get()) { + throw new SecurityException("getToken request failed due to token refresh failure"); + } + return oAuthClient.getoAuthCredentialRef().get().getAccessToken(); + } + + @Override + String getTokenType() { + return TOKEN_TYPE; + } + + /** + * Set refresh token, this method is for refresh token renewal without requiring to restart + * client. This method only works when the authorization type is OAuth. + * + * @param refreshToken the new refresh token + */ + void setRefreshToken(String refreshToken) { + oAuthClient.getoAuthCredentialRef().get().setRefreshToken(refreshToken); + } + + /** refreshToken - Get new access token using refresh_token, client_id, client_secret */ + @Override + void refreshToken() { + for (int retries = 0; retries < Constants.MAX_OAUTH_REFRESH_TOKEN_RETRY; retries++) { + try { + oAuthClient.refreshToken(); + + // Schedule next refresh + long nextRefreshDelay = + (long) + (oAuthClient.getoAuthCredentialRef().get().getExpiresIn() + * this.updateThresholdRatio); + tokenRefresher.schedule(this::refreshToken, nextRefreshDelay, TimeUnit.SECONDS); + LOGGER.debug( + "Refresh access token, next refresh is scheduled after {} seconds", nextRefreshDelay); + + return; + } catch (SFException e1) { + // Exponential backoff retries + try { + Thread.sleep((1L << retries) * 1000L); + } catch (InterruptedException e2) { + throw new SFException(ErrorCode.OAUTH_REFRESH_TOKEN_ERROR, e2.getMessage()); + } + } + } + + refreshFailed.set(true); + throw new SecurityException("Fail to refresh access token"); + } + + /** Currently, it only shuts down the instance of ExecutorService. */ + @Override + public void close() { + tokenRefresher.shutdown(); + } +} diff --git a/src/main/java/net/snowflake/ingest/connection/RequestBuilder.java b/src/main/java/net/snowflake/ingest/connection/RequestBuilder.java index 44d846b94..3fdb8a660 100644 --- a/src/main/java/net/snowflake/ingest/connection/RequestBuilder.java +++ b/src/main/java/net/snowflake/ingest/connection/RequestBuilder.java @@ -1,41 +1,32 @@ /* - * Copyright (c) 2012-2017 Snowflake Computing Inc. All rights reserved. + * Copyright (c) 2012-2023 Snowflake Computing Inc. All rights reserved. */ package net.snowflake.ingest.connection; +import static net.snowflake.ingest.utils.Constants.ENABLE_TELEMETRY_TO_SF; import static net.snowflake.ingest.utils.Utils.isNullOrEmpty; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; import java.net.URI; import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Paths; import java.security.KeyPair; import java.util.List; import java.util.Map; -import java.util.Optional; -import java.util.Properties; import java.util.UUID; -import net.snowflake.ingest.SimpleIngestManager; +import net.snowflake.client.jdbc.internal.apache.http.HttpHeaders; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpGet; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpPost; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpUriRequest; +import net.snowflake.client.jdbc.internal.apache.http.client.utils.URIBuilder; +import net.snowflake.client.jdbc.internal.apache.http.entity.ContentType; +import net.snowflake.client.jdbc.internal.apache.http.entity.StringEntity; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; import net.snowflake.ingest.utils.ErrorCode; import net.snowflake.ingest.utils.SFException; import net.snowflake.ingest.utils.SnowflakeURL; import net.snowflake.ingest.utils.StagedFileWrapper; -import org.apache.http.HttpHeaders; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.methods.HttpPost; -import org.apache.http.client.methods.HttpUriRequest; -import org.apache.http.client.utils.URIBuilder; -import org.apache.http.entity.ContentType; -import org.apache.http.entity.StringEntity; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,7 +43,7 @@ public class RequestBuilder { /* Member variables Begin */ // the security manager which will handle token generation - private SecurityManager securityManager; + private final SecurityManager securityManager; // whatever the actual scheme is private String scheme; @@ -65,6 +56,9 @@ public class RequestBuilder { private final String userAgentSuffix; + // Reference to the telemetry service + private final TelemetryService telemetryService; + /* Member variables End */ /* Static constants Begin */ @@ -87,13 +81,6 @@ public class RequestBuilder { // the endpoint for history time range queries private static final String HISTORY_RANGE_ENDPOINT_FORMAT = "/v1/data/pipes/%s/loadHistoryScan"; - // the endpoint for configure snowpipe client - private static final String CONFIGURE_CLIENT_ENDPOINT_FORMAT = - "/v1/data/pipes/%s/client/configure"; - - // the endpoint for get snowpipe client status - private static final String CLIENT_STATUS_ENDPOINT_FORMAT = "/v1/data/pipes/%s/client/status"; - // optional number of max seconds of items to fetch(eg. in the last hour) private static final String RECENT_HISTORY_IN_SECONDS = "recentSeconds"; @@ -118,16 +105,12 @@ public class RequestBuilder { // and object mapper for all marshalling and unmarshalling private static final ObjectMapper objectMapper = new ObjectMapper(); - private static final String RESOURCES_FILE = "project.properties"; - - private static final Properties PROPERTIES = loadProperties(); - private static final String USER_AGENT = getDefaultUserAgent(); // Don't change! public static final String CLIENT_NAME = "SnowpipeJavaSDK"; - public static final String DEFAULT_VERSION = "1.0.1-beta"; + public static final String DEFAULT_VERSION = "2.0.0"; public static final String JAVA_USER_AGENT = "JAVA"; @@ -145,10 +128,11 @@ public class RequestBuilder { * * @param accountName - the name of the Snowflake account to which we're connecting * @param userName - the username of the entity loading files - * @param keyPair - the Public/Private key pair we'll use to authenticate + * @param credential - the credential we'll use to authenticate */ - public RequestBuilder(String accountName, String userName, KeyPair keyPair) { - this(accountName, userName, keyPair, DEFAULT_SCHEME, DEFAULT_HOST_SUFFIX, DEFAULT_PORT, null); + public RequestBuilder(String accountName, String userName, Object credential) { + this( + accountName, userName, credential, DEFAULT_SCHEME, DEFAULT_HOST_SUFFIX, DEFAULT_PORT, null); } /** @@ -156,15 +140,17 @@ public RequestBuilder(String accountName, String userName, KeyPair keyPair) { * * @param accountName - the name of the Snowflake account to which we're connecting * @param userName - the username of the entity loading files - * @param keyPair - the Public/Private key pair we'll use to authenticate + * @param credential - the credential we'll use to authenticate + * @param userAgentSuffix - The suffix part of HTTP Header User-Agent */ public RequestBuilder( String accountName, String userName, String hostName, - KeyPair keyPair, + Object credential, String userAgentSuffix) { - this(accountName, userName, keyPair, DEFAULT_SCHEME, hostName, DEFAULT_PORT, userAgentSuffix); + this( + accountName, userName, credential, DEFAULT_SCHEME, hostName, DEFAULT_PORT, userAgentSuffix); } /** @@ -173,7 +159,7 @@ public RequestBuilder( * * @param accountName - the account name to which we're connecting * @param userName - for whom are we connecting? - * @param keyPair - our auth credentials + * @param credential - the credential we'll use to authenticate * @param schemeName - are we HTTP or HTTPS? * @param hostName - the host for this snowflake instance * @param portNum - the port number @@ -181,11 +167,11 @@ public RequestBuilder( public RequestBuilder( String accountName, String userName, - KeyPair keyPair, + Object credential, String schemeName, String hostName, int portNum) { - this(accountName, userName, keyPair, schemeName, hostName, portNum, null); + this(accountName, userName, credential, schemeName, hostName, portNum, null); } /** @@ -193,7 +179,7 @@ public RequestBuilder( * * @param accountName - the account name to which we're connecting * @param userName - for whom are we connecting? - * @param keyPair - our auth credentials + * @param credential - the credential we'll use to authenticate * @param schemeName - are we HTTP or HTTPS? * @param hostName - the host for this snowflake instance * @param portNum - the port number @@ -202,20 +188,91 @@ public RequestBuilder( public RequestBuilder( String accountName, String userName, - KeyPair keyPair, + Object credential, String schemeName, String hostName, int portNum, String userAgentSuffix) { + this( + accountName, + userName, + credential, + schemeName, + hostName, + portNum, + userAgentSuffix, + null, + null, + null); + } + + /** + * RequestBuilder - constructor used by streaming ingest + * + * @param url - the Snowflake account to which we're connecting + * @param userName - the username of the entity loading files + * @param credential - the credential we'll use to authenticate + * @param httpClient - reference to the http client + * @param clientName - name of the client, used to uniquely identify a client if used + */ + public RequestBuilder( + SnowflakeURL url, + String userName, + Object credential, + CloseableHttpClient httpClient, + String clientName) { + this( + url.getAccount(), + userName, + credential, + url.getScheme(), + url.getUrlWithoutPort(), + url.getPort(), + null, + null, + httpClient, + clientName); + } + + /** + * RequestBuilder - this constructor is for testing purposes only + * + * @param accountName - the account name to which we're connecting + * @param userName - for whom are we connecting? + * @param credential - our auth credentials, either JWT key pair or OAuth credential + * @param schemeName - are we HTTP or HTTPS? + * @param hostName - the host for this snowflake instance + * @param portNum - the port number + * @param userAgentSuffix - The suffix part of HTTP Header User-Agent + * @param securityManager - The security manager for authentication + * @param httpClient - reference to the http client + * @param clientName - name of the client, used to uniquely identify a client if used + */ + public RequestBuilder( + String accountName, + String userName, + Object credential, + String schemeName, + String hostName, + int portNum, + String userAgentSuffix, + SecurityManager securityManager, + CloseableHttpClient httpClient, + String clientName) { // none of these arguments should be null - if (accountName == null || userName == null || keyPair == null) { + if (accountName == null || userName == null || credential == null) { throw new IllegalArgumentException(); } - // create our security/token manager - securityManager = new SecurityManager(accountName, userName, keyPair); + // Set up the telemetry service if needed + // TODO: SNOW-854272 Support telemetry service when using OAuth authentication + this.telemetryService = + ENABLE_TELEMETRY_TO_SF && credential instanceof KeyPair + ? new TelemetryService( + httpClient, clientName, schemeName + "://" + hostName + ":" + portNum) + : null; - // stash references to the account and user name as well + // stash references to the account and username as well String account = accountName.toUpperCase(); String user = userName.toUpperCase(); @@ -225,6 +282,27 @@ public RequestBuilder( this.host = hostName; this.userAgentSuffix = userAgentSuffix; + // create our security/token manager + if (securityManager == null) { + if (credential instanceof KeyPair) { + this.securityManager = + new JWTManager(accountName, userName, (KeyPair) credential, telemetryService); + } else if (credential instanceof OAuthCredential) { + this.securityManager = + new OAuthManager( + accountName, + userName, + (OAuthCredential) credential, + makeBaseURI(), + telemetryService); + } else { + throw new IllegalArgumentException( + "Credential should be instance of either KeyPair or OAuthCredential"); + } + } else { + this.securityManager = securityManager; + } + LOGGER.info( "Creating a RequestBuilder with arguments : " + "Account : {}, User : {}, Scheme : {}, Host : {}, Port : {}, userAgentSuffix: {}", @@ -236,52 +314,6 @@ public RequestBuilder( this.userAgentSuffix); } - /** - * RequestBuilder - constructor used by streaming ingest - * - * @param url - the Snowflake account to which we're connecting - * @param userName - the username of the entity loading files - * @param keyPair - the Public/Private key pair we'll use to authenticate - */ - public RequestBuilder(SnowflakeURL url, String userName, KeyPair keyPair) { - this( - url.getAccount(), - userName, - keyPair, - url.getScheme(), - url.getUrlWithoutPort(), - url.getPort()); - } - - private static Properties loadProperties() { - Properties properties = new Properties(); - properties.put("version", DEFAULT_VERSION); - - try { - URL res = SimpleIngestManager.class.getClassLoader().getResource(RESOURCES_FILE); - if (res == null) { - throw new UncheckedIOException(new FileNotFoundException(RESOURCES_FILE)); - } - - URI uri; - try { - uri = res.toURI(); - } catch (URISyntaxException ex) { - throw new IllegalArgumentException(ex); - } - - try (InputStream is = Files.newInputStream(Paths.get(uri))) { - properties.load(is); - } catch (IOException ex) { - throw new UncheckedIOException("Failed to load resource", ex); - } - } catch (Exception e) { - LOGGER.warn("Could not read version info: " + e.toString()); - } - - return properties; - } - /** * Creates a string for user agent which should always be present in all requests to Snowflake * (Snowpipe APIs) @@ -293,8 +325,7 @@ private static Properties loadProperties() { * @return the default agent string */ private static String getDefaultUserAgent() { - final String clientVersion = PROPERTIES.getProperty("version"); - StringBuilder defaultUserAgent = new StringBuilder(CLIENT_NAME + "/" + clientVersion); + StringBuilder defaultUserAgent = new StringBuilder(CLIENT_NAME + "/" + DEFAULT_VERSION); final String osInformation = String.format( @@ -310,48 +341,35 @@ private static String getDefaultUserAgent() { // Add Java Version final String javaVersion = System.getProperty("java.version"); - defaultUserAgent.append(JAVA_USER_AGENT + "/" + javaVersion); + defaultUserAgent.append(JAVA_USER_AGENT + "/").append(javaVersion); + String userAgent = defaultUserAgent.toString(); - return defaultUserAgent.toString(); + LOGGER.info("Default user agent " + userAgent); + return userAgent; } private static String buildCustomUserAgent(String additionalUserAgentInfo) { return USER_AGENT.trim() + " " + additionalUserAgentInfo; } + /** A simple POJO for generating our POST body to the insert endpoint */ private static class IngestRequest { // the list of files we're loading private final List files; - // additional info passed along with files which includes clientSequencer and offsetToken - // can be null - @JsonInclude(JsonInclude.Include.NON_NULL) - private final InsertFilesClientInfo clientInfo; - - /** Constructor used when both files and clientInfo are passed in request */ - public IngestRequest(List files, InsertFilesClientInfo clientInfo) { - this.files = files; - this.clientInfo = clientInfo; - } - /** * Ctor used when only files is used in request body. * *

clientInfo will be defaulted to null */ public IngestRequest(List files) { - this(files, null); + this.files = files; } /* Gets the list of files which were added in request */ public List getFiles() { return files; } - - /* Gets the clientInfo associated with files which was added in request */ - public InsertFilesClientInfo getClientInfo() { - return clientInfo; - } } /** @@ -367,6 +385,17 @@ private URIBuilder makeBaseURI(UUID requestId) { throw new IllegalArgumentException(); } + // construct the builder object without param + URIBuilder builder = makeBaseURI(); + + // set the request id + builder.setParameter(REQUEST_ID, requestId.toString()); + + return builder; + } + + /** Construct a URIBuilder for common parts of request without any parameter */ + private URIBuilder makeBaseURI() { // construct the builder object URIBuilder builder = new URIBuilder(); @@ -379,9 +408,6 @@ private URIBuilder makeBaseURI(UUID requestId) { // set the port name builder.setPort(port); - // set the request id - builder.setParameter(REQUEST_ID, requestId.toString()); - return builder; } @@ -492,79 +518,14 @@ private URI makeHistoryRangeURI( } /** - * Given a request UUID, and a fully qualified pipe name make a URI for configure snowpipe client - * http://snowflakeURL{:PORT}/v1/data/pipes/{pipeName}/client/configure - * - *

Where snowflake URL can be an old url or new regionless URL. - * - *

Request Body is Empty - * - *

And our response looks like: - * - *

-   *   {
-   *      'clientSequencer': LONG
-   *   }
-   * 
- * - * @param requestId the label for this request - * @param pipe the pipe name - * @return configure snowpipe client URI - * @throws URISyntaxException - */ - private URI makeConfigureClientURI(UUID requestId, String pipe) throws URISyntaxException { - if (pipe == null) { - throw new IllegalArgumentException(); - } - URIBuilder builder = makeBaseURI(requestId); - builder.setPath(String.format(CONFIGURE_CLIENT_ENDPOINT_FORMAT, pipe)); - LOGGER.info("Final Configure Client URIBuilder - {}", builder); - return builder.build(); - } - - /** - * makeGetClientURI - Given a request UUID, and a fully qualified pipe name make a URI for getting - * snowpipe client http://snowflakeURL{:PORT}/v1/data/pipes/{pipeName}/client/status - * - *

Where snowflake URL can be an old url or new regionless URL. - * - *

Request Body is Empty - * - *

And our response looks like: - * - *

-   *   {
-   *      'offsetToken': STRING
-   *      'clientSequencer': LONG
-   *   }
-   * 
- * - * @param requestId the label for this request - * @param pipe the pipe name - * @return get client URI - * @throws URISyntaxException - */ - private URI makeGetClientURI(UUID requestId, String pipe) throws URISyntaxException { - if (pipe == null) { - throw new IllegalArgumentException(); - } - URIBuilder builder = makeBaseURI(requestId); - builder.setPath(String.format(CLIENT_STATUS_ENDPOINT_FORMAT, pipe)); - LOGGER.info("Final Get Client URIBuilder - {}", builder); - return builder.build(); - } - - /** - * Given a list of files, and an optional clientInfo generate json string which later can be - * passed in request body of insertFiles API + * Given a list of files, generate a json string which later can be passed in request body of + * insertFiles API * * @param files the list of files we want to send - * @param clientInfo optional clientInfo which can be empty. * @return the string json blob * @throws IllegalArgumentException if files passed in is null */ - private String serializeInsertFilesRequest( - List files, Optional clientInfo) { + private String serializeInsertFilesRequest(List files) { // if the files argument is null, throw if (files == null) { LOGGER.info("Null files argument in RequestBuilder"); @@ -572,10 +533,7 @@ private String serializeInsertFilesRequest( } // create pojo - IngestRequest ingestRequest = - clientInfo - .map(insertFilesClientInfo -> new IngestRequest(files, insertFilesClientInfo)) - .orElseGet(() -> new IngestRequest(files)); + IngestRequest ingestRequest = new IngestRequest(files); // serialize to a string try { @@ -605,21 +563,26 @@ private static void addUserAgent(HttpUriRequest request, String userAgentSuffix) } /** - * addToken - adds a the JWT token to a request + * addToken - adds a token to a request * * @param request the URI request - * @param token the token to add */ - private static void addToken(HttpUriRequest request, String token) { - request.setHeader(HttpHeaders.AUTHORIZATION, BEARER_PARAMETER + token); - request.setHeader(SF_HEADER_AUTHORIZATION_TOKEN_TYPE, JWT_TOKEN_TYPE); + public void addToken(HttpUriRequest request) { + request.setHeader(HttpHeaders.AUTHORIZATION, BEARER_PARAMETER + securityManager.getToken()); + request.setHeader(SF_HEADER_AUTHORIZATION_TOKEN_TYPE, this.securityManager.getTokenType()); } - private static void addHeaders(HttpUriRequest request, String token, String userAgentSuffix) { + /** + * addHeader - adds necessary header to a request + * + * @param request the URI request + * @param userAgentSuffix user agent suffix + */ + private void addHeaders(HttpUriRequest request, String userAgentSuffix) { addUserAgent(request, userAgentSuffix); // Add the auth token - addToken(request, token); + addToken(request); // Add Accept header request.setHeader(HttpHeaders.ACCEPT, HTTP_HEADER_CONTENT_TYPE_JSON); @@ -639,28 +602,6 @@ private static void addHeaders(HttpUriRequest request, String token, String user public HttpPost generateInsertRequest( UUID requestId, String pipe, List files, boolean showSkippedFiles) throws URISyntaxException { - return generateInsertRequest(requestId, pipe, files, showSkippedFiles, Optional.empty()); - } - - /** - * generateInsertRequest - given a pipe, list of files and clientInfo, make a request for the - * insert endpoint - * - * @param requestId a UUID we will use to label this request - * @param pipe a fully qualified pipe name - * @param files a list of files - * @param showSkippedFiles a boolean which returns skipped files when set to true - * @param clientInfo - * @return a post request with all the data we need - * @throws URISyntaxException if the URI components provided are improper - */ - public HttpPost generateInsertRequest( - UUID requestId, - String pipe, - List files, - boolean showSkippedFiles, - Optional clientInfo) - throws URISyntaxException { // make the insert URI URI insertURI = makeInsertURI(requestId, pipe, showSkippedFiles); LOGGER.info("Created Insert Request : {} ", insertURI); @@ -668,12 +609,11 @@ public HttpPost generateInsertRequest( // Make the post request HttpPost post = new HttpPost(insertURI); - addHeaders(post, securityManager.getToken(), this.userAgentSuffix); + addHeaders(post, this.userAgentSuffix); // the entity for the containing the json final StringEntity entity = - new StringEntity( - serializeInsertFilesRequest(files, clientInfo), ContentType.APPLICATION_JSON); + new StringEntity(serializeInsertFilesRequest(files), ContentType.APPLICATION_JSON); post.setEntity(entity); return post; @@ -697,7 +637,7 @@ public HttpGet generateHistoryRequest( // make the get request HttpGet get = new HttpGet(historyURI); - addHeaders(get, securityManager.getToken(), this.userAgentSuffix); + addHeaders(get, this.userAgentSuffix); return get; } @@ -723,7 +663,7 @@ public HttpGet generateHistoryRangeRequest( HttpGet get = new HttpGet(historyRangeURI); - addHeaders(get, securityManager.getToken(), this.userAgentSuffix /*User agent information*/); + addHeaders(get, this.userAgentSuffix /*User agent information*/); return get; } @@ -751,7 +691,7 @@ public HttpPost generateStreamingIngestPostRequest( // Make the post request HttpPost post = new HttpPost(uri); - addHeaders(post, securityManager.getToken(), this.userAgentSuffix /*User agent information*/); + addHeaders(post, this.userAgentSuffix /*User agent information*/); // The entity for the containing the json final StringEntity entity = new StringEntity(payload, ContentType.APPLICATION_JSON); @@ -760,22 +700,6 @@ public HttpPost generateStreamingIngestPostRequest( return post; } - /** - * Given a requestId and a pipe, make a configure client request - * - * @param requestID a UUID we will use to label this request - * @param pipe a fully qualified pipe name - * @return configure client request - * @throws URISyntaxException - */ - public HttpPost generateConfigureClientRequest(UUID requestID, String pipe) - throws URISyntaxException { - URI configureClientURI = makeConfigureClientURI(requestID, pipe); - HttpPost post = new HttpPost(configureClientURI); - addHeaders(post, securityManager.getToken(), this.userAgentSuffix); - return post; - } - /** * Generate post request for streaming ingest related APIs * @@ -798,19 +722,25 @@ public HttpPost generateStreamingIngestPostRequest( } /** - * Given a requestId and a pipe, make a get client status request + * Set refresh token, this method is for refresh token renewal without requiring to restart + * client. This method only works when the authorization type is OAuth * - * @param requestID UUID - * @param pipe a fully qualified pipe name - * @return get client status request - * @throws URISyntaxException + * @param refreshToken the new refresh token */ - public HttpGet generateGetClientStatusRequest(UUID requestID, String pipe) - throws URISyntaxException { - URI getClientStatusURI = makeGetClientURI(requestID, pipe); - HttpGet get = new HttpGet(getClientStatusURI); - addHeaders(get, securityManager.getToken(), this.userAgentSuffix); - return get; + public void setRefreshToken(String refreshToken) { + if (securityManager instanceof OAuthManager) { + ((OAuthManager) securityManager).setRefreshToken(refreshToken); + } + } + + /** Get authorization type */ + public String getAuthType() { + return securityManager.getTokenType(); + } + + /** Refresh token manually */ + public void refreshToken() { + securityManager.refreshToken(); } /** @@ -822,5 +752,13 @@ public HttpGet generateGetClientStatusRequest(UUID requestID, String pipe) */ public void closeResources() { securityManager.close(); + if (telemetryService != null) { + telemetryService.close(); + } + } + + /** Get the telemetry service */ + public TelemetryService getTelemetryService() { + return telemetryService; } } diff --git a/src/main/java/net/snowflake/ingest/connection/SecurityManager.java b/src/main/java/net/snowflake/ingest/connection/SecurityManager.java index 7832ab88e..3fe89aeff 100644 --- a/src/main/java/net/snowflake/ingest/connection/SecurityManager.java +++ b/src/main/java/net/snowflake/ingest/connection/SecurityManager.java @@ -1,128 +1,71 @@ /* - * Copyright (c) 2012-2017 Snowflake Computing Inc. All rights reserved. + * Copyright (c) 2012-2023 Snowflake Computing Inc. All rights reserved. */ package net.snowflake.ingest.connection; -import com.nimbusds.jose.JOSEException; -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.JWSHeader; -import com.nimbusds.jose.JWSSigner; -import com.nimbusds.jose.crypto.RSASSASigner; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.SignedJWT; -import java.security.KeyPair; -import java.util.Date; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import net.snowflake.ingest.utils.Cryptor; import net.snowflake.ingest.utils.ThreadFactoryUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * This class manages creating and automatically renewing the JWT token + * This class manages creating and automatically renewing the token * * @author obabarinsa - * @since 1.8 */ -final class SecurityManager implements AutoCloseable { +abstract class SecurityManager implements AutoCloseable { // the logger for SecurityManager - private static final Logger LOGGER = LoggerFactory.getLogger(SecurityManager.class); + protected static final Logger LOGGER = LoggerFactory.getLogger(SecurityManager.class); - // the token lifetime is 59 minutes - private static final float LIFETIME = 59; - - // the renewal time is 54 minutes - private static final int RENEWAL_INTERVAL = 54; - - // The public - private key pair we're using to connect to the service - private transient KeyPair keyPair; - - // the name of the account on behalf of which we're connecting - private String account; + protected final String account; // Fingerprint of public key sent from client in jwt payload - private String publicKeyFingerPrint; + protected String publicKeyFingerPrint; // the name of the user who will be loading the files - private String user; + protected final String user; - // the token itself - private AtomicReference token; - - // Did we fail to regenerate our token at some point? - private AtomicBoolean regenFailed; + // Did we fail to refresh our token at some point? + protected final AtomicBoolean refreshFailed; // Thread factory for daemon threads so that application can shutdown final ThreadFactory tf = ThreadFactoryUtil.poolThreadFactory(getClass().getSimpleName(), true); - // the thread we use for renewing all tokens - private final ScheduledExecutorService keyRenewer = Executors.newScheduledThreadPool(1, tf); + // the thread we use for refresh tokens + protected ScheduledExecutorService tokenRefresher = Executors.newScheduledThreadPool(1, tf); + + // Reference to the Telemetry Service in the client + protected final TelemetryService telemetryService; /** - * Creates a SecurityManager entity for a given account, user and KeyPair with a specified time to - * renew the token + * Creates a SecurityManager entity for a given account * - * @param accountname - the snowflake account name of this user + * @param accountName - the snowflake account name of this user * @param username - the snowflake username of the current user - * @param keyPair - the public/private key pair we're using to connect - * @param timeTillRenewal - the time measure until we renew the token - * @param unit the unit by which timeTillRenewal is measured */ - SecurityManager( - String accountname, String username, KeyPair keyPair, int timeTillRenewal, TimeUnit unit) { + SecurityManager(String accountName, String username, TelemetryService telemetryService) { // if any of our arguments are null, throw an exception - if (accountname == null || username == null || keyPair == null) { + if (accountName == null || username == null) { throw new IllegalArgumentException(); } - account = parseAccount(accountname); + account = parseAccount(accountName); user = username.toUpperCase(); - // create our automatic reference to a string (our token) - token = new AtomicReference<>(); - // we haven't yet failed to regenerate our token - regenFailed = new AtomicBoolean(); - - // we have to keep around the keys - this.keyPair = keyPair; - - // generate our first token - regenerateToken(); + this.refreshFailed = new AtomicBoolean(); - // schedule all future renewals - keyRenewer.scheduleAtFixedRate(this::regenerateToken, timeTillRenewal, timeTillRenewal, unit); + this.telemetryService = telemetryService; } - /** - * Creates a SecurityManager entity for a given account, user and KeyPair with the default time to - * renew (54 minutes) - * - * @param accountname - the snowflake account name of this user - * @param username - the snowflake username of the current user - * @param keyPair - the public/private key pair we're using to connect - */ - SecurityManager(String accountname, String username, KeyPair keyPair) { - this(accountname, username, keyPair, RENEWAL_INTERVAL, TimeUnit.MINUTES); + /** Test only */ + void setRefreshFailed(Boolean refreshFailed) { + this.refreshFailed.set(refreshFailed); } - /** - * Trims an account name if it contains a "." - * - *

Snowflake's python connector trims an accountname if it contains a "." - * - * @param accountName given accountName in SimpleIngestManager's constructor - * @return initial part of account name if originally it contained a ".", otherwise return the - * same accountName. - * @see Python - * Connector - */ private String parseAccount(final String accountName) { String parseAccount = null; if (accountName.contains(".")) { @@ -136,94 +79,19 @@ private String parseAccount(final String accountName) { return parseAccount.toUpperCase(); } - /** regenerateToken - Regenerates our Token given our current user, account and keypair */ - private void regenerateToken() { - // create our JWT claim builder object - JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder(); - - // set the subject to the fully qualified username - String subject = String.format("%s.%s", account, user); - LOGGER.info("Creating Token with subject {}", subject); - - // set the issuer - String publicKeyFPInJwt = calculatePublicKeyFp(keyPair); - String issuer = String.format("%s.%s.%s", account, user, publicKeyFPInJwt); - LOGGER.info("Creating Token with issuer {}", issuer); - - // iat set to now - Date iat = new Date(System.currentTimeMillis()); - - // expiration in 59 minutes - Date exp = new Date(iat.getTime() + 59 * 60 * 1000); - - // build claim set - JWTClaimsSet claimsSet = - builder.issuer(issuer).subject(subject).issueTime(iat).expirationTime(exp).build(); - - SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claimsSet); - - JWSSigner signer = new RSASSASigner(this.keyPair.getPrivate()); - - String newToken; - try { - signedJWT.sign(signer); - newToken = signedJWT.serialize(); - } catch (JOSEException e) { - regenFailed.set(true); - LOGGER.error("Failed to regenerate token! Exception is as follows : {}", e.getMessage()); - throw new SecurityException(); - } - - // atomically update the string - LOGGER.info("Created new JWT"); - token.set(newToken); - } - /** * getToken - returns we've most recently generated * - * @return the string version of a valid JWT token - * @throws SecurityException if we failed to regenerate a token since the last call - */ - String getToken() { - // if we failed to regenerate the token at some point, throw - if (regenFailed.get()) { - LOGGER.error("getToken request failed due to token regeneration failure"); - throw new SecurityException(); - } - - return token.get(); - } - - /* Only used in testing at the moment */ - String getAccount() { - return this.account; - } - - /** - * Given a keypair - * - * @return the fingerprint of public key - *

The idea is to hash public key's raw bytes using SHA-256 and converts hash into a string - * using Base64 encoding. + * @return the string version of a valid token + * @throws SecurityException if we failed to regenerate or refresh a token since the last call */ - private String calculatePublicKeyFp(KeyPair keyPair) { - // get the raw bytes of public key - byte[] publicKeyRawBytes = keyPair.getPublic().getEncoded(); + abstract String getToken(); - // take sha256 on raw bytes and do base64 encode - publicKeyFingerPrint = String.format("SHA256:%s", Cryptor.sha256HashBase64(publicKeyRawBytes)); - return publicKeyFingerPrint; - } + /** getTokenType - returns the token type, either "KEYPAIR_JWT" or "OAUTH" */ + abstract String getTokenType(); - /** Only called by SecurityManagerTest */ - String getPublicKeyFingerPrint() { - return publicKeyFingerPrint; - } + /** refreshToken - regenerate or fetch a new token */ + abstract void refreshToken(); - /** Currently, it only shuts down the instance of ExecutorService. */ - @Override - public void close() { - keyRenewer.shutdown(); - } + public abstract void close(); } diff --git a/src/main/java/net/snowflake/ingest/connection/ServiceResponseHandler.java b/src/main/java/net/snowflake/ingest/connection/ServiceResponseHandler.java index 8c9590a22..bf20afda8 100644 --- a/src/main/java/net/snowflake/ingest/connection/ServiceResponseHandler.java +++ b/src/main/java/net/snowflake/ingest/connection/ServiceResponseHandler.java @@ -8,11 +8,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.UUID; +import net.snowflake.client.jdbc.internal.apache.http.HttpEntity; +import net.snowflake.client.jdbc.internal.apache.http.HttpResponse; +import net.snowflake.client.jdbc.internal.apache.http.HttpStatus; +import net.snowflake.client.jdbc.internal.apache.http.StatusLine; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpGet; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpPost; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpUriRequest; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import net.snowflake.client.jdbc.internal.apache.http.util.EntityUtils; import net.snowflake.ingest.utils.BackOffException; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.StatusLine; -import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,9 +38,8 @@ public enum ApiName { INSERT_FILES("POST"), INSERT_REPORT("GET"), LOAD_HISTORY_SCAN("GET"), - CLIENT_CONFIGURE("POST"), - CLIENT_STATUS("GET"), STREAMING_OPEN_CHANNEL("POST"), + STREAMING_DROP_CHANNEL("POST"), STREAMING_CHANNEL_STATUS("POST"), STREAMING_REGISTER_BLOB("POST"), STREAMING_CLIENT_CONFIGURE("POST"); @@ -75,12 +79,20 @@ private static boolean isStatusOK(StatusLine statusLine) { * * @param response the HTTPResponse we want to distill into an IngestResponse * @param requestId + * @param httpClient HttpClient for retries + * @param httpPostForIngestFile HttpRequest for retries + * @param builder RequestBuilder for retries * @return An IngestResponse with all of the parsed out information * @throws IOException if our entity is somehow corrupt or we can't get it * @throws IngestResponseException - if we have an uncategorized network issue * @throws BackOffException - if we have a 503 issue */ - public static IngestResponse unmarshallIngestResponse(HttpResponse response, UUID requestId) + public static IngestResponse unmarshallIngestResponse( + HttpResponse response, + UUID requestId, + CloseableHttpClient httpClient, + HttpPost httpPostForIngestFile, + RequestBuilder builder) throws IOException, IngestResponseException, BackOffException { // we can't unmarshall a null response if (response == null) { @@ -89,10 +101,11 @@ public static IngestResponse unmarshallIngestResponse(HttpResponse response, UUI } // handle the exceptional status code - handleExceptionalStatus(response, requestId, ApiName.INSERT_FILES); + response = + handleExceptionalStatus( + response, requestId, ApiName.INSERT_FILES, httpClient, httpPostForIngestFile, builder); - // grab the response entity - String blob = EntityUtils.toString(response.getEntity()); + String blob = consumeAndReturnResponseEntityAsString(response.getEntity()); // Read out the blob entity into a class return mapper.readValue(blob, IngestResponse.class); @@ -104,12 +117,20 @@ public static IngestResponse unmarshallIngestResponse(HttpResponse response, UUI * * @param response the HttpResponse object we are trying to deserialize * @param requestId + * @param httpClient HttpClient for retries + * @param httpGetHistory HttpRequest for retries + * @param builder RequestBuilder for retries * @return a HistoryResponse with all the parsed out information * @throws IOException if our entity is somehow corrupt or we can't get it * @throws IngestResponseException - if we have an uncategorized network issue * @throws BackOffException - if we have a 503 issue */ - public static HistoryResponse unmarshallHistoryResponse(HttpResponse response, UUID requestId) + public static HistoryResponse unmarshallHistoryResponse( + HttpResponse response, + UUID requestId, + CloseableHttpClient httpClient, + HttpGet httpGetHistory, + RequestBuilder builder) throws IOException, IngestResponseException, BackOffException { // we can't unmarshall a null response if (response == null) { @@ -118,10 +139,11 @@ public static HistoryResponse unmarshallHistoryResponse(HttpResponse response, U } // handle the exceptional status code - handleExceptionalStatus(response, requestId, ApiName.INSERT_REPORT); + response = + handleExceptionalStatus( + response, requestId, ApiName.INSERT_REPORT, httpClient, httpGetHistory, builder); - // grab the string version of the response entity - String blob = EntityUtils.toString(response.getEntity()); + String blob = consumeAndReturnResponseEntityAsString(response.getEntity()); // read out our blob into a pojo return mapper.readValue(blob, HistoryResponse.class); @@ -132,13 +154,20 @@ public static HistoryResponse unmarshallHistoryResponse(HttpResponse response, U * * @param response the HttpResponse object we are trying to deserialize * @param requestId + * @param httpClient HttpClient for retries + * @param request HttpRequest for retries + * @param builder HttpBuilder for retries * @return HistoryRangeResponse * @throws IOException if our entity is somehow corrupt or we can't get it * @throws IngestResponseException - if we have an uncategorized network issue * @throws BackOffException - if we have a 503 issue */ public static HistoryRangeResponse unmarshallHistoryRangeResponse( - HttpResponse response, UUID requestId) + HttpResponse response, + UUID requestId, + CloseableHttpClient httpClient, + HttpGet request, + RequestBuilder builder) throws IOException, IngestResponseException, BackOffException { // we can't unmarshall a null response @@ -148,73 +177,15 @@ public static HistoryRangeResponse unmarshallHistoryRangeResponse( } // handle the exceptional status code - handleExceptionalStatus(response, requestId, ApiName.LOAD_HISTORY_SCAN); - - // grab the string version of the response entity - String blob = EntityUtils.toString(response.getEntity()); + response = + handleExceptionalStatus( + response, requestId, ApiName.LOAD_HISTORY_SCAN, httpClient, request, builder); + String blob = consumeAndReturnResponseEntityAsString(response.getEntity()); // read out our blob into a pojo return mapper.readValue(blob, HistoryRangeResponse.class); } - /** - * unmarshallConfigureClientResponse - Given an HttpResponse object, attempts to deserialize it - * into a ConfigureClientResponse - * - * @param response HttpResponse - * @param requestId - * @return ConfigureClientResponse - * @throws IOException if our entity is somehow corrupt or we can't get it - * @throws IngestResponseException - if we have an uncategorized network issue - * @throws BackOffException - if we have a 503 issue - */ - public static ConfigureClientResponse unmarshallConfigureClientResponse( - HttpResponse response, UUID requestId) - throws IOException, IngestResponseException, BackOffException { - if (response == null) { - LOGGER.warn("Null response passed to unmarshallConfigureClientResponse"); - throw new IllegalArgumentException(); - } - - // handle the exceptional status code - handleExceptionalStatus(response, requestId, ApiName.CLIENT_CONFIGURE); - - // grab the string version of the response entity - String blob = EntityUtils.toString(response.getEntity()); - - // read out our blob into a pojo - return mapper.readValue(blob, ConfigureClientResponse.class); - } - - /** - * unmarshallGetClientStatus - Given an HttpResponse object, attempts to deserialize it into a - * ClientStatusResponse - * - * @param response HttpResponse - * @param requestId - * @return ClientStatusResponse - * @throws IOException if our entity is somehow corrupt or we can't get it - * @throws IngestResponseException - if we have an uncategorized network issue - * @throws BackOffException - if we have a 503 issue - */ - public static ClientStatusResponse unmarshallGetClientStatus( - HttpResponse response, UUID requestId) - throws IOException, IngestResponseException, BackOffException { - if (response == null) { - LOGGER.warn("Null response passed to unmarshallClientStatusResponse"); - throw new IllegalArgumentException(); - } - - // handle the exceptional status code - handleExceptionalStatus(response, requestId, ApiName.CLIENT_STATUS); - - // grab the string version of the response entity - String blob = EntityUtils.toString(response.getEntity()); - - // read out our blob into a pojo - return mapper.readValue(blob, ClientStatusResponse.class); - } - /** * unmarshallStreamingIngestResponse Given an HttpResponse object - attempts to deserialize it * into an Object based on input type @@ -227,7 +198,12 @@ public static ClientStatusResponse unmarshallGetClientStatus( * @throws IngestResponseException if received an exceptional status code */ public static T unmarshallStreamingIngestResponse( - HttpResponse response, Class valueType, ApiName apiName) + HttpResponse response, + Class valueType, + ApiName apiName, + CloseableHttpClient httpClient, + HttpUriRequest request, + RequestBuilder requestBuilder) throws IOException, IngestResponseException { // We can't unmarshall a null response if (response == null) { @@ -236,10 +212,11 @@ public static T unmarshallStreamingIngestResponse( } // Handle the exceptional status code - handleExceptionalStatus(response, null, apiName); + response = + handleExceptionalStatus(response, null, apiName, httpClient, request, requestBuilder); // Grab the string version of the response entity - String blob = EntityUtils.toString(response.getEntity()); + String blob = consumeAndReturnResponseEntityAsString(response.getEntity()); // Read out our blob into a pojo return mapper.readValue(blob, valueType); @@ -250,35 +227,73 @@ public static T unmarshallStreamingIngestResponse( * * @param response HttpResponse * @param requestId + * @param apiName enum to represent the corresponding api name + * @param httpClient HttpClient for retries + * @param request HttpRequest for retries + * @param requestBuilder RequestBuilder for retries + * @return modified HttpResponse * @throws IOException if our entity is somehow corrupt or we can't get it * @throws IngestResponseException - for all other non OK status * @throws BackOffException - if we have a 503 issue */ - private static void handleExceptionalStatus( - HttpResponse response, UUID requestId, ApiName apiName) + private static HttpResponse handleExceptionalStatus( + HttpResponse response, + UUID requestId, + ApiName apiName, + CloseableHttpClient httpClient, + HttpUriRequest request, + RequestBuilder requestBuilder) throws IOException, IngestResponseException, BackOffException { - StatusLine statusLine = response.getStatusLine(); - if (!isStatusOK(statusLine)) { + if (!isStatusOK(response.getStatusLine())) { + StatusLine statusLine = response.getStatusLine(); + LOGGER.warn( + "{} Status hit from {}, requestId:{}", + statusLine.getStatusCode(), + apiName, + requestId == null ? "" : requestId.toString()); + // if we have a 503 exception throw a backoff switch (statusLine.getStatusCode()) { // If we have a 503, BACKOFF case HttpStatus.SC_SERVICE_UNAVAILABLE: - LOGGER.warn( - "503 Status hit from {}, backoff, requestId:{}", - apiName, - requestId == null ? "" : requestId.toString()); throw new BackOffException(); + case HttpStatus.SC_UNAUTHORIZED: + LOGGER.warn("Authorization failed, refreshing Token succeeded, retry"); + requestBuilder.refreshToken(); + requestBuilder.addToken(request); + response = httpClient.execute(request); + if (!isStatusOK(response.getStatusLine())) { + throw new SecurityException("Authorization failed after retry"); + } + break; default: - LOGGER.error( - "Exceptional Status Code from {}: {}, requestId:{}", - apiName, - statusLine.getStatusCode(), - requestId == null ? "" : requestId.toString()); - String blob = EntityUtils.toString(response.getEntity()); + String blob = consumeAndReturnResponseEntityAsString(response.getEntity()); throw new IngestResponseException( statusLine.getStatusCode(), IngestResponseException.IngestExceptionBody.parseBody(blob)); } } + return response; + } + + /** + * Consumes the HttpEntity as mentioned in HttpClient Docs + * + *

Also returns the string version of this entity. + * + * @param httpResponseEntity the response entity obtained after successfully calling associated + * Rest APIs + * @return String version of this http response which will be later used to deserialize into + * respective Response Object + * @throws IOException if parsing error + */ + private static String consumeAndReturnResponseEntityAsString(HttpEntity httpResponseEntity) + throws IOException { + // grab the string version of the response entity + String responseEntityAsString = EntityUtils.toString(httpResponseEntity); + + EntityUtils.consumeQuietly(httpResponseEntity); + return responseEntityAsString; } } diff --git a/src/main/java/net/snowflake/ingest/connection/SnowflakeOAuthClient.java b/src/main/java/net/snowflake/ingest/connection/SnowflakeOAuthClient.java new file mode 100644 index 000000000..a90540659 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/connection/SnowflakeOAuthClient.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.connection; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import net.snowflake.client.jdbc.internal.apache.http.HttpHeaders; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.CloseableHttpResponse; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpPost; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpUriRequest; +import net.snowflake.client.jdbc.internal.apache.http.client.utils.URIBuilder; +import net.snowflake.client.jdbc.internal.apache.http.entity.ContentType; +import net.snowflake.client.jdbc.internal.apache.http.entity.StringEntity; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import net.snowflake.client.jdbc.internal.apache.http.util.EntityUtils; +import net.snowflake.client.jdbc.internal.google.api.client.http.HttpStatusCodes; +import net.snowflake.client.jdbc.internal.google.gson.JsonObject; +import net.snowflake.client.jdbc.internal.google.gson.JsonParser; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.HttpUtil; +import net.snowflake.ingest.utils.SFException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/* + * Implementation of Snowflake OAuth Client, used for refreshing an OAuth access token. + */ +public class SnowflakeOAuthClient implements OAuthClient { + + static final Logger LOGGER = LoggerFactory.getLogger(SnowflakeOAuthClient.class); + private static final String TOKEN_REQUEST_ENDPOINT = "/oauth/token-request"; + + // Content type header to specify the encoding + private static final String OAUTH_CONTENT_TYPE_HEADER = "application/x-www-form-urlencoded"; + private static final String GRANT_TYPE_PARAM = "grant_type"; + private static final String ACCESS_TOKEN = "access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + private static final String EXPIRES_IN = "expires_in"; + + // OAuth credential + private final AtomicReference oAuthCredential; + + // exact uri for token request + private final URI tokenRequestURI; + + // Http client for submitting token refresh request + private final CloseableHttpClient httpClient; + + /** + * Creates an SnowflakeOAuthClient given account, credential and base uri + * + * @param accountName - the snowflake account name of this user + * @param oAuthCredential - the OAuth credential we're using to connect + * @param baseURIBuilder - the uri builder with common scheme, host and port + */ + SnowflakeOAuthClient( + String accountName, OAuthCredential oAuthCredential, URIBuilder baseURIBuilder) { + this.oAuthCredential = new AtomicReference<>(oAuthCredential); + + // build token request uri + baseURIBuilder.setPath(TOKEN_REQUEST_ENDPOINT); + try { + this.tokenRequestURI = baseURIBuilder.build(); + } catch (URISyntaxException e) { + throw new SFException(e, ErrorCode.MAKE_URI_FAILURE, e.getMessage()); + } + + this.httpClient = HttpUtil.getHttpClient(accountName); + } + + /** Get access token */ + @Override + public AtomicReference getoAuthCredentialRef() { + return oAuthCredential; + } + + /** Refresh access token using a valid refresh token */ + @Override + public void refreshToken() { + String respBodyString = null; + try (CloseableHttpResponse httpResponse = httpClient.execute(makeRefreshTokenRequest())) { + respBodyString = EntityUtils.toString(httpResponse.getEntity()); + + if (httpResponse.getStatusLine().getStatusCode() == HttpStatusCodes.STATUS_CODE_OK) { + JsonObject respBody = JsonParser.parseString(respBodyString).getAsJsonObject(); + + if (respBody.has(ACCESS_TOKEN) && respBody.has(EXPIRES_IN)) { + // Trim surrounding quotation marks + String newAccessToken = respBody.get(ACCESS_TOKEN).toString().replaceAll("^\"|\"$", ""); + + oAuthCredential.get().setAccessToken(newAccessToken); + oAuthCredential.get().setExpiresIn(respBody.get(EXPIRES_IN).getAsInt()); + return; + } + } + throw new SFException( + ErrorCode.OAUTH_REFRESH_TOKEN_ERROR, + "Refresh access token fail with response: " + respBodyString); + } catch (Exception e) { + throw new SFException(ErrorCode.OAUTH_REFRESH_TOKEN_ERROR, e.getMessage()); + } + } + + /** Helper method for making refresh request */ + private HttpUriRequest makeRefreshTokenRequest() { + HttpPost post = new HttpPost(tokenRequestURI); + post.addHeader(HttpHeaders.CONTENT_TYPE, OAUTH_CONTENT_TYPE_HEADER); + post.addHeader(HttpHeaders.AUTHORIZATION, oAuthCredential.get().getAuthHeader()); + + Map payload = new HashMap<>(); + try { + payload.put(GRANT_TYPE_PARAM, URLEncoder.encode(REFRESH_TOKEN, "UTF-8")); + payload.put( + REFRESH_TOKEN, URLEncoder.encode(oAuthCredential.get().getRefreshToken(), "UTF-8")); + } catch (UnsupportedEncodingException e) { + throw new SFException(e, ErrorCode.OAUTH_REFRESH_TOKEN_ERROR, e.getMessage()); + } + + String payloadString = + payload.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + + final StringEntity entity = + new StringEntity(payloadString, ContentType.APPLICATION_FORM_URLENCODED); + post.setEntity(entity); + + return post; + } +} diff --git a/src/main/java/net/snowflake/ingest/connection/TelemetryService.java b/src/main/java/net/snowflake/ingest/connection/TelemetryService.java new file mode 100644 index 000000000..15e2a0c5a --- /dev/null +++ b/src/main/java/net/snowflake/ingest/connection/TelemetryService.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.connection; + +import static net.snowflake.ingest.connection.RequestBuilder.DEFAULT_VERSION; + +import com.codahale.metrics.Histogram; +import com.codahale.metrics.Meter; +import com.codahale.metrics.Snapshot; +import com.codahale.metrics.Timer; +import com.google.common.util.concurrent.RateLimiter; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import net.snowflake.client.jdbc.internal.fasterxml.jackson.databind.ObjectMapper; +import net.snowflake.client.jdbc.internal.fasterxml.jackson.databind.node.ObjectNode; +import net.snowflake.client.jdbc.telemetry.TelemetryClient; +import net.snowflake.client.jdbc.telemetry.TelemetryUtil; +import net.snowflake.ingest.utils.Logging; + +/** + * Telemetry service to collect logs in the SDK and send them to Snowflake through the JDBC client + * telemetry API + */ +public class TelemetryService { + // Enum for different client telemetries + enum TelemetryType { + STREAMING_INGEST_LATENCY_IN_SEC("streaming_ingest_latency_in_ms"), + STREAMING_INGEST_CLIENT_FAILURE("streaming_ingest_client_failure"), + STREAMING_INGEST_THROUGHPUT_BYTES_PER_SEC("streaming_ingest_throughput_bytes_per_sec"), + STREAMING_INGEST_CPU_MEMORY_USAGE("streaming_ingest_cpu_memory_usage"), + STREAMING_INGEST_BATCH_OFFSET_MISMATCH("streaming_ingest_batch_offset_mismatch"); + + private final String name; + + TelemetryType(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.name; + } + } + + private static final Logging logger = new Logging(TelemetryService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final String TYPE = "type"; + private static final String CLIENT_NAME = "client_name"; + private static final String COUNT = "count"; + private static final String MAX = "max"; + private static final String MIN = "min"; + private static final String MEDIAN = "median"; + private static final String MEAN = "mean"; + private static final String PERCENTILE99TH = "99thPercentile"; + private final TelemetryClient telemetry; + private final String clientName; + private final Map rateLimitersMap; + + /** + * Default constructor + * + * @param httpClient http client + * @param clientName name of the client + * @param url account url + */ + TelemetryService(CloseableHttpClient httpClient, String clientName, String url) { + this.clientName = clientName; + this.telemetry = (TelemetryClient) TelemetryClient.createSessionlessTelemetry(httpClient, url); + this.rateLimitersMap = new HashMap<>(); + } + + /** Flush the telemetry buffer and close the telemetry service */ + public void close() { + this.telemetry.close(); + } + + /** Report the Streaming Ingest latency metrics */ + public void reportLatencyInSec( + Timer buildLatency, Timer uploadLatency, Timer registerLatency, Timer flushLatency) { + if (flushLatency.getCount() > 0) { + ObjectNode msg = MAPPER.createObjectNode(); + msg.set("build_latency_ms", buildMsgFromTimer(buildLatency)); + msg.set("upload_latency_ms", buildMsgFromTimer(uploadLatency)); + msg.set("register_latency_ms", buildMsgFromTimer(registerLatency)); + msg.set("flush_latency_ms", buildMsgFromTimer(flushLatency)); + send(TelemetryType.STREAMING_INGEST_LATENCY_IN_SEC, msg); + } + } + + /** Report the Streaming Ingest failure metrics */ + public void reportClientFailure(String summary, String exception) { + ObjectNode msg = MAPPER.createObjectNode(); + msg.put("summary", summary); + msg.put("client_version", DEFAULT_VERSION); + msg.put("exception", exception); + send(TelemetryType.STREAMING_INGEST_CLIENT_FAILURE, msg); + } + + /** Report the Streaming Ingest throughput metrics */ + public void reportThroughputBytesPerSecond(Meter inputThroughput, Meter uploadThroughput) { + if (inputThroughput.getCount() > 0) { + ObjectNode msg = MAPPER.createObjectNode(); + msg.put(COUNT, inputThroughput.getCount()); + msg.put("input_mean_rate_bytes_per_sec", inputThroughput.getMeanRate()); + msg.put("upload_mean_rate_bytes_per_sec", uploadThroughput.getMeanRate()); + send(TelemetryType.STREAMING_INGEST_THROUGHPUT_BYTES_PER_SEC, msg); + } + } + + /** Report the Streaming Ingest CUP/memory usage metrics */ + public void reportCpuMemoryUsage(Histogram cpuUsage) { + if (cpuUsage.getCount() > 0) { + ObjectNode msg = MAPPER.createObjectNode(); + Snapshot cpuSnapshot = cpuUsage.getSnapshot(); + Runtime runTime = Runtime.getRuntime(); + msg.put(COUNT, cpuUsage.getCount()); + msg.put("cpu_max", cpuSnapshot.getMax()); + msg.put("cpu_mean", cpuSnapshot.getMean()); + msg.put("max_memory", runTime.maxMemory()); + msg.put("total_memory", runTime.totalMemory()); + msg.put("free_memory", runTime.freeMemory()); + send(TelemetryType.STREAMING_INGEST_CPU_MEMORY_USAGE, msg); + } + } + + /** Report the offset token mismatch in a batch */ + public void reportBatchOffsetMismatch( + String channelName, + String prevBatchEndOffset, + String startOffset, + String endOffset, + long rowCount) { + // Add a rate limiter to report the mismatch at most once every second per channel + RateLimiter rateLimiter = + rateLimitersMap.computeIfAbsent(channelName, v -> RateLimiter.create(1.0)); + if (rateLimiter.tryAcquire()) { + ObjectNode msg = MAPPER.createObjectNode(); + msg.put("channel_name", channelName); + msg.put("prev_batch_end_offset", prevBatchEndOffset); + msg.put("start_offset", startOffset); + msg.put("end_offset", endOffset); + msg.put("row_count", rowCount); + send(TelemetryType.STREAMING_INGEST_BATCH_OFFSET_MISMATCH, msg); + } else { + logger.logDebug( + "Rate limit exceeded on reportBatchOffsetMismatch, skipping report it to SF."); + } + } + + /** Send log to Snowflake asynchronously through JDBC client telemetry */ + void send(TelemetryType type, ObjectNode msg) { + try { + msg.put(TYPE, type.toString()); + msg.put(CLIENT_NAME, clientName); + telemetry.addLogToBatch(TelemetryUtil.buildJobData(msg)); + } catch (Exception e) { + logger.logWarn("Failed to send telemetry data, error: {}", e.getMessage()); + } + } + + /** Build message from a Timer metric */ + private ObjectNode buildMsgFromTimer(Timer timer) { + ObjectNode msg = MAPPER.createObjectNode(); + Snapshot buildSnapshot = timer.getSnapshot(); + msg.put(COUNT, timer.getCount()); + msg.put(MAX, TimeUnit.NANOSECONDS.toMillis(buildSnapshot.getMax())); + msg.put(MIN, TimeUnit.NANOSECONDS.toMillis(buildSnapshot.getMin())); + msg.put(MEAN, TimeUnit.NANOSECONDS.toMillis((long) buildSnapshot.getMean())); + msg.put(MEDIAN, TimeUnit.NANOSECONDS.toMillis((long) buildSnapshot.getMedian())); + msg.put( + PERCENTILE99TH, TimeUnit.NANOSECONDS.toMillis((long) buildSnapshot.get99thPercentile())); + return msg; + } + + /** Refresh JWT token stored in the telemetry client */ + public void refreshToken(String token) { + telemetry.refreshToken(token); + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/DropChannelRequest.java b/src/main/java/net/snowflake/ingest/streaming/DropChannelRequest.java new file mode 100644 index 000000000..3bc096c16 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/DropChannelRequest.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2024 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming; + +import net.snowflake.ingest.utils.Utils; + +/** A class that is used to drop a {@link SnowflakeStreamingIngestChannel} */ +public class DropChannelRequest { + // Name of the channel + private final String channelName; + + // Name of the database that the channel belongs to + private final String dbName; + + // Name of the schema that the channel belongs to + private final String schemaName; + + // Name of the table that the channel belongs to + private final String tableName; + + public static DropChannelRequestBuilder builder(String channelName) { + return new DropChannelRequestBuilder(channelName); + } + + /** A builder class to build a DropChannelRequest */ + public static class DropChannelRequestBuilder { + private final String channelName; + private String dbName; + private String schemaName; + private String tableName; + + public DropChannelRequestBuilder(String channelName) { + this.channelName = channelName; + } + + public DropChannelRequestBuilder setDBName(String dbName) { + this.dbName = dbName; + return this; + } + + public DropChannelRequestBuilder setSchemaName(String schemaName) { + this.schemaName = schemaName; + return this; + } + + public DropChannelRequestBuilder setTableName(String tableName) { + this.tableName = tableName; + return this; + } + + public DropChannelRequest build() { + return new DropChannelRequest(this); + } + } + + public DropChannelRequest(DropChannelRequestBuilder builder) { + Utils.assertStringNotNullOrEmpty("channel name", builder.channelName); + Utils.assertStringNotNullOrEmpty("database name", builder.dbName); + Utils.assertStringNotNullOrEmpty("schema name", builder.schemaName); + Utils.assertStringNotNullOrEmpty("table name", builder.tableName); + + this.channelName = builder.channelName; + this.dbName = builder.dbName; + this.schemaName = builder.schemaName; + this.tableName = builder.tableName; + } + + public String getDBName() { + return this.dbName; + } + + public String getSchemaName() { + return this.schemaName; + } + + public String getTableName() { + return this.tableName; + } + + public String getChannelName() { + return this.channelName; + } + + public String getFullyQualifiedTableName() { + return String.format("%s.%s.%s", this.dbName, this.schemaName, this.tableName); + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/InsertValidationResponse.java b/src/main/java/net/snowflake/ingest/streaming/InsertValidationResponse.java index f64260864..f57d384c9 100644 --- a/src/main/java/net/snowflake/ingest/streaming/InsertValidationResponse.java +++ b/src/main/java/net/snowflake/ingest/streaming/InsertValidationResponse.java @@ -47,16 +47,21 @@ public void addError(InsertError error) { /** Wraps the row content and exception when there is a failure */ public static class InsertError { private final Object rowContent; - private final SFException exception; + private SFException exception; // Used to map this error row with original row in the insertRows Iterable. // i.e the rowIndex can be index 9 in the list of 10 rows. // index is 0 based so as to match with incoming Iterable - private final long rowIndex; + private long rowIndex; - public InsertError(Object row, SFException exception, long rowIndex) { + // List of extra column names in the input row compared with the table schema + private List extraColNames; + + // List of missing non-nullable column names in the input row compared with the table schema + private List missingNotNullColNames; + + public InsertError(Object row, long rowIndex) { this.rowContent = row; - this.exception = exception; this.rowIndex = rowIndex; } @@ -70,11 +75,29 @@ public String getMessage() { return this.exception.getMessage(); } + /** + * Set the insert exception + * + * @param exception exception encountered during the insert + */ + public void setException(SFException exception) { + this.exception = exception; + } + /** Get the exception */ public SFException getException() { return this.exception; } + /** + * Set the row index + * + * @param rowIndex the corresponding row index in the original input row list + */ + public void setRowIndex(long rowIndex) { + this.rowIndex = rowIndex; + } + /** * Get the rowIndex. Please note, this index is 0 based so it can be used in fetching nth row * from the input. ({@link SnowflakeStreamingIngestChannel#insertRows(Iterable, String)}) @@ -82,5 +105,28 @@ public SFException getException() { public long getRowIndex() { return rowIndex; } + + /** Set the extra column names in the input row compared with the table schema */ + public void setExtraColNames(List extraColNames) { + this.extraColNames = extraColNames; + } + + /** Get the list of extra column names in the input row compared with the table schema */ + public List getExtraColNames() { + return extraColNames; + } + + /** Set the missing non-nullable column names in the input row compared with the table schema */ + public void setMissingNotNullColNames(List missingNotNullColNames) { + this.missingNotNullColNames = missingNotNullColNames; + } + + /** + * Get the list of missing non-nullable column names in the input row compared with the table + * schema + */ + public List getMissingNotNullColNames() { + return missingNotNullColNames; + } } } diff --git a/src/main/java/net/snowflake/ingest/streaming/OffsetTokenVerificationFunction.java b/src/main/java/net/snowflake/ingest/streaming/OffsetTokenVerificationFunction.java new file mode 100644 index 000000000..847e56501 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/OffsetTokenVerificationFunction.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming; + +/** + * Interface to provide a custom offset verification logic. If specified, verification failures will + * be logged as warnings and reported to Snowflake. This interface could be used when there are + * certain assumption about the offset token behavior. Please reach out to Snowflake if you notice + * any unexpected behaviors. + * + *

Below is an example that verifies that all offset tokens are monotonically increasing numbers: + * + *

+ *     
+ *      private static final OffsetTokenVerificationFunction offsetTokenVerificationFunction =
+ *       (prevBatchEndOffset, curBatchStartOffset, curBatchEndOffset, rowCount) -> {
+ *         boolean isMatch = true;
+ *
+ *         if (curBatchStartOffset != null) {
+ *           try {
+ *             long curStart = Long.parseLong(curBatchStartOffset);
+ *             long curEnd = Long.parseLong(curBatchEndOffset);
+ *
+ *             // We verify that the end_offset - start_offset + 1 = row_count
+ *             if (curEnd - curStart + 1 != rowCount) {
+ *               isMatch = false;
+ *             }
+ *
+ *             // We verify that start_offset_of_current_batch = end_offset_of_previous_batch+1
+ *             if (prevBatchEndOffset != null) {
+ *               long prevEnd = Long.parseLong(prevBatchEndOffset);
+ *               if (curStart != prevEnd + 1) {
+ *                 isMatch = false;
+ *               }
+ *             }
+ *           } catch (NumberFormatException ignored) {
+ *             // Do nothing since we can't compare the offset, or report a mismatch if number is expected
+ *           }
+ *         }
+ *
+ *         return isMatch;
+ *       };
+ *     
+ * 
+ */ +public interface OffsetTokenVerificationFunction { + /** + * @param prevBatchEndOffset end offset token of the previous batch + * @param curBatchStartOffset start offset token of the current batch + * @param curBatchEndOffset end offset token of the current batch + * @param rowCount number of rows in the current batch + * @return a boolean indicates whether the verification passed or not, if not, we will log a + * warning and report it to SF + */ + boolean verify( + String prevBatchEndOffset, + String curBatchStartOffset, + String curBatchEndOffset, + long rowCount); +} diff --git a/src/main/java/net/snowflake/ingest/streaming/OpenChannelRequest.java b/src/main/java/net/snowflake/ingest/streaming/OpenChannelRequest.java index 0370771e1..cc8782dbd 100644 --- a/src/main/java/net/snowflake/ingest/streaming/OpenChannelRequest.java +++ b/src/main/java/net/snowflake/ingest/streaming/OpenChannelRequest.java @@ -4,6 +4,7 @@ package net.snowflake.ingest.streaming; +import java.time.ZoneId; import net.snowflake.ingest.utils.Utils; /** A class that is used to open/create a {@link SnowflakeStreamingIngestChannel} */ @@ -11,8 +12,16 @@ public class OpenChannelRequest { public enum OnErrorOption { CONTINUE, // CONTINUE loading the rows, and return all the errors in the response ABORT, // ABORT the entire batch, and throw an exception when we hit the first error + SKIP_BATCH, // If an error in the batch is detected return a response containing all error row + // indexes. No data is ingested } + /** + * Default value of the timezone, which will be used for TIMESTAMP_LTZ and TIMESTAMP_TZ column + * types when the user input does not have any timezone information. + */ + private static final ZoneId DEFAULT_DEFAULT_TIMEZONE = ZoneId.of("America/Los_Angeles"); + // Name of the channel private final String channelName; @@ -28,6 +37,14 @@ public enum OnErrorOption { // On_error option on this channel private final OnErrorOption onErrorOption; + // Default timezone for TIMESTAMP_LTZ and TIMESTAMP_TZ columns + private final ZoneId defaultTimezone; + + private final String offsetToken; + private final boolean isOffsetTokenProvided; + + private final OffsetTokenVerificationFunction offsetTokenVerificationFunction; + public static OpenChannelRequestBuilder builder(String channelName) { return new OpenChannelRequestBuilder(channelName); } @@ -39,9 +56,16 @@ public static class OpenChannelRequestBuilder { private String schemaName; private String tableName; private OnErrorOption onErrorOption; + private ZoneId defaultTimezone; + + private String offsetToken; + private boolean isOffsetTokenProvided = false; + + private OffsetTokenVerificationFunction offsetTokenVerificationFunction; public OpenChannelRequestBuilder(String channelName) { this.channelName = channelName; + this.defaultTimezone = DEFAULT_DEFAULT_TIMEZONE; } public OpenChannelRequestBuilder setDBName(String dbName) { @@ -64,6 +88,23 @@ public OpenChannelRequestBuilder setOnErrorOption(OnErrorOption onErrorOption) { return this; } + public OpenChannelRequestBuilder setDefaultTimezone(ZoneId defaultTimezone) { + this.defaultTimezone = defaultTimezone; + return this; + } + + public OpenChannelRequestBuilder setOffsetToken(String offsetToken) { + this.offsetToken = offsetToken; + this.isOffsetTokenProvided = true; + return this; + } + + public OpenChannelRequestBuilder setOffsetTokenVerificationFunction( + OffsetTokenVerificationFunction function) { + this.offsetTokenVerificationFunction = function; + return this; + } + public OpenChannelRequest build() { return new OpenChannelRequest(this); } @@ -75,12 +116,17 @@ private OpenChannelRequest(OpenChannelRequestBuilder builder) { Utils.assertStringNotNullOrEmpty("schema name", builder.schemaName); Utils.assertStringNotNullOrEmpty("table name", builder.tableName); Utils.assertNotNull("on_error option", builder.onErrorOption); + Utils.assertNotNull("default_timezone", builder.defaultTimezone); this.channelName = builder.channelName; this.dbName = builder.dbName; this.schemaName = builder.schemaName; this.tableName = builder.tableName; this.onErrorOption = builder.onErrorOption; + this.defaultTimezone = builder.defaultTimezone; + this.offsetToken = builder.offsetToken; + this.isOffsetTokenProvided = builder.isOffsetTokenProvided; + this.offsetTokenVerificationFunction = builder.offsetTokenVerificationFunction; } public String getDBName() { @@ -99,6 +145,10 @@ public String getChannelName() { return this.channelName; } + public ZoneId getDefaultTimezone() { + return this.defaultTimezone; + } + public String getFullyQualifiedTableName() { return String.format("%s.%s.%s", this.dbName, this.schemaName, this.tableName); } @@ -106,4 +156,16 @@ public String getFullyQualifiedTableName() { public OnErrorOption getOnErrorOption() { return this.onErrorOption; } + + public String getOffsetToken() { + return this.offsetToken; + } + + public boolean isOffsetTokenProvided() { + return this.isOffsetTokenProvided; + } + + public OffsetTokenVerificationFunction getOffsetTokenVerificationFunction() { + return this.offsetTokenVerificationFunction; + } } diff --git a/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestChannel.java b/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestChannel.java index e1f0fdbc1..7ffbf2549 100644 --- a/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestChannel.java +++ b/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestChannel.java @@ -4,17 +4,23 @@ package net.snowflake.ingest.streaming; +import java.time.ZoneId; import java.util.Map; import java.util.concurrent.CompletableFuture; import javax.annotation.Nullable; +import net.snowflake.ingest.streaming.internal.ColumnProperties; /** * A logical partition that represents a connection to a single Snowflake table, data will be - * ingested into the channel, and then flush to Snowflake table periodically in the background. Note - * that only one client (or thread) could write to a channel at a given time, so if there are - * multiple clients (or multiple threads in the same client) try to ingest using the same channel, - * the latest client (or thread) that opens the channel will win and all the other opened channels - * will be invalid + * ingested into the channel, and then flushed to Snowflake table periodically in the background. + * + *

Channels are identified by their name and only one channel with the same name may ingest data + * at the same time. When a new channel is opened, all previously opened channels with the same name + * are invalidated (this applies for the table globally. not just in a single JVM). In order to + * ingest data from multiple threads/clients/applications, we recommend opening multiple channels, + * each with a different name. + * + *

Thread safety note: Implementations of this interface are required to be thread safe. */ public interface SnowflakeStreamingIngestChannel { /** @@ -67,17 +73,174 @@ public interface SnowflakeStreamingIngestChannel { boolean isClosed(); /** - * Close the channel, this will make sure all the data in this channel is committed before closing + * Close the channel, this function will make sure all the data in this channel is committed * * @return a completable future which will be completed when the channel is closed */ CompletableFuture close(); + /** + * Close the channel, this function will make sure all the data in this channel is committed + * + *

Note that this call with drop=true will delete Offset + * Token and other state from Snowflake servers unless the channel has already been opened in + * another client. So only use it if you are completely done ingesting data for this channel. If + * you open a channel with the same name in the future, it will behave like a new channel. + * + * @param drop if true, the channel will be dropped after all data is successfully committed. + * @return a completable future which will be completed when the channel is closed + */ + CompletableFuture close(boolean drop); + /** * Insert one row into the channel, the row is represented using Map where the key is column name - * and the value is a row of data + * and the value is a row of data. The following table summarizes supported value types and their + * formats: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * * - * @param row object data to write + *
Snowflake Column TypeAllowed Java Data Type
CHAR, VARCHAR + *
    + *
  • String
  • + *
  • primitive data types (int, boolean, char, …)
  • + *
+ *
BINARY + *
    + *
  • byte[]
  • + *
  • String (hex-encoded)
  • + *
+ *
NUMBER, FLOAT + *
    + *
  • numeric types (BigInteger, BigDecimal, byte, int, double, …)
  • + *
  • String
  • + *
+ *
BOOLEAN + *
    + *
  • boolean
  • + *
  • numeric types (BigInteger, BigDecimal, byte, int, double, …)
  • + *
  • String
  • + *
+ * See boolean conversion details. + *
TIME + *
    + *
  • {@link java.time.LocalTime}
  • + *
  • {@link java.time.OffsetTime}
  • + *
  • + * String (in one of the following formats): + *
      + *
    • {@link java.time.format.DateTimeFormatter#ISO_LOCAL_TIME}
    • + *
    • {@link java.time.format.DateTimeFormatter#ISO_OFFSET_TIME}
    • + *
    • Integer-stored time (see Snowflake Docs for more details)
    • + *
    + *
  • + * + *
+ *
DATE + *
    + *
  • {@link java.time.LocalDate}
  • + *
  • {@link java.time.LocalDateTime}
  • + *
  • {@link java.time.OffsetDateTime}
  • + *
  • {@link java.time.ZonedDateTime}
  • + *
  • {@link java.time.Instant}
  • + *
  • + * String (in one of the following formats): + *
      + *
    • {@link java.time.format.DateTimeFormatter#ISO_LOCAL_DATE}
    • + *
    • {@link java.time.format.DateTimeFormatter#ISO_LOCAL_DATE_TIME}
    • + *
    • {@link java.time.format.DateTimeFormatter#ISO_OFFSET_DATE_TIME}
    • + *
    • {@link java.time.format.DateTimeFormatter#ISO_ZONED_DATE_TIME}
    • + *
    • Integer-stored date (see Snowflake Docs for more details)
    • + *
    + *
  • + *
+ *
TIMESTAMP_NTZ, TIMESTAMP_LTZ, TIMESTAMP_TZ + *
    + *
  • {@link java.time.LocalDate}
  • + *
  • {@link java.time.LocalDateTime}
  • + *
  • {@link java.time.OffsetDateTime}
  • + *
  • {@link java.time.ZonedDateTime}
  • + *
  • {@link java.time.Instant}
  • + *
  • + * String (in one of the following formats): + *
      + *
    • {@link java.time.format.DateTimeFormatter#ISO_LOCAL_DATE}
    • + *
    • {@link java.time.format.DateTimeFormatter#ISO_LOCAL_DATE_TIME}
    • + *
    • {@link java.time.format.DateTimeFormatter#ISO_OFFSET_DATE_TIME}
    • + *
    • {@link java.time.format.DateTimeFormatter#ISO_ZONED_DATE_TIME}
    • + *
    • Integer-stored timestamp (see Snowflake Docs for more details)
    • + *
    + *
  • + * + *
+ * + * For TIMESTAMP_LTZ and TIMESTAMP_TZ, all input without timezone will be by default interpreted in the timezone "America/Los_Angeles". This can be changed by calling {@link net.snowflake.ingest.streaming.OpenChannelRequest.OpenChannelRequestBuilder#setDefaultTimezone(ZoneId)}. + *
VARIANT, ARRAY + *
    + *
  • String (must be a valid JSON value)
  • + *
  • primitive data types and their arrays
  • + *
  • BigInteger, BigDecimal
  • + *
  • {@link java.time.LocalDate}
  • + *
  • {@link java.time.LocalDateTime}
  • + *
  • {@link java.time.OffsetDateTime}
  • + *
  • {@link java.time.ZonedDateTime}
  • + *
  • Map<String, T> where T is a valid VARIANT type
  • + *
  • T[] where T is a valid VARIANT type
  • + *
  • List<T> where T is a valid VARIANT type
  • + *
+ *
OBJECT + *
    + *
  • String (must be a valid JSON object)
  • + *
  • Map<String, T> where T is a valid variant type
  • + *
+ *
GEOGRAPHY, GEOMETRYNot supported
+ * + * @param row object data to write. For predictable results, we recommend not to concurrently + * modify the input row data. * @param offsetToken offset of given row, used for replay in case of failures. It could be null * if you don't plan on replaying or can't replay * @return insert response that possibly contains errors because of insertion failures @@ -86,13 +249,25 @@ public interface SnowflakeStreamingIngestChannel { /** * Insert a batch of rows into the channel, each row is represented using Map where the key is - * column name and the value is a row of data + * column name and the value is a row of data. See {@link + * SnowflakeStreamingIngestChannel#insertRow(Map, String)} for more information about accepted + * values. * * @param rows object data to write - * @param offsetToken offset of last row in the row-set, used for replay in case of failures, It - * could be null if you don't plan on replaying or can't replay + * @param startOffsetToken start offset of the batch/row-set + * @param endOffsetToken end offset of the batch/row-set, used for replay in case of failures, * + * It could be null if you don't plan on replaying or can't replay * @return insert response that possibly contains errors because of insertion failures */ + InsertValidationResponse insertRows( + Iterable> rows, + @Nullable String startOffsetToken, + @Nullable String endOffsetToken); + + /** + * Insert a batch of rows into the channel with the end offset token only, please see {@link + * SnowflakeStreamingIngestChannel#insertRows(Iterable, String, String)} for more information. + */ InsertValidationResponse insertRows( Iterable> rows, @Nullable String offsetToken); @@ -101,5 +276,15 @@ InsertValidationResponse insertRows( * * @return the latest committed offset token */ + @Nullable String getLatestCommittedOffsetToken(); + + /** + * Gets the table schema associated with this channel. Note that this is the table schema at the + * time of a channel open event. The schema may be changed on the Snowflake side in which case + * this will continue to show an old schema version until the channel is re-opened. + * + * @return map representing Column Name to Column Properties + */ + Map getTableSchema(); } diff --git a/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestClient.java b/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestClient.java index 9dcc3b716..2de8a45c2 100644 --- a/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestClient.java +++ b/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestClient.java @@ -4,11 +4,16 @@ package net.snowflake.ingest.streaming; +import java.util.List; +import java.util.Map; + /** * A class that is the starting point for using the Streaming Ingest client APIs, a single client * maps to exactly one account in Snowflake; however, multiple clients can point to the same * account. Each client will contain information for Snowflake authentication and authorization, and * it will be used to create one or more {@link SnowflakeStreamingIngestChannel} + * + *

Thread safety note: Implementations of this interface are required to be thread safe. */ public interface SnowflakeStreamingIngestClient extends AutoCloseable { @@ -20,6 +25,24 @@ public interface SnowflakeStreamingIngestClient extends AutoCloseable { */ SnowflakeStreamingIngestChannel openChannel(OpenChannelRequest request); + /** + * Drop the specified channel on the server using a {@link DropChannelRequest} + * + *

Note that this call will blindly drop the latest version of the channel and any + * pending data will be lost. It will also delete Offset + * Token and other state from Snowflake servers. So only use it if you are completely done + * ingesting data for this channel. If you open a channel with the same name in the future, it + * will behave like a new channel. + * + *

Also see {@link SnowflakeStreamingIngestChannel#close(boolean)} to drop channels on close. + * That approach will drop the local version of the channel and if the channel has been + * concurrently reopened by another client, that version of the channel won't be affected. + * + * @param request the drop channel request + */ + void dropChannel(DropChannelRequest request); + /** * Get the client name * @@ -27,6 +50,28 @@ public interface SnowflakeStreamingIngestClient extends AutoCloseable { */ String getName(); - /** @return a boolean to indicate whether the client is closed */ + /** + * Set refresh token, this method is for refresh token renewal without requiring to restart + * client. This method only works when the authorization type is OAuth. + * + * @param refreshToken the new refresh token + */ + void setRefreshToken(String refreshToken); + + /** + * Check whether the client is closed or not, if you want to make sure all data are committed + * before closing, please call {@link SnowflakeStreamingIngestClient#close()} before closing the + * entire client + * + * @return a boolean to indicate whether the client is closed + */ boolean isClosed(); + + /** + * Return the latest committed offset token for a list of channels + * + * @return a map of channel fully qualified name to latest committed offset token + */ + Map getLatestCommittedOffsetTokens( + List channels); } diff --git a/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestClientFactory.java b/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestClientFactory.java index d99f54e56..cd6d78787 100644 --- a/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestClientFactory.java +++ b/src/main/java/net/snowflake/ingest/streaming/SnowflakeStreamingIngestClientFactory.java @@ -8,8 +8,6 @@ import java.util.Properties; import net.snowflake.ingest.streaming.internal.SnowflakeStreamingIngestClientInternal; import net.snowflake.ingest.utils.Constants; -import net.snowflake.ingest.utils.ErrorCode; -import net.snowflake.ingest.utils.SFException; import net.snowflake.ingest.utils.SnowflakeURL; import net.snowflake.ingest.utils.Utils; @@ -30,6 +28,9 @@ public static class Builder { // Allows client to override some default parameter values private Map parameterOverrides; + // Indicates whether it's under test mode + private boolean isTestMode; + private Builder(String name) { this.name = name; } @@ -44,37 +45,20 @@ public Builder setParameterOverrides(Map parameterOverrides) { return this; } + public Builder setIsTestMode(boolean isTestMode) { + this.isTestMode = isTestMode; + return this; + } + public SnowflakeStreamingIngestClient build() { Utils.assertStringNotNullOrEmpty("client name", this.name); Utils.assertNotNull("connection properties", this.prop); - if (!this.prop.containsKey(Constants.ACCOUNT_URL)) { - if (!this.prop.containsKey(Constants.HOST)) { - throw new SFException(ErrorCode.MISSING_CONFIG, "host"); - } - if (!this.prop.containsKey(Constants.SCHEME)) { - throw new SFException(ErrorCode.MISSING_CONFIG, "scheme"); - } - if (!this.prop.containsKey(Constants.PORT)) { - throw new SFException(ErrorCode.MISSING_CONFIG, "port"); - } - - this.prop.put( - Constants.ACCOUNT_URL, - Utils.constructAccountUrl( - this.prop.get(Constants.SCHEME).toString(), - this.prop.get(Constants.HOST).toString(), - Integer.parseInt(prop.get(Constants.PORT).toString()))); - } - - if (!this.prop.containsKey(Constants.ROLE)) { - throw new SFException(ErrorCode.MISSING_CONFIG, "role"); - } + Properties prop = Utils.createProperties(this.prop); + SnowflakeURL accountURL = new SnowflakeURL(prop.getProperty(Constants.ACCOUNT_URL)); - SnowflakeURL accountURL = new SnowflakeURL(this.prop.getProperty(Constants.ACCOUNT_URL)); - Properties prop = Utils.createProperties(this.prop, accountURL.sslEnabled()); - return new SnowflakeStreamingIngestClientInternal( - this.name, accountURL, prop, parameterOverrides); + return new SnowflakeStreamingIngestClientInternal<>( + this.name, accountURL, prop, this.parameterOverrides, this.isTestMode); } } } diff --git a/src/main/java/net/snowflake/ingest/streaming/example/SnowflakeStreamingIngestExample.java b/src/main/java/net/snowflake/ingest/streaming/example/SnowflakeStreamingIngestExample.java index 85409ce20..ceb8aaa1d 100644 --- a/src/main/java/net/snowflake/ingest/streaming/example/SnowflakeStreamingIngestExample.java +++ b/src/main/java/net/snowflake/ingest/streaming/example/SnowflakeStreamingIngestExample.java @@ -4,11 +4,12 @@ package net.snowflake.ingest.streaming.example; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import java.nio.file.Files; import java.nio.file.Paths; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; import java.util.Properties; import net.snowflake.ingest.streaming.InsertValidationResponse; @@ -16,43 +17,83 @@ import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClient; import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClientFactory; -import net.snowflake.ingest.utils.Utils; -/** Examples on how to use the Streaming Ingest client APIs */ +/** + * Example on how to use the Streaming Ingest client APIs. + * + *

Please read the README.md file for detailed steps + */ public class SnowflakeStreamingIngestExample { + // Please follow the example in profile_streaming.json.example to see the required properties, or + // if you have already set up profile.json with Snowpipe before, all you need is to add the "role" + // property. If the "role" is not specified, the default user role will be applied. private static String PROFILE_PATH = "profile.json"; private static final ObjectMapper mapper = new ObjectMapper(); public static void main(String[] args) throws Exception { - ObjectNode profile = - (ObjectNode) mapper.readTree(new String(Files.readAllBytes(Paths.get(PROFILE_PATH)))); - Properties prop = Utils.getPropertiesFromJson(profile); + Properties props = new Properties(); + Iterator> propIt = + mapper.readTree(new String(Files.readAllBytes(Paths.get(PROFILE_PATH)))).fields(); + while (propIt.hasNext()) { + Map.Entry prop = propIt.next(); + props.put(prop.getKey(), prop.getValue().asText()); + } // Create a streaming ingest client try (SnowflakeStreamingIngestClient client = - SnowflakeStreamingIngestClientFactory.builder("CLIENT").setProperties(prop).build()) { - // Create an open channel request on table T_STREAMINGINGEST + SnowflakeStreamingIngestClientFactory.builder("MY_CLIENT").setProperties(props).build()) { + + // Create an open channel request on table MY_TABLE, note that the corresponding + // db/schema/table needs to be present + // Example: create or replace table MY_TABLE(c1 number); OpenChannelRequest request1 = - OpenChannelRequest.builder("CHANNEL") - .setDBName("DB_STREAMINGINGEST") - .setSchemaName("SCHEMA_STREAMINGINGEST") - .setTableName("T_STREAMINGINGEST") - .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + OpenChannelRequest.builder("MY_CHANNEL") + .setDBName("MY_DATABASE") + .setSchemaName("MY_SCHEMA") + .setTableName("MY_TABLE") + .setOnErrorOption( + OpenChannelRequest.OnErrorOption.CONTINUE) // Another ON_ERROR option is ABORT .build(); // Open a streaming ingest channel from the given client SnowflakeStreamingIngestChannel channel1 = client.openChannel(request1); - // Insert a few rows into the channel, - for (int val = 0; val < 1000; val++) { + // Insert rows into the channel (Using insertRows API) + final int totalRowsInTable = 1000; + for (int val = 0; val < totalRowsInTable; val++) { Map row = new HashMap<>(); + + // c1 corresponds to the column name in table row.put("c1", val); - InsertValidationResponse response = channel1.insertRow(row, null); + + // Insert the row with the current offset_token + InsertValidationResponse response = channel1.insertRow(row, String.valueOf(val)); if (response.hasErrors()) { - // Simply throw if there is an exception + // Simply throw if there is an exception, or you can do whatever you want with the + // erroneous row throw response.getInsertErrors().get(0).getException(); } } + + // If needed, you can check the offset_token registered in Snowflake to make sure everything + // is committed + final int expectedOffsetTokenInSnowflake = totalRowsInTable - 1; // 0 based offset_token + final int maxRetries = 10; + int retryCount = 0; + + do { + String offsetTokenFromSnowflake = channel1.getLatestCommittedOffsetToken(); + if (offsetTokenFromSnowflake != null + && offsetTokenFromSnowflake.equals(String.valueOf(expectedOffsetTokenInSnowflake))) { + System.out.println("SUCCESSFULLY inserted " + totalRowsInTable + " rows"); + break; + } + retryCount++; + } while (retryCount < maxRetries); + + // Close the channel, the function internally will make sure everything is committed (or throw + // an exception if there is any issue) + channel1.close().get(); } } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/AbstractRowBuffer.java b/src/main/java/net/snowflake/ingest/streaming/internal/AbstractRowBuffer.java new file mode 100644 index 000000000..4a13f5adb --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/AbstractRowBuffer.java @@ -0,0 +1,735 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import com.google.common.annotations.VisibleForTesting; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.snowflake.ingest.connection.TelemetryService; +import net.snowflake.ingest.streaming.InsertValidationResponse; +import net.snowflake.ingest.streaming.OffsetTokenVerificationFunction; +import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.Logging; +import net.snowflake.ingest.utils.Pair; +import net.snowflake.ingest.utils.SFException; + +/** + * The abstract implementation of the buffer in the Streaming Ingest channel that holds the + * un-flushed rows, these rows will be converted to the underlying format implementation for faster + * processing + * + * @param type of column data ({@link ParquetChunkData} for Parquet) + */ +abstract class AbstractRowBuffer implements RowBuffer { + private static final Logging logger = new Logging(AbstractRowBuffer.class); + // these types cannot be packed into the data chunk because they are not readable by the server + // side scanner + private static final int INVALID_SERVER_SIDE_DATA_TYPE_ORDINAL = -1; + + // The maximum recommended batch size of data passed into a single insertRows() call + private static final int INSERT_ROWS_RECOMMENDED_MAX_BATCH_SIZE_IN_BYTES = 16 * 1024 * 1024; + + // Snowflake table column logical type + enum ColumnLogicalType { + ANY, + BOOLEAN(1), + ROWINDEX, + NULL(15), + REAL(8), + FIXED(2), + TEXT(9), + CHAR, + BINARY(10), + DATE(7), + TIME(6), + TIMESTAMP_LTZ(3), + TIMESTAMP_NTZ(4), + TIMESTAMP_TZ(5), + INTERVAL, + RAW, + ARRAY(13, true), + OBJECT(12, true), + VARIANT(11, true), + ROW, + SEQUENCE, + FUNCTION, + USER_DEFINED_TYPE, + ; + + // ordinal should be in sync with the server side scanner + private final int ordinal; + // whether it is a composite data type: array, object or variant + private final boolean object; + + ColumnLogicalType() { + // no valid server side ordinal by default + this(INVALID_SERVER_SIDE_DATA_TYPE_ORDINAL); + } + + ColumnLogicalType(int ordinal) { + this(ordinal, false); + } + + ColumnLogicalType(int ordinal, boolean object) { + this.ordinal = ordinal; + this.object = object; + } + + /** + * Ordinal to encode the data type for the server side scanner + * + *

currently used for Parquet format + */ + public int getOrdinal() { + return ordinal; + } + + /** Whether the data type is a composite type: OBJECT, VARIANT, ARRAY. */ + public boolean isObject() { + return object; + } + } + + // Snowflake table column physical type + enum ColumnPhysicalType { + ROWINDEX(9), + DOUBLE(7), + SB1(1), + SB2(2), + SB4(3), + SB8(4), + SB16(5), + LOB(8), + BINARY, + ROW(10), + ; + + // ordinal should be in sync with the server side scanner + private final int ordinal; + + ColumnPhysicalType() { + // no valid server side ordinal by default + this(INVALID_SERVER_SIDE_DATA_TYPE_ORDINAL); + } + + ColumnPhysicalType(int ordinal) { + this.ordinal = ordinal; + } + + /** + * Ordinal to encode the data type for the server side scanner + * + *

currently used for Parquet format + */ + public int getOrdinal() { + return ordinal; + } + } + + /** Insert rows function strategy for ON_ERROR=CONTINUE */ + public class ContinueIngestionStrategy implements IngestionStrategy { + @Override + public InsertValidationResponse insertRows( + AbstractRowBuffer rowBuffer, + Iterable> rows, + String startOffsetToken, + String endOffsetToken) { + InsertValidationResponse response = new InsertValidationResponse(); + float rowsSizeInBytes = 0F; + int rowIndex = 0; + int prevRowCount = rowBuffer.bufferedRowCount; + for (Map row : rows) { + InsertValidationResponse.InsertError error = + new InsertValidationResponse.InsertError(row, rowIndex); + try { + if (rowBuffer.bufferedRowCount == Integer.MAX_VALUE) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "Row count reaches MAX value"); + } + Set inputColumnNames = verifyInputColumns(row, error, rowIndex); + rowsSizeInBytes += + addRow( + row, rowBuffer.bufferedRowCount, rowBuffer.statsMap, inputColumnNames, rowIndex); + rowBuffer.bufferedRowCount++; + } catch (SFException e) { + error.setException(e); + response.addError(error); + } catch (Throwable e) { + logger.logWarn("Unexpected error happens during insertRows: {}", e); + error.setException(new SFException(e, ErrorCode.INTERNAL_ERROR, e.getMessage())); + response.addError(error); + } + rowIndex++; + } + checkBatchSizeRecommendedMaximum(rowsSizeInBytes); + checkOffsetMismatch( + rowBuffer.channelState.getEndOffsetToken(), startOffsetToken, endOffsetToken, rowIndex); + rowBuffer.channelState.updateOffsetToken(startOffsetToken, endOffsetToken, prevRowCount); + rowBuffer.bufferSize += rowsSizeInBytes; + rowBuffer.rowSizeMetric.accept(rowsSizeInBytes); + return response; + } + } + + /** Insert rows function strategy for ON_ERROR=ABORT */ + public class AbortIngestionStrategy implements IngestionStrategy { + @Override + public InsertValidationResponse insertRows( + AbstractRowBuffer rowBuffer, + Iterable> rows, + String startOffsetToken, + String endOffsetToken) { + // If the on_error option is ABORT, simply throw the first exception + InsertValidationResponse response = new InsertValidationResponse(); + float rowsSizeInBytes = 0F; + float tempRowsSizeInBytes = 0F; + int tempRowCount = 0; + for (Map row : rows) { + Set inputColumnNames = verifyInputColumns(row, null, tempRowCount); + tempRowsSizeInBytes += + addTempRow(row, tempRowCount, rowBuffer.tempStatsMap, inputColumnNames, tempRowCount); + tempRowCount++; + checkBatchSizeEnforcedMaximum(tempRowsSizeInBytes); + if ((long) rowBuffer.bufferedRowCount + tempRowCount >= Integer.MAX_VALUE) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "Row count reaches MAX value"); + } + } + checkBatchSizeRecommendedMaximum(tempRowsSizeInBytes); + + moveTempRowsToActualBuffer(tempRowCount); + + rowsSizeInBytes = tempRowsSizeInBytes; + rowBuffer.statsMap.forEach( + (colName, stats) -> + rowBuffer.statsMap.put( + colName, + RowBufferStats.getCombinedStats(stats, rowBuffer.tempStatsMap.get(colName)))); + checkOffsetMismatch( + rowBuffer.channelState.getEndOffsetToken(), + startOffsetToken, + endOffsetToken, + tempRowCount); + rowBuffer.channelState.updateOffsetToken( + startOffsetToken, endOffsetToken, rowBuffer.bufferedRowCount); + rowBuffer.bufferedRowCount += tempRowCount; + rowBuffer.bufferSize += rowsSizeInBytes; + rowBuffer.rowSizeMetric.accept(rowsSizeInBytes); + return response; + } + } + + /** Insert rows function strategy for ON_ERROR=SKIP_BATCH */ + public class SkipBatchIngestionStrategy implements IngestionStrategy { + @Override + public InsertValidationResponse insertRows( + AbstractRowBuffer rowBuffer, + Iterable> rows, + String startOffsetToken, + String endOffsetToken) { + InsertValidationResponse response = new InsertValidationResponse(); + float rowsSizeInBytes = 0F; + float tempRowsSizeInBytes = 0F; + int tempRowCount = 0; + int rowIndex = 0; + for (Map row : rows) { + InsertValidationResponse.InsertError error = + new InsertValidationResponse.InsertError(row, rowIndex); + try { + Set inputColumnNames = verifyInputColumns(row, error, rowIndex); + tempRowsSizeInBytes += + addTempRow(row, tempRowCount, rowBuffer.tempStatsMap, inputColumnNames, rowIndex); + tempRowCount++; + } catch (SFException e) { + error.setException(e); + response.addError(error); + } catch (Throwable e) { + logger.logWarn("Unexpected error happens during insertRows: {}", e); + error.setException(new SFException(e, ErrorCode.INTERNAL_ERROR, e.getMessage())); + response.addError(error); + } + rowIndex++; + checkBatchSizeEnforcedMaximum(tempRowsSizeInBytes); + if ((long) rowBuffer.bufferedRowCount + rowIndex >= Integer.MAX_VALUE) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "Row count reaches MAX value"); + } + } + + if (!response.hasErrors()) { + checkBatchSizeRecommendedMaximum(tempRowsSizeInBytes); + moveTempRowsToActualBuffer(tempRowCount); + rowsSizeInBytes = tempRowsSizeInBytes; + rowBuffer.statsMap.forEach( + (colName, stats) -> + rowBuffer.statsMap.put( + colName, + RowBufferStats.getCombinedStats(stats, rowBuffer.tempStatsMap.get(colName)))); + checkOffsetMismatch( + rowBuffer.channelState.getEndOffsetToken(), startOffsetToken, endOffsetToken, rowIndex); + rowBuffer.channelState.updateOffsetToken( + startOffsetToken, endOffsetToken, rowBuffer.bufferedRowCount); + rowBuffer.bufferedRowCount += tempRowCount; + rowBuffer.bufferSize += rowsSizeInBytes; + rowBuffer.rowSizeMetric.accept(rowsSizeInBytes); + } + return response; + } + } + + // Map the column name to the stats + @VisibleForTesting Map statsMap; + + // Temp stats map to use until all the rows are validated + @VisibleForTesting Map tempStatsMap; + + // Lock used to protect the buffers from concurrent read/write + private final Lock flushLock; + + // Current row count buffered + @VisibleForTesting volatile int bufferedRowCount; + + // Current buffer size + private volatile float bufferSize; + + // Names of non-nullable columns + private final Set nonNullableFieldNames; + + // Buffer's channel fully qualified name with database, schema and table + final String channelFullyQualifiedName; + + // Metric callback to report size of inserted rows + private final Consumer rowSizeMetric; + + // State of the owning channel + final ChannelRuntimeState channelState; + + // ON_ERROR option for this channel + final OpenChannelRequest.OnErrorOption onErrorOption; + + final ZoneId defaultTimezone; + + // Buffer parameters that are set at the owning client level + final ClientBufferParameters clientBufferParameters; + + // Function used to verify offset token logic + final OffsetTokenVerificationFunction offsetTokenVerificationFunction; + + // Telemetry service use to report telemetry to SF + final TelemetryService telemetryService; + + AbstractRowBuffer( + OpenChannelRequest.OnErrorOption onErrorOption, + ZoneId defaultTimezone, + String fullyQualifiedChannelName, + Consumer rowSizeMetric, + ChannelRuntimeState channelRuntimeState, + ClientBufferParameters clientBufferParameters, + OffsetTokenVerificationFunction offsetTokenVerificationFunction, + TelemetryService telemetryService) { + this.onErrorOption = onErrorOption; + this.defaultTimezone = defaultTimezone; + this.rowSizeMetric = rowSizeMetric; + this.channelState = channelRuntimeState; + this.channelFullyQualifiedName = fullyQualifiedChannelName; + this.nonNullableFieldNames = new HashSet<>(); + this.flushLock = new ReentrantLock(); + this.bufferedRowCount = 0; + this.bufferSize = 0F; + this.clientBufferParameters = clientBufferParameters; + this.offsetTokenVerificationFunction = offsetTokenVerificationFunction; + this.telemetryService = telemetryService; + + // Initialize empty stats + this.statsMap = new HashMap<>(); + this.tempStatsMap = new HashMap<>(); + } + + /** + * Adds non-nullable field name. It is used to check if all non-nullable fields have been + * provided. + * + * @param nonNullableFieldName non-nullable filed name + */ + void addNonNullableFieldName(String nonNullableFieldName) { + nonNullableFieldNames.add(nonNullableFieldName); + } + + /** Throws an exception if the column has a collation defined. */ + void validateColumnCollation(ColumnMetadata column) { + if (column.getCollation() != null) { + throw new SFException( + ErrorCode.UNSUPPORTED_DATA_TYPE, + String.format( + "Column %s with collation %s detected. Ingestion into collated columns is not" + + " supported", + column.getName(), column.getCollation())); + } + } + + /** + * Get the current buffer size + * + * @return the current buffer size + */ + @Override + public float getSize() { + return bufferSize; + } + + /** + * Verify that the input row columns are all valid. + * + *

Checks that the columns, specified in the row, are present in the table and values for all + * non-nullable columns are specified. + * + * @param row the input row + * @param error the insert error that we return to the customer + * @param rowIndex the index of the current row in the input batch + * @return the set of input column names + */ + Set verifyInputColumns( + Map row, InsertValidationResponse.InsertError error, int rowIndex) { + // Map of unquoted column name -> original column name + Map inputColNamesMap = + row.keySet().stream() + .collect(Collectors.toMap(LiteralQuoteUtils::unquoteColumnName, value -> value)); + + // Check for extra columns in the row + List extraCols = new ArrayList<>(); + for (String columnName : inputColNamesMap.keySet()) { + if (!hasColumn(columnName)) { + extraCols.add(inputColNamesMap.get(columnName)); + } + } + + if (!extraCols.isEmpty()) { + if (error != null) { + error.setExtraColNames(extraCols); + } + throw new SFException( + ErrorCode.INVALID_FORMAT_ROW, + "Extra columns: " + extraCols, + String.format( + "Columns not present in the table shouldn't be specified, rowIndex:%d", rowIndex)); + } + + // Check for missing columns in the row + List missingCols = new ArrayList<>(); + for (String columnName : this.nonNullableFieldNames) { + if (!inputColNamesMap.containsKey(columnName)) { + missingCols.add(statsMap.get(columnName).getColumnDisplayName()); + } + } + + if (!missingCols.isEmpty()) { + if (error != null) { + error.setMissingNotNullColNames(missingCols); + } + throw new SFException( + ErrorCode.INVALID_FORMAT_ROW, + "Missing columns: " + missingCols, + String.format( + "Values for all non-nullable columns must be specified, rowIndex:%d", rowIndex)); + } + + return inputColNamesMap.keySet(); + } + + /** + * Insert a batch of rows into the row buffer + * + * @param rows input row + * @param startOffsetToken start offset token of the batch + * @param endOffsetToken offset token of the latest row in the batch + * @return insert response that possibly contains errors because of insertion failures + */ + @Override + public InsertValidationResponse insertRows( + Iterable> rows, String startOffsetToken, String endOffsetToken) { + if (!hasColumns()) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "Empty column fields"); + } + InsertValidationResponse response = null; + this.flushLock.lock(); + try { + this.channelState.updateInsertStats(System.currentTimeMillis(), this.bufferedRowCount); + IngestionStrategy ingestionStrategy = createIngestionStrategy(onErrorOption); + response = ingestionStrategy.insertRows(this, rows, startOffsetToken, endOffsetToken); + } finally { + this.tempStatsMap.values().forEach(RowBufferStats::reset); + clearTempRows(); + this.flushLock.unlock(); + } + return response; + } + + /** + * Flush the data in the row buffer by taking the ownership of the old vectors and pass all the + * required info back to the flush service to build the blob + * + * @param filePath the name of the file the data will be written in + * @return A ChannelData object that contains the info needed by the flush service to build a blob + */ + @Override + public ChannelData flush(final String filePath) { + logger.logDebug("Start get data for channel={}", channelFullyQualifiedName); + if (this.bufferedRowCount > 0) { + Optional oldData = Optional.empty(); + int oldRowCount = 0; + float oldBufferSize = 0F; + long oldRowSequencer = 0; + String oldEndOffsetToken = null; + String oldStartOffsetToken = null; + Map oldColumnEps = null; + Pair oldMinMaxInsertTimeInMs = null; + + logger.logDebug("Buffer flush about to take lock on channel={}", channelFullyQualifiedName); + + this.flushLock.lock(); + try { + if (this.bufferedRowCount > 0) { + // Transfer the ownership of the vectors + oldData = getSnapshot(filePath); + oldRowCount = this.bufferedRowCount; + oldBufferSize = this.bufferSize; + oldRowSequencer = this.channelState.incrementAndGetRowSequencer(); + oldEndOffsetToken = this.channelState.getEndOffsetToken(); + oldStartOffsetToken = this.channelState.getStartOffsetToken(); + oldColumnEps = new HashMap<>(this.statsMap); + oldMinMaxInsertTimeInMs = + new Pair<>( + this.channelState.getFirstInsertInMs(), this.channelState.getLastInsertInMs()); + // Reset everything in the buffer once we save all the info + reset(); + } + } finally { + this.flushLock.unlock(); + } + + logger.logDebug( + "Buffer flush released lock on channel={}, rowCount={}, bufferSize={}", + channelFullyQualifiedName, + oldRowCount, + oldBufferSize); + + if (oldData.isPresent()) { + ChannelData data = new ChannelData<>(); + data.setVectors(oldData.get()); + data.setRowCount(oldRowCount); + data.setBufferSize(oldBufferSize); + data.setRowSequencer(oldRowSequencer); + data.setEndOffsetToken(oldEndOffsetToken); + data.setStartOffsetToken(oldStartOffsetToken); + data.setColumnEps(oldColumnEps); + data.setMinMaxInsertTimeInMs(oldMinMaxInsertTimeInMs); + data.setFlusherFactory(this::createFlusher); + return data; + } + } + return null; + } + + /** Whether row has a column with a given name. */ + abstract boolean hasColumn(String name); + + /** + * Add an input row to the buffer. + * + * @param row input row + * @param bufferedRowIndex Row number of buffered rows which will eventually by flushed. + * @param statsMap column stats map + * @param formattedInputColumnNames list of input column names after formatting + * @param insertRowIndex Index of the rows given in insertRows API. Not the same as + * bufferedRowIndex + * @return row size + */ + abstract float addRow( + Map row, + int bufferedRowIndex, + Map statsMap, + Set formattedInputColumnNames, + final long insertRowIndex); + + /** + * Add an input row to the temporary row buffer. + * + *

The temporary row buffer is used to add rows before validating all of them. Once all the + * rows to add have been validated, the temporary row buffer is moved to the actual one. + * + * @param row input row + * @param curRowIndex current row index to use + * @param statsMap column stats map + * @param formattedInputColumnNames list of input column names after formatting + * @param insertRowIndex index of the row being inserteed from User Input List + * @return row size + */ + abstract float addTempRow( + Map row, + int curRowIndex, + Map statsMap, + Set formattedInputColumnNames, + long insertRowIndex); + + /** Move rows from the temporary buffer to the current row buffer. */ + abstract void moveTempRowsToActualBuffer(int tempRowCount); + + /** Clear the temporary row buffer. */ + abstract void clearTempRows(); + + /** If row has any column. */ + abstract boolean hasColumns(); + + /** Reset the variables after each flush. Note that the caller needs to handle synchronization. */ + void reset() { + this.bufferedRowCount = 0; + this.bufferSize = 0F; + this.statsMap.replaceAll((key, value) -> value.forkEmpty()); + } + + /** + * Get buffered data snapshot for later flushing. + * + * @param filePath the name of the file the data will be written in + */ + abstract Optional getSnapshot(final String filePath); + + @VisibleForTesting + abstract Object getVectorValueAt(String column, int index); + + @VisibleForTesting + abstract int getTempRowCount(); + + /** + * Close row buffer by releasing its internal resources only. Note that the allocated memory for + * the channel is released elsewhere (in close). + */ + abstract void closeInternal(); + + /** Close the row buffer and release allocated memory for the channel. */ + @Override + public synchronized void close(String name) { + + closeInternal(); + } + + /** + * Given a set of col names to stats, build the right ep info + * + * @param rowCount: count of rows in the given buffer + * @param colStats: map of column name to RowBufferStats + * @return the EPs built from column stats + */ + static EpInfo buildEpInfoFromStats(long rowCount, Map colStats) { + EpInfo epInfo = new EpInfo(rowCount, new HashMap<>()); + for (Map.Entry colStat : colStats.entrySet()) { + RowBufferStats stat = colStat.getValue(); + FileColumnProperties dto = new FileColumnProperties(stat); + String colName = colStat.getValue().getColumnDisplayName(); + epInfo.getColumnEps().put(colName, dto); + } + epInfo.verifyEpInfo(); + return epInfo; + } + + /** Row buffer factory. */ + static AbstractRowBuffer createRowBuffer( + OpenChannelRequest.OnErrorOption onErrorOption, + ZoneId defaultTimezone, + Constants.BdecVersion bdecVersion, + String fullyQualifiedChannelName, + Consumer rowSizeMetric, + ChannelRuntimeState channelRuntimeState, + ClientBufferParameters clientBufferParameters, + OffsetTokenVerificationFunction offsetTokenVerificationFunction, + TelemetryService telemetryService) { + switch (bdecVersion) { + case THREE: + //noinspection unchecked + return (AbstractRowBuffer) + new ParquetRowBuffer( + onErrorOption, + defaultTimezone, + fullyQualifiedChannelName, + rowSizeMetric, + channelRuntimeState, + clientBufferParameters, + offsetTokenVerificationFunction, + telemetryService); + default: + throw new SFException( + ErrorCode.INTERNAL_ERROR, "Unsupported BDEC format version: " + bdecVersion); + } + } + + private void checkBatchSizeEnforcedMaximum(float batchSizeInBytes) { + if (batchSizeInBytes > clientBufferParameters.getMaxChunkSizeInBytes()) { + throw new SFException( + ErrorCode.MAX_BATCH_SIZE_EXCEEDED, + clientBufferParameters.getMaxChunkSizeInBytes(), + INSERT_ROWS_RECOMMENDED_MAX_BATCH_SIZE_IN_BYTES); + } + } + + private void checkBatchSizeRecommendedMaximum(float batchSizeInBytes) { + if (batchSizeInBytes > INSERT_ROWS_RECOMMENDED_MAX_BATCH_SIZE_IN_BYTES) { + logger.logWarn( + "The batch of rows passed to 'insertRows' is over the recommended max batch size. Given" + + " {} bytes, recommended max batch is {} bytes. For optimal performance and memory" + + " utilization, we recommend splitting large batches into multiple smaller ones and" + + " call insertRows for each smaller batch separately.", + batchSizeInBytes, + INSERT_ROWS_RECOMMENDED_MAX_BATCH_SIZE_IN_BYTES); + } + } + + /** + * We verify the offset token based on the provided verification logic and report to SF if there + * is a mismatch. + */ + private void checkOffsetMismatch( + String prevEndOffset, String curStartOffset, String curEndOffset, int rowCount) { + if (offsetTokenVerificationFunction != null + && !offsetTokenVerificationFunction.verify( + prevEndOffset, curStartOffset, curEndOffset, rowCount)) { + logger.logWarn( + "The channel {} might have an offset token mismatch based on the provided offset token" + + " verification logic, preEndOffset={}, curStartOffset={}, curEndOffset={}," + + " rowCount={}.", + channelFullyQualifiedName, + prevEndOffset, + curStartOffset, + curEndOffset, + rowCount); + if (telemetryService != null) { + telemetryService.reportBatchOffsetMismatch( + channelFullyQualifiedName, prevEndOffset, curStartOffset, curEndOffset, rowCount); + } + } + } + + /** Create the ingestion strategy based on the channel on_error option */ + IngestionStrategy createIngestionStrategy(OpenChannelRequest.OnErrorOption onErrorOption) { + switch (onErrorOption) { + case CONTINUE: + return new ContinueIngestionStrategy<>(); + case ABORT: + return new AbortIngestionStrategy<>(); + case SKIP_BATCH: + return new SkipBatchIngestionStrategy<>(); + default: + throw new IllegalArgumentException("Unknown on error option: " + onErrorOption); + } + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ArrowRowBuffer.java b/src/main/java/net/snowflake/ingest/streaming/internal/ArrowRowBuffer.java deleted file mode 100644 index ea70c50be..000000000 --- a/src/main/java/net/snowflake/ingest/streaming/internal/ArrowRowBuffer.java +++ /dev/null @@ -1,962 +0,0 @@ -/* - * Copyright (c) 2021 Snowflake Computing Inc. All rights reserved. - */ - -package net.snowflake.ingest.streaming.internal; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; -import java.util.stream.Collectors; -import net.snowflake.client.jdbc.internal.google.common.collect.Sets; -import net.snowflake.client.jdbc.internal.snowflake.common.util.Power10; -import net.snowflake.ingest.streaming.InsertValidationResponse; -import net.snowflake.ingest.streaming.OpenChannelRequest; -import net.snowflake.ingest.utils.ErrorCode; -import net.snowflake.ingest.utils.Logging; -import net.snowflake.ingest.utils.SFException; -import net.snowflake.ingest.utils.Utils; -import org.apache.arrow.memory.BufferAllocator; -import org.apache.arrow.util.VisibleForTesting; -import org.apache.arrow.vector.BaseFixedWidthVector; -import org.apache.arrow.vector.BaseVariableWidthVector; -import org.apache.arrow.vector.BigIntVector; -import org.apache.arrow.vector.BitVector; -import org.apache.arrow.vector.DateDayVector; -import org.apache.arrow.vector.DecimalVector; -import org.apache.arrow.vector.FieldVector; -import org.apache.arrow.vector.Float8Vector; -import org.apache.arrow.vector.IntVector; -import org.apache.arrow.vector.SmallIntVector; -import org.apache.arrow.vector.TinyIntVector; -import org.apache.arrow.vector.VarBinaryVector; -import org.apache.arrow.vector.VarCharVector; -import org.apache.arrow.vector.VectorSchemaRoot; -import org.apache.arrow.vector.complex.StructVector; -import org.apache.arrow.vector.types.Types; -import org.apache.arrow.vector.types.pojo.ArrowType; -import org.apache.arrow.vector.types.pojo.Field; -import org.apache.arrow.vector.types.pojo.FieldType; -import org.apache.arrow.vector.util.Text; -import org.apache.arrow.vector.util.TransferPair; - -/** - * The buffer in the Streaming Ingest channel that holds the un-flushed rows, these rows will be - * converted to Arrow format for faster processing - */ -class ArrowRowBuffer { - - // Snowflake table column logical type - private static enum ColumnLogicalType { - ANY, - BOOLEAN, - ROWINDEX, - NULL, - REAL, - FIXED, - TEXT, - CHAR, - BINARY, - DATE, - TIME, - TIMESTAMP_LTZ, - TIMESTAMP_NTZ, - TIMESTAMP_TZ, - INTERVAL, - RAW, - ARRAY, - OBJECT, - VARIANT, - ROW, - SEQUENCE, - FUNCTION, - USER_DEFINED_TYPE, - } - - // Snowflake table column physical type - private static enum ColumnPhysicalType { - ROWINDEX, - DOUBLE, - SB1, - SB2, - SB4, - SB8, - SB16, - LOB, - BINARY, - ROW, - } - - private static final Logging logger = new Logging(ArrowRowBuffer.class); - - // Constants for column fields - private static final String FIELD_EPOCH_IN_SECONDS = "epoch"; // seconds since epoch - private static final String FIELD_TIME_ZONE = "timezone"; // time zone index - private static final String FIELD_FRACTION_IN_NANOSECONDS = "fraction"; // fraction in nanoseconds - - // Column metadata that will send back to server as part of the blob, and will be used by the - // Arrow reader - private static final String COLUMN_PHYSICAL_TYPE = "physicalType"; - private static final String COLUMN_LOGICAL_TYPE = "logicalType"; - private static final String COLUMN_NULLABLE = "nullable"; - static final String COLUMN_SCALE = "scale"; - private static final String COLUMN_PRECISION = "precision"; - private static final String COLUMN_CHAR_LENGTH = "charLength"; - private static final String COLUMN_BYTE_LENGTH = "byteLength"; - @VisibleForTesting static final int DECIMAL_BIT_WIDTH = 128; - - // Holder for a set of the Arrow vectors (buffers) - @VisibleForTesting VectorSchemaRoot vectorsRoot; - - // For ABORT on_error option, temp vectors are needed to temporarily holding the rows until - // they're all validated, then the rows will be transferred to the final VectorSchemaRoot - @VisibleForTesting VectorSchemaRoot tempVectorsRoot; - - // Map the column name to Arrow column field - private final Map fields; - - // Map the column name to the stats - @VisibleForTesting Map statsMap; - - // Temp stats map to use until all the rows are validated - @VisibleForTesting Map tempStatsMap; - - // Lock used to protect the buffers from concurrent read/write - private final Lock flushLock; - - // Current row count - @VisibleForTesting volatile int rowCount; - - // Allocator used to allocate the buffers - private final BufferAllocator allocator; - - // Current buffer size - private volatile float bufferSize; - - // Reference to the Streaming Ingest channel that owns this buffer - @VisibleForTesting final SnowflakeStreamingIngestChannelInternal owningChannel; - - // Names of non-nullable columns - private final Set nonNullableFieldNames; - - /** - * Given a set of col names to stats, build the right ep info - * - * @param rowCount: count of rows in the given arrow buffer - * @param colStats: map of column name to RowBufferStats - * @return the EPs built from column stats - */ - static EpInfo buildEpInfoFromStats(long rowCount, Map colStats) { - EpInfo epInfo = new EpInfo(rowCount, new HashMap<>()); - for (Map.Entry colStat : colStats.entrySet()) { - RowBufferStats stat = colStat.getValue(); - FileColumnProperties dto = new FileColumnProperties(stat); - - String colName = colStat.getKey(); - epInfo.getColumnEps().put(colName, dto); - } - return epInfo; - } - - /** - * Construct a ArrowRowBuffer object - * - * @param channel - */ - ArrowRowBuffer(SnowflakeStreamingIngestChannelInternal channel) { - this.owningChannel = channel; - this.allocator = channel.getAllocator(); - this.fields = new HashMap<>(); - this.nonNullableFieldNames = new HashSet<>(); - this.flushLock = new ReentrantLock(); - this.rowCount = 0; - this.bufferSize = 0F; - - // Initialize empty stats - this.statsMap = new HashMap<>(); - this.tempStatsMap = new HashMap<>(); - } - - /** - * Setup the column fields and vectors using the column metadata from the server - * - * @param columns list of column metadata - */ - void setupSchema(List columns) { - List vectors = new ArrayList<>(); - List tempVectors = new ArrayList<>(); - - for (ColumnMetadata column : columns) { - Field field = buildField(column); - FieldVector vector = field.createVector(this.allocator); - if (!field.isNullable()) { - this.nonNullableFieldNames.add(field.getName()); - } - this.fields.put(column.getName(), field); - vectors.add(vector); - this.statsMap.put(column.getName(), new RowBufferStats(column.getCollation())); - - if (this.owningChannel.getOnErrorOption() == OpenChannelRequest.OnErrorOption.ABORT) { - FieldVector tempVector = field.createVector(this.allocator); - tempVectors.add(tempVector); - this.tempStatsMap.put(column.getName(), new RowBufferStats(column.getCollation())); - } - } - - this.vectorsRoot = new VectorSchemaRoot(vectors); - this.tempVectorsRoot = new VectorSchemaRoot(tempVectors); - } - - /** - * Close the row buffer and release resources. Note that the caller needs to handle - * synchronization - */ - void close() { - if (this.vectorsRoot != null) { - this.vectorsRoot.close(); - this.tempVectorsRoot.close(); - } - this.fields.clear(); - Utils.closeAllocator(this.allocator); - } - - /** Reset the variables after each flush. Note that the caller needs to handle synchronization */ - void reset() { - this.vectorsRoot.clear(); - this.rowCount = 0; - this.bufferSize = 0F; - this.statsMap.replaceAll( - (key, value) -> new RowBufferStats(value.getCollationDefinitionString())); - } - - /** - * Get the current buffer size - * - * @return the current buffer size - */ - float getSize() { - return this.bufferSize; - } - - /** - * Insert a batch of rows into the row buffer - * - * @param rows input row - * @param offsetToken offset token of the latest row in the batch - * @return insert response that possibly contains errors because of insertion failures - */ - InsertValidationResponse insertRows(Iterable> rows, String offsetToken) { - float rowSize = 0F; - if (this.fields.isEmpty()) { - throw new SFException(ErrorCode.INTERNAL_ERROR, "Empty column fields"); - } - - InsertValidationResponse response = new InsertValidationResponse(); - this.flushLock.lock(); - try { - if (this.owningChannel.getOnErrorOption() == OpenChannelRequest.OnErrorOption.CONTINUE) { - // Used to map incoming row(nth row) to InsertError(for nth row) in response - long rowIndex = 0; - for (Map row : rows) { - try { - rowSize += convertRowToArrow(row, this.vectorsRoot, this.rowCount, this.statsMap); - this.rowCount++; - this.bufferSize += rowSize; - } catch (SFException e) { - response.addError(new InsertValidationResponse.InsertError(row, e, rowIndex)); - } catch (Throwable e) { - logger.logWarn("Unexpected error happens during insertRows: {}", e.getMessage()); - response.addError( - new InsertValidationResponse.InsertError( - row, new SFException(e, ErrorCode.INTERNAL_ERROR, e.getMessage()), rowIndex)); - } - rowIndex++; - } - } else { - // If the on_error option is ABORT, simply throw the first exception - float tempRowSize = 0F; - int tempRowCount = 0; - for (Map row : rows) { - tempRowSize += - convertRowToArrow(row, this.tempVectorsRoot, tempRowCount, this.tempStatsMap); - tempRowCount++; - } - - // If all the rows are inserted successfully, transfer the rows from temp vectors to - // the final vectors and update the row size and row count - // TODO: switch to VectorSchemaRootAppender once it works for all vector types - for (Field field : fields.values()) { - FieldVector from = this.tempVectorsRoot.getVector(field); - FieldVector to = this.vectorsRoot.getVector(field); - for (int rowIdx = 0; rowIdx < tempRowCount; rowIdx++) { - to.copyFromSafe(rowIdx, this.rowCount + rowIdx, from); - } - } - rowSize = tempRowSize; - this.rowCount += tempRowCount; - this.bufferSize += rowSize; - this.statsMap.forEach( - (colName, stats) -> { - this.statsMap.put( - colName, RowBufferStats.getCombinedStats(stats, this.tempStatsMap.get(colName))); - }); - } - - this.owningChannel.setOffsetToken(offsetToken); - this.owningChannel.collectRowSize(rowSize); - } finally { - this.tempStatsMap.values().forEach(RowBufferStats::reset); - this.tempVectorsRoot.clear(); - this.flushLock.unlock(); - } - - return response; - } - - /** - * Flush the data in the row buffer by taking the ownership of the old vectors and pass all the - * required info back to the flush service to build the blob - * - * @return A ChannelData object that contains the info needed by the flush service to build a blob - */ - ChannelData flush() { - logger.logDebug("Start get data for channel={}", this.owningChannel.getFullyQualifiedName()); - if (this.rowCount > 0) { - List oldVectors = new ArrayList<>(); - int oldRowCount = 0; - float oldBufferSize = 0F; - long oldRowSequencer = 0; - String oldOffsetToken = null; - Map oldColumnEps = null; - - logger.logDebug( - "Arrow buffer flush about to take lock on channel={}", - this.owningChannel.getFullyQualifiedName()); - - this.flushLock.lock(); - try { - if (this.rowCount > 0) { - // Transfer the ownership of the vectors - for (FieldVector vector : this.vectorsRoot.getFieldVectors()) { - vector.setValueCount(this.rowCount); - if (vector instanceof DecimalVector) { - // DecimalVectors do not transfer FieldType metadata when using - // vector.getTransferPair. We need to explicitly create the new vector to transfer to - // in order to keep the metadata. - ArrowType arrowType = - new ArrowType.Decimal( - ((DecimalVector) vector).getPrecision(), - ((DecimalVector) vector).getScale(), - DECIMAL_BIT_WIDTH); - FieldType fieldType = - new FieldType( - vector.getField().isNullable(), - arrowType, - null, - vector.getField().getMetadata()); - Field f = new Field(vector.getName(), fieldType, null); - DecimalVector newVector = new DecimalVector(f, this.allocator); - TransferPair t = vector.makeTransferPair(newVector); - t.transfer(); - oldVectors.add((FieldVector) t.getTo()); - } else { - TransferPair t = vector.getTransferPair(this.allocator); - t.transfer(); - oldVectors.add((FieldVector) t.getTo()); - } - } - - oldRowCount = this.rowCount; - oldBufferSize = this.bufferSize; - oldRowSequencer = this.owningChannel.incrementAndGetRowSequencer(); - oldOffsetToken = this.owningChannel.getOffsetToken(); - oldColumnEps = new HashMap<>(this.statsMap); - // Reset everything in the buffer once we save all the info - reset(); - } - } finally { - this.flushLock.unlock(); - } - - logger.logDebug( - "Arrow buffer flush released lock on channel={}, rowCount={}, bufferSize={}", - this.owningChannel.getFullyQualifiedName(), - rowCount, - bufferSize); - - if (!oldVectors.isEmpty()) { - ChannelData data = new ChannelData(); - VectorSchemaRoot vectors = new VectorSchemaRoot(oldVectors); - vectors.setRowCount(oldRowCount); - data.setVectors(vectors); - data.setBufferSize(oldBufferSize); - data.setChannel(this.owningChannel); - data.setRowSequencer(oldRowSequencer); - data.setOffsetToken(oldOffsetToken); - data.setColumnEps(oldColumnEps); - - return data; - } - } - return null; - } - - /** - * Build the column field from the column metadata - * - * @param column column metadata - * @return Column field object - */ - Field buildField(ColumnMetadata column) { - ArrowType arrowType; - FieldType fieldType; - List children = null; - - // Put info into the metadata, which will be used by the Arrow reader later - Map metadata = new HashMap<>(); - metadata.put(COLUMN_LOGICAL_TYPE, column.getLogicalType()); - metadata.put(COLUMN_PHYSICAL_TYPE, column.getPhysicalType()); - metadata.put(COLUMN_NULLABLE, String.valueOf(column.getNullable())); - - ColumnPhysicalType physicalType; - ColumnLogicalType logicalType; - try { - physicalType = ColumnPhysicalType.valueOf(column.getPhysicalType()); - logicalType = ColumnLogicalType.valueOf(column.getLogicalType()); - } catch (IllegalArgumentException e) { - throw new SFException( - ErrorCode.UNKNOWN_DATA_TYPE, column.getLogicalType(), column.getPhysicalType()); - } - - if (column.getPrecision() != null) { - metadata.put(COLUMN_PRECISION, column.getPrecision().toString()); - } - if (column.getScale() != null) { - metadata.put(COLUMN_SCALE, column.getScale().toString()); - } - if (column.getByteLength() != null) { - metadata.put(COLUMN_BYTE_LENGTH, column.getByteLength().toString()); - } - if (column.getLength() != null) { - metadata.put(COLUMN_CHAR_LENGTH, column.getLength().toString()); - } - - // Handle differently depends on the column logical and physical types - switch (logicalType) { - case FIXED: - if ((column.getScale() != null && column.getScale() != 0) - || physicalType == ColumnPhysicalType.SB16) { - arrowType = - new ArrowType.Decimal(column.getPrecision(), column.getScale(), DECIMAL_BIT_WIDTH); - } else { - switch (physicalType) { - case SB1: - arrowType = Types.MinorType.TINYINT.getType(); - break; - case SB2: - arrowType = Types.MinorType.SMALLINT.getType(); - break; - case SB4: - arrowType = Types.MinorType.INT.getType(); - break; - case SB8: - arrowType = Types.MinorType.BIGINT.getType(); - break; - default: - throw new SFException( - ErrorCode.UNKNOWN_DATA_TYPE, column.getLogicalType(), column.getPhysicalType()); - } - } - break; - case ANY: - case ARRAY: - case CHAR: - case TEXT: - case OBJECT: - case VARIANT: - arrowType = Types.MinorType.VARCHAR.getType(); - break; - case TIMESTAMP_LTZ: - case TIMESTAMP_NTZ: - switch (physicalType) { - case SB8: - arrowType = Types.MinorType.BIGINT.getType(); - break; - case SB16: - { - arrowType = Types.MinorType.STRUCT.getType(); - FieldType fieldTypeEpoch = - new FieldType(true, Types.MinorType.BIGINT.getType(), null, metadata); - FieldType fieldTypeFraction = - new FieldType(true, Types.MinorType.INT.getType(), null, metadata); - Field fieldEpoch = new Field(FIELD_EPOCH_IN_SECONDS, fieldTypeEpoch, null); - Field fieldFraction = - new Field(FIELD_FRACTION_IN_NANOSECONDS, fieldTypeFraction, null); - children = new ArrayList<>(); - children.add(fieldEpoch); - children.add(fieldFraction); - break; - } - default: - throw new SFException( - ErrorCode.UNKNOWN_DATA_TYPE, column.getLogicalType(), column.getPhysicalType()); - } - break; - case TIMESTAMP_TZ: - switch (physicalType) { - case SB8: - { - arrowType = Types.MinorType.STRUCT.getType(); - FieldType fieldTypeEpoch = - new FieldType(true, Types.MinorType.BIGINT.getType(), null, metadata); - FieldType fieldTypeTimezone = - new FieldType(true, Types.MinorType.INT.getType(), null, metadata); - Field fieldEpoch = new Field(FIELD_EPOCH_IN_SECONDS, fieldTypeEpoch, null); - Field fieldTimezone = new Field(FIELD_TIME_ZONE, fieldTypeTimezone, null); - - children = new ArrayList<>(); - children.add(fieldEpoch); - children.add(fieldTimezone); - break; - } - case SB16: - { - arrowType = Types.MinorType.STRUCT.getType(); - FieldType fieldTypeEpoch = - new FieldType(true, Types.MinorType.BIGINT.getType(), null, metadata); - FieldType fieldTypeFraction = - new FieldType(true, Types.MinorType.INT.getType(), null, metadata); - FieldType fieldTypeTimezone = - new FieldType(true, Types.MinorType.INT.getType(), null, metadata); - Field fieldEpoch = new Field(FIELD_EPOCH_IN_SECONDS, fieldTypeEpoch, null); - Field fieldFraction = - new Field(FIELD_FRACTION_IN_NANOSECONDS, fieldTypeFraction, null); - Field fieldTimezone = new Field(FIELD_TIME_ZONE, fieldTypeTimezone, null); - - children = new ArrayList<>(); - children.add(fieldEpoch); - children.add(fieldFraction); - children.add(fieldTimezone); - break; - } - default: - throw new SFException( - ErrorCode.UNKNOWN_DATA_TYPE, - "Unknown physical type for TIMESTAMP_TZ: " + physicalType); - } - break; - case DATE: - arrowType = Types.MinorType.DATEDAY.getType(); - break; - case TIME: - switch (physicalType) { - case SB4: - arrowType = Types.MinorType.INT.getType(); - break; - case SB8: - arrowType = Types.MinorType.BIGINT.getType(); - break; - default: - throw new SFException( - ErrorCode.UNKNOWN_DATA_TYPE, column.getLogicalType(), column.getPhysicalType()); - } - break; - case BOOLEAN: - arrowType = Types.MinorType.BIT.getType(); - break; - case BINARY: - arrowType = Types.MinorType.VARBINARY.getType(); - break; - case REAL: - arrowType = Types.MinorType.FLOAT8.getType(); - break; - default: - throw new SFException( - ErrorCode.UNKNOWN_DATA_TYPE, column.getLogicalType(), column.getPhysicalType()); - } - - // Create the corresponding column field base on the column data type - fieldType = new FieldType(column.getNullable(), arrowType, null, metadata); - return new Field(column.getName(), fieldType, children); - } - - private String formatColumnName(String columnName) { - Utils.assertStringNotNullOrEmpty("invalid column name", columnName); - return (columnName.charAt(0) == '"' && columnName.charAt(columnName.length() - 1) == '"') - ? columnName.substring(1, columnName.length() - 1) - : columnName.toUpperCase(); - } - - /** - * Verify that the input row columns are all valid - * - * @param row the input row - * @return the set of input column names - */ - private Set verifyInputColumns(Map row) { - Set inputColumns = - row.keySet().stream().map(this::formatColumnName).collect(Collectors.toSet()); - - for (String columnName : this.nonNullableFieldNames) { - if (!inputColumns.contains(columnName)) { - throw new SFException( - ErrorCode.INVALID_ROW, - "Missing column: " + columnName, - "Values for all non-nullable columns must be specified."); - } - } - - for (String columnName : inputColumns) { - Field field = this.fields.get(columnName); - if (field == null) { - throw new SFException( - ErrorCode.INVALID_ROW, - "Extra column: " + columnName, - "Columns not present in the table shouldn't be specified."); - } - } - - return inputColumns; - } - - /** - * Convert the input row to the correct Arrow format - * - * @param row input row - * @param sourceVectors vectors (buffers) that hold the row - * @param curRowIndex current row index to use - * @param statsMap column stats map - * @return row size - */ - private float convertRowToArrow( - Map row, - VectorSchemaRoot sourceVectors, - int curRowIndex, - Map statsMap) { - // Verify all the input columns are valid - Set inputColumnNames = verifyInputColumns(row); - - float rowBufferSize = 0F; - for (Map.Entry entry : row.entrySet()) { - rowBufferSize += 0.125; // 1/8 for null value bitmap - String columnName = this.formatColumnName(entry.getKey()); - Object value = entry.getValue(); - Field field = this.fields.get(columnName); - Utils.assertNotNull("Arrow column field", field); - FieldVector vector = sourceVectors.getVector(field); - Utils.assertNotNull("Arrow column vector", vector); - RowBufferStats stats = statsMap.get(columnName); - Utils.assertNotNull("Arrow column stats", stats); - ColumnLogicalType logicalType = - ColumnLogicalType.valueOf(field.getMetadata().get(COLUMN_LOGICAL_TYPE)); - ColumnPhysicalType physicalType = - ColumnPhysicalType.valueOf(field.getMetadata().get(COLUMN_PHYSICAL_TYPE)); - - if (value == null) { - if (!field.getFieldType().isNullable()) { - throw new SFException( - ErrorCode.INVALID_ROW, columnName, "Passed null to non nullable field"); - } - insertNull(vector, stats, curRowIndex); - } else { - switch (logicalType) { - case FIXED: - if (!field.getMetadata().get(COLUMN_SCALE).equals("0") - || physicalType == ColumnPhysicalType.SB16) { - int scale = - DataValidationUtil.validateAndParseInteger(field.getMetadata().get(COLUMN_SCALE)); - BigDecimal bigDecimalValue = DataValidationUtil.validateAndParseBigDecimal(value); - - // vector.setSafe requires the BigDecimal input scale explicitly match its scale - bigDecimalValue = bigDecimalValue.setScale(scale); - ((DecimalVector) vector).setSafe(curRowIndex, bigDecimalValue); - BigInteger intRep = - bigDecimalValue - .multiply(BigDecimal.valueOf(Power10.intTable[scale])) - .toBigInteger(); - stats.addIntValue(intRep); - rowBufferSize += 16; - } else { - switch (physicalType) { - case SB1: - byte byteValue = DataValidationUtil.validateAndParseByte(value); - ((TinyIntVector) vector).setSafe(curRowIndex, byteValue); - stats.addIntValue(BigInteger.valueOf(byteValue)); - rowBufferSize += 1; - break; - case SB2: - short shortValue = DataValidationUtil.validateAndParseShort(value); - ((SmallIntVector) vector).setSafe(curRowIndex, shortValue); - stats.addIntValue(BigInteger.valueOf(shortValue)); - rowBufferSize += 2; - break; - case SB4: - int intVal = DataValidationUtil.validateAndParseInteger(value); - ((IntVector) vector).setSafe(curRowIndex, intVal); - stats.addIntValue(BigInteger.valueOf(intVal)); - rowBufferSize += 4; - break; - case SB8: - long longValue = DataValidationUtil.validateAndParseLong(value); - ((BigIntVector) vector).setSafe(curRowIndex, longValue); - stats.addIntValue(BigInteger.valueOf(longValue)); - rowBufferSize += 8; - break; - default: - throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); - } - } - break; - case ANY: - case CHAR: - case TEXT: - { - String maxLengthString = field.getMetadata().get(COLUMN_CHAR_LENGTH); - String str = - DataValidationUtil.validateAndParseString( - value, - Optional.ofNullable(maxLengthString) - .map(s -> DataValidationUtil.validateAndParseInteger(maxLengthString))); - Text text = new Text(str); - ((VarCharVector) vector).setSafe(curRowIndex, text); - stats.addStrValue(str); - rowBufferSize += text.getBytes().length; - break; - } - case OBJECT: - { - String str = DataValidationUtil.validateAndParseObject(value); - Text text = new Text(str); - ((VarCharVector) vector).setSafe(curRowIndex, text); - rowBufferSize += text.getBytes().length; - break; - } - case ARRAY: - case VARIANT: - { - String str = DataValidationUtil.validateAndParseVariant(value); - Text text = new Text(str); - ((VarCharVector) vector).setSafe(curRowIndex, text); - rowBufferSize += text.getBytes().length; - break; - } - case TIMESTAMP_LTZ: - case TIMESTAMP_NTZ: - switch (physicalType) { - case SB8: - { - BigIntVector bigIntVector = (BigIntVector) vector; - BigInteger timeInScale = - DataValidationUtil.validateAndParseTime(value, field.getMetadata()); - bigIntVector.setSafe(curRowIndex, timeInScale.longValue()); - stats.addIntValue(timeInScale); - rowBufferSize += 8; - break; - } - case SB16: - { - StructVector structVector = (StructVector) vector; - BigIntVector epochVector = - (BigIntVector) structVector.getChild(FIELD_EPOCH_IN_SECONDS); - IntVector fractionVector = - (IntVector) structVector.getChild(FIELD_FRACTION_IN_NANOSECONDS); - rowBufferSize += 0.25; // for children vector's null value - structVector.setIndexDefined(curRowIndex); - - TimestampWrapper timestampWrapper = - DataValidationUtil.validateAndParseTimestampNtzSb16( - value, field.getMetadata()); - epochVector.setSafe(curRowIndex, timestampWrapper.getEpoch()); - fractionVector.setSafe(curRowIndex, timestampWrapper.getFraction()); - rowBufferSize += 12; - stats.addIntValue(timestampWrapper.getTimeInScale()); - break; - } - default: - throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); - } - break; - case TIMESTAMP_TZ: - switch (physicalType) { - case SB8: - { - StructVector structVector = (StructVector) vector; - BigIntVector epochVector = - (BigIntVector) structVector.getChild(FIELD_EPOCH_IN_SECONDS); - IntVector timezoneVector = (IntVector) structVector.getChild(FIELD_TIME_ZONE); - - rowBufferSize += 0.25; // for children vector's null value - structVector.setIndexDefined(curRowIndex); - - TimestampWrapper timestampWrapper = - DataValidationUtil.validateAndParseTimestampTz(value, field.getMetadata()); - epochVector.setSafe(curRowIndex, timestampWrapper.getTimeInScale().longValue()); - timezoneVector.setSafe( - curRowIndex, - timestampWrapper - .getTimeZoneIndex() - .orElseThrow( - () -> - new SFException( - ErrorCode.INVALID_ROW, - value, - "Unable to parse timezone for TIMESTAMP_TZ column"))); - rowBufferSize += 12; - BigInteger timeInBinary = - timestampWrapper - .getSfTimestamp() - .orElseThrow( - () -> - new SFException( - ErrorCode.INVALID_ROW, - value, - "Unable to parse timestamp for TIMESTAMP_TZ column")) - .toBinary(Integer.parseInt(field.getMetadata().get(COLUMN_SCALE)), true); - stats.addIntValue(timeInBinary); - break; - } - case SB16: - { - StructVector structVector = (StructVector) vector; - BigIntVector epochVector = - (BigIntVector) structVector.getChild(FIELD_EPOCH_IN_SECONDS); - IntVector fractionVector = - (IntVector) structVector.getChild(FIELD_FRACTION_IN_NANOSECONDS); - IntVector timezoneVector = (IntVector) structVector.getChild(FIELD_TIME_ZONE); - - rowBufferSize += 0.375; // for children vector's null value - structVector.setIndexDefined(curRowIndex); - - TimestampWrapper timestampWrapper = - DataValidationUtil.validateAndParseTimestampTz(value, field.getMetadata()); - epochVector.setSafe(curRowIndex, timestampWrapper.getEpoch()); - fractionVector.setSafe(curRowIndex, timestampWrapper.getFraction()); - timezoneVector.setSafe( - curRowIndex, - timestampWrapper - .getTimeZoneIndex() - .orElseThrow( - () -> - new SFException( - ErrorCode.INVALID_ROW, - value, - "Unable to parse timezone for TIMESTAMP_TZ column"))); - rowBufferSize += 16; - BigInteger timeInBinary = - timestampWrapper - .getSfTimestamp() - .orElseThrow( - () -> - new SFException( - ErrorCode.INVALID_ROW, - value, - "Unable to parse timestamp for TIMESTAMP_TZ column")) - .toBinary(Integer.parseInt(field.getMetadata().get(COLUMN_SCALE)), true); - stats.addIntValue(timeInBinary); - break; - } - default: - throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); - } - break; - case DATE: - { - DateDayVector dateDayVector = (DateDayVector) vector; - // Expect days past the epoch - int intValue = DataValidationUtil.validateAndParseDate(value); - dateDayVector.setSafe(curRowIndex, intValue); - stats.addIntValue(BigInteger.valueOf(intValue)); - rowBufferSize += 4; - break; - } - case TIME: - switch (physicalType) { - case SB4: - { - BigInteger timeInScale = - DataValidationUtil.validateAndParseTime(value, field.getMetadata()); - stats.addIntValue(timeInScale); - ((IntVector) vector).setSafe(curRowIndex, timeInScale.intValue()); - stats.addIntValue(timeInScale); - rowBufferSize += 4; - break; - } - case SB8: - { - BigInteger timeInScale = - DataValidationUtil.validateAndParseTime(value, field.getMetadata()); - ((BigIntVector) vector).setSafe(curRowIndex, timeInScale.longValue()); - stats.addIntValue(timeInScale); - rowBufferSize += 8; - break; - } - default: - throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); - } - break; - case BOOLEAN: - { - int intValue = DataValidationUtil.validateAndParseBoolean(value); - ((BitVector) vector).setSafe(curRowIndex, intValue); - rowBufferSize += 0.125; - stats.addIntValue(BigInteger.valueOf(intValue)); - break; - } - case BINARY: - byte[] bytes = DataValidationUtil.validateAndParseBinary(value); - ((VarBinaryVector) vector).setSafe(curRowIndex, bytes); - stats.addStrValue(new String(bytes, StandardCharsets.UTF_8)); - rowBufferSize += bytes.length; - break; - case REAL: - double doubleValue = DataValidationUtil.validateAndParseReal(value); - ((Float8Vector) vector).setSafe(curRowIndex, doubleValue); - stats.addRealValue(doubleValue); - rowBufferSize += 8; - break; - default: - throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); - } - } - } - - // Insert nulls to the columns that doesn't show up in the input - for (String columnName : Sets.difference(this.fields.keySet(), inputColumnNames)) { - insertNull( - sourceVectors.getVector(this.fields.get(columnName)), - statsMap.get(columnName), - curRowIndex); - } - - return rowBufferSize; - } - - /** Helper function to insert null value to a field vector */ - private void insertNull(FieldVector vector, RowBufferStats stats, int curRowIndex) { - if (BaseFixedWidthVector.class.isAssignableFrom(vector.getClass())) { - ((BaseFixedWidthVector) vector).setNull(curRowIndex); - } else if (BaseVariableWidthVector.class.isAssignableFrom(vector.getClass())) { - ((BaseVariableWidthVector) vector).setNull(curRowIndex); - } else if (vector instanceof StructVector) { - ((StructVector) vector).setNull(curRowIndex); - ((StructVector) vector) - .getChildrenFromFields() - .forEach( - child -> { - ((BaseFixedWidthVector) child).setNull(curRowIndex); - }); - } else { - throw new SFException(ErrorCode.INTERNAL_ERROR, "Unexpected FieldType"); - } - stats.incCurrentNullCount(); - } -} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/BinaryStringUtils.java b/src/main/java/net/snowflake/ingest/streaming/internal/BinaryStringUtils.java new file mode 100644 index 000000000..21abed8f5 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/BinaryStringUtils.java @@ -0,0 +1,44 @@ +package net.snowflake.ingest.streaming.internal; + +import java.nio.ByteBuffer; +import org.apache.commons.codec.binary.Hex; + +public class BinaryStringUtils { + private static final int MAX_LOB_LEN = 32; + + /** Returns the number of unicode code points in a string */ + static int unicodeCharactersCount(String s) { + return s.codePointCount(0, s.length()); + } + + /** + * Truncate an array of bytes to 32 bytes and optionally increment the last byte(s). More the one + * byte can be incremented in case it overflows. + */ + static String truncateBytesAsHex(byte[] bytes, boolean truncateUp) { + if (bytes.length <= MAX_LOB_LEN) { + return Hex.encodeHexString(bytes); + } + + // In order not to mutate the original byte array, let's make a copy + byte[] result = new byte[MAX_LOB_LEN]; + System.arraycopy(bytes, 0, result, 0, MAX_LOB_LEN); + + // Round the least significant byte(s) up + if (truncateUp) { + int idx; + for (idx = MAX_LOB_LEN - 1; idx >= 0; idx--) { + // increment the current byte, if there was no overflow, we can stop + if (++result[idx] != 0) { + break; + } + } + // Whole prefix has overflown, return infinity + if (idx == -1) { + return "Z"; + } + } + + return Hex.encodeHexString(ByteBuffer.wrap(result, 0, MAX_LOB_LEN)); + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/BlobBuilder.java b/src/main/java/net/snowflake/ingest/streaming/internal/BlobBuilder.java index 9cb366a2c..b88090e01 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/BlobBuilder.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/BlobBuilder.java @@ -8,23 +8,30 @@ import static net.snowflake.ingest.utils.Constants.BLOB_CHUNK_METADATA_LENGTH_SIZE_IN_BYTES; import static net.snowflake.ingest.utils.Constants.BLOB_EXTENSION_TYPE; import static net.snowflake.ingest.utils.Constants.BLOB_FILE_SIZE_SIZE_IN_BYTES; -import static net.snowflake.ingest.utils.Constants.BLOB_FORMAT_VERSION; import static net.snowflake.ingest.utils.Constants.BLOB_NO_HEADER; import static net.snowflake.ingest.utils.Constants.BLOB_TAG_SIZE_IN_BYTES; import static net.snowflake.ingest.utils.Constants.BLOB_VERSION_SIZE_IN_BYTES; -import static net.snowflake.ingest.utils.Constants.COMPRESS_BLOB_TWICE; import static net.snowflake.ingest.utils.Utils.toByteArray; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.List; -import java.util.zip.GZIPOutputStream; -import javax.xml.bind.DatatypeConverter; +import java.util.zip.CRC32; +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.Cryptor; import net.snowflake.ingest.utils.Logging; +import net.snowflake.ingest.utils.Pair; +import org.apache.commons.codec.binary.Hex; /** * Build a single blob file that contains file header plus data. The header will be a @@ -39,8 +46,8 @@ *

  • variable size chunks metadata in json format * * - *

    After the metadata, it will be one or more chunks of variable size Arrow data, and each chunk - * will be encrypted and compressed separately. + *

    After the metadata, it will be one or more chunks of variable size Parquet data, and each + * chunk will be encrypted and compressed separately. */ class BlobBuilder { @@ -48,61 +55,139 @@ class BlobBuilder { private static final Logging logger = new Logging(BlobBuilder.class); /** - * Gzip compress the given chunk data + * Builds blob. * - * @param filePath blob file full path - * @param chunkData uncompressed chunk data - * @return compressed chunk data - * @throws IOException + * @param filePath Path of the destination file in cloud storage + * @param blobData All the data for one blob. Assumes that all ChannelData in the inner List + * belongs to the same table. Will error if this is not the case + * @param bdecVersion version of blob + * @return {@link Blob} data */ - static byte[] compress(String filePath, ByteArrayOutputStream chunkData) throws IOException { - int uncompressedSize = chunkData.size(); - ByteArrayOutputStream compressedOutputStream = new ByteArrayOutputStream(uncompressedSize); - try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(compressedOutputStream, true)) { - gzipOutputStream.write(chunkData.toByteArray()); - } - int firstCompressedSize = compressedOutputStream.size(); - - // Based on current experiment, compressing twice will give us the best compression - // ratio and compression time combination - int doubleCompressedSize = 0; - if (COMPRESS_BLOB_TWICE) { - ByteArrayOutputStream doubleCompressedOutputStream = - new ByteArrayOutputStream(firstCompressedSize); - try (GZIPOutputStream doubleGzipOutputStream = - new GZIPOutputStream(doubleCompressedOutputStream, true)) { - doubleGzipOutputStream.write(compressedOutputStream.toByteArray()); + static Blob constructBlobAndMetadata( + String filePath, List>> blobData, Constants.BdecVersion bdecVersion) + throws IOException, NoSuchPaddingException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException, InvalidKeyException, IllegalBlockSizeException, + BadPaddingException { + List chunksMetadataList = new ArrayList<>(); + List chunksDataList = new ArrayList<>(); + long curDataSize = 0L; + CRC32 crc = new CRC32(); + + // TODO: channels with different schema can't be combined even if they belongs to same table + for (List> channelsDataPerTable : blobData) { + ChannelFlushContext firstChannelFlushContext = + channelsDataPerTable.get(0).getChannelContext(); + + Flusher flusher = channelsDataPerTable.get(0).createFlusher(); + Flusher.SerializationResult serializedChunk = + flusher.serialize(channelsDataPerTable, filePath); + + if (!serializedChunk.channelsMetadataList.isEmpty()) { + ByteArrayOutputStream chunkData = serializedChunk.chunkData; + Pair paddedChunk = + padChunk(chunkData, Constants.ENCRYPTION_ALGORITHM_BLOCK_SIZE_BYTES); + byte[] paddedChunkData = paddedChunk.getFirst(); + int paddedChunkLength = paddedChunk.getSecond(); + + // Encrypt the compressed chunk data, the encryption key is derived using the key from + // server with the full blob path. + // We need to maintain IV as a block counter for the whole file, even interleaved, + // to align with decryption on the Snowflake query path. + // TODO: address alignment for the header SNOW-557866 + long iv = curDataSize / Constants.ENCRYPTION_ALGORITHM_BLOCK_SIZE_BYTES; + byte[] encryptedCompressedChunkData = + Cryptor.encrypt( + paddedChunkData, firstChannelFlushContext.getEncryptionKey(), filePath, iv); + + // Compute the md5 of the chunk data + String md5 = computeMD5(encryptedCompressedChunkData, paddedChunkLength); + int encryptedCompressedChunkDataSize = encryptedCompressedChunkData.length; + + // Create chunk metadata + long startOffset = curDataSize; + ChunkMetadata chunkMetadata = + ChunkMetadata.builder() + .setOwningTableFromChannelContext(firstChannelFlushContext) + // The start offset will be updated later in BlobBuilder#build to include the blob + // header + .setChunkStartOffset(startOffset) + // The paddedChunkLength is used because it is the actual data size used for + // decompression and md5 calculation on server side. + .setChunkLength(paddedChunkLength) + .setUncompressedChunkLength((int) serializedChunk.chunkEstimatedUncompressedSize) + .setChannelList(serializedChunk.channelsMetadataList) + .setChunkMD5(md5) + .setEncryptionKeyId(firstChannelFlushContext.getEncryptionKeyId()) + .setEpInfo( + AbstractRowBuffer.buildEpInfoFromStats( + serializedChunk.rowCount, serializedChunk.columnEpStatsMapCombined)) + .setFirstInsertTimeInMs(serializedChunk.chunkMinMaxInsertTimeInMs.getFirst()) + .setLastInsertTimeInMs(serializedChunk.chunkMinMaxInsertTimeInMs.getSecond()) + .build(); + + // Add chunk metadata and data to the list + chunksMetadataList.add(chunkMetadata); + chunksDataList.add(encryptedCompressedChunkData); + curDataSize += encryptedCompressedChunkDataSize; + crc.update(encryptedCompressedChunkData, 0, encryptedCompressedChunkDataSize); + + logger.logInfo( + "Finish building chunk in blob={}, table={}, rowCount={}, startOffset={}," + + " estimatedUncompressedSize={}, paddedChunkLength={}, encryptedCompressedSize={}," + + " bdecVersion={}", + filePath, + firstChannelFlushContext.getFullyQualifiedTableName(), + serializedChunk.rowCount, + startOffset, + serializedChunk.chunkEstimatedUncompressedSize, + paddedChunkLength, + encryptedCompressedChunkDataSize, + bdecVersion); } - doubleCompressedSize = doubleCompressedOutputStream.size(); - compressedOutputStream = doubleCompressedOutputStream; } - logger.logDebug( - "Finish compressing chunk in blob={}, uncompressedSize={}, firstCompressedSize={}," - + " doubleCompressedSize={}", - filePath, - uncompressedSize, - firstCompressedSize, - doubleCompressedSize); + // Build blob file bytes + byte[] blobBytes = + buildBlob(chunksMetadataList, chunksDataList, crc.getValue(), curDataSize, bdecVersion); + return new Blob(blobBytes, chunksMetadataList, new BlobStats()); + } - return compressedOutputStream.toByteArray(); + /** + * Pad the compressed data for encryption. Encryption needs padding to the + * ENCRYPTION_ALGORITHM_BLOCK_SIZE_BYTES to align with decryption on the Snowflake query path + * starting from this chunk offset. + * + * @param chunkData uncompressed chunk data + * @param blockSizeToAlignTo block size to align to for encryption + * @return padded compressed chunk data, aligned to blockSizeToAlignTo, and actual length of + * compressed data before padding at the end + * @throws IOException + */ + static Pair padChunk(ByteArrayOutputStream chunkData, int blockSizeToAlignTo) + throws IOException { + int actualSize = chunkData.size(); + int paddingSize = blockSizeToAlignTo - actualSize % blockSizeToAlignTo; + chunkData.write(new byte[paddingSize]); + return new Pair<>(chunkData.toByteArray(), actualSize); } /** - * Build the blob file + * Build the blob file bytes * * @param chunksMetadataList List of chunk metadata * @param chunksDataList List of chunk data * @param chunksChecksum Checksum for the chunk data portion * @param chunksDataSize Total size for the chunk data portion after compression + * @param bdecVersion BDEC file version * @return The blob file as a byte array * @throws JsonProcessingException */ - static byte[] build( + static byte[] buildBlob( List chunksMetadataList, List chunksDataList, long chunksChecksum, - long chunksDataSize) + long chunksDataSize, + Constants.BdecVersion bdecVersion) throws IOException { byte[] chunkMetadataListInBytes = MAPPER.writeValueAsBytes(chunksMetadataList); @@ -120,17 +205,17 @@ static byte[] build( ByteArrayOutputStream blob = new ByteArrayOutputStream(); if (!BLOB_NO_HEADER) { blob.write(BLOB_EXTENSION_TYPE.getBytes()); - blob.write(BLOB_FORMAT_VERSION); + blob.write(bdecVersion.toByte()); blob.write(toByteArray(metadataSize + chunksDataSize)); blob.write(toByteArray(chunksChecksum)); blob.write(toByteArray(chunkMetadataListInBytes.length)); blob.write(chunkMetadataListInBytes); } - for (byte[] arrowData : chunksDataList) { - blob.write(arrowData); + for (byte[] chunkData : chunksDataList) { + blob.write(chunkData); } - // We need to update the start offset for the EP and Arrow data in the request since + // We need to update the start offset for the EP and Parquet data in the request since // some metadata was added at the beginning for (ChunkMetadata chunkMetadata : chunksMetadataList) { chunkMetadata.advanceStartOffset(metadataSize); @@ -165,6 +250,19 @@ static String computeMD5(byte[] data, int length) throws NoSuchAlgorithmExceptio MessageDigest.getInstance("MD5"); md.update(data, 0, length); byte[] digest = md.digest(); - return DatatypeConverter.printHexBinary(digest).toLowerCase(); + return Hex.encodeHexString(digest); + } + + /** Blob data to store in a file and register by server side. */ + static class Blob { + final byte[] blobBytes; + final List chunksMetadataList; + final BlobStats blobStats; + + Blob(byte[] blobBytes, List chunksMetadataList, BlobStats blobStats) { + this.blobBytes = blobBytes; + this.chunksMetadataList = chunksMetadataList; + this.blobStats = blobStats; + } } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/BlobMetadata.java b/src/main/java/net/snowflake/ingest/streaming/internal/BlobMetadata.java index e1dbdf764..fb366db9c 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/BlobMetadata.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/BlobMetadata.java @@ -4,19 +4,52 @@ package net.snowflake.ingest.streaming.internal; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.annotations.VisibleForTesting; import java.util.List; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.ParameterProvider; /** Metadata for a blob that sends to Snowflake as part of the register blob request */ class BlobMetadata { private final String path; private final String md5; + private final Constants.BdecVersion bdecVersion; private final List chunks; + private final BlobStats blobStats; + private final boolean spansMixedTables; - BlobMetadata(String path, String md5, List chunks) { + // used for testing only + @VisibleForTesting + BlobMetadata(String path, String md5, List chunks, BlobStats blobStats) { + this( + path, + md5, + ParameterProvider.BLOB_FORMAT_VERSION_DEFAULT, + chunks, + blobStats, + chunks == null ? false : chunks.size() > 1); + } + + BlobMetadata( + String path, + String md5, + Constants.BdecVersion bdecVersion, + List chunks, + BlobStats blobStats, + boolean spansMixedTables) { this.path = path; this.md5 = md5; + this.bdecVersion = bdecVersion; this.chunks = chunks; + this.blobStats = blobStats; + this.spansMixedTables = spansMixedTables; + } + + @JsonIgnore + Constants.BdecVersion getVersion() { + return bdecVersion; } @JsonProperty("path") @@ -33,4 +66,30 @@ String getMD5() { List getChunks() { return this.chunks; } + + @JsonProperty("bdec_version") + byte getVersionByte() { + return bdecVersion.toByte(); + } + + @JsonProperty("blob_stats") + BlobStats getBlobStats() { + return this.blobStats; + } + + @JsonProperty("spans_mixed_tables") + boolean getSpansMixedTables() { + return this.spansMixedTables; + } + + /** Create {@link BlobMetadata}. */ + static BlobMetadata createBlobMetadata( + String path, + String md5, + Constants.BdecVersion bdecVersion, + List chunks, + BlobStats blobStats, + boolean spansMixedTables) { + return new BlobMetadata(path, md5, bdecVersion, chunks, blobStats, spansMixedTables); + } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/BlobStats.java b/src/main/java/net/snowflake/ingest/streaming/internal/BlobStats.java new file mode 100644 index 000000000..42994bbb4 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/BlobStats.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import com.codahale.metrics.Timer; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.concurrent.TimeUnit; + +/** Latency stats for a blob */ +class BlobStats { + // flush duration cannot be calculated in the client sdk, so we pass just the start time + private long flushStartMs; + + private long buildDurationMs; + private long uploadDurationMs; + + @JsonProperty("build_duration_ms") + long getBuildDurationMs() { + return this.buildDurationMs; + } + + @JsonProperty("build_duration_ms") + void setBuildDurationMs(Timer.Context buildLatencyContext) { + if (buildLatencyContext != null) { + this.buildDurationMs = TimeUnit.NANOSECONDS.toMillis(buildLatencyContext.stop()); + } + } + + @JsonProperty("upload_duration_ms") + long getUploadDurationMs() { + return this.uploadDurationMs; + } + + @JsonProperty("upload_duration_ms") + void setUploadDurationMs(Timer.Context uploadLatencyContext) { + if (uploadLatencyContext != null) { + this.uploadDurationMs = TimeUnit.NANOSECONDS.toMillis(uploadLatencyContext.stop()); + } + } + + @JsonProperty("flush_start_ms") + long getFlushStartMs() { + return this.flushStartMs; + } + + @JsonProperty("flush_start_ms") + void setFlushStartMs(long flushStartMs) { + this.flushStartMs = flushStartMs; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelCache.java b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelCache.java index bb4f39ec7..3926832fb 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelCache.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelCache.java @@ -13,12 +13,14 @@ * channels belong to the same table will be stored together in order to combine the channel data * during flush. The key is a fully qualified table name and the value is a set of channels that * belongs to this table + * + * @param type of column data ({@link ParquetChunkData}) */ -class ChannelCache { +class ChannelCache { // Cache to hold all the valid channels, the key for the outer map is FullyQualifiedTableName and // the key for the inner map is ChannelName - private ConcurrentHashMap< - String, ConcurrentHashMap> + private final ConcurrentHashMap< + String, ConcurrentHashMap>> cache = new ConcurrentHashMap<>(); /** @@ -26,15 +28,16 @@ class ChannelCache { * * @param channel */ - void addChannel(SnowflakeStreamingIngestChannelInternal channel) { - ConcurrentHashMap channels = + void addChannel(SnowflakeStreamingIngestChannelInternal channel) { + ConcurrentHashMap> channels = this.cache.computeIfAbsent( channel.getFullyQualifiedTableName(), v -> new ConcurrentHashMap<>()); - SnowflakeStreamingIngestChannelInternal oldChannel = channels.put(channel.getName(), channel); + SnowflakeStreamingIngestChannelInternal oldChannel = + channels.put(channel.getName(), channel); // Invalidate old channel if it exits to block new inserts and return error to users earlier if (oldChannel != null) { - oldChannel.invalidate(); + oldChannel.invalidate("removed from cache"); } } @@ -43,7 +46,7 @@ void addChannel(SnowflakeStreamingIngestChannelInternal channel) { * * @return */ - Iterator>> + Iterator>>> iterator() { return this.cache.entrySet().iterator(); } @@ -57,11 +60,11 @@ void closeAllChannels() { /** Remove a channel in the channel cache if the channel sequencer matches */ // TODO: background cleaner to cleanup old stale channels that are not closed? - void removeChannelIfSequencersMatch(SnowflakeStreamingIngestChannelInternal channel) { + void removeChannelIfSequencersMatch(SnowflakeStreamingIngestChannelInternal channel) { cache.computeIfPresent( channel.getFullyQualifiedTableName(), (k, v) -> { - SnowflakeStreamingIngestChannelInternal channelInCache = v.get(channel.getName()); + SnowflakeStreamingIngestChannelInternal channelInCache = v.get(channel.getName()); // We need to compare the channel sequencer in case the old channel was already been // removed return channelInCache != null @@ -81,12 +84,12 @@ void invalidateChannelIfSequencersMatch( String channelName, Long channelSequencer) { String fullyQualifiedTableName = String.format("%s.%s.%s", dbName, schemaName, tableName); - ConcurrentHashMap channelsMapPerTable = + ConcurrentHashMap> channelsMapPerTable = cache.get(fullyQualifiedTableName); if (channelsMapPerTable != null) { - SnowflakeStreamingIngestChannelInternal channel = channelsMapPerTable.get(channelName); + SnowflakeStreamingIngestChannelInternal channel = channelsMapPerTable.get(channelName); if (channel != null && channel.getChannelSequencer().equals(channelSequencer)) { - channel.invalidate(); + channel.invalidate("invalidate with matched sequencer"); } } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelData.java b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelData.java index 013075c56..81f49d2fd 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelData.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelData.java @@ -6,21 +6,28 @@ import java.util.HashMap; import java.util.Map; +import java.util.function.Supplier; import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.Pair; import net.snowflake.ingest.utils.SFException; -import org.apache.arrow.vector.VectorSchemaRoot; /** * Contains the data and metadata returned for each channel flush, which will be used to build the * blob and register blob request + * + * @param type of column data (Parquet {@link ParquetChunkData} */ -class ChannelData { +class ChannelData { private Long rowSequencer; - private String offsetToken; - private VectorSchemaRoot vectors; + private String endOffsetToken; + private String startOffsetToken; + private T vectors; private float bufferSize; - private SnowflakeStreamingIngestChannelInternal channel; + private int rowCount; private Map columnEps; + private Pair minMaxInsertTimeInMs; + private ChannelFlushContext channelFlushContext; + private Supplier> flusherFactory; // TODO performance test this vs in place update /** @@ -37,11 +44,17 @@ public static Map getCombinedColumnStatsMap( if (left == null || right == null) { throw new SFException(ErrorCode.INTERNAL_ERROR, "null column stats"); } + if (left.size() != right.size()) { - throw new SFException(ErrorCode.INTERNAL_ERROR, "Column stats map key mismatch"); + throw new SFException( + ErrorCode.INTERNAL_ERROR, + String.format( + "Column stats map key size mismatch, left=%d, right=%d, leftKeySet=%s," + + " rightKeySet=%s", + left.size(), right.size(), left.keySet(), right.keySet())); } - Map result = new HashMap<>(); + Map result = new HashMap<>(); try { for (String key : left.keySet()) { RowBufferStats leftStats = left.get(key); @@ -49,11 +62,23 @@ public static Map getCombinedColumnStatsMap( result.put(key, RowBufferStats.getCombinedStats(leftStats, rightStats)); } } catch (NullPointerException npe) { - throw new SFException(ErrorCode.INTERNAL_ERROR, "Column stats map key mismatch"); + throw new SFException(npe, ErrorCode.INTERNAL_ERROR, "Column stats map key mismatch"); } return result; } + /** + * Combines the two paris of min/max insert timestamp together + * + * @return A new pair which the first element is min(left min, right min) and the second element + * is max(left max, right max) + */ + public static Pair getCombinedMinMaxInsertTimeInMs( + Pair left, Pair right) { + return new Pair<>( + Math.min(left.getFirst(), right.getFirst()), Math.max(left.getSecond(), right.getSecond())); + } + public Map getColumnEps() { return columnEps; } @@ -70,24 +95,36 @@ void setRowSequencer(Long rowSequencer) { this.rowSequencer = rowSequencer; } - String getOffsetToken() { - return this.offsetToken; + String getEndOffsetToken() { + return this.endOffsetToken; + } + + String getStartOffsetToken() { + return this.startOffsetToken; + } + + void setEndOffsetToken(String endOffsetToken) { + this.endOffsetToken = endOffsetToken; } - void setOffsetToken(String offsetToken) { - this.offsetToken = offsetToken; + void setStartOffsetToken(String startOffsetToken) { + this.startOffsetToken = startOffsetToken; } - VectorSchemaRoot getVectors() { + T getVectors() { return this.vectors; } - void setVectors(VectorSchemaRoot vectors) { + void setVectors(T vectors) { this.vectors = vectors; } int getRowCount() { - return this.vectors.getRowCount(); + return this.rowCount; + } + + void setRowCount(int rowCount) { + this.rowCount = rowCount; } float getBufferSize() { @@ -98,16 +135,46 @@ void setBufferSize(float bufferSize) { this.bufferSize = bufferSize; } - SnowflakeStreamingIngestChannelInternal getChannel() { - return this.channel; + public ChannelFlushContext getChannelContext() { + return channelFlushContext; + } + + public void setChannelContext(ChannelFlushContext channelFlushContext) { + this.channelFlushContext = channelFlushContext; + } + + public Flusher createFlusher() { + return flusherFactory.get(); + } + + public void setFlusherFactory(Supplier> flusherFactory) { + this.flusherFactory = flusherFactory; + } + + Pair getMinMaxInsertTimeInMs() { + return this.minMaxInsertTimeInMs; } - void setChannel(SnowflakeStreamingIngestChannelInternal channel) { - this.channel = channel; + void setMinMaxInsertTimeInMs(Pair minMaxInsertTimeInMs) { + this.minMaxInsertTimeInMs = minMaxInsertTimeInMs; } @Override public String toString() { - return this.channel.toString(); + return "ChannelData{" + + "rowSequencer=" + + rowSequencer + + ", endOffsetToken='" + + endOffsetToken + + ", startOffsetToken='" + + startOffsetToken + + '\'' + + ", bufferSize=" + + bufferSize + + ", rowCount=" + + rowCount + + ", channelContext=" + + channelFlushContext + + '}'; } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelFlushContext.java b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelFlushContext.java new file mode 100644 index 000000000..3e5265719 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelFlushContext.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +/** + * Channel immutable identification and encryption attributes. + * + *

    It is shared with the {@link ChannelData} and used for BDEC flush as well. + */ +class ChannelFlushContext { + private final String name; + private final String fullyQualifiedName; + private final String dbName; + private final String schemaName; + private final String tableName; + private final String fullyQualifiedTableName; + + // Sequencer for this channel, corresponding to client sequencer at server side because each + // connection to a channel at server side will be seen as a connection from a new client + private final Long channelSequencer; + + // Data encryption key + private final String encryptionKey; + + // Data encryption key id + private final Long encryptionKeyId; + + ChannelFlushContext( + String name, + String dbName, + String schemaName, + String tableName, + Long channelSequencer, + String encryptionKey, + Long encryptionKeyId) { + this.name = name; + this.fullyQualifiedName = String.format("%s.%s.%s.%s", dbName, schemaName, tableName, name); + this.dbName = dbName; + this.schemaName = schemaName; + this.tableName = tableName; + this.fullyQualifiedTableName = + String.format("%s.%s.%s", this.getDbName(), this.getSchemaName(), this.getTableName()); + this.channelSequencer = channelSequencer; + this.encryptionKey = encryptionKey; + this.encryptionKeyId = encryptionKeyId; + } + + @Override + public String toString() { + return "ChannelContext{" + + "name='" + + getName() + + '\'' + + ", fullyQualifiedName='" + + getFullyQualifiedName() + + '\'' + + ", dbName='" + + getDbName() + + '\'' + + ", schemaName='" + + getSchemaName() + + '\'' + + ", tableName='" + + getTableName() + + '\'' + + ", fullyQualifiedTableName='" + + getFullyQualifiedTableName() + + '\'' + + ", channelSequencer=" + + getChannelSequencer() + + ", encryptionKey='" + + getEncryptionKey() + + '\'' + + ", encryptionKeyId=" + + getEncryptionKeyId() + + '}'; + } + + String getName() { + return name; + } + + String getFullyQualifiedName() { + return fullyQualifiedName; + } + + String getDbName() { + return dbName; + } + + String getSchemaName() { + return schemaName; + } + + String getTableName() { + return tableName; + } + + String getFullyQualifiedTableName() { + return fullyQualifiedTableName; + } + + Long getChannelSequencer() { + return channelSequencer; + } + + String getEncryptionKey() { + return encryptionKey; + } + + Long getEncryptionKeyId() { + return encryptionKeyId; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelMetadata.java b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelMetadata.java index 3086634cb..bd072e7c3 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelMetadata.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelMetadata.java @@ -16,7 +16,8 @@ class ChannelMetadata { private final String channelName; private final Long clientSequencer; private final Long rowSequencer; - @Nullable private final String offsetToken; + @Nullable private final String endOffsetToken; + @Nullable private final String startOffsetToken; static Builder builder() { return new Builder(); @@ -28,10 +29,11 @@ static class Builder { private Long clientSequencer; private Long rowSequencer; @Nullable private String offsetToken; // offset token could be null + @Nullable private String startOffsetToken; // start offset token could be null - Builder setOwningChannel(SnowflakeStreamingIngestChannelInternal channel) { - this.channelName = channel.getName(); - this.clientSequencer = channel.getChannelSequencer(); + Builder setOwningChannelFromContext(ChannelFlushContext channelFlushContext) { + this.channelName = channelFlushContext.getName(); + this.clientSequencer = channelFlushContext.getChannelSequencer(); return this; } @@ -45,6 +47,11 @@ Builder setOffsetToken(String offsetToken) { return this; } + Builder setStartOffsetToken(String startOffsetToken) { + this.startOffsetToken = startOffsetToken; + return this; + } + ChannelMetadata build() { return new ChannelMetadata(this); } @@ -58,7 +65,8 @@ private ChannelMetadata(Builder builder) { this.channelName = builder.channelName; this.clientSequencer = builder.clientSequencer; this.rowSequencer = builder.rowSequencer; - this.offsetToken = builder.offsetToken; + this.endOffsetToken = builder.offsetToken; + this.startOffsetToken = builder.startOffsetToken; } @JsonProperty("channel_name") @@ -79,6 +87,18 @@ Long getRowSequencer() { @Nullable @JsonProperty("offset_token") String getOffsetToken() { - return this.offsetToken; + return this.endOffsetToken; + } + + @Nullable + @JsonProperty("start_offset_token") + String getStartOffsetToken() { + return this.startOffsetToken; + } + + @Nullable + @JsonProperty("end_offset_token") + String getEndOffsetToken() { + return this.endOffsetToken; } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelRegisterStatus.java b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelRegisterStatus.java index 3eaa13c64..5bdc791ec 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelRegisterStatus.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelRegisterStatus.java @@ -12,6 +12,7 @@ */ class ChannelRegisterStatus { private Long statusCode; + private String message; private String channelName; private Long channelSequencer; @@ -24,6 +25,15 @@ Long getStatusCode() { return this.statusCode; } + @JsonProperty("message") + public String getMessage() { + return message == null ? StreamingIngestResponseCode.getMessageByCode(statusCode) : message; + } + + public void setMessage(String message) { + this.message = message; + } + @JsonProperty("channel") void setChannelName(String channelName) { this.channelName = channelName; diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelRuntimeState.java b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelRuntimeState.java new file mode 100644 index 000000000..8fa7720d3 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelRuntimeState.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import java.util.concurrent.atomic.AtomicLong; + +/** Internal state of channel that is used and mutated by both channel and its buffer. */ +class ChannelRuntimeState { + // Indicates whether the channel is still valid + private volatile boolean isValid; + + // The channel's current start offset token + private volatile String startOffsetToken; + + // The channel's current end offset token + private volatile String endOffsetToken; + + // The channel's current row sequencer + private final AtomicLong rowSequencer; + + // First and last insert time in ms, used for end2end latency measurement + private Long firstInsertInMs; + private Long lastInsertInMs; + + ChannelRuntimeState(String endOffsetToken, long rowSequencer, boolean isValid) { + this.endOffsetToken = endOffsetToken; + this.rowSequencer = new AtomicLong(rowSequencer); + this.isValid = isValid; + } + + /** + * Returns whether the channel is in a valid state. + * + * @return whether the channel is valid + */ + boolean isValid() { + return isValid; + } + + /** Invalidate the channel that has this state object. */ + void invalidate() { + isValid = false; + } + + /** @return current end offset token */ + String getEndOffsetToken() { + return endOffsetToken; + } + + /** @return current start offset token */ + String getStartOffsetToken() { + return startOffsetToken; + } + + /** @return current offset token after first incrementing it by one. */ + long incrementAndGetRowSequencer() { + return rowSequencer.incrementAndGet(); + } + + /** @return row sequencer of the channel. */ + long getRowSequencer() { + return rowSequencer.get(); + } + + /** + * Updates the channel's start and end offset token. + * + * @param startOffsetToken new start offset token of the batch + * @param endOffsetToken new end offset token + */ + void updateOffsetToken(String startOffsetToken, String endOffsetToken, int rowCount) { + if (rowCount == 0) { + this.startOffsetToken = startOffsetToken; + } + this.endOffsetToken = endOffsetToken; + } + + /** Update the insert stats for the current row buffer whenever needed */ + void updateInsertStats(long currentTimeInMs, int rowCount) { + if (rowCount == 0) { + this.firstInsertInMs = currentTimeInMs; + } + this.lastInsertInMs = currentTimeInMs; + } + + /** Get the insert timestamp of the first row in the current row buffer */ + Long getFirstInsertInMs() { + return this.firstInsertInMs; + } + + /** Get the insert timestamp of the last row in the current row buffer */ + Long getLastInsertInMs() { + return this.lastInsertInMs; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelsStatusRequest.java b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelsStatusRequest.java index 8ae7bf54f..b98782ab9 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelsStatusRequest.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelsStatusRequest.java @@ -4,8 +4,6 @@ package net.snowflake.ingest.streaming.internal; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; @@ -29,16 +27,12 @@ static class ChannelStatusRequestDTO { // Client Sequencer private final Long clientSequencer; - // Optional Row Sequencer - private final Long rowSequencer; - ChannelStatusRequestDTO(SnowflakeStreamingIngestChannelInternal channel) { this.channelName = channel.getName(); this.databaseName = channel.getDBName(); this.schemaName = channel.getSchemaName(); this.tableName = channel.getTableName(); this.clientSequencer = channel.getChannelSequencer(); - this.rowSequencer = channel.getRowSequencer(); } @JsonProperty("table") @@ -65,12 +59,6 @@ String getChannelName() { Long getClientSequencer() { return clientSequencer; } - - @JsonInclude(Include.NON_EMPTY) - @JsonProperty("row_sequencer") - Long getRowSequencer() { - return rowSequencer; - } } // Optional Request ID. Used for diagnostic purposes. diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelsStatusResponse.java b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelsStatusResponse.java index 0f49d3a5f..96a24581d 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/ChannelsStatusResponse.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ChannelsStatusResponse.java @@ -8,7 +8,7 @@ import java.util.List; /** Class used to serialize a response for the channels status endpoint */ -class ChannelsStatusResponse { +class ChannelsStatusResponse extends StreamingIngestResponse { static class ChannelStatusResponseDTO { @@ -75,6 +75,7 @@ void setStatusCode(Long statusCode) { } @JsonProperty("status_code") + @Override Long getStatusCode() { return this.statusCode; } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ChunkMetadata.java b/src/main/java/net/snowflake/ingest/streaming/internal/ChunkMetadata.java index a7626c4fd..7b42dbc5e 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/ChunkMetadata.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ChunkMetadata.java @@ -15,10 +15,13 @@ class ChunkMetadata { private final String tableName; private Long chunkStartOffset; private final Integer chunkLength; + private final Integer uncompressedChunkLength; private final List channels; private final String chunkMD5; private final EpInfo epInfo; private final Long encryptionKeyId; + private final Long firstInsertTimeInMs; + private final Long lastInsertTimeInMs; static Builder builder() { return new Builder(); @@ -30,16 +33,21 @@ static class Builder { private String schemaName; private String tableName; private Long chunkStartOffset; - private Integer chunkLength; + private Integer chunkLength; // compressedChunkLength + + private Integer uncompressedChunkLength; + private List channels; private String chunkMD5; private EpInfo epInfo; private Long encryptionKeyId; + private Long firstInsertTimeInMs; + private Long lastInsertTimeInMs; - Builder setOwningTable(SnowflakeStreamingIngestChannelInternal channel) { - this.dbName = channel.getDBName(); - this.schemaName = channel.getSchemaName(); - this.tableName = channel.getTableName(); + Builder setOwningTableFromChannelContext(ChannelFlushContext channelFlushContext) { + this.dbName = channelFlushContext.getDbName(); + this.schemaName = channelFlushContext.getSchemaName(); + this.tableName = channelFlushContext.getTableName(); return this; } @@ -58,6 +66,15 @@ Builder setChunkLength(Integer chunkLength) { return this; } + /** + * Currently we send estimated uncompressed size that is close to the actual parquet data size + * and mostly about user data but parquet encoding overhead may be slightly different. + */ + public Builder setUncompressedChunkLength(Integer uncompressedChunkLength) { + this.uncompressedChunkLength = uncompressedChunkLength; + return this; + } + Builder setChannelList(List channels) { this.channels = channels; return this; @@ -73,6 +90,16 @@ Builder setEncryptionKeyId(Long encryptionKeyId) { return this; } + Builder setFirstInsertTimeInMs(Long firstInsertTimeInMs) { + this.firstInsertTimeInMs = firstInsertTimeInMs; + return this; + } + + Builder setLastInsertTimeInMs(Long lastInsertTimeInMs) { + this.lastInsertTimeInMs = lastInsertTimeInMs; + return this; + } + ChunkMetadata build() { return new ChunkMetadata(this); } @@ -88,16 +115,21 @@ private ChunkMetadata(Builder builder) { Utils.assertNotNull("chunk MD5", builder.chunkMD5); Utils.assertNotNull("chunk ep info", builder.epInfo); Utils.assertNotNull("encryption key id", builder.encryptionKeyId); + Utils.assertNotNull("chunk first insert time in ms", builder.firstInsertTimeInMs); + Utils.assertNotNull("chunk last insert time in ms", builder.lastInsertTimeInMs); this.dbName = builder.dbName; this.schemaName = builder.schemaName; this.tableName = builder.tableName; this.chunkStartOffset = builder.chunkStartOffset; this.chunkLength = builder.chunkLength; + this.uncompressedChunkLength = builder.uncompressedChunkLength; this.channels = builder.channels; this.chunkMD5 = builder.chunkMD5; this.epInfo = builder.epInfo; this.encryptionKeyId = builder.encryptionKeyId; + this.firstInsertTimeInMs = builder.firstInsertTimeInMs; + this.lastInsertTimeInMs = builder.lastInsertTimeInMs; } /** @@ -134,6 +166,11 @@ Integer getChunkLength() { return chunkLength; } + @JsonProperty("chunk_length_uncompressed") + public Integer getUncompressedChunkLength() { + return uncompressedChunkLength; + } + @JsonProperty("channels") List getChannels() { return this.channels; @@ -153,4 +190,14 @@ EpInfo getEpInfo() { Long getEncryptionKeyId() { return this.encryptionKeyId; } + + @JsonProperty("first_insert_time_in_ms") + Long getFirstInsertTimeInMs() { + return this.firstInsertTimeInMs; + } + + @JsonProperty("last_insert_time_in_ms") + Long getLastInsertTimeInMs() { + return this.lastInsertTimeInMs; + } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ClientBufferParameters.java b/src/main/java/net/snowflake/ingest/streaming/internal/ClientBufferParameters.java new file mode 100644 index 000000000..278d4abea --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ClientBufferParameters.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.ParameterProvider; + +/** Channel's buffer relevant parameters that are set at the owning client level. */ +public class ClientBufferParameters { + + private boolean enableParquetInternalBuffering; + + private long maxChunkSizeInBytes; + + private long maxAllowedRowSizeInBytes; + + private Constants.BdecParquetCompression bdecParquetCompression; + + /** + * Private constructor used for test methods + * + * @param enableParquetInternalBuffering flag whether buffering in internal Parquet buffers is + * enabled + * @param maxChunkSizeInBytes maximum chunk size in bytes + * @param maxAllowedRowSizeInBytes maximum row size in bytes + */ + private ClientBufferParameters( + boolean enableParquetInternalBuffering, + long maxChunkSizeInBytes, + long maxAllowedRowSizeInBytes, + Constants.BdecParquetCompression bdecParquetCompression) { + this.enableParquetInternalBuffering = enableParquetInternalBuffering; + this.maxChunkSizeInBytes = maxChunkSizeInBytes; + this.maxAllowedRowSizeInBytes = maxAllowedRowSizeInBytes; + this.bdecParquetCompression = bdecParquetCompression; + } + + /** @param clientInternal reference to the client object where the relevant parameters are set */ + public ClientBufferParameters(SnowflakeStreamingIngestClientInternal clientInternal) { + this.enableParquetInternalBuffering = + clientInternal != null + ? clientInternal.getParameterProvider().getEnableParquetInternalBuffering() + : ParameterProvider.ENABLE_PARQUET_INTERNAL_BUFFERING_DEFAULT; + this.maxChunkSizeInBytes = + clientInternal != null + ? clientInternal.getParameterProvider().getMaxChunkSizeInBytes() + : ParameterProvider.MAX_CHUNK_SIZE_IN_BYTES_DEFAULT; + this.maxAllowedRowSizeInBytes = + clientInternal != null + ? clientInternal.getParameterProvider().getMaxAllowedRowSizeInBytes() + : ParameterProvider.MAX_ALLOWED_ROW_SIZE_IN_BYTES_DEFAULT; + this.bdecParquetCompression = + clientInternal != null + ? clientInternal.getParameterProvider().getBdecParquetCompressionAlgorithm() + : ParameterProvider.BDEC_PARQUET_COMPRESSION_ALGORITHM_DEFAULT; + } + + /** + * @param enableParquetInternalBuffering flag whether buffering in internal Parquet buffers is + * enabled + * @param maxChunkSizeInBytes maximum chunk size in bytes + * @param maxAllowedRowSizeInBytes maximum row size in bytes + * @return ClientBufferParameters object + */ + public static ClientBufferParameters test_createClientBufferParameters( + boolean enableParquetInternalBuffering, + long maxChunkSizeInBytes, + long maxAllowedRowSizeInBytes, + Constants.BdecParquetCompression bdecParquetCompression) { + return new ClientBufferParameters( + enableParquetInternalBuffering, + maxChunkSizeInBytes, + maxAllowedRowSizeInBytes, + bdecParquetCompression); + } + + public boolean getEnableParquetInternalBuffering() { + return enableParquetInternalBuffering; + } + + public long getMaxChunkSizeInBytes() { + return maxChunkSizeInBytes; + } + + public long getMaxAllowedRowSizeInBytes() { + return maxAllowedRowSizeInBytes; + } + + public Constants.BdecParquetCompression getBdecParquetCompression() { + return bdecParquetCompression; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ColumnMetadata.java b/src/main/java/net/snowflake/ingest/streaming/internal/ColumnMetadata.java index 108035a02..73b75da9e 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/ColumnMetadata.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ColumnMetadata.java @@ -11,6 +11,7 @@ /** Column metadata for each column in a Snowflake table */ class ColumnMetadata { private String name; + private String internalName; private String type; private String logicalType; private String physicalType; @@ -21,9 +22,16 @@ class ColumnMetadata { private boolean nullable; private String collation; + /** + * The column ordinal is an internal id of the column used by server scanner for the column + * identification. + */ + private Integer ordinal; + @JsonProperty("name") void setName(String name) { this.name = name; + this.internalName = LiteralQuoteUtils.unquoteColumnName(name); } String getName() { @@ -111,6 +119,19 @@ boolean getNullable() { return this.nullable; } + @JsonProperty("ordinal") + void setOrdinal(Integer ordinal) { + this.ordinal = ordinal; + } + + public Integer getOrdinal() { + return ordinal; + } + + String getInternalName() { + return internalName; + } + @Override public String toString() { Map map = new HashMap<>(); diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ColumnProperties.java b/src/main/java/net/snowflake/ingest/streaming/internal/ColumnProperties.java new file mode 100644 index 000000000..856170c57 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ColumnProperties.java @@ -0,0 +1,60 @@ +package net.snowflake.ingest.streaming.internal; + +/** + * Class that encapsulates column properties. These are the same properties showed in the output of + * SHOW COLUMNS. Note + * that this is slightly different than the internal column metadata used elsewhere in this SDK. + */ +public class ColumnProperties { + private String type; + + private String logicalType; + + private Integer precision; + + private Integer scale; + + private Integer byteLength; + + private Integer length; + + private boolean nullable; + + ColumnProperties(ColumnMetadata columnMetadata) { + this.type = columnMetadata.getType(); + this.logicalType = columnMetadata.getLogicalType(); + this.precision = columnMetadata.getPrecision(); + this.scale = columnMetadata.getScale(); + this.byteLength = columnMetadata.getByteLength(); + this.length = columnMetadata.getLength(); + this.nullable = columnMetadata.getNullable(); + } + + public String getType() { + return type; + } + + public String getLogicalType() { + return logicalType; + } + + public Integer getPrecision() { + return precision; + } + + public Integer getScale() { + return scale; + } + + public Integer getByteLength() { + return byteLength; + } + + public Integer getLength() { + return length; + } + + public boolean isNullable() { + return nullable; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/DataValidationUtil.java b/src/main/java/net/snowflake/ingest/streaming/internal/DataValidationUtil.java index 190a69250..814423c28 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/DataValidationUtil.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/DataValidationUtil.java @@ -4,620 +4,912 @@ package net.snowflake.ingest.streaming.internal; +import static net.snowflake.ingest.streaming.internal.BinaryStringUtils.unicodeCharactersCount; + +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import java.math.BigDecimal; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.concurrent.TimeUnit; -import javax.xml.bind.DatatypeConverter; +import java.util.function.Supplier; import net.snowflake.client.jdbc.internal.google.common.collect.Sets; -import net.snowflake.client.jdbc.internal.snowflake.common.core.SFDate; -import net.snowflake.client.jdbc.internal.snowflake.common.core.SFTimestamp; import net.snowflake.client.jdbc.internal.snowflake.common.core.SnowflakeDateTimeFormat; import net.snowflake.client.jdbc.internal.snowflake.common.util.Power10; +import net.snowflake.ingest.streaming.internal.serialization.ByteArraySerializer; +import net.snowflake.ingest.streaming.internal.serialization.ZonedDateTimeSerializer; import net.snowflake.ingest.utils.ErrorCode; import net.snowflake.ingest.utils.SFException; +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; /** Utility class for parsing and validating inputs based on Snowflake types */ class DataValidationUtil { - static final int MAX_STRING_LENGTH = 16777216; - static final BigInteger MAX_BIGINTEGER = BigInteger.valueOf(10).pow(38); - static final BigInteger MIN_BIGINTEGER = - BigInteger.valueOf(-1).multiply(BigInteger.valueOf(10).pow(38)); + + /** + * Seconds limit used for integer-stored timestamp scale guessing. Value needs to be aligned with + * the value from {@link SnowflakeDateTimeFormat#parse} + */ + private static final long SECONDS_LIMIT_FOR_EPOCH = 31536000000L; + + /** + * Milliseconds limit used for integer-stored timestamp scale guessing. Value needs to be aligned + * with the value from {@link SnowflakeDateTimeFormat#parse} + */ + private static final long MILLISECONDS_LIMIT_FOR_EPOCH = SECONDS_LIMIT_FOR_EPOCH * 1000L; + + /** + * Microseconds limit used for integer-stored timestamp scale guessing. Value needs to be aligned + * with the value from {@link SnowflakeDateTimeFormat#parse} + */ + private static final long MICROSECONDS_LIMIT_FOR_EPOCH = SECONDS_LIMIT_FOR_EPOCH * 1000000L; + + public static final int BYTES_8_MB = 8 * 1024 * 1024; + public static final int BYTES_16_MB = 2 * BYTES_8_MB; + + // TODO SNOW-664249: There is a few-byte mismatch between the value sent by the user and its + // server-side representation. Validation leaves a small buffer for this difference. + static final int MAX_SEMI_STRUCTURED_LENGTH = BYTES_16_MB - 64; + private static final ObjectMapper objectMapper = new ObjectMapper(); - private static final SnowflakeDateTimeFormat snowflakeDateTimeFormatter = - SnowflakeDateTimeFormat.fromSqlFormat("auto"); + + // The version of Jackson we are using does not support serialization of date objects from the + // java.time package. Here we define a module with custom java.time serializers. Additionally, we + // define custom serializer for byte[] because the Jackson default is to serialize it as + // base64-encoded string, and we would like to serialize it as JSON array of numbers. + static { + SimpleModule module = new SimpleModule(); + module.addSerializer(byte[].class, new ByteArraySerializer()); + module.addSerializer(ZonedDateTime.class, new ZonedDateTimeSerializer()); + module.addSerializer(LocalTime.class, new ToStringSerializer()); + module.addSerializer(OffsetTime.class, new ToStringSerializer()); + module.addSerializer(LocalDate.class, new ToStringSerializer()); + module.addSerializer(LocalDateTime.class, new ToStringSerializer()); + module.addSerializer(OffsetDateTime.class, new ToStringSerializer()); + objectMapper.registerModule(module); + } /** - * Expects string JSON + * Validates and parses input as JSON. All types in the object tree must be valid variant types, + * see {@link DataValidationUtil#isAllowedSemiStructuredType}. * - * @param input the input data, must be able to convert to String + * @param input Object to validate + * @return JSON tree representing the input */ - static String validateAndParseVariant(Object input) { - String output; - try { - output = input.toString(); - } catch (Exception e) { - throw new SFException( - e, ErrorCode.INVALID_ROW, input.toString(), "Input column can't be convert to String."); + private static JsonNode validateAndParseSemiStructuredAsJsonTree( + String columnName, Object input, String snowflakeType, final long insertRowIndex) { + if (input instanceof String) { + String stringInput = (String) input; + verifyValidUtf8(stringInput, columnName, snowflakeType, insertRowIndex); + try { + return objectMapper.readTree(stringInput); + } catch (JsonProcessingException e) { + throw valueFormatNotAllowedException( + columnName, snowflakeType, "Not a valid JSON", insertRowIndex); + } + } else if (isAllowedSemiStructuredType(input)) { + return objectMapper.valueToTree(input); } - if (output.length() > MAX_STRING_LENGTH) { - throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), - String.format( - "Variant too long. length=%d maxLength=%d", output.length(), MAX_STRING_LENGTH)); - } - return output; + throw typeNotAllowedException( + columnName, + input.getClass(), + snowflakeType, + new String[] { + "String", + "Primitive data types and their arrays", + "java.time.*", + "List", + "Map", + "T[]" + }, + insertRowIndex); } /** - * Expects string JSON or JsonNode + * Validates and parses input as JSON. All types in the object tree must be valid variant types, + * see {@link DataValidationUtil#isAllowedSemiStructuredType}. * - * @param input Must be valid string JSON or JsonNode + * @param input Object to validate + * @param insertRowIndex + * @return JSON string representing the input */ - static String validateAndParseObject(Object input) { - String output; - try { - JsonNode node = objectMapper.readTree(input.toString()); - output = node.toString(); - } catch (Exception e) { - throw new SFException( - e, ErrorCode.INVALID_ROW, input.toString(), "Input column can't be convert to Json"); + static String validateAndParseVariant(String columnName, Object input, long insertRowIndex) { + JsonNode node = + validateAndParseSemiStructuredAsJsonTree(columnName, input, "VARIANT", insertRowIndex); + + // Missing nodes are not valid json, ingest them as NULL instead + if (node.isMissingNode()) { + return null; } - if (output.length() > MAX_STRING_LENGTH) { - throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), + String output = node.toString(); + int stringLength = output.getBytes(StandardCharsets.UTF_8).length; + if (stringLength > MAX_SEMI_STRUCTURED_LENGTH) { + throw valueFormatNotAllowedException( + columnName, + "VARIANT", String.format( - "Object too large. length=%d maxLength=%d", output.length(), MAX_STRING_LENGTH)); + "Variant too long: length=%d maxLength=%d", stringLength, MAX_SEMI_STRUCTURED_LENGTH), + insertRowIndex); } return output; } /** - * Validates and parses input for TIMESTAMP_NTZ Snowflake type + * Validates that passed object is allowed data type for semi-structured columns (i.e. VARIANT, + * ARRAY, OBJECT). For non-trivial types like maps, arrays or lists, it recursively traverses the + * object tree and validates that all types in the tree are also allowed. Allowed Java types: * - * @param input String date in valid format or seconds past the epoch. Accepts fractional seconds - * with precision up to the column's scale - * @param metadata - * @return TimestampWrapper with epoch seconds, fractional seconds, and epoch time in the column - * scale + *

      + *
    • primitive types (int, long, boolean, ...) + *
    • String + *
    • BigInteger + *
    • BigDecimal + *
    • LocalTime + *
    • OffsetTime + *
    • LocalDate + *
    • LocalDateTime + *
    • OffsetDateTime + *
    • ZonedDateTime + *
    • Map where T is an allowed semi-structured type + *
    • List where T is an allowed semi-structured type + *
    • primitive arrays (char[], int[], ...) + *
    • T[] where T is an allowed semi-structured type + *
    + * + * @param o Object to validate + * @return If the passed object is allowed for ingestion into semi-structured column */ - static TimestampWrapper validateAndParseTimestampNtzSb16( - Object input, Map metadata) { - try { - int scale = Integer.parseInt(metadata.get(ArrowRowBuffer.COLUMN_SCALE)); - String valueString = getStringValue(input); + static boolean isAllowedSemiStructuredType(Object o) { + // Allow null + if (o == null) { + return true; + } - long epoch; - int fraction; + // Allow string + if (o instanceof String) { + return true; + } - SFTimestamp timestamp = snowflakeDateTimeFormatter.parse(valueString); - // The formatter will not correctly parse numbers with decimal values - if (timestamp != null) { - epoch = timestamp.getSeconds().longValue(); - fraction = getFractionFromTimestamp(timestamp); - } else { - /* - * Assume the data is of the form ".". For example "10.5" - * is interpreted as meaning 10.5 seconds past the epoch. The following logic breaks out and - * stores the epoch seconds and fractional seconds values separately - */ - String[] items = valueString.split("\\."); - if (items.length != 2) { - throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), - String.format("Unable to parse timestamp from value. value=%s", valueString)); - } - epoch = Long.parseLong(items[0]); - int l = items.length > 1 ? items[1].length() : 0; - /* Fraction is in nanoseconds, but Snowflake will error if the fraction gives - * accuracy greater than the scale - * */ - fraction = l == 0 ? 0 : Integer.parseInt(items[1]) * (l < 9 ? Power10.intTable[9 - l] : 1); - } - if (fraction % Power10.intTable[9 - scale] != 0) { - throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), - String.format( - "Row specifies accuracy greater than column scale. fraction=%d, scale=%d", - fraction, scale)); + // Allow all primitive Java data types + if (o instanceof Long + || o instanceof Integer + || o instanceof Short + || o instanceof Byte + || o instanceof Float + || o instanceof Double + || o instanceof Boolean + || o instanceof Character) { + return true; + } + + // Allow BigInteger and BigDecimal + if (o instanceof BigInteger || o instanceof BigDecimal) { + return true; + } + + // Allow supported types from java.time package + if (o instanceof java.time.LocalTime + || o instanceof OffsetTime + || o instanceof LocalDate + || o instanceof LocalDateTime + || o instanceof ZonedDateTime + || o instanceof OffsetDateTime) { + return true; + } + + // Map is allowed, as long as T is also a supported semi-structured type + if (o instanceof Map) { + boolean allKeysAreStrings = + ((Map) o).keySet().stream().allMatch(x -> x instanceof String); + if (!allKeysAreStrings) { + return false; } - return new TimestampWrapper(epoch, fraction, getTimeInScale(valueString, scale)); - } catch (NumberFormatException e) { - throw new SFException(ErrorCode.INVALID_ROW, input.toString(), e.getMessage()); + boolean allValuesAreAllowed = + ((Map) o) + .values().stream().allMatch(DataValidationUtil::isAllowedSemiStructuredType); + return allValuesAreAllowed; } - } - /** - * Given a SFTimestamp, get the number of nanoseconds since the last whole second. This - * corresponds to the fraction component for Timestamp types - * - * @param input SFTimestamp - * @return Timestamp fraction value, the number of nanoseconds since the last whole second - */ - private static int getFractionFromTimestamp(SFTimestamp input) { - BigDecimal epochSecondsInNanoSeconds = - new BigDecimal(input.getSeconds().multiply(BigInteger.valueOf(Power10.intTable[9]))); - return input.getNanosSinceEpoch().subtract(epochSecondsInNanoSeconds).intValue(); + // Allow arrays of primitive data types + if (o instanceof byte[] + || o instanceof short[] + || o instanceof int[] + || o instanceof long[] + || o instanceof float[] + || o instanceof double[] + || o instanceof boolean[] + || o instanceof char[]) { + return true; + } + + // Allow arrays of allowed semi-structured objects + if (o.getClass().isArray()) { + return Arrays.stream((Object[]) o).allMatch(DataValidationUtil::isAllowedSemiStructuredType); + } + + // Allow lists consisting of allowed semi-structured objects + if (o instanceof List) { + return ((List) o).stream().allMatch(DataValidationUtil::isAllowedSemiStructuredType); + } + + // If nothing matches, reject the input + return false; } /** - * Validates and parses input for TIMESTAMP_TZ Snowflake type + * Validates and parses JSON array. Non-array types are converted into single-element arrays. All + * types in the array tree must be valid variant types, see {@link + * DataValidationUtil#isAllowedSemiStructuredType}. * - * @param input TIMESTAMP_TZ in "2021-01-01 01:00:00 +0100" format - * @param metadata Column metadata containing scale - * @return TimestampWrapper with epoch seconds, fractional seconds, and epoch time in the column - * scale + * @param input Object to validate + * @param insertRowIndex + * @return JSON array representing the input */ - static TimestampWrapper validateAndParseTimestampTz(Object input, Map metadata) { - try { - if (input instanceof String) { - String stringInput = (String) input; - int scale = Integer.parseInt(metadata.get(ArrowRowBuffer.COLUMN_SCALE)); - SFTimestamp timestamp = snowflakeDateTimeFormatter.parse(stringInput); - if (timestamp == null) { - throw new SFException( - ErrorCode.INVALID_ROW, input, "Cannot be converted to a timestamp_tz value"); - } + static String validateAndParseArray(String columnName, Object input, long insertRowIndex) { + JsonNode jsonNode = + validateAndParseSemiStructuredAsJsonTree(columnName, input, "ARRAY", insertRowIndex); - int fraction = getFractionFromTimestamp(timestamp); - - BigDecimal epochTimeAtSecondsScale = - timestamp.getNanosSinceEpoch().divide(BigDecimal.valueOf(Power10.intTable[9])); - if (fraction % Power10.intTable[9 - scale] != 0) { - throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), - String.format( - "Row specifies accuracy greater than column scale. fraction=%d, scale=%d", - fraction, scale)); - } - TimestampWrapper wrapper = - new TimestampWrapper( - timestamp.getSeconds().longValue(), - fraction, - getTimeInScale(getStringValue(epochTimeAtSecondsScale), scale), - timestamp); - return wrapper; - } else { - throw new SFException( - ErrorCode.INVALID_ROW, input, "Cannot be converted to a timestamp_tz value"); - } - } catch (NumberFormatException e) { - throw new SFException(ErrorCode.INVALID_ROW, input.toString(), e.getMessage()); + // Non-array values are ingested as single-element arrays, mimicking the Worksheets behavior + if (!jsonNode.isArray()) { + jsonNode = objectMapper.createArrayNode().add(jsonNode); + } + + String output = jsonNode.toString(); + // Throw an exception if the size is too large + int stringLength = output.getBytes(StandardCharsets.UTF_8).length; + if (stringLength > MAX_SEMI_STRUCTURED_LENGTH) { + throw valueFormatNotAllowedException( + columnName, + "ARRAY", + String.format( + "Array too large. length=%d maxLength=%d", stringLength, MAX_SEMI_STRUCTURED_LENGTH), + insertRowIndex); } + return output; } /** - * Validates the input is less than the suppoed maximum allowed string + * Validates and parses JSON object. Input is rejected if the value does not represent JSON object + * (e.g. String '{}' or Map). All types in the object tree must be valid variant types, + * see {@link DataValidationUtil#isAllowedSemiStructuredType}. * - * @param input Object to validate and parse to String - * @param maxLengthOptional Maximum allowed length of the output String, if empty then uses - * maximum allowed by Snowflake - * (https://docs.snowflake.com/en/sql-reference/data-types-text.html#varchar) + * @param input Object to validate + * @param insertRowIndex + * @return JSON object representing the input */ - static String validateAndParseString(Object input, Optional maxLengthOptional) { - int maxLength = maxLengthOptional.orElse(MAX_STRING_LENGTH); - String output = getStringValue(input); - if (output.length() > maxLength) { - throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), - String.format("String too long. length=%d maxLength=%d", output.length(), maxLength)); + static String validateAndParseObject(String columnName, Object input, long insertRowIndex) { + JsonNode jsonNode = + validateAndParseSemiStructuredAsJsonTree(columnName, input, "OBJECT", insertRowIndex); + if (!jsonNode.isObject()) { + throw valueFormatNotAllowedException(columnName, "OBJECT", "Not an object", insertRowIndex); + } + + String output = jsonNode.toString(); + // Throw an exception if the size is too large + int stringLength = output.getBytes(StandardCharsets.UTF_8).length; + if (stringLength > MAX_SEMI_STRUCTURED_LENGTH) { + throw valueFormatNotAllowedException( + columnName, + "OBJECT", + String.format( + "Object too large. length=%d maxLength=%d", stringLength, MAX_SEMI_STRUCTURED_LENGTH), + insertRowIndex); } return output; } /** - * Returns a BigDecimal representation of the input. Strings of the form "1.23E4" will be treated - * as being written in * scientific notation (e.g. 1.23 * 10^4) - * - * @param input + * Converts user input to offset date time, which is the canonical representation of dates and + * timestamps. */ - static BigDecimal validateAndParseBigDecimal(Object input) { - BigDecimal output; - try { - if (input instanceof BigDecimal) { - output = (BigDecimal) input; - } else if (input instanceof BigInteger) { - output = new BigDecimal((BigInteger) input); - } else if (input instanceof String) { - output = stringToBigDecimal((String) input); - } else { - output = stringToBigDecimal((input.toString())); - } - if (output.toBigInteger().compareTo(MAX_BIGINTEGER) >= 0 - || output.toBigInteger().compareTo(MIN_BIGINTEGER) <= 0) { - throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), - String.format( - "Number out of representable range, Max=%s, Min=%s", - MIN_BIGINTEGER, MAX_BIGINTEGER)); - } - return output; - } catch (NumberFormatException e) { - throw new SFException(ErrorCode.INVALID_ROW, input.toString(), e.getMessage()); + private static OffsetDateTime inputToOffsetDateTime( + String columnName, + String typeName, + Object input, + ZoneId defaultTimezone, + final long insertRowIndex) { + if (input instanceof OffsetDateTime) { + return (OffsetDateTime) input; } - } - static BigDecimal handleScientificNotationError(String input, NumberFormatException e) { - // Support scientific notation on string input - String[] splitInput = input.toLowerCase().split("e"); - if (splitInput.length != 2) { - throw e; + if (input instanceof ZonedDateTime) { + return ((ZonedDateTime) input).toOffsetDateTime(); } - return (new BigDecimal(splitInput[0])) - .multiply(BigDecimal.valueOf(10).pow(Integer.parseInt(splitInput[1]))); - } - static BigDecimal stringToBigDecimal(String input) { - try { - return new BigDecimal(input); - } catch (NumberFormatException e) { - return handleScientificNotationError(input, e); + if (input instanceof LocalDateTime) { + return ((LocalDateTime) input).atZone(defaultTimezone).toOffsetDateTime(); } - } - /** - * Validates the input represents a valid NUMBER(38,0) and returns a BigInteger. Accepts Java - * number types and strings. Strings of the form "1.23E4" will be treated as being written in - * scientific notation (e.g. 1.23 * 10^4) - * - * @param input - */ - static BigInteger validateAndParseBigInteger(Object input) { - try { - if (input instanceof Double) { - checkInteger((double) input); - return new BigDecimal(input.toString()).toBigInteger(); - } else if (input instanceof Float) { - checkInteger((float) input); - return new BigDecimal(input.toString()).toBigInteger(); + if (input instanceof LocalDate) { + return ((LocalDate) input).atStartOfDay().atZone(defaultTimezone).toOffsetDateTime(); + } + + if (input instanceof Instant) { + // Just like integer-stored timestamps, instants are always interpreted in UTC + return ((Instant) input).atZone(ZoneOffset.UTC).toOffsetDateTime(); + } + + if (input instanceof String) { + String stringInput = ((String) input).trim(); + { + // First, try to parse ZonedDateTime + ZonedDateTime zoned = catchParsingError(() -> ZonedDateTime.parse(stringInput)); + if (zoned != null) { + return zoned.toOffsetDateTime(); + } } - BigInteger output; - if (input instanceof BigInteger) { - output = (BigInteger) input; - } else if (input instanceof String) { - BigDecimal bigDecimal = stringToBigDecimal((String) input); - if (bigDecimal.stripTrailingZeros().scale() <= 0) { - return bigDecimal.toBigInteger(); - } else { - throw new SFException(ErrorCode.INVALID_ROW, input.toString(), "Invalid integer"); + + { + // Next, try to parse OffsetDateTime + OffsetDateTime offset = catchParsingError(() -> OffsetDateTime.parse(stringInput)); + if (offset != null) { + return offset; } - } else { - output = new BigInteger(input.toString()); } - if (output.compareTo(MAX_BIGINTEGER) >= 0 || output.compareTo(MIN_BIGINTEGER) <= 0) { - throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), - String.format( - "Number out of representable range, Max=%s, Min=%s", - MIN_BIGINTEGER, MAX_BIGINTEGER)); + + { + // Alternatively, try to parse LocalDateTime + LocalDateTime localDateTime = catchParsingError(() -> LocalDateTime.parse(stringInput)); + if (localDateTime != null) { + return localDateTime.atZone(defaultTimezone).toOffsetDateTime(); + } } - return output; - } catch (NumberFormatException e) { - throw new SFException(ErrorCode.INVALID_ROW, input.toString(), e.getMessage()); - } - } - static void checkInteger(float input) { - if (Math.floor(input) != input) { - throw new SFException(ErrorCode.INVALID_ROW, input, "Value must be integer"); + { + // Alternatively, try to parse LocalDate + LocalDate localDate = catchParsingError(() -> LocalDate.parse(stringInput)); + if (localDate != null) { + return localDate.atStartOfDay().atZone(defaultTimezone).toOffsetDateTime(); + } + } + + { + // Alternatively, try to parse integer-stored timestamp + // Just like in Snowflake, integer-stored timestamps are always in UTC + Instant instant = catchParsingError(() -> parseInstantGuessScale(stringInput)); + if (instant != null) { + return instant.atOffset(ZoneOffset.UTC); + } + } + + // Couldn't parse anything, throw an exception + throw valueFormatNotAllowedException( + columnName, + typeName, + "Not a valid value, see" + + " https://docs.snowflake.com/en/user-guide/data-load-snowpipe-streaming-overview" + + " for the list of supported formats", + insertRowIndex); } + + // Type is not supported, throw an exception + throw typeNotAllowedException( + columnName, + input.getClass(), + typeName, + new String[] {"String", "LocalDate", "LocalDateTime", "ZonedDateTime", "OffsetDateTime"}, + insertRowIndex); } - static void checkInteger(double input) { - if (Math.floor(input) != input) { - throw new SFException(ErrorCode.INVALID_ROW, input, "Value must be integer"); + private static T catchParsingError(Supplier op) { + try { + return op.get(); + } catch (DateTimeParseException | NumberFormatException e) { + return null; } } /** - * Validates input is an integer @see {@link #validateAndParseInteger(Object)} and that it does - * not exceed the MIN/MAX Short value + * Validates and parses input for TIMESTAMP_NTZ, TIMESTAMP_LTZ and TIMEATAMP_TZ Snowflake types. + * Allowed Java types: * - * @param input + *
      + *
    • String + *
    • LocalDate + *
    • LocalDateTime + *
    • OffsetDateTime + *
    • ZonedDateTime + *
    + * + * @param columnName Column name, used in validation error messages + * @param input String date in valid format, seconds past the epoch or java.time.* object. Accepts + * fractional seconds with precision up to the column's scale + * @param scale decimal scale of timestamp 16 byte integer + * @param defaultTimezone Input, which does not carry timezone information is going to be + * interpreted in the default timezone. + * @param trimTimezone Whether timezone information should be removed from the resulting date, + * should be true for TIMESTAMP_NTZ columns. + * @param insertRowIndex + * @return TimestampWrapper */ - static short validateAndParseShort(Object input) { - Integer intValue = validateAndParseInteger(input); - if (intValue > Short.MAX_VALUE || intValue < Short.MIN_VALUE) { + static TimestampWrapper validateAndParseTimestamp( + String columnName, + Object input, + int scale, + ZoneId defaultTimezone, + boolean trimTimezone, + long insertRowIndex) { + OffsetDateTime offsetDateTime = + inputToOffsetDateTime(columnName, "TIMESTAMP", input, defaultTimezone, insertRowIndex); + + if (trimTimezone) { + offsetDateTime = offsetDateTime.withOffsetSameLocal(ZoneOffset.UTC); + } + if (offsetDateTime.getYear() < 1 || offsetDateTime.getYear() > 9999) { throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), + ErrorCode.INVALID_VALUE_ROW, String.format( - "Value exceeds min/max short. min=%s, max%s", Short.MIN_VALUE, Short.MAX_VALUE)); + "Timestamp out of representable inclusive range of years between 1 and 9999," + + " rowIndex:%d", + insertRowIndex)); } - return intValue.shortValue(); + return new TimestampWrapper(offsetDateTime, scale); } /** - * Validates input is an integer @see {@link #validateAndParseInteger(Object)} and that it does - * not exceed the MIN/MAX Byte value + * Converts input to string, validates that length is less than max allowed string size + * https://docs.snowflake.com/en/sql-reference/data-types-text.html#varchar. Allowed data types: * - * @param input + *
      + *
    • String + *
    • Number + *
    • boolean + *
    • char + *
    + * + * @param input Object to validate and parse to String + * @param maxLengthOptional Maximum allowed length of the output String, if empty then uses + * maximum allowed by Snowflake + * (https://docs.snowflake.com/en/sql-reference/data-types-text.html#varchar) + * @param insertRowIndex */ - static byte validateAndParseByte(Object input) { - Integer intValue = validateAndParseInteger(input); - if (intValue > Byte.MAX_VALUE || intValue < Byte.MIN_VALUE) { - throw new SFException( - ErrorCode.INVALID_ROW, - input.toString(), + static String validateAndParseString( + String columnName, Object input, Optional maxLengthOptional, long insertRowIndex) { + String output; + if (input instanceof String) { + output = (String) input; + verifyValidUtf8(output, columnName, "STRING", insertRowIndex); + } else if (input instanceof Number) { + output = new BigDecimal(input.toString()).stripTrailingZeros().toPlainString(); + } else if (input instanceof Boolean || input instanceof Character) { + output = input.toString(); + } else { + throw typeNotAllowedException( + columnName, + input.getClass(), + "STRING", + new String[] {"String", "Number", "boolean", "char"}, + insertRowIndex); + } + byte[] utf8Bytes = output.getBytes(StandardCharsets.UTF_8); + + // Strings can never be larger than 16MB + if (utf8Bytes.length > BYTES_16_MB) { + throw valueFormatNotAllowedException( + columnName, + "STRING", String.format( - "Value exceeds min/max byte. min=%s, max%s", Byte.MIN_VALUE, Byte.MAX_VALUE)); + "String too long: length=%d bytes maxLength=%d bytes", utf8Bytes.length, BYTES_16_MB), + insertRowIndex); } - return intValue.byteValue(); + + // If max allowed length is specified (e.g. VARCHAR(10)), the number of unicode characters must + // not exceed this value + maxLengthOptional.ifPresent( + maxAllowedCharacters -> { + int actualCharacters = unicodeCharactersCount(output); + if (actualCharacters > maxAllowedCharacters) { + throw valueFormatNotAllowedException( + columnName, + "STRING", + String.format( + "String too long: length=%d characters maxLength=%d characters", + actualCharacters, maxAllowedCharacters), + insertRowIndex); + } + }); + return output; } /** - * Validates the input can be represented as an integer with value between Integer.MIN_VALUE and - * Integer.MAX_VALUE - * - * @param input + * Returns a BigDecimal representation of the input. Strings of the form "1.23E4" will be treated + * as being written in * scientific notation (e.g. 1.23 * 10^4). Does not perform any size + * validation. Allowed Java types: + *
  • byte, short, int, long + *
  • float, double + *
  • BigInteger, BigDecimal + *
  • String */ - static int validateAndParseInteger(Object input) { - try { - if (input instanceof Integer) { - return (int) input; - } else if (input instanceof Long) { - if ((long) input > Integer.MAX_VALUE || (long) input < Integer.MIN_VALUE) { - throw new SFException( - ErrorCode.INVALID_ROW, input.toString(), "Value greater than max integer"); - } - return ((Long) input).intValue(); - } else if (input instanceof Double) { - // Number must be integer - checkInteger((double) input); - if (((Number) input).longValue() > Integer.MAX_VALUE - || ((Number) input).longValue() < Integer.MIN_VALUE) { - throw new SFException( - ErrorCode.INVALID_ROW, input.toString(), "Value greater than max integer"); - } - return ((Number) input).intValue(); - } else if (input instanceof Float) { - // Number must be integer - checkInteger((float) input); - - if (((Number) input).longValue() > Integer.MAX_VALUE - || ((Number) input).longValue() < Integer.MIN_VALUE) { - throw new SFException( - ErrorCode.INVALID_ROW, input.toString(), "Value greater than max integer"); - } - return ((Number) input).intValue(); - } else if (input instanceof BigInteger) { - if (((BigInteger) input).compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0 - || ((BigInteger) input).compareTo(BigInteger.valueOf(Integer.MIN_VALUE)) < 0) { - throw new SFException( - ErrorCode.INVALID_ROW, input.toString(), "Value greater than max integer"); - } - return ((BigInteger) input).intValue(); - } else { - try { - return Integer.parseInt(input.toString()); - } catch (NumberFormatException e) { - Double doubleValue = handleScientificNotationError(input.toString(), e).doubleValue(); - if (Math.floor(doubleValue) == doubleValue) { - return doubleValue.intValue(); - } else { - throw new SFException(ErrorCode.INVALID_ROW, input.toString(), "Value must be integer"); - } - } + static BigDecimal validateAndParseBigDecimal( + String columnName, Object input, long insertRowIndex) { + if (input instanceof BigDecimal) { + return (BigDecimal) input; + } else if (input instanceof BigInteger) { + return new BigDecimal((BigInteger) input); + } else if (input instanceof Byte + || input instanceof Short + || input instanceof Integer + || input instanceof Long) { + return BigDecimal.valueOf(((Number) input).longValue()); + } else if (input instanceof Float || input instanceof Double) { + return BigDecimal.valueOf(((Number) input).doubleValue()); + } else if (input instanceof String) { + try { + final String stringInput = ((String) input).trim(); + return new BigDecimal(stringInput); + } catch (NumberFormatException e) { + throw valueFormatNotAllowedException( + columnName, "NUMBER", "Not a valid number", insertRowIndex); } - } catch (NumberFormatException err) { - throw new SFException(ErrorCode.INVALID_ROW, input.toString(), err.getMessage()); + } else { + throw typeNotAllowedException( + columnName, + input.getClass(), + "NUMBER", + new String[] { + "int", "long", "byte", "short", "float", "double", "BigDecimal", "BigInteger", "String" + }, + insertRowIndex); } } /** - * Validates the input can be represented as an integer with value between Long.MIN_VALUE and - * Long.MAX_VALUE + * Returns the number of days between the epoch and the passed date. Allowed Java types: * - * @param input + *
      + *
    • String + *
    • {@link LocalDate} + *
    • {@link LocalDateTime} + *
    • {@link OffsetDateTime} + *
    • {@link ZonedDateTime} + *
    • {@link Instant} + *
    */ - static long validateAndParseLong(Object input) { - try { - if (input instanceof Integer) { - return Long.valueOf((int) input); - } else if (input instanceof Long) { - return (long) input; - } else if (input instanceof Double) { - // Number must be integer - checkInteger((double) input); - BigInteger bigInt = validateAndParseBigInteger(input); - if (bigInt.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0 - || bigInt.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) { - throw new SFException( - ErrorCode.INVALID_ROW, input.toString(), "Value greater than max long"); - } - return ((Number) input).longValue(); - } else if (input instanceof Float) { - // Number must be integer - checkInteger((float) input); - BigInteger bigInt = validateAndParseBigInteger(input); - if (bigInt.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0 - || bigInt.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) { - throw new SFException( - ErrorCode.INVALID_ROW, input.toString(), "Value greater than max long"); - } - return ((Number) input).longValue(); - } else if (input instanceof BigInteger) { - if (((BigInteger) input).compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0 - || ((BigInteger) input).compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) { - throw new SFException( - ErrorCode.INVALID_ROW, input.toString(), "Value greater than max long"); - } - return ((BigInteger) input).longValue(); - } else { - try { - return Long.parseLong(input.toString()); - } catch (NumberFormatException e) { - Double doubleValue = handleScientificNotationError(input.toString(), e).doubleValue(); - if (Math.floor(doubleValue) == doubleValue) { - return doubleValue.longValue(); - } else { - throw new SFException(ErrorCode.INVALID_ROW, input.toString(), "Value must be integer"); - } - } - } - } catch (NumberFormatException err) { - throw new SFException(ErrorCode.INVALID_ROW, input.toString(), err.getMessage()); + static int validateAndParseDate(String columnName, Object input, long insertRowIndex) { + OffsetDateTime offsetDateTime = + inputToOffsetDateTime(columnName, "DATE", input, ZoneOffset.UTC, insertRowIndex); + + if (offsetDateTime.getYear() < -9999 || offsetDateTime.getYear() > 9999) { + throw new SFException( + ErrorCode.INVALID_VALUE_ROW, + String.format( + "Date out of representable inclusive range of years between -9999 and 9999," + + " rowIndex:%d", + insertRowIndex)); } + + return Math.toIntExact(offsetDateTime.toLocalDate().toEpochDay()); } /** - * Expects input in epoch days or "YYYY-MM-DD" format. Uses @see {@link - * #validateAndParseInteger(Object)} to validate and parse input as integer + * Validates input for data type BINARY. Allowed Java types: * - * @param input epoch days or "YYYY-MM-DD" date format + *
      + *
    • byte[] + *
    • String (hex-encoded) + *
    + * + * @param input Array to validate + * @param maxLengthOptional Max array length, defaults to 8MB, which is the max allowed length for + * BINARY column + * @param insertRowIndex + * @return Validated array */ - static int validateAndParseDate(Object input) { - try { - return validateAndParseInteger(input); - } catch (SFException e) { - // Try parsing the time from a String - Optional timestamp = - Optional.ofNullable(snowflakeDateTimeFormatter.parse(input.toString())); - return timestamp - .map( - t -> - // Adjust nanoseconds past the epoch to match the column scale value - (int) TimeUnit.MILLISECONDS.toDays(SFDate.fromTimestamp(t).getTime())) - .orElseThrow( - () -> - new SFException( - ErrorCode.INVALID_ROW, input, "Cannot be converted to a Date value")); - } - } - - static byte[] validateAndParseBinary(Object input) { + static byte[] validateAndParseBinary( + String columnName, Object input, Optional maxLengthOptional, long insertRowIndex) { + byte[] output; if (input instanceof byte[]) { - return (byte[]) input; + // byte[] is a mutable object, we need to create a defensive copy to protect against + // concurrent modifications of the array, which could lead to mismatch between data + // and metadata + byte[] originalInputArray = (byte[]) input; + output = new byte[originalInputArray.length]; + System.arraycopy(originalInputArray, 0, output, 0, originalInputArray.length); + } else if (input instanceof String) { + try { + String stringInput = ((String) input).trim(); + output = Hex.decodeHex(stringInput); + } catch (DecoderException e) { + throw valueFormatNotAllowedException( + columnName, "BINARY", "Not a valid hex string", insertRowIndex); + } + } else { + throw typeNotAllowedException( + columnName, + input.getClass(), + "BINARY", + new String[] {"byte[]", "String"}, + insertRowIndex); } - try { - return DatatypeConverter.parseHexBinary(input.toString()); - } catch (IllegalArgumentException e) { - throw new SFException(ErrorCode.INVALID_ROW, input, e.getMessage()); + + int maxLength = maxLengthOptional.orElse(BYTES_8_MB); + if (output.length > maxLength) { + throw valueFormatNotAllowedException( + columnName, + "BINARY", + String.format("Binary too long: length=%d maxLength=%d", output.length, maxLength), + insertRowIndex); } + return output; } /** - * @param input Seconds past the epoch. or String Time representation - * @param metadata + * Returns the number of units since 00:00, depending on the scale (scale=0: seconds, scale=3: + * milliseconds, scale=9: nanoseconds). Allowed Java types: + * + *
      + *
    • String + *
    • {@link LocalTime} + *
    • {@link OffsetTime} + *
    */ - static BigInteger validateAndParseTime(Object input, Map metadata) { - int scale = Integer.parseInt(metadata.get(ArrowRowBuffer.COLUMN_SCALE)); - return getTimeInScale(getStringValue(input), scale); + static BigInteger validateAndParseTime( + String columnName, Object input, int scale, long insertRowIndex) { + if (input instanceof LocalTime) { + LocalTime localTime = (LocalTime) input; + return BigInteger.valueOf(localTime.toNanoOfDay()).divide(Power10.sb16Table[9 - scale]); + } else if (input instanceof OffsetTime) { + return validateAndParseTime( + columnName, ((OffsetTime) input).toLocalTime(), scale, insertRowIndex); + } else if (input instanceof String) { + String stringInput = ((String) input).trim(); + { + // First, try to parse LocalTime + LocalTime localTime = catchParsingError(() -> LocalTime.parse(stringInput)); + if (localTime != null) { + return validateAndParseTime(columnName, localTime, scale, insertRowIndex); + } + } + + { + // Alternatively, try to parse OffsetTime + OffsetTime offsetTime = catchParsingError((() -> OffsetTime.parse(stringInput))); + if (offsetTime != null) { + return validateAndParseTime(columnName, offsetTime.toLocalTime(), scale, insertRowIndex); + } + } + + { + // Alternatively, try to parse integer-stored time + Instant parsedInstant = catchParsingError(() -> parseInstantGuessScale(stringInput)); + if (parsedInstant != null) { + return validateAndParseTime( + columnName, + LocalDateTime.ofInstant(parsedInstant, ZoneOffset.UTC).toLocalTime(), + scale, + insertRowIndex); + } + } + + throw valueFormatNotAllowedException( + columnName, + "TIME", + "Not a valid time, see" + + " https://docs.snowflake.com/en/user-guide/data-load-snowpipe-streaming-overview" + + " for the list of supported formats", + insertRowIndex); + + } else { + throw typeNotAllowedException( + columnName, + input.getClass(), + "TIME", + new String[] {"String", "LocalTime", "OffsetTime"}, + insertRowIndex); + } } /** - * Returns the time past the epoch in the given scale. + * Attempts to parse integer-stored date from string input. Tries to guess the scale according to + * the rules documented at + * https://docs.snowflake.com/en/user-guide/date-time-input-output.html#auto-detection-of-integer-stored-date-time-and-timestamp-values. * - * @param value - * @param scale Value between 0 and 9. For seconds scale = 0, for milliseconds scale = 3, for - * nanoseconds scale = 9 + * @param input String to parse, must represent a valid long + * @return Instant representing the input + * @throws NumberFormatException If the input in not a valid long */ - static BigInteger getTimeInScale(String value, int scale) { + private static Instant parseInstantGuessScale(String input) { + BigInteger epochNanos; try { - BigDecimal decVal = new BigDecimal(value); - BigDecimal epochScale = decVal.multiply(BigDecimal.valueOf(Power10.intTable[scale])); - return epochScale.toBigInteger(); + long val = Long.parseLong(input); + + if (val > -SECONDS_LIMIT_FOR_EPOCH && val < SECONDS_LIMIT_FOR_EPOCH) { + epochNanos = BigInteger.valueOf(val).multiply(Power10.sb16Table[9]); + } else if (val > -MILLISECONDS_LIMIT_FOR_EPOCH && val < MILLISECONDS_LIMIT_FOR_EPOCH) { + epochNanos = BigInteger.valueOf(val).multiply(Power10.sb16Table[6]); + } else if (val > -MICROSECONDS_LIMIT_FOR_EPOCH && val < MICROSECONDS_LIMIT_FOR_EPOCH) { + epochNanos = BigInteger.valueOf(val).multiply(Power10.sb16Table[3]); + } else { + epochNanos = BigInteger.valueOf(val); + } } catch (NumberFormatException e) { - // Try parsing the time from a String - Optional timestamp = - Optional.ofNullable(snowflakeDateTimeFormatter.parse(value)); - return timestamp - .map( - t -> - // Adjust nanoseconds past the epoch to match the column scale value - t.getNanosSinceEpoch() - .divide(BigDecimal.valueOf(Power10.intTable[9 - scale])) - .toBigInteger()) - .orElseThrow( - () -> - new SFException( - ErrorCode.INVALID_ROW, value, "Cannot be converted to a time value")); + // The input is bigger than max long value, treat it as nano-seconds directly + epochNanos = new BigInteger(input); } - } - - static String getStringValue(Object value) { - String stringValue; - if (value instanceof String) { - stringValue = (String) value; - } else if (value instanceof BigDecimal) { - stringValue = ((BigDecimal) value).toPlainString(); - } else { - stringValue = value.toString(); - } - return stringValue; + return Instant.ofEpochSecond( + epochNanos.divide(Power10.sb16Table[9]).longValue(), + epochNanos.remainder(Power10.sb16Table[9]).longValue()); } /** - * Converts input to double value. + * Converts input to double value. Allowed Java types: + * + *
      + *
    • Number + *
    • String + *
    * * @param input + * @param insertRowIndex */ - static double validateAndParseReal(Object input) { - double doubleValue; - try { - if (input instanceof Double) { - doubleValue = (double) input; - } else if (input instanceof Float) { - doubleValue = Double.parseDouble(input.toString()); - } else if (input instanceof BigDecimal) { - doubleValue = ((BigDecimal) input).doubleValue(); - } else { - doubleValue = Double.parseDouble((String) input); + static double validateAndParseReal(String columnName, Object input, long insertRowIndex) { + if (input instanceof Float) { + return Double.parseDouble(input.toString()); + } else if (input instanceof Number) { + return ((Number) input).doubleValue(); + } else if (input instanceof String) { + String stringInput = ((String) input).trim(); + try { + return Double.parseDouble(stringInput); + } catch (NumberFormatException err) { + stringInput = stringInput.toLowerCase(); + switch (stringInput) { + case "nan": + return Double.NaN; + case "inf": + return Double.POSITIVE_INFINITY; + case "-inf": + return Double.NEGATIVE_INFINITY; + default: + throw valueFormatNotAllowedException( + columnName, "REAL", "Not a valid decimal number", insertRowIndex); + } } - } catch (NumberFormatException err) { - throw new SFException(ErrorCode.INVALID_ROW, input.toString()); } - - return doubleValue; + throw typeNotAllowedException( + columnName, input.getClass(), "REAL", new String[] {"Number", "String"}, insertRowIndex); } /** * Validate and parse input to integer output, 1=true, 0=false. String values converted to boolean * according to https://docs.snowflake.com/en/sql-reference/functions/to_boolean.html#usage-notes + * Allowed Java types: * - * @param input + *
      + *
    • boolean + *
    • String + *
    • java.lang.Number + *
    + * + * @param input Input to be converted * @return 1 or 0 where 1=true, 0=false */ - static int validateAndParseBoolean(Object input) { - int output = 0; + static int validateAndParseBoolean(String columnName, Object input, long insertRowIndex) { if (input instanceof Boolean) { - output = (boolean) input ? 1 : 0; + return (boolean) input ? 1 : 0; } else if (input instanceof Number) { - output = ((Number) input).doubleValue() > 0 ? 1 : 0; - } else { - output = convertStringToBoolean((String) input) ? 1 : 0; + return new BigDecimal(input.toString()).compareTo(BigDecimal.ZERO) == 0 ? 0 : 1; + } else if (input instanceof String) { + return convertStringToBoolean(columnName, (String) input, insertRowIndex) ? 1 : 0; + } + + throw typeNotAllowedException( + columnName, + input.getClass(), + "BOOLEAN", + new String[] {"boolean", "Number", "String"}, + insertRowIndex); + } + + static void checkValueInRange( + BigDecimal bigDecimalValue, int scale, int precision, final long insertRowIndex) { + if (bigDecimalValue.abs().compareTo(BigDecimal.TEN.pow(precision - scale)) >= 0) { + throw new SFException( + ErrorCode.INVALID_FORMAT_ROW, + String.format( + "Number out of representable exclusive range of (-1e%s..1e%s), rowIndex:%d", + precision - scale, precision - scale, insertRowIndex)); } - return output; } static Set allowedBooleanStringsLowerCased = Sets.newHashSet("1", "0", "yes", "no", "y", "n", "t", "f", "true", "false", "on", "off"); - static boolean convertStringToBoolean(String value) { - String lowerCasedValue = value.toLowerCase(); - if (!allowedBooleanStringsLowerCased.contains(lowerCasedValue)) { - throw new SFException(ErrorCode.INVALID_ROW, value); - } - return "1".equals(lowerCasedValue) - || "yes".equals(lowerCasedValue) - || "y".equals(lowerCasedValue) - || "t".equals(lowerCasedValue) - || "true".equals(lowerCasedValue) - || "on".equals(lowerCasedValue); + private static boolean convertStringToBoolean( + String columnName, String value, final long insertRowIndex) { + String normalizedInput = value.toLowerCase().trim(); + if (!allowedBooleanStringsLowerCased.contains(normalizedInput)) { + throw valueFormatNotAllowedException( + columnName, + "BOOLEAN", + "Not a valid boolean, see" + + " https://docs.snowflake.com/en/sql-reference/data-types-logical.html#conversion-to-boolean" + + " for the list of supported formats", + insertRowIndex); + } + return "1".equals(normalizedInput) + || "yes".equals(normalizedInput) + || "y".equals(normalizedInput) + || "t".equals(normalizedInput) + || "true".equals(normalizedInput) + || "on".equals(normalizedInput); + } + + /** + * Create exception that a Java type cannot be ingested into a specific Snowflake column type + * + * @param javaType Java type failing the validation + * @param snowflakeType Target Snowflake column type + * @param allowedJavaTypes Java types supported for the Java type + */ + private static SFException typeNotAllowedException( + String columnName, + Class javaType, + String snowflakeType, + String[] allowedJavaTypes, + final long insertRowIndex) { + return new SFException( + ErrorCode.INVALID_FORMAT_ROW, + String.format( + "Object of type %s cannot be ingested into Snowflake column %s of type %s, rowIndex:%d", + javaType.getName(), columnName, snowflakeType, insertRowIndex), + String.format( + String.format("Allowed Java types: %s", String.join(", ", allowedJavaTypes)))); + } + + /** + * Create exception when the Java type is correct, but the value is invalid (e.g. boolean cannot + * be parsed from a string) + * + *

    Note: Do not log actual Object Value + * + * @param columnName Column Name + * @param snowflakeType Snowflake column type + * @param reason Reason why value format is not allowed. + * @param rowIndex Index of the Input row primarily for debugging purposes. + * @return SFException is thrown + */ + private static SFException valueFormatNotAllowedException( + String columnName, String snowflakeType, String reason, final long rowIndex) { + return new SFException( + ErrorCode.INVALID_VALUE_ROW, + String.format( + "Value cannot be ingested into Snowflake column %s of type %s, rowIndex:%d, reason:" + + " %s", + columnName, snowflakeType, rowIndex, reason)); + } + + /** + * Validates that a string is valid UTF-8 string. It catches situations like unmatched high/low + * UTF-16 surrogate, for example. + */ + private static void verifyValidUtf8( + String input, String columnName, String dataType, final long insertRowIndex) { + String roundTripStr = + new String(input.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); + if (!input.equals(roundTripStr)) { + throw valueFormatNotAllowedException( + columnName, dataType, "Invalid Unicode string", insertRowIndex); + } } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/DropChannelResponse.java b/src/main/java/net/snowflake/ingest/streaming/internal/DropChannelResponse.java new file mode 100644 index 000000000..fce91f1d2 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/DropChannelResponse.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Response for a {@link net.snowflake.ingest.streaming.DropChannelRequest}. */ +class DropChannelResponse extends StreamingIngestResponse { + private Long statusCode; + private String message; + private String dbName; + private String schemaName; + private String tableName; + private String channelName; + + @JsonProperty("status_code") + void setStatusCode(Long statusCode) { + this.statusCode = statusCode; + } + + @Override + Long getStatusCode() { + return this.statusCode; + } + + @JsonProperty("message") + void setMessage(String message) { + this.message = message; + } + + String getMessage() { + return this.message; + } + + @JsonProperty("database") + void setDBName(String dbName) { + this.dbName = dbName; + } + + String getDBName() { + return this.dbName; + } + + @JsonProperty("schema") + void setSchemaName(String schemaName) { + this.schemaName = schemaName; + } + + String getSchemaName() { + return this.schemaName; + } + + @JsonProperty("table") + void setTableName(String tableName) { + this.tableName = tableName; + } + + String getTableName() { + return this.tableName; + } + + @JsonProperty("channel") + void setChannelName(String channelName) { + this.channelName = channelName; + } + + String getChannelName() { + return this.channelName; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/DropChannelVersionRequest.java b/src/main/java/net/snowflake/ingest/streaming/internal/DropChannelVersionRequest.java new file mode 100644 index 000000000..abc6b8067 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/DropChannelVersionRequest.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import net.snowflake.ingest.streaming.DropChannelRequest; + +/** + * Same as DropChannelRequest but allows specifying a client sequencer to drop a specific version. + */ +class DropChannelVersionRequest extends DropChannelRequest { + private final Long clientSequencer; + + public DropChannelVersionRequest(DropChannelRequestBuilder builder, long clientSequencer) { + super(builder); + this.clientSequencer = clientSequencer; + } + + Long getClientSequencer() { + return this.clientSequencer; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/EpInfo.java b/src/main/java/net/snowflake/ingest/streaming/internal/EpInfo.java index 32f77ceac..e6e6a4d9d 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/EpInfo.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/EpInfo.java @@ -1,7 +1,11 @@ package net.snowflake.ingest.streaming.internal; +import static net.snowflake.ingest.utils.Constants.EP_NDV_UNKNOWN; + import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Map; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; /** Class used to serialize/deserialize EP information */ class EpInfo { @@ -17,6 +21,30 @@ class EpInfo { this.columnEps = columnEps; } + /** Some basic verification logic to make sure the EP info is correct */ + public void verifyEpInfo() { + for (Map.Entry entry : columnEps.entrySet()) { + String colName = entry.getKey(); + FileColumnProperties colEp = entry.getValue(); + // Make sure the null count should always smaller than the total row count + if (colEp.getNullCount() > rowCount) { + throw new SFException( + ErrorCode.INTERNAL_ERROR, + String.format( + "Null count bigger than total row count on col=%s, nullCount=%s, rowCount=%s", + colName, colEp.getNullCount(), rowCount)); + } + + // Make sure the NDV should always be -1 + if (colEp.getDistinctValues() != EP_NDV_UNKNOWN) { + throw new SFException( + ErrorCode.INTERNAL_ERROR, + String.format( + "Unexpected NDV on col=%s, value=%d", colName, colEp.getDistinctValues())); + } + } + } + @JsonProperty("rows") long getRowCount() { return rowCount; diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/FileColumnProperties.java b/src/main/java/net/snowflake/ingest/streaming/internal/FileColumnProperties.java index 9322a44ff..a305a52f7 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/FileColumnProperties.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/FileColumnProperties.java @@ -1,11 +1,14 @@ package net.snowflake.ingest.streaming.internal; +import static net.snowflake.ingest.streaming.internal.BinaryStringUtils.truncateBytesAsHex; + import com.fasterxml.jackson.annotation.JsonProperty; import java.math.BigInteger; import java.util.Objects; /** Audit register endpoint/FileColumnPropertyDTO property list. */ class FileColumnProperties { + private int columnOrdinal; private String minStrValue; private String maxStrValue; @@ -38,7 +41,11 @@ class FileColumnProperties { // Default value to use for min/max int when all data in the given column is NULL public static final BigInteger DEFAULT_MIN_MAX_INT_VAL_FOR_EP = BigInteger.valueOf(0); + // Default value to use for min/max real when all data in the given column is NULL + public static final Double DEFAULT_MIN_MAX_REAL_VAL_FOR_EP = 0d; + FileColumnProperties(RowBufferStats stats) { + this.setColumnOrdinal(stats.getOrdinal()); this.setCollation(stats.getCollationDefinitionString()); this.setMaxIntValue( stats.getCurrentMaxIntValue() == null @@ -48,17 +55,44 @@ class FileColumnProperties { stats.getCurrentMinIntValue() == null ? DEFAULT_MIN_MAX_INT_VAL_FOR_EP : stats.getCurrentMinIntValue()); - this.setMinRealValue(stats.getCurrentMinRealValue()); - this.setMaxRealValue(stats.getCurrentMaxRealValue()); + this.setMinRealValue( + stats.getCurrentMinRealValue() == null + ? DEFAULT_MIN_MAX_REAL_VAL_FOR_EP + : stats.getCurrentMinRealValue()); + this.setMaxRealValue( + stats.getCurrentMaxRealValue() == null + ? DEFAULT_MIN_MAX_REAL_VAL_FOR_EP + : stats.getCurrentMaxRealValue()); this.setMaxLength(stats.getCurrentMaxLength()); - this.setMaxStrNonCollated(stats.getCurrentMaxStrValue()); - this.setMinStrNonCollated(stats.getCurrentMinStrValue()); - this.setMaxStrValue(stats.getCurrentMaxColStrValue()); - this.setMinStrValue(stats.getCurrentMinColStrValue()); + + this.setMaxStrNonCollated(null); + this.setMinStrNonCollated(null); + + // current hex-encoded min value, truncated down to 32 bytes + if (stats.getCurrentMinStrValue() != null) { + String truncatedAsHex = truncateBytesAsHex(stats.getCurrentMinStrValue(), false); + this.setMinStrValue(truncatedAsHex); + } + + // current hex-encoded max value, truncated up to 32 bytes + if (stats.getCurrentMaxStrValue() != null) { + String truncatedAsHex = truncateBytesAsHex(stats.getCurrentMaxStrValue(), true); + this.setMaxStrValue(truncatedAsHex); + } + this.setNullCount(stats.getCurrentNullCount()); this.setDistinctValues(stats.getDistinctValues()); } + @JsonProperty("columnId") + public int getColumnOrdinal() { + return columnOrdinal; + } + + public void setColumnOrdinal(int columnOrdinal) { + this.columnOrdinal = columnOrdinal; + } + // Annotation required in order to have package private fields serialized @JsonProperty("minStrValue") String getMinStrValue() { @@ -171,6 +205,7 @@ void setMaxStrNonCollated(String maxStrNonCollated) { @Override public String toString() { final StringBuilder sb = new StringBuilder("{"); + sb.append("\"columnOrdinal\": ").append(columnOrdinal); if (minIntValue != null) { sb.append(", \"minIntValue\": ").append(minIntValue); sb.append(", \"maxIntValue\": ").append(maxIntValue); @@ -196,7 +231,8 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; FileColumnProperties that = (FileColumnProperties) o; - return distinctValues == that.distinctValues + return Objects.equals(columnOrdinal, that.columnOrdinal) + && distinctValues == that.distinctValues && nullCount == that.nullCount && maxLength == that.maxLength && Objects.equals(minStrValue, that.minStrValue) @@ -213,6 +249,7 @@ public boolean equals(Object o) { @Override public int hashCode() { return Objects.hash( + columnOrdinal, minStrValue, maxStrValue, collation, diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/FlushService.java b/src/main/java/net/snowflake/ingest/streaming/internal/FlushService.java index 60c72463d..30dadbe66 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/FlushService.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/FlushService.java @@ -5,27 +5,26 @@ package net.snowflake.ingest.streaming.internal; import static net.snowflake.ingest.utils.Constants.BLOB_EXTENSION_TYPE; -import static net.snowflake.ingest.utils.Constants.CPU_IO_TIME_RATIO; import static net.snowflake.ingest.utils.Constants.DISABLE_BACKGROUND_FLUSH; import static net.snowflake.ingest.utils.Constants.MAX_BLOB_SIZE_IN_BYTES; import static net.snowflake.ingest.utils.Constants.MAX_THREAD_COUNT; import static net.snowflake.ingest.utils.Constants.THREAD_SHUTDOWN_TIMEOUT_IN_SEC; +import static net.snowflake.ingest.utils.Utils.getStackTrace; import com.codahale.metrics.Timer; -import java.io.ByteArrayOutputStream; +import com.google.common.annotations.VisibleForTesting; import java.io.IOException; import java.lang.management.ManagementFactory; -import java.nio.file.Path; -import java.nio.file.Paths; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Calendar; -import java.util.HashMap; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.TimeZone; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -33,27 +32,20 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -import java.util.zip.CRC32; import javax.crypto.BadPaddingException; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import net.snowflake.client.jdbc.SnowflakeSQLException; import net.snowflake.client.jdbc.internal.google.common.util.concurrent.ThreadFactoryBuilder; import net.snowflake.ingest.utils.Constants; -import net.snowflake.ingest.utils.Cryptor; import net.snowflake.ingest.utils.ErrorCode; import net.snowflake.ingest.utils.Logging; import net.snowflake.ingest.utils.Pair; import net.snowflake.ingest.utils.SFException; import net.snowflake.ingest.utils.Utils; -import org.apache.arrow.util.VisibleForTesting; -import org.apache.arrow.vector.VectorLoader; -import org.apache.arrow.vector.VectorSchemaRoot; -import org.apache.arrow.vector.VectorUnloader; -import org.apache.arrow.vector.ipc.ArrowStreamWriter; -import org.apache.arrow.vector.ipc.message.ArrowRecordBatch; /** * Responsible for flushing data from client to Snowflake tables. When a flush is triggered, it will @@ -62,25 +54,30 @@ *

  • build the blob using the ChannelData returned from previous step *
  • upload the blob to stage *
  • register the blob to the targeted Snowflake table + * + * @param type of column data ({@link ParquetChunkData}) */ -class FlushService { +class FlushService { + + // The max number of upload retry attempts to the stage + private static final int DEFAULT_MAX_UPLOAD_RETRIES = 5; // Static class to save the list of channels that are used to build a blob, which is mainly used // to invalidate all the channels when there is a failure - static class BlobData { - private final String filePath; - private final List> data; + static class BlobData { + private final String path; + private final List>> data; - BlobData(String filePath, List> data) { - this.filePath = filePath; + BlobData(String path, List>> data) { + this.path = path; this.data = data; } - String getFilePath() { - return filePath; + String getPath() { + return path; } - List> getData() { + List>> getData() { return data; } } @@ -91,7 +88,7 @@ List> getData() { private final AtomicLong counter; // The client that owns this flush service - private final SnowflakeStreamingIngestClientInternal owningClient; + private final SnowflakeStreamingIngestClientInternal owningClient; // Thread to schedule the flush job @VisibleForTesting ScheduledExecutorService flushWorker; @@ -103,13 +100,13 @@ List> getData() { @VisibleForTesting ExecutorService buildUploadWorkers; // Reference to the channel cache - private final ChannelCache channelCache; + private final ChannelCache channelCache; // Reference to the Streaming Ingest stage private final StreamingIngestStage targetStage; // Reference to register service - private final RegisterService registerService; + private final RegisterService registerService; // Indicates whether we need to schedule a flush @VisibleForTesting volatile boolean isNeedFlush; @@ -123,6 +120,9 @@ List> getData() { // A map which stores the timer for each blob in order to capture the flush latency private final Map latencyTimerContextMap; + // blob encoding version + private final Constants.BdecVersion bdecVersion; + /** * Constructor for TESTING that takes (usually mocked) StreamingIngestStage * @@ -131,19 +131,20 @@ List> getData() { * @param isTestMode */ FlushService( - SnowflakeStreamingIngestClientInternal client, - ChannelCache cache, + SnowflakeStreamingIngestClientInternal client, + ChannelCache cache, StreamingIngestStage targetStage, // For testing boolean isTestMode) { this.owningClient = client; this.channelCache = cache; this.targetStage = targetStage; this.counter = new AtomicLong(0); - this.registerService = new RegisterService(client, isTestMode); + this.registerService = new RegisterService<>(client, isTestMode); this.isNeedFlush = false; this.lastFlushTime = System.currentTimeMillis(); this.isTestMode = isTestMode; - this.latencyTimerContextMap = new HashMap<>(); + this.latencyTimerContextMap = new ConcurrentHashMap<>(); + this.bdecVersion = this.owningClient.getParameterProvider().getBlobFormatVersion(); createWorkers(); } @@ -155,7 +156,7 @@ List> getData() { * @param isTestMode */ FlushService( - SnowflakeStreamingIngestClientInternal client, ChannelCache cache, boolean isTestMode) { + SnowflakeStreamingIngestClientInternal client, ChannelCache cache, boolean isTestMode) { this.owningClient = client; this.channelCache = cache; try { @@ -165,17 +166,20 @@ List> getData() { client.getRole(), client.getHttpClient(), client.getRequestBuilder(), - client.getName()); + client.getName(), + DEFAULT_MAX_UPLOAD_RETRIES, + client.getProxyProperties()); } catch (SnowflakeSQLException | IOException err) { throw new SFException(err, ErrorCode.UNABLE_TO_CONNECT_TO_STAGE); } - this.registerService = new RegisterService(client, isTestMode); + this.registerService = new RegisterService<>(client, isTestMode); this.counter = new AtomicLong(0); this.isNeedFlush = false; this.lastFlushTime = System.currentTimeMillis(); this.isTestMode = isTestMode; - this.latencyTimerContextMap = new HashMap<>(); + this.latencyTimerContextMap = new ConcurrentHashMap<>(); + this.bdecVersion = this.owningClient.getParameterProvider().getBlobFormatVersion(); createWorkers(); } @@ -256,12 +260,13 @@ private CompletableFuture registerFuture() { */ CompletableFuture flush(boolean isForce) { long timeDiffMillis = System.currentTimeMillis() - this.lastFlushTime; + if (isForce || (!DISABLE_BACKGROUND_FLUSH - && !this.isTestMode + && !isTestMode() && (this.isNeedFlush || timeDiffMillis - >= this.owningClient.getParameterProvider().getBufferFlushIntervalInMs()))) { + >= this.owningClient.getParameterProvider().getCachedMaxClientLagInMs()))) { return this.statsFuture() .thenCompose((v) -> this.distributeFlush(isForce, timeDiffMillis)) @@ -278,7 +283,25 @@ private void createWorkers() { this.flushWorker = Executors.newSingleThreadScheduledExecutor(flushThreadFactory); this.flushWorker.scheduleWithFixedDelay( () -> { - flush(false); + flush(false) + .exceptionally( + e -> { + String errorMessage = + String.format( + "Background flush task failed, client=%s, exception=%s, detail=%s," + + " trace=%s.", + this.owningClient.getName(), + e.getCause(), + e.getCause().getMessage(), + getStackTrace(e.getCause())); + logger.logError(errorMessage); + if (this.owningClient.getTelemetryService() != null) { + this.owningClient + .getTelemetryService() + .reportClientFailure(this.getClass().getSimpleName(), errorMessage); + } + return null; + }); }, 0, this.owningClient.getParameterProvider().getBufferFlushCheckIntervalInMs(), @@ -290,13 +313,14 @@ private void createWorkers() { this.registerWorker = Executors.newSingleThreadExecutor(registerThreadFactory); // Create threads for building and uploading blobs - // Size: number of available processors * (1 + IO time/CPU time), currently the - // CPU_IO_TIME_RATIO is 1 under the assumption that build and upload will take similar time + // Size: number of available processors * (1 + IO time/CPU time) ThreadFactory buildUploadThreadFactory = new ThreadFactoryBuilder().setNameFormat("ingest-build-upload-thread-%d").build(); int buildUploadThreadCount = Math.min( - Runtime.getRuntime().availableProcessors() * (1 + CPU_IO_TIME_RATIO), MAX_THREAD_COUNT); + Runtime.getRuntime().availableProcessors() + * (1 + this.owningClient.getParameterProvider().getIOTimeCpuRatio()), + MAX_THREAD_COUNT); this.buildUploadWorkers = Executors.newFixedThreadPool(buildUploadThreadCount, buildUploadThreadFactory); @@ -312,69 +336,156 @@ private void createWorkers() { * off a build blob work when certain size has reached or we have reached the end */ void distributeFlushTasks() { - Iterator>> + Iterator< + Map.Entry< + String, ConcurrentHashMap>>> itr = this.channelCache.iterator(); - List>> blobs = new ArrayList<>(); - - while (itr.hasNext()) { - List> blobData = new ArrayList<>(); - float totalBufferSize = 0; - - // Distribute work at table level, create a new blob if reaching the blob size limit - while (itr.hasNext() && totalBufferSize <= MAX_BLOB_SIZE_IN_BYTES) { - ConcurrentHashMap table = - itr.next().getValue(); - List channelsDataPerTable = new ArrayList<>(); - // TODO: we could do parallel stream to get the channelData if needed - for (SnowflakeStreamingIngestChannelInternal channel : table.values()) { - if (channel.isValid()) { - ChannelData data = channel.getData(); - if (data != null) { - channelsDataPerTable.add(data); - totalBufferSize += data.getBufferSize(); - } - } + List, CompletableFuture>> blobs = new ArrayList<>(); + List> leftoverChannelsDataPerTable = new ArrayList<>(); + + while (itr.hasNext() || !leftoverChannelsDataPerTable.isEmpty()) { + List>> blobData = new ArrayList<>(); + float totalBufferSizeInBytes = 0F; + final String blobPath = getBlobPath(this.targetStage.getClientPrefix()); + + // Distribute work at table level, split the blob if reaching the blob size limit or the + // channel has different encryption key ids + while (itr.hasNext() || !leftoverChannelsDataPerTable.isEmpty()) { + List> channelsDataPerTable = Collections.synchronizedList(new ArrayList<>()); + if (!leftoverChannelsDataPerTable.isEmpty()) { + channelsDataPerTable.addAll(leftoverChannelsDataPerTable); + leftoverChannelsDataPerTable.clear(); + } else if (blobData.size() + >= this.owningClient + .getParameterProvider() + .getMaxChunksInBlobAndRegistrationRequest()) { + // Create a new blob if the current one already contains max allowed number of chunks + logger.logInfo( + "Max allowed number of chunks in the current blob reached. chunkCount={}" + + " maxChunkCount={} currentBlobPath={}", + blobData.size(), + this.owningClient.getParameterProvider().getMaxChunksInBlobAndRegistrationRequest(), + blobPath); + break; + } else { + ConcurrentHashMap> table = + itr.next().getValue(); + // Use parallel stream since getData could be the performance bottleneck when we have a + // high number of channels + table.values().parallelStream() + .forEach( + channel -> { + if (channel.isValid()) { + ChannelData data = channel.getData(blobPath); + if (data != null) { + channelsDataPerTable.add(data); + } + } + }); } + if (!channelsDataPerTable.isEmpty()) { - blobData.add(channelsDataPerTable); + int idx = 0; + float totalBufferSizePerTableInBytes = 0F; + while (idx < channelsDataPerTable.size()) { + ChannelData channelData = channelsDataPerTable.get(idx); + // Stop processing the rest of channels when needed + if (idx > 0 + && shouldStopProcessing( + totalBufferSizeInBytes, + totalBufferSizePerTableInBytes, + channelData, + channelsDataPerTable.get(idx - 1))) { + leftoverChannelsDataPerTable.addAll( + channelsDataPerTable.subList(idx, channelsDataPerTable.size())); + logger.logInfo( + "Creation of another blob is needed because of blob/chunk size limit or" + + " different encryption ids or different schema, client={}, table={}," + + " blobSize={}, chunkSize={}, nextChannelSize={}, encryptionId1={}," + + " encryptionId2={}, schema1={}, schema2={}", + this.owningClient.getName(), + channelData.getChannelContext().getTableName(), + totalBufferSizeInBytes, + totalBufferSizePerTableInBytes, + channelData.getBufferSize(), + channelData.getChannelContext().getEncryptionKeyId(), + channelsDataPerTable.get(idx - 1).getChannelContext().getEncryptionKeyId(), + channelData.getColumnEps().keySet(), + channelsDataPerTable.get(idx - 1).getColumnEps().keySet()); + break; + } + totalBufferSizeInBytes += channelData.getBufferSize(); + totalBufferSizePerTableInBytes += channelData.getBufferSize(); + idx++; + } + // Add processed channels to the current blob, stop if we need to create a new blob + blobData.add(channelsDataPerTable.subList(0, idx)); + if (idx != channelsDataPerTable.size()) { + break; + } } } // Kick off a build job - if (!blobData.isEmpty()) { - String filePath = getFilePath(this.targetStage.getClientPrefix()); + if (blobData.isEmpty()) { + // we decrement the counter so that we do not have gaps in the blob names created by this + // client. See method getBlobPath() below. + this.counter.decrementAndGet(); + } else { + long flushStartMs = System.currentTimeMillis(); if (this.owningClient.flushLatency != null) { - latencyTimerContextMap.putIfAbsent(filePath, this.owningClient.flushLatency.time()); + latencyTimerContextMap.putIfAbsent(blobPath, this.owningClient.flushLatency.time()); } blobs.add( new Pair<>( - new BlobData(filePath, blobData), + new BlobData<>(blobPath, blobData), CompletableFuture.supplyAsync( () -> { try { - logger.logDebug( - "buildUploadWorkers stats={}", this.buildUploadWorkers.toString()); - return buildAndUpload(filePath, blobData); - } catch (IOException e) { - logger.logError( - "Building blob failed={}, exception={}, detail={}, all channels in the" - + " blob will be invalidated", - filePath, - e, - e.getMessage()); - invalidateAllChannelsInBlob(blobData); - return null; - } catch (NoSuchAlgorithmException e) { - throw new SFException(e, ErrorCode.MD5_HASHING_NOT_AVAILABLE); - } catch (InvalidAlgorithmParameterException - | NoSuchPaddingException - | IllegalBlockSizeException - | BadPaddingException - | InvalidKeyException e) { - throw new SFException(e, ErrorCode.ENCRYPTION_FAILURE); + BlobMetadata blobMetadata = buildAndUpload(blobPath, blobData); + blobMetadata.getBlobStats().setFlushStartMs(flushStartMs); + return blobMetadata; + } catch (Throwable e) { + Throwable ex = e.getCause() == null ? e : e.getCause(); + String errorMessage = + String.format( + "Building blob failed, client=%s, blob=%s, exception=%s," + + " detail=%s, trace=%s, all channels in the blob will be" + + " invalidated", + this.owningClient.getName(), + blobPath, + ex, + ex.getMessage(), + getStackTrace(ex)); + logger.logError(errorMessage); + if (this.owningClient.getTelemetryService() != null) { + this.owningClient + .getTelemetryService() + .reportClientFailure(this.getClass().getSimpleName(), errorMessage); + } + + if (e instanceof IOException) { + invalidateAllChannelsInBlob(blobData); + return null; + } else if (e instanceof NoSuchAlgorithmException) { + throw new SFException(e, ErrorCode.MD5_HASHING_NOT_AVAILABLE); + } else if (e instanceof InvalidAlgorithmParameterException + | e instanceof NoSuchPaddingException + | e instanceof IllegalBlockSizeException + | e instanceof BadPaddingException + | e instanceof InvalidKeyException) { + throw new SFException(e, ErrorCode.ENCRYPTION_FAILURE); + } else { + throw new SFException(e, ErrorCode.INTERNAL_ERROR, e.getMessage()); + } } }, this.buildUploadWorkers))); + logger.logInfo( + "buildAndUpload task added for client={}, blob={}, buildUploadWorkers stats={}", + this.owningClient.getName(), + blobPath, + this.buildUploadWorkers.toString()); } } @@ -383,195 +494,94 @@ void distributeFlushTasks() { } /** - * Builds and uploads file to cloud storage. + * Check whether we should stop merging more channels into the same chunk, we need to stop in a + * few cases: * - * @param filePath Path of the destination file in cloud storage + *

    When the blob size is larger than a certain threshold + * + *

    When the chunk size is larger than a certain threshold + * + *

    When the encryption key ids are not the same + * + *

    When the schemas are not the same + */ + private boolean shouldStopProcessing( + float totalBufferSizeInBytes, + float totalBufferSizePerTableInBytes, + ChannelData current, + ChannelData prev) { + return totalBufferSizeInBytes + current.getBufferSize() > MAX_BLOB_SIZE_IN_BYTES + || totalBufferSizePerTableInBytes + current.getBufferSize() + > this.owningClient.getParameterProvider().getMaxChunkSizeInBytes() + || !Objects.equals( + current.getChannelContext().getEncryptionKeyId(), + prev.getChannelContext().getEncryptionKeyId()) + || !current.getColumnEps().keySet().equals(prev.getColumnEps().keySet()); + } + + /** + * Builds and uploads blob to cloud storage. + * + * @param blobPath Path of the destination blob in cloud storage * @param blobData All the data for one blob. Assumes that all ChannelData in the inner List * belongs to the same table. Will error if this is not the case * @return BlobMetadata for FlushService.upload */ - BlobMetadata buildAndUpload(String filePath, List> blobData) + BlobMetadata buildAndUpload(String blobPath, List>> blobData) throws IOException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException { - List chunksMetadataList = new ArrayList<>(); - List chunksDataList = new ArrayList<>(); - long curDataSize = 0L; - CRC32 crc = new CRC32(); Timer.Context buildContext = Utils.createTimerContext(this.owningClient.buildLatency); - // TODO: channels with different schema can't be combined even if they belongs to same table - for (List channelsDataPerTable : blobData) { - List channelsMetadataList = new ArrayList<>(); - long rowCount = 0L; - VectorSchemaRoot root = null; - ArrowStreamWriter streamWriter = null; - VectorLoader loader = null; - SnowflakeStreamingIngestChannelInternal firstChannel = null; - ByteArrayOutputStream chunkData = new ByteArrayOutputStream(); - Map columnEpStatsMapCombined = null; - - try { - for (ChannelData data : channelsDataPerTable) { - // Create channel metadata - ChannelMetadata channelMetadata = - ChannelMetadata.builder() - .setOwningChannel(data.getChannel()) - .setRowSequencer(data.getRowSequencer()) - .setOffsetToken(data.getOffsetToken()) - .build(); - // Add channel metadata to the metadata list - channelsMetadataList.add(channelMetadata); - - logger.logDebug( - "Start building channel={}, rowCount={}, bufferSize={} in blob={}", - data.getChannel().getFullyQualifiedName(), - data.getRowCount(), - data.getBufferSize(), - filePath); - - if (root == null) { - columnEpStatsMapCombined = data.getColumnEps(); - root = data.getVectors(); - streamWriter = new ArrowStreamWriter(root, null, chunkData); - loader = new VectorLoader(root); - firstChannel = data.getChannel(); - streamWriter.start(); - } else { - // This method assumes that channelsDataPerTable is grouped by table. We double check - // here and throw an error if the assumption is violated - if (!data.getChannel() - .getFullyQualifiedTableName() - .equals(firstChannel.getFullyQualifiedTableName())) { - throw new SFException(ErrorCode.INVALID_DATA_IN_CHUNK); - } - - columnEpStatsMapCombined = - ChannelData.getCombinedColumnStatsMap( - columnEpStatsMapCombined, data.getColumnEps()); + // Construct the blob along with the metadata of the blob + BlobBuilder.Blob blob = BlobBuilder.constructBlobAndMetadata(blobPath, blobData, bdecVersion); - VectorUnloader unloader = new VectorUnloader(data.getVectors()); - ArrowRecordBatch recordBatch = unloader.getRecordBatch(); - loader.load(recordBatch); - recordBatch.close(); - data.getVectors().close(); - } - - // Write channel data using the stream writer - streamWriter.writeBatch(); - rowCount += data.getRowCount(); - - logger.logDebug( - "Finish building channel={}, rowCount={}, bufferSize={} in blob={}", - data.getChannel().getFullyQualifiedName(), - data.getRowCount(), - data.getBufferSize(), - filePath); - } - } finally { - if (streamWriter != null) { - streamWriter.close(); - root.close(); - } - } - - if (!channelsMetadataList.isEmpty()) { - // Compress the chunk data - byte[] compressedChunkData = BlobBuilder.compress(filePath, chunkData); - - // Encrypt the compressed chunk data, the encryption key is derived using the key from - // server with the full blob path. - // We need to maintain IV as a block counter for the whole file, even interleaved, - // to align with decryption on the Snowflake query path. - // TODO: address alignment for the header SNOW-557866 - long iv = curDataSize / Constants.ENCRYPTION_ALGORITHM_BLOCK_SIZE_BYTES; - byte[] encryptedCompressedChunkData = - Cryptor.encrypt(compressedChunkData, firstChannel.getEncryptionKey(), filePath, iv); - - // Encryption adds padding to the ENCRYPTION_ALGORITHM_BLOCK_SIZE_BYTES - // to align with decryption on the Snowflake query path starting from this chunk offset. - // The padding does not have arrow data and not compressed. - // Hence, the actual chunk size is smaller by the padding size. - // The compression on the Snowflake query path needs the correct size of the compressed - // data. - int compressedChunkLength = compressedChunkData.length; - - // Compute the md5 of the chunk data - String md5 = BlobBuilder.computeMD5(encryptedCompressedChunkData, compressedChunkLength); - int encryptedCompressedChunkDataSize = encryptedCompressedChunkData.length; - - // Create chunk metadata - ChunkMetadata chunkMetadata = - ChunkMetadata.builder() - .setOwningTable(firstChannel) - // The start offset will be updated later in BlobBuilder#build to include the blob - // header - .setChunkStartOffset(curDataSize) - // The compressedChunkLength is used because it is the actual data size used for - // decompression and md5 calculation on server side. - .setChunkLength(compressedChunkLength) - .setChannelList(channelsMetadataList) - .setChunkMD5(md5) - .setEncryptionKeyId(firstChannel.getEncryptionKeyId()) - .setEpInfo(ArrowRowBuffer.buildEpInfoFromStats(rowCount, columnEpStatsMapCombined)) - .build(); - - // Add chunk metadata and data to the list - chunksMetadataList.add(chunkMetadata); - chunksDataList.add(encryptedCompressedChunkData); - curDataSize += encryptedCompressedChunkDataSize; - crc.update(encryptedCompressedChunkData, 0, encryptedCompressedChunkDataSize); - - logger.logDebug( - "Finish building chunk in blob={}, table={}, rowCount={}, uncompressedSize={}," - + " compressedChunkLength={}, encryptedCompressedSize={}", - filePath, - firstChannel.getFullyQualifiedTableName(), - rowCount, - chunkData.size(), - compressedChunkLength, - encryptedCompressedChunkDataSize, - compressedChunkLength); - } - } - - // Build blob file, and then upload to streaming ingest dedicated stage - byte[] blob = - BlobBuilder.build(chunksMetadataList, chunksDataList, crc.getValue(), curDataSize); - if (buildContext != null) { - buildContext.stop(); - } + blob.blobStats.setBuildDurationMs(buildContext); - return upload(filePath, blob, chunksMetadataList); + return upload(blobPath, blob.blobBytes, blob.chunksMetadataList, blob.blobStats); } /** * Upload a blob to Streaming Ingest dedicated stage * - * @param filePath full path of the blob file + * @param blobPath full path of the blob * @param blob blob data * @param metadata a list of chunk metadata + * @param blobStats an object to track latencies and other stats of the blob * @return BlobMetadata object used to create the register blob request */ - BlobMetadata upload(String filePath, byte[] blob, List metadata) + BlobMetadata upload( + String blobPath, byte[] blob, List metadata, BlobStats blobStats) throws NoSuchAlgorithmException { - logger.logDebug("Start uploading file={}, size={}", filePath, blob.length); + logger.logInfo("Start uploading blob={}, size={}", blobPath, blob.length); + long startTime = System.currentTimeMillis(); Timer.Context uploadContext = Utils.createTimerContext(this.owningClient.uploadLatency); - - this.targetStage.put(filePath, blob); + this.targetStage.put(blobPath, blob); if (uploadContext != null) { - uploadContext.stop(); + blobStats.setUploadDurationMs(uploadContext); this.owningClient.uploadThroughput.mark(blob.length); this.owningClient.blobSizeHistogram.update(blob.length); this.owningClient.blobRowCountHistogram.update( metadata.stream().mapToLong(i -> i.getEpInfo().getRowCount()).sum()); } - logger.logDebug("Finish uploading file={}", filePath); - - return new BlobMetadata(filePath, BlobBuilder.computeMD5(blob), metadata); + logger.logInfo( + "Finish uploading blob={}, size={}, timeInMillis={}", + blobPath, + blob.length, + System.currentTimeMillis() - startTime); + + // at this point we know for sure if the BDEC file has data for more than one chunk, i.e. + // spans mixed tables or not + return BlobMetadata.createBlobMetadata( + blobPath, + BlobBuilder.computeMD5(blob), + bdecVersion, + metadata, + blobStats, + metadata == null ? false : metadata.size() > 1); } /** @@ -605,18 +615,18 @@ void setNeedFlush() { } /** - * Generate a blob file path, which is: "YEAR/MONTH/DAY_OF_MONTH/HOUR_OF_DAY/MINUTE/.BDEC" * * @return the generated blob file path */ - private String getFilePath(String clientPrefix) { + private String getBlobPath(String clientPrefix) { Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); - return getFilePath(calendar, clientPrefix); + return getBlobPath(calendar, clientPrefix); } /** For TESTING */ - String getFilePath(Calendar calendar, String clientPrefix) { + String getBlobPath(Calendar calendar, String clientPrefix) { if (isTestMode && clientPrefix == null) { clientPrefix = "testPrefix"; } @@ -627,9 +637,10 @@ String getFilePath(Calendar calendar, String clientPrefix) { int day = calendar.get(Calendar.DAY_OF_MONTH); int hour = calendar.get(Calendar.HOUR_OF_DAY); int minute = calendar.get(Calendar.MINUTE); - long time = calendar.getTimeInMillis(); + long time = TimeUnit.MILLISECONDS.toSeconds(calendar.getTimeInMillis()); long threadId = Thread.currentThread().getId(); - String fileName = + // Create the blob short name, the clientPrefix contains the deployment id + String blobShortName = Long.toString(time, 36) + "_" + clientPrefix @@ -639,15 +650,7 @@ String getFilePath(Calendar calendar, String clientPrefix) { + this.counter.getAndIncrement() + "." + BLOB_EXTENSION_TYPE; - Path filePath = - Paths.get( - Integer.toString(year), - Integer.toString(month), - Integer.toString(day), - Integer.toString(hour), - Integer.toString(minute), - fileName); - return filePath.toString(); + return year + "/" + month + "/" + day + "/" + hour + "/" + minute + "/" + blobShortName; } /** @@ -655,20 +658,19 @@ String getFilePath(Calendar calendar, String clientPrefix) { * * @param blobData list of channels that belongs to the blob */ - void invalidateAllChannelsInBlob(List> blobData) { + void invalidateAllChannelsInBlob(List>> blobData) { blobData.forEach( chunkData -> chunkData.forEach( channelData -> { - channelData.getVectors().close(); this.owningClient .getChannelCache() .invalidateChannelIfSequencersMatch( - channelData.getChannel().getDBName(), - channelData.getChannel().getSchemaName(), - channelData.getChannel().getTableName(), - channelData.getChannel().getName(), - channelData.getChannel().getChannelSequencer()); + channelData.getChannelContext().getDbName(), + channelData.getChannelContext().getSchemaName(), + channelData.getChannelContext().getTableName(), + channelData.getChannelContext().getName(), + channelData.getChannelContext().getChannelSequencer()); })); } @@ -676,4 +678,27 @@ void invalidateAllChannelsInBlob(List> blobData) { String getClientPrefix() { return this.targetStage.getClientPrefix(); } + + /** + * Throttle if the number of queued buildAndUpload tasks is bigger than the total number of + * available processors + */ + boolean throttleDueToQueuedFlushTasks() { + ThreadPoolExecutor buildAndUpload = (ThreadPoolExecutor) this.buildUploadWorkers; + boolean throttleOnQueuedTasks = + buildAndUpload.getQueue().size() > Runtime.getRuntime().availableProcessors(); + if (throttleOnQueuedTasks) { + logger.logWarn( + "Throttled due too many queue flush tasks (probably because of slow uploading speed)," + + " client={}, buildUploadWorkers stats={}", + this.owningClient.getName(), + this.buildUploadWorkers.toString()); + } + return throttleOnQueuedTasks; + } + + /** Get whether we're running under test mode */ + boolean isTestMode() { + return this.isTestMode; + } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/Flusher.java b/src/main/java/net/snowflake/ingest/streaming/internal/Flusher.java new file mode 100644 index 000000000..3bb339975 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/Flusher.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import net.snowflake.ingest.utils.Pair; + +/** + * Interface to convert {@link ChannelData} buffered in {@link RowBuffer} to the underlying format + * implementation for faster processing. + * + * @param type of column data ({@link ParquetChunkData}) + */ +public interface Flusher { + /** + * Serialize buffered rows into the underlying format. + * + * @param channelsDataPerTable buffered rows + * @param filePath file path + * @return {@link SerializationResult} + * @throws IOException + */ + SerializationResult serialize(List> channelsDataPerTable, String filePath) + throws IOException; + + /** Holds result of the buffered rows conversion: channel metadata and stats. */ + class SerializationResult { + final List channelsMetadataList; + final Map columnEpStatsMapCombined; + final long rowCount; + final float chunkEstimatedUncompressedSize; + final ByteArrayOutputStream chunkData; + final Pair chunkMinMaxInsertTimeInMs; + + public SerializationResult( + List channelsMetadataList, + Map columnEpStatsMapCombined, + long rowCount, + float chunkEstimatedUncompressedSize, + ByteArrayOutputStream chunkData, + Pair chunkMinMaxInsertTimeInMs) { + this.channelsMetadataList = channelsMetadataList; + this.columnEpStatsMapCombined = columnEpStatsMapCombined; + this.rowCount = rowCount; + this.chunkEstimatedUncompressedSize = chunkEstimatedUncompressedSize; + this.chunkData = chunkData; + this.chunkMinMaxInsertTimeInMs = chunkMinMaxInsertTimeInMs; + } + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/IngestionStrategy.java b/src/main/java/net/snowflake/ingest/streaming/internal/IngestionStrategy.java new file mode 100644 index 000000000..2c44b50c4 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/IngestionStrategy.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import java.util.Map; +import net.snowflake.ingest.streaming.InsertValidationResponse; + +/** Interface to a batch of rows into the row buffer based on different on error options */ +public interface IngestionStrategy { + /** + * Insert a batch of rows into the row buffer + * + * @param rows input row + * @param startOffsetToken start offset token of the batch + * @param endOffsetToken offset token of the latest row in the batch + * @return insert response that possibly contains errors because of insertion failures + */ + InsertValidationResponse insertRows( + AbstractRowBuffer rowBuffer, + Iterable> rows, + String startOffsetToken, + String endOffsetToken); +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/LiteralQuoteUtils.java b/src/main/java/net/snowflake/ingest/streaming/internal/LiteralQuoteUtils.java new file mode 100644 index 000000000..12fe5cd3a --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/LiteralQuoteUtils.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ +package net.snowflake.ingest.streaming.internal; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; + +/** + * Util class to normalise literals to match server side metadata. + * + *

    Note: The methods in this class have to be kept in sync with the respective methods on server + * side. + */ +class LiteralQuoteUtils { + + /** Maximum number of unquoted column names to store in cache */ + static final int UNQUOTED_COLUMN_NAME_CACHE_MAX_SIZE = 30000; + + /** Cache storing unquoted column names */ + private static final LoadingCache unquotedColumnNamesCache; + + static { + unquotedColumnNamesCache = + Caffeine.newBuilder() + .maximumSize(UNQUOTED_COLUMN_NAME_CACHE_MAX_SIZE) + .build(LiteralQuoteUtils::unquoteColumnNameInternal); + } + + /** + * Unquote column name expected to be used from the outside. It decides is unquoting would be + * expensive. If not, it unquotes directly, otherwise it return a value from a loading cache. + */ + static String unquoteColumnName(String columnName) { + return unquotedColumnNamesCache.get(columnName); + } + + /** + * Unquote SQL literal. + * + *

    Normalises the column name to how it is stored internally. This function needs to keep in + * sync with server side normalisation. The reason, we do it here, is to store the normalised + * column name in BDEC file metadata. + * + * @param columnName column name literal to unquote + * @return unquoted literal + */ + private static String unquoteColumnNameInternal(String columnName) { + int length = columnName.length(); + + if (length == 0) { + return columnName; + } + + // If this is an identifier that starts and ends with double quotes, + // remove them - accounting for escaped double quotes. + // Differs from the second condition in that this one allows repeated + // double quotes + if (columnName.charAt(0) == '"' + && (length >= 2 + && columnName.charAt(length - 1) == '"' + && + // Condition that the string contains no single double-quotes + // but allows repeated double-quotes + !columnName.substring(1, length - 1).replace("\"\"", "").contains("\""))) { + // Remove quotes and turn escaped double-quotes to single double-quotes + return columnName.substring(1, length - 1).replace("\"\"", "\""); + } + + // If this is an identifier that starts and ends with double quotes, + // remove them. Internal single double-quotes are not allowed. + else if (columnName.charAt(0) == '"' + && (length >= 2 + && columnName.charAt(length - 1) == '"' + && !columnName.substring(1, length - 1).contains("\""))) { + // Remove the quotes + return columnName.substring(1, length - 1); + } + + // unquoted string that can have escaped spaces + else { + // replace escaped spaces in unquoted name + if (columnName.contains("\\ ")) { + columnName = columnName.replace("\\ ", " "); + } + return columnName.toUpperCase(); + } + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/MemoryInfoProvider.java b/src/main/java/net/snowflake/ingest/streaming/internal/MemoryInfoProvider.java new file mode 100644 index 000000000..f426e898d --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/MemoryInfoProvider.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +/** Provider information about available system memory */ +public interface MemoryInfoProvider { + /** @return Max memory the JVM can allocate */ + long getMaxMemory(); + + /** @return Total allocated JVM memory so far */ + long getTotalMemory(); + + /** @return Free JVM memory */ + long getFreeMemory(); +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/MemoryInfoProviderFromRuntime.java b/src/main/java/net/snowflake/ingest/streaming/internal/MemoryInfoProviderFromRuntime.java new file mode 100644 index 000000000..3a957f225 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/MemoryInfoProviderFromRuntime.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +/** Reads memory information from JVM runtime */ +public class MemoryInfoProviderFromRuntime implements MemoryInfoProvider { + @Override + public long getMaxMemory() { + return Runtime.getRuntime().maxMemory(); + } + + @Override + public long getTotalMemory() { + return Runtime.getRuntime().totalMemory(); + } + + @Override + public long getFreeMemory() { + return Runtime.getRuntime().freeMemory(); + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/OpenChannelResponse.java b/src/main/java/net/snowflake/ingest/streaming/internal/OpenChannelResponse.java index c103314ca..beb0eb0c4 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/OpenChannelResponse.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/OpenChannelResponse.java @@ -8,7 +8,7 @@ import java.util.List; /** Response to the OpenChannelRequest */ -class OpenChannelResponse { +class OpenChannelResponse extends StreamingIngestResponse { private Long statusCode; private String message; private String dbName; @@ -27,6 +27,7 @@ void setStatusCode(Long statusCode) { this.statusCode = statusCode; } + @Override Long getStatusCode() { return this.statusCode; } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ParquetChunkData.java b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetChunkData.java new file mode 100644 index 000000000..16b1ededa --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetChunkData.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Map; +import org.apache.parquet.hadoop.BdecParquetWriter; + +/** Parquet data holder to buffer rows. */ +public class ParquetChunkData { + // buffered rows serialized into Java objects. Needed for the Parquet w/o memory optimization. + final List> rows; + + final BdecParquetWriter parquetWriter; + final ByteArrayOutputStream output; + final Map metadata; + + /** + * Construct parquet data chunk. + * + * @param rows buffered row data as a list + * @param parquetWriter buffered parquet row data + * @param output byte array file output + * @param metadata chunk metadata + */ + public ParquetChunkData( + List> rows, + BdecParquetWriter parquetWriter, + ByteArrayOutputStream output, + Map metadata) { + this.rows = rows; + this.parquetWriter = parquetWriter; + this.output = output; + this.metadata = metadata; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ParquetFlusher.java b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetFlusher.java new file mode 100644 index 000000000..39ec66dbb --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetFlusher.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.Logging; +import net.snowflake.ingest.utils.Pair; +import net.snowflake.ingest.utils.SFException; +import org.apache.parquet.hadoop.BdecParquetReader; +import org.apache.parquet.hadoop.BdecParquetWriter; +import org.apache.parquet.schema.MessageType; + +/** + * Converts {@link ChannelData} buffered in {@link RowBuffer} to the Parquet format for faster + * processing. + */ +public class ParquetFlusher implements Flusher { + private static final Logging logger = new Logging(ParquetFlusher.class); + private final MessageType schema; + private final boolean enableParquetInternalBuffering; + private final long maxChunkSizeInBytes; + + private final Constants.BdecParquetCompression bdecParquetCompression; + + /** + * Construct parquet flusher from its schema and set flag that indicates whether Parquet memory + * optimization is enabled, i.e. rows will be buffered in internal Parquet buffer. + */ + public ParquetFlusher( + MessageType schema, + boolean enableParquetInternalBuffering, + long maxChunkSizeInBytes, + Constants.BdecParquetCompression bdecParquetCompression) { + this.schema = schema; + this.enableParquetInternalBuffering = enableParquetInternalBuffering; + this.maxChunkSizeInBytes = maxChunkSizeInBytes; + this.bdecParquetCompression = bdecParquetCompression; + } + + @Override + public SerializationResult serialize( + List> channelsDataPerTable, String filePath) + throws IOException { + if (enableParquetInternalBuffering) { + return serializeFromParquetWriteBuffers(channelsDataPerTable, filePath); + } + return serializeFromJavaObjects(channelsDataPerTable, filePath); + } + + private SerializationResult serializeFromParquetWriteBuffers( + List> channelsDataPerTable, String filePath) + throws IOException { + List channelsMetadataList = new ArrayList<>(); + long rowCount = 0L; + float chunkEstimatedUncompressedSize = 0f; + String firstChannelFullyQualifiedTableName = null; + Map columnEpStatsMapCombined = null; + BdecParquetWriter mergedChannelWriter = null; + ByteArrayOutputStream mergedChunkData = new ByteArrayOutputStream(); + Pair chunkMinMaxInsertTimeInMs = null; + + for (ChannelData data : channelsDataPerTable) { + // Create channel metadata + ChannelMetadata channelMetadata = + ChannelMetadata.builder() + .setOwningChannelFromContext(data.getChannelContext()) + .setRowSequencer(data.getRowSequencer()) + .setOffsetToken(data.getEndOffsetToken()) + .setStartOffsetToken(data.getStartOffsetToken()) + .build(); + // Add channel metadata to the metadata list + channelsMetadataList.add(channelMetadata); + + logger.logDebug( + "Parquet Flusher: Start building channel={}, rowCount={}, bufferSize={} in blob={}", + data.getChannelContext().getFullyQualifiedName(), + data.getRowCount(), + data.getBufferSize(), + filePath); + + if (mergedChannelWriter == null) { + columnEpStatsMapCombined = data.getColumnEps(); + mergedChannelWriter = data.getVectors().parquetWriter; + mergedChunkData = data.getVectors().output; + firstChannelFullyQualifiedTableName = data.getChannelContext().getFullyQualifiedTableName(); + chunkMinMaxInsertTimeInMs = data.getMinMaxInsertTimeInMs(); + } else { + // This method assumes that channelsDataPerTable is grouped by table. We double check + // here and throw an error if the assumption is violated + if (!data.getChannelContext() + .getFullyQualifiedTableName() + .equals(firstChannelFullyQualifiedTableName)) { + throw new SFException(ErrorCode.INVALID_DATA_IN_CHUNK); + } + + columnEpStatsMapCombined = + ChannelData.getCombinedColumnStatsMap(columnEpStatsMapCombined, data.getColumnEps()); + data.getVectors().parquetWriter.close(); + BdecParquetReader.readFileIntoWriter( + data.getVectors().output.toByteArray(), mergedChannelWriter); + chunkMinMaxInsertTimeInMs = + ChannelData.getCombinedMinMaxInsertTimeInMs( + chunkMinMaxInsertTimeInMs, data.getMinMaxInsertTimeInMs()); + } + + rowCount += data.getRowCount(); + chunkEstimatedUncompressedSize += data.getBufferSize(); + + logger.logDebug( + "Parquet Flusher: Finish building channel={}, rowCount={}, bufferSize={} in blob={}", + data.getChannelContext().getFullyQualifiedName(), + data.getRowCount(), + data.getBufferSize(), + filePath); + } + + if (mergedChannelWriter != null) { + mergedChannelWriter.close(); + } + return new SerializationResult( + channelsMetadataList, + columnEpStatsMapCombined, + rowCount, + chunkEstimatedUncompressedSize, + mergedChunkData, + chunkMinMaxInsertTimeInMs); + } + + private SerializationResult serializeFromJavaObjects( + List> channelsDataPerTable, String filePath) + throws IOException { + List channelsMetadataList = new ArrayList<>(); + long rowCount = 0L; + float chunkEstimatedUncompressedSize = 0f; + String firstChannelFullyQualifiedTableName = null; + Map columnEpStatsMapCombined = null; + List> rows = null; + BdecParquetWriter parquetWriter; + ByteArrayOutputStream mergedData = new ByteArrayOutputStream(); + Pair chunkMinMaxInsertTimeInMs = null; + + for (ChannelData data : channelsDataPerTable) { + // Create channel metadata + ChannelMetadata channelMetadata = + ChannelMetadata.builder() + .setOwningChannelFromContext(data.getChannelContext()) + .setRowSequencer(data.getRowSequencer()) + .setOffsetToken(data.getEndOffsetToken()) + .setStartOffsetToken(data.getStartOffsetToken()) + .build(); + // Add channel metadata to the metadata list + channelsMetadataList.add(channelMetadata); + + logger.logDebug( + "Parquet Flusher: Start building channel={}, rowCount={}, bufferSize={} in blob={}," + + " enableParquetMemoryOptimization={}", + data.getChannelContext().getFullyQualifiedName(), + data.getRowCount(), + data.getBufferSize(), + filePath, + enableParquetInternalBuffering); + + if (rows == null) { + columnEpStatsMapCombined = data.getColumnEps(); + rows = new ArrayList<>(); + firstChannelFullyQualifiedTableName = data.getChannelContext().getFullyQualifiedTableName(); + chunkMinMaxInsertTimeInMs = data.getMinMaxInsertTimeInMs(); + } else { + // This method assumes that channelsDataPerTable is grouped by table. We double check + // here and throw an error if the assumption is violated + if (!data.getChannelContext() + .getFullyQualifiedTableName() + .equals(firstChannelFullyQualifiedTableName)) { + throw new SFException(ErrorCode.INVALID_DATA_IN_CHUNK); + } + + columnEpStatsMapCombined = + ChannelData.getCombinedColumnStatsMap(columnEpStatsMapCombined, data.getColumnEps()); + chunkMinMaxInsertTimeInMs = + ChannelData.getCombinedMinMaxInsertTimeInMs( + chunkMinMaxInsertTimeInMs, data.getMinMaxInsertTimeInMs()); + } + + rows.addAll(data.getVectors().rows); + + rowCount += data.getRowCount(); + chunkEstimatedUncompressedSize += data.getBufferSize(); + + logger.logDebug( + "Parquet Flusher: Finish building channel={}, rowCount={}, bufferSize={} in blob={}," + + " enableParquetMemoryOptimization={}", + data.getChannelContext().getFullyQualifiedName(), + data.getRowCount(), + data.getBufferSize(), + filePath, + enableParquetInternalBuffering); + } + + Map metadata = channelsDataPerTable.get(0).getVectors().metadata; + parquetWriter = + new BdecParquetWriter( + mergedData, + schema, + metadata, + firstChannelFullyQualifiedTableName, + maxChunkSizeInBytes, + bdecParquetCompression); + rows.forEach(parquetWriter::writeRow); + parquetWriter.close(); + + return new SerializationResult( + channelsMetadataList, + columnEpStatsMapCombined, + rowCount, + chunkEstimatedUncompressedSize, + mergedData, + chunkMinMaxInsertTimeInMs); + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ParquetRowBuffer.java b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetRowBuffer.java new file mode 100644 index 000000000..17aaa9136 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetRowBuffer.java @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import net.snowflake.client.jdbc.internal.google.common.collect.Sets; +import net.snowflake.ingest.connection.TelemetryService; +import net.snowflake.ingest.streaming.OffsetTokenVerificationFunction; +import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; +import org.apache.parquet.hadoop.BdecParquetWriter; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; + +/** + * The buffer in the Streaming Ingest channel that holds the un-flushed rows, these rows will be + * converted to Parquet format for faster processing + */ +public class ParquetRowBuffer extends AbstractRowBuffer { + private static final String PARQUET_MESSAGE_TYPE_NAME = "bdec"; + + private final Map fieldIndex; + + /* map that contains metadata like typeinfo for columns and other information needed by the server scanner */ + private final Map metadata; + + /* Unflushed rows as Java objects. Needed for the Parquet w/o memory optimization. */ + private final List> data; + + /* BDEC Parquet writer. It is used to buffer unflushed data in Parquet internal buffers instead of using Java objects */ + private BdecParquetWriter bdecParquetWriter; + + private ByteArrayOutputStream fileOutput; + private final List> tempData; + + private MessageType schema; + + /** Construct a ParquetRowBuffer object. */ + ParquetRowBuffer( + OpenChannelRequest.OnErrorOption onErrorOption, + ZoneId defaultTimezone, + String fullyQualifiedChannelName, + Consumer rowSizeMetric, + ChannelRuntimeState channelRuntimeState, + ClientBufferParameters clientBufferParameters, + OffsetTokenVerificationFunction offsetTokenVerificationFunction, + TelemetryService telemetryService) { + super( + onErrorOption, + defaultTimezone, + fullyQualifiedChannelName, + rowSizeMetric, + channelRuntimeState, + clientBufferParameters, + offsetTokenVerificationFunction, + telemetryService); + this.fieldIndex = new HashMap<>(); + this.metadata = new HashMap<>(); + this.data = new ArrayList<>(); + this.tempData = new ArrayList<>(); + } + + @Override + public void setupSchema(List columns) { + fieldIndex.clear(); + metadata.clear(); + metadata.put("sfVer", "1,1"); + List parquetTypes = new ArrayList<>(); + int id = 1; + for (ColumnMetadata column : columns) { + validateColumnCollation(column); + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(column, id); + parquetTypes.add(typeInfo.getParquetType()); + this.metadata.putAll(typeInfo.getMetadata()); + int columnIndex = parquetTypes.size() - 1; + fieldIndex.put( + column.getInternalName(), + new ParquetColumn(column, columnIndex, typeInfo.getPrimitiveTypeName())); + if (!column.getNullable()) { + addNonNullableFieldName(column.getInternalName()); + } + this.statsMap.put( + column.getInternalName(), + new RowBufferStats(column.getName(), column.getCollation(), column.getOrdinal())); + + if (onErrorOption == OpenChannelRequest.OnErrorOption.ABORT + || onErrorOption == OpenChannelRequest.OnErrorOption.SKIP_BATCH) { + this.tempStatsMap.put( + column.getInternalName(), + new RowBufferStats(column.getName(), column.getCollation(), column.getOrdinal())); + } + + id++; + } + schema = new MessageType(PARQUET_MESSAGE_TYPE_NAME, parquetTypes); + createFileWriter(); + tempData.clear(); + data.clear(); + } + + /** Create BDEC file writer if Parquet memory optimization is enabled. */ + private void createFileWriter() { + fileOutput = new ByteArrayOutputStream(); + try { + if (clientBufferParameters.getEnableParquetInternalBuffering()) { + bdecParquetWriter = + new BdecParquetWriter( + fileOutput, + schema, + metadata, + channelFullyQualifiedName, + clientBufferParameters.getMaxChunkSizeInBytes(), + clientBufferParameters.getBdecParquetCompression()); + } else { + this.bdecParquetWriter = null; + } + data.clear(); + } catch (IOException e) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "cannot create parquet writer", e); + } + } + + @Override + boolean hasColumn(String name) { + return fieldIndex.containsKey(name); + } + + @Override + float addRow( + Map row, + int bufferedRowIndex, + Map statsMap, + Set formattedInputColumnNames, + final long insertRowIndex) { + return addRow(row, this::writeRow, statsMap, formattedInputColumnNames, insertRowIndex); + } + + void writeRow(List row) { + if (clientBufferParameters.getEnableParquetInternalBuffering()) { + bdecParquetWriter.writeRow(row); + } else { + data.add(row); + } + } + + @Override + float addTempRow( + Map row, + int curRowIndex, + Map statsMap, + Set formattedInputColumnNames, + long insertRowIndex) { + return addRow(row, tempData::add, statsMap, formattedInputColumnNames, insertRowIndex); + } + + /** + * Adds a row to the parquet buffer. + * + * @param row row to add + * @param out internal buffer to add to + * @param statsMap column stats map + * @param inputColumnNames list of input column names after formatting + * @param insertRowsCurrIndex Row index of the input Rows passed in {@link + * net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel#insertRows(Iterable, + * String)} + * @return row size + */ + private float addRow( + Map row, + Consumer> out, + Map statsMap, + Set inputColumnNames, + long insertRowsCurrIndex) { + Object[] indexedRow = new Object[fieldIndex.size()]; + float size = 0F; + + // Create new empty stats just for the current row. + Map forkedStatsMap = new HashMap<>(); + + for (Map.Entry entry : row.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + String columnName = LiteralQuoteUtils.unquoteColumnName(key); + ParquetColumn parquetColumn = fieldIndex.get(columnName); + int colIndex = parquetColumn.index; + RowBufferStats forkedStats = statsMap.get(columnName).forkEmpty(); + forkedStatsMap.put(columnName, forkedStats); + ColumnMetadata column = parquetColumn.columnMetadata; + ParquetValueParser.ParquetBufferValue valueWithSize = + ParquetValueParser.parseColumnValueToParquet( + value, column, parquetColumn.type, forkedStats, defaultTimezone, insertRowsCurrIndex); + indexedRow[colIndex] = valueWithSize.getValue(); + size += valueWithSize.getSize(); + } + + long rowSizeRoundedUp = Double.valueOf(Math.ceil(size)).longValue(); + + if (rowSizeRoundedUp > clientBufferParameters.getMaxAllowedRowSizeInBytes()) { + throw new SFException( + ErrorCode.MAX_ROW_SIZE_EXCEEDED, + String.format( + "rowSizeInBytes:%.3f, maxAllowedRowSizeInBytes:%d, rowIndex:%d", + size, clientBufferParameters.getMaxAllowedRowSizeInBytes(), insertRowsCurrIndex)); + } + + out.accept(Arrays.asList(indexedRow)); + + // All input values passed validation, iterate over the columns again and combine their existing + // statistics with the forked statistics for the current row. + for (Map.Entry forkedColStats : forkedStatsMap.entrySet()) { + String columnName = forkedColStats.getKey(); + statsMap.put( + columnName, + RowBufferStats.getCombinedStats(statsMap.get(columnName), forkedColStats.getValue())); + } + + // Increment null count for column missing in the input map + for (String columnName : Sets.difference(this.fieldIndex.keySet(), inputColumnNames)) { + statsMap.get(columnName).incCurrentNullCount(); + } + return size; + } + + @Override + void moveTempRowsToActualBuffer(int tempRowCount) { + tempData.forEach(this::writeRow); + } + + @Override + void clearTempRows() { + tempData.clear(); + } + + @Override + boolean hasColumns() { + return !fieldIndex.isEmpty(); + } + + @Override + Optional getSnapshot(final String filePath) { + // We insert the filename in the file itself as metadata so that streams can work on replicated + // mixed tables. For a more detailed discussion on the topic see SNOW-561447 and + // http://go/streams-on-replicated-mixed-tables + metadata.put(Constants.PRIMARY_FILE_ID_KEY, StreamingIngestUtils.getShortname(filePath)); + + List> oldData = new ArrayList<>(); + if (!clientBufferParameters.getEnableParquetInternalBuffering()) { + data.forEach(r -> oldData.add(new ArrayList<>(r))); + } + return bufferedRowCount <= 0 + ? Optional.empty() + : Optional.of(new ParquetChunkData(oldData, bdecParquetWriter, fileOutput, metadata)); + } + + /** Used only for testing. */ + @Override + Object getVectorValueAt(String column, int index) { + if (data == null) { + return null; + } + int colIndex = fieldIndex.get(column).index; + Object value = data.get(index).get(colIndex); + ColumnMetadata columnMetadata = fieldIndex.get(column).columnMetadata; + String physicalTypeStr = columnMetadata.getPhysicalType(); + ColumnPhysicalType physicalType = ColumnPhysicalType.valueOf(physicalTypeStr); + String logicalTypeStr = columnMetadata.getLogicalType(); + ColumnLogicalType logicalType = ColumnLogicalType.valueOf(logicalTypeStr); + if (logicalType == ColumnLogicalType.FIXED) { + if (physicalType == ColumnPhysicalType.SB1) { + value = ((Integer) value).byteValue(); + } + if (physicalType == ColumnPhysicalType.SB2) { + value = ((Integer) value).shortValue(); + } + if (physicalType == ColumnPhysicalType.SB16) { + value = new BigDecimal(new BigInteger((byte[]) value), columnMetadata.getScale()); + } + } + if (logicalType == ColumnLogicalType.BINARY && value != null) { + value = value instanceof String ? ((String) value).getBytes(StandardCharsets.UTF_8) : value; + } + return value; + } + + @Override + int getTempRowCount() { + return tempData.size(); + } + + @Override + void reset() { + super.reset(); + createFileWriter(); + data.clear(); + } + + /** Close the row buffer by releasing its internal resources. */ + @Override + void closeInternal() { + if (bdecParquetWriter != null) { + try { + bdecParquetWriter.close(); + } catch (IOException e) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "Failed to close parquet writer", e); + } + } + } + + @Override + public Flusher createFlusher() { + return new ParquetFlusher( + schema, + clientBufferParameters.getEnableParquetInternalBuffering(), + clientBufferParameters.getMaxChunkSizeInBytes(), + clientBufferParameters.getBdecParquetCompression()); + } + + private static class ParquetColumn { + final ColumnMetadata columnMetadata; + final int index; + final PrimitiveType.PrimitiveTypeName type; + + private ParquetColumn( + ColumnMetadata columnMetadata, int index, PrimitiveType.PrimitiveTypeName type) { + this.columnMetadata = columnMetadata; + this.index = index; + this.type = type; + } + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ParquetTypeGenerator.java b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetTypeGenerator.java new file mode 100644 index 000000000..c6d0e87c3 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetTypeGenerator.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.apache.parquet.schema.Types; + +/** Generates the Parquet types for the Snowflake's column types */ +public class ParquetTypeGenerator { + + /** + * Util class that contains Parquet type and other metadata for that type needed by the Snowflake + * server side scanner + */ + static class ParquetTypeInfo { + private Type parquetType; + private Map metadata; + + public Type getParquetType() { + return this.parquetType; + } + + public Map getMetadata() { + return this.metadata; + } + + public void setParquetType(Type parquetType) { + this.parquetType = parquetType; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + public PrimitiveType.PrimitiveTypeName getPrimitiveTypeName() { + return parquetType.asPrimitiveType().getPrimitiveTypeName(); + } + } + + private static final Set TIME_SUPPORTED_PHYSICAL_TYPES = + new HashSet<>( + Arrays.asList( + AbstractRowBuffer.ColumnPhysicalType.SB4, AbstractRowBuffer.ColumnPhysicalType.SB8)); + private static final Set + TIMESTAMP_SUPPORTED_PHYSICAL_TYPES = + new HashSet<>( + Arrays.asList( + AbstractRowBuffer.ColumnPhysicalType.SB8, + AbstractRowBuffer.ColumnPhysicalType.SB16)); + + /** + * Generate the column parquet type and metadata from the column metadata received from server + * side. + * + * @param column column metadata as received from server side + * @param id column id if column.getOrdinal() is not available + * @return column parquet type + */ + static ParquetTypeInfo generateColumnParquetTypeInfo(ColumnMetadata column, int id) { + id = column.getOrdinal() == null ? id : column.getOrdinal(); + ParquetTypeInfo res = new ParquetTypeInfo(); + Type parquetType; + Map metadata = new HashMap<>(); + String name = column.getInternalName(); + + AbstractRowBuffer.ColumnPhysicalType physicalType; + AbstractRowBuffer.ColumnLogicalType logicalType; + try { + physicalType = AbstractRowBuffer.ColumnPhysicalType.valueOf(column.getPhysicalType()); + logicalType = AbstractRowBuffer.ColumnLogicalType.valueOf(column.getLogicalType()); + } catch (IllegalArgumentException e) { + throw new SFException( + ErrorCode.UNKNOWN_DATA_TYPE, column.getLogicalType(), column.getPhysicalType()); + } + + metadata.put(Integer.toString(id), logicalType.getOrdinal() + "," + physicalType.getOrdinal()); + + // Parquet Type.Repetition in general supports repeated values for the same row column, like a + // list of values. + // This generator uses only either 0 or 1 value for nullable data type (OPTIONAL: 0 or none + // value if it is null) + // or exactly 1 value for non-nullable data type (REQUIRED) + Type.Repetition repetition = + column.getNullable() ? Type.Repetition.OPTIONAL : Type.Repetition.REQUIRED; + + // Handle differently depends on the column logical and physical types + switch (logicalType) { + case FIXED: + parquetType = getFixedColumnParquetType(column, id, physicalType, repetition); + break; + case ARRAY: + case OBJECT: + case VARIANT: + // mark the column metadata as being an object json for the server side scanner + metadata.put(id + ":obj_enc", "1"); + // parquetType is same as the next one + case ANY: + case CHAR: + case TEXT: + case BINARY: + parquetType = + Types.primitive(PrimitiveType.PrimitiveTypeName.BINARY, repetition) + .as(LogicalTypeAnnotation.stringType()) + .id(id) + .named(name); + break; + case TIMESTAMP_LTZ: + case TIMESTAMP_NTZ: + case TIMESTAMP_TZ: + parquetType = + getTimeColumnParquetType( + column.getScale(), + physicalType, + logicalType, + TIMESTAMP_SUPPORTED_PHYSICAL_TYPES, + repetition, + id, + name); + break; + case DATE: + parquetType = + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, repetition) + .as(LogicalTypeAnnotation.dateType()) + .id(id) + .named(name); + break; + case TIME: + parquetType = + getTimeColumnParquetType( + column.getScale(), + physicalType, + logicalType, + TIME_SUPPORTED_PHYSICAL_TYPES, + repetition, + id, + name); + break; + case BOOLEAN: + parquetType = + Types.primitive(PrimitiveType.PrimitiveTypeName.BOOLEAN, repetition).id(id).named(name); + break; + case REAL: + parquetType = + Types.primitive(PrimitiveType.PrimitiveTypeName.DOUBLE, repetition).id(id).named(name); + break; + default: + throw new SFException( + ErrorCode.UNKNOWN_DATA_TYPE, column.getLogicalType(), column.getPhysicalType()); + } + res.setParquetType(parquetType); + res.setMetadata(metadata); + return res; + } + + /** + * Get the parquet type for column of Snowflake FIXED logical type. + * + * @param column column metadata + * @param id column id in Snowflake table schema + * @param physicalType Snowflake physical type of column + * @param repetition parquet repetition type of column + * @return column parquet type + */ + private static Type getFixedColumnParquetType( + ColumnMetadata column, + int id, + AbstractRowBuffer.ColumnPhysicalType physicalType, + Type.Repetition repetition) { + String name = column.getInternalName(); + // the LogicalTypeAnnotation.DecimalLogicalTypeAnnotation is used by server side scanner + // to discover data type scale and precision + LogicalTypeAnnotation.DecimalLogicalTypeAnnotation decimalLogicalTypeAnnotation = + column.getScale() != null && column.getPrecision() != null + ? LogicalTypeAnnotation.DecimalLogicalTypeAnnotation.decimalType( + column.getScale(), column.getPrecision()) + : null; + Type parquetType; + if ((column.getScale() != null && column.getScale() != 0) + || physicalType == AbstractRowBuffer.ColumnPhysicalType.SB16) { + parquetType = + Types.primitive(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, repetition) + .length(16) + .as(decimalLogicalTypeAnnotation) + .id(id) + .named(name); + } else { + switch (physicalType) { + case SB1: + case SB2: + case SB4: + parquetType = + Types.primitive(PrimitiveType.PrimitiveTypeName.INT32, repetition) + .as(decimalLogicalTypeAnnotation) + .id(id) + .length(4) + .named(name); + break; + case SB8: + parquetType = + Types.primitive(PrimitiveType.PrimitiveTypeName.INT64, repetition) + .as(decimalLogicalTypeAnnotation) + .id(id) + .length(8) + .named(name); + break; + default: + throw new SFException( + ErrorCode.UNKNOWN_DATA_TYPE, column.getLogicalType(), column.getPhysicalType()); + } + } + return parquetType; + } + + /** + * Get the parquet type for column of a Snowflake time logical type. + * + * @param scale column scale + * @param physicalType Snowflake physical type of column + * @param logicalType Snowflake logical type of column + * @param supportedPhysicalTypes supported Snowflake physical types for the given column + * @param repetition parquet repetition type of column + * @param id column id in Snowflake table schema + * @param name column name + * @return column parquet type + */ + private static Type getTimeColumnParquetType( + Integer scale, + AbstractRowBuffer.ColumnPhysicalType physicalType, + AbstractRowBuffer.ColumnLogicalType logicalType, + Set supportedPhysicalTypes, + Type.Repetition repetition, + int id, + String name) { + if (scale == null || scale > 9 || scale < 0 || !supportedPhysicalTypes.contains(physicalType)) { + throw new SFException( + ErrorCode.UNKNOWN_DATA_TYPE, + "Data type: " + logicalType + ", " + physicalType + ", scale: " + scale); + } + + PrimitiveType.PrimitiveTypeName type = getTimePrimitiveType(physicalType); + LogicalTypeAnnotation typeAnnotation; + int length; + switch (physicalType) { + case SB4: + typeAnnotation = LogicalTypeAnnotation.decimalType(scale, 9); + length = 4; + break; + case SB8: + typeAnnotation = LogicalTypeAnnotation.decimalType(scale, 18); + length = 8; + break; + case SB16: + typeAnnotation = LogicalTypeAnnotation.decimalType(scale, 38); + length = 16; + break; + default: + throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); + } + return Types.primitive(type, repetition).as(typeAnnotation).length(length).id(id).named(name); + } + + /** + * Get the parquet primitive type name for column of a Snowflake time logical type. + * + * @param physicalType Snowflake physical type of column + * @return column parquet primitive type name + */ + private static PrimitiveType.PrimitiveTypeName getTimePrimitiveType( + AbstractRowBuffer.ColumnPhysicalType physicalType) { + PrimitiveType.PrimitiveTypeName type; + switch (physicalType) { + case SB4: + type = PrimitiveType.PrimitiveTypeName.INT32; + break; + case SB8: + type = PrimitiveType.PrimitiveTypeName.INT64; + break; + case SB16: + type = PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY; + break; + default: + throw new UnsupportedOperationException("Time physical type: " + physicalType); + } + return type; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/ParquetValueParser.java b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetValueParser.java new file mode 100644 index 000000000..282a007d4 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/ParquetValueParser.java @@ -0,0 +1,428 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.time.ZoneId; +import java.util.Optional; +import javax.annotation.Nullable; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; +import net.snowflake.ingest.utils.Utils; +import org.apache.parquet.schema.PrimitiveType; + +/** Parses a user column value into Parquet internal representation for buffering. */ +class ParquetValueParser { + + // Parquet uses BitPacking to encode boolean, hence 1 bit per value + public static final float BIT_ENCODING_BYTE_LEN = 1.0f / 8; + + /** + * On average parquet needs 2 bytes / 8 values for the RLE+bitpack encoded definition level. + * + *
      + * There are two cases how definition level (0 for null values, 1 for non-null values) is + * encoded: + *
    • If there are at least 8 repeated values in a row, they are run-length encoded (length + + * value itself). E.g. 11111111 -> 8 1 + *
    • If there are less than 8 repeated values, they are written in group as part of a + * bit-length encoded run, e.g. 1111 -> 15 A bit-length encoded run ends when either 64 + * groups of 8 values have been written or if a new RLE run starts. + *

      To distinguish between RLE and bitpack run, there is 1 extra bytes written as header + * when a bitpack run starts. + *

    + * + *
      + * For more details see ColumnWriterV1#createDLWriter and {@link + * org.apache.parquet.column.values.rle.RunLengthBitPackingHybridEncoder#writeInt(int)} + *
    + * + *

    Since we don't have nested types, repetition level is always 0 and is not stored at all by + * Parquet. + */ + public static final float DEFINITION_LEVEL_ENCODING_BYTE_LEN = 2.0f / 8; + + // Parquet stores length in 4 bytes before the actual data bytes + public static final int BYTE_ARRAY_LENGTH_ENCODING_BYTE_LEN = 4; + + /** Parquet internal value representation for buffering. */ + static class ParquetBufferValue { + private final Object value; + private final float size; + + ParquetBufferValue(Object value, float size) { + this.value = value; + this.size = size; + } + + Object getValue() { + return value; + } + + float getSize() { + return size; + } + } + + /** + * Parses a user column value into Parquet internal representation for buffering. + * + * @param value column value provided by user in a row + * @param columnMetadata column metadata + * @param typeName Parquet primitive type name + * @param stats column stats to update + * @param insertRowsCurrIndex Row index corresponding the row to parse (w.r.t input rows in + * insertRows API, and not buffered row) + * @return parsed value and byte size of Parquet internal representation + */ + static ParquetBufferValue parseColumnValueToParquet( + Object value, + ColumnMetadata columnMetadata, + PrimitiveType.PrimitiveTypeName typeName, + RowBufferStats stats, + ZoneId defaultTimezone, + long insertRowsCurrIndex) { + Utils.assertNotNull("Parquet column stats", stats); + float estimatedParquetSize = 0F; + estimatedParquetSize += DEFINITION_LEVEL_ENCODING_BYTE_LEN; + if (value != null) { + AbstractRowBuffer.ColumnLogicalType logicalType = + AbstractRowBuffer.ColumnLogicalType.valueOf(columnMetadata.getLogicalType()); + AbstractRowBuffer.ColumnPhysicalType physicalType = + AbstractRowBuffer.ColumnPhysicalType.valueOf(columnMetadata.getPhysicalType()); + switch (typeName) { + case BOOLEAN: + int intValue = + DataValidationUtil.validateAndParseBoolean( + columnMetadata.getName(), value, insertRowsCurrIndex); + value = intValue > 0; + stats.addIntValue(BigInteger.valueOf(intValue)); + estimatedParquetSize += BIT_ENCODING_BYTE_LEN; + break; + case INT32: + int intVal = + getInt32Value( + columnMetadata.getName(), + value, + columnMetadata.getScale(), + Optional.ofNullable(columnMetadata.getPrecision()).orElse(0), + logicalType, + physicalType, + insertRowsCurrIndex); + value = intVal; + stats.addIntValue(BigInteger.valueOf(intVal)); + estimatedParquetSize += 4; + break; + case INT64: + long longValue = + getInt64Value( + columnMetadata.getName(), + value, + columnMetadata.getScale(), + Optional.ofNullable(columnMetadata.getPrecision()).orElse(0), + logicalType, + physicalType, + defaultTimezone, + insertRowsCurrIndex); + value = longValue; + stats.addIntValue(BigInteger.valueOf(longValue)); + estimatedParquetSize += 8; + break; + case DOUBLE: + double doubleValue = + DataValidationUtil.validateAndParseReal( + columnMetadata.getName(), value, insertRowsCurrIndex); + value = doubleValue; + stats.addRealValue(doubleValue); + estimatedParquetSize += 8; + break; + case BINARY: + int length = 0; + if (logicalType == AbstractRowBuffer.ColumnLogicalType.BINARY) { + value = + getBinaryValueForLogicalBinary(value, stats, columnMetadata, insertRowsCurrIndex); + length = ((byte[]) value).length; + } else { + String str = getBinaryValue(value, stats, columnMetadata, insertRowsCurrIndex); + value = str; + if (str != null) { + length = str.getBytes().length; + } + } + if (value != null) { + estimatedParquetSize += (BYTE_ARRAY_LENGTH_ENCODING_BYTE_LEN + length); + } + + break; + case FIXED_LEN_BYTE_ARRAY: + BigInteger intRep = + getSb16Value( + columnMetadata.getName(), + value, + columnMetadata.getScale(), + Optional.ofNullable(columnMetadata.getPrecision()).orElse(0), + logicalType, + physicalType, + defaultTimezone, + insertRowsCurrIndex); + stats.addIntValue(intRep); + value = getSb16Bytes(intRep); + estimatedParquetSize += 16; + break; + default: + throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); + } + } + + if (value == null) { + if (!columnMetadata.getNullable()) { + throw new SFException( + ErrorCode.INVALID_FORMAT_ROW, + columnMetadata.getName(), + "Passed null to non nullable field"); + } + stats.incCurrentNullCount(); + } + + return new ParquetBufferValue(value, estimatedParquetSize); + } + + /** + * Parses an int32 value based on Snowflake logical type. + * + * @param value column value provided by user in a row + * @param scale data type scale + * @param precision data type precision + * @param logicalType Snowflake logical type + * @param physicalType Snowflake physical type + * @param insertRowsCurrIndex Used for logging the row of index given in insertRows API + * @return parsed int32 value + */ + private static int getInt32Value( + String columnName, + Object value, + @Nullable Integer scale, + Integer precision, + AbstractRowBuffer.ColumnLogicalType logicalType, + AbstractRowBuffer.ColumnPhysicalType physicalType, + final long insertRowsCurrIndex) { + int intVal; + switch (logicalType) { + case DATE: + intVal = DataValidationUtil.validateAndParseDate(columnName, value, insertRowsCurrIndex); + break; + case TIME: + Utils.assertNotNull("Unexpected null scale for TIME data type", scale); + intVal = + DataValidationUtil.validateAndParseTime(columnName, value, scale, insertRowsCurrIndex) + .intValue(); + break; + case FIXED: + BigDecimal bigDecimalValue = + DataValidationUtil.validateAndParseBigDecimal(columnName, value, insertRowsCurrIndex); + bigDecimalValue = bigDecimalValue.setScale(scale, RoundingMode.HALF_UP); + DataValidationUtil.checkValueInRange( + bigDecimalValue, scale, precision, insertRowsCurrIndex); + intVal = bigDecimalValue.intValue(); + break; + default: + throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); + } + return intVal; + } + + /** + * Parses an int64 value based on Snowflake logical type. + * + * @param value column value provided by user in a row + * @param scale data type scale + * @param precision data type precision + * @param logicalType Snowflake logical type + * @param physicalType Snowflake physical type + * @return parsed int64 value + */ + private static long getInt64Value( + String columnName, + Object value, + int scale, + int precision, + AbstractRowBuffer.ColumnLogicalType logicalType, + AbstractRowBuffer.ColumnPhysicalType physicalType, + ZoneId defaultTimezone, + final long insertRowsCurrIndex) { + long longValue; + switch (logicalType) { + case TIME: + Utils.assertNotNull("Unexpected null scale for TIME data type", scale); + longValue = + DataValidationUtil.validateAndParseTime(columnName, value, scale, insertRowsCurrIndex) + .longValue(); + break; + case TIMESTAMP_LTZ: + case TIMESTAMP_NTZ: + boolean trimTimezone = logicalType == AbstractRowBuffer.ColumnLogicalType.TIMESTAMP_NTZ; + longValue = + DataValidationUtil.validateAndParseTimestamp( + columnName, value, scale, defaultTimezone, trimTimezone, insertRowsCurrIndex) + .toBinary(false) + .longValue(); + break; + case TIMESTAMP_TZ: + longValue = + DataValidationUtil.validateAndParseTimestamp( + columnName, value, scale, defaultTimezone, false, insertRowsCurrIndex) + .toBinary(true) + .longValue(); + break; + case FIXED: + BigDecimal bigDecimalValue = + DataValidationUtil.validateAndParseBigDecimal(columnName, value, insertRowsCurrIndex); + bigDecimalValue = bigDecimalValue.setScale(scale, RoundingMode.HALF_UP); + DataValidationUtil.checkValueInRange( + bigDecimalValue, scale, precision, insertRowsCurrIndex); + longValue = bigDecimalValue.longValue(); + break; + default: + throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); + } + return longValue; + } + + /** + * Parses an int128 value based on Snowflake logical type. + * + * @param value column value provided by user in a row + * @param scale data type scale + * @param precision data type precision + * @param logicalType Snowflake logical type + * @param physicalType Snowflake physical type + * @return parsed int64 value + */ + private static BigInteger getSb16Value( + String columnName, + Object value, + int scale, + int precision, + AbstractRowBuffer.ColumnLogicalType logicalType, + AbstractRowBuffer.ColumnPhysicalType physicalType, + ZoneId defaultTimezone, + final long insertRowsCurrIndex) { + switch (logicalType) { + case TIMESTAMP_TZ: + return DataValidationUtil.validateAndParseTimestamp( + columnName, value, scale, defaultTimezone, false, insertRowsCurrIndex) + .toBinary(true); + case TIMESTAMP_LTZ: + case TIMESTAMP_NTZ: + boolean trimTimezone = logicalType == AbstractRowBuffer.ColumnLogicalType.TIMESTAMP_NTZ; + return DataValidationUtil.validateAndParseTimestamp( + columnName, value, scale, defaultTimezone, trimTimezone, insertRowsCurrIndex) + .toBinary(false); + case FIXED: + BigDecimal bigDecimalValue = + DataValidationUtil.validateAndParseBigDecimal(columnName, value, insertRowsCurrIndex); + // explicitly match the BigDecimal input scale with the Snowflake data type scale + bigDecimalValue = bigDecimalValue.setScale(scale, RoundingMode.HALF_UP); + DataValidationUtil.checkValueInRange( + bigDecimalValue, scale, precision, insertRowsCurrIndex); + return bigDecimalValue.unscaledValue(); + default: + throw new SFException(ErrorCode.UNKNOWN_DATA_TYPE, logicalType, physicalType); + } + } + + /** + * Converts an int128 value to its byte array representation. + * + * @param intRep int128 value + * @return byte array representation + */ + static byte[] getSb16Bytes(BigInteger intRep) { + byte[] bytes = intRep.toByteArray(); + byte padByte = (byte) (bytes[0] < 0 ? -1 : 0); + byte[] bytesBE = new byte[16]; + for (int i = 0; i < 16 - bytes.length; i++) { + bytesBE[i] = padByte; + } + System.arraycopy(bytes, 0, bytesBE, 16 - bytes.length, bytes.length); + return bytesBE; + } + + /** + * Converts an object or string to its byte array representation. + * + * @param value value to parse + * @param stats column stats to update + * @param columnMetadata column metadata + * @param insertRowsCurrIndex Used for logging the row of index given in insertRows API + * @return string representation + */ + private static String getBinaryValue( + Object value, + RowBufferStats stats, + ColumnMetadata columnMetadata, + final long insertRowsCurrIndex) { + AbstractRowBuffer.ColumnLogicalType logicalType = + AbstractRowBuffer.ColumnLogicalType.valueOf(columnMetadata.getLogicalType()); + String str; + if (logicalType.isObject()) { + switch (logicalType) { + case OBJECT: + str = + DataValidationUtil.validateAndParseObject( + columnMetadata.getName(), value, insertRowsCurrIndex); + break; + case VARIANT: + str = + DataValidationUtil.validateAndParseVariant( + columnMetadata.getName(), value, insertRowsCurrIndex); + break; + case ARRAY: + str = + DataValidationUtil.validateAndParseArray( + columnMetadata.getName(), value, insertRowsCurrIndex); + break; + default: + throw new SFException( + ErrorCode.UNKNOWN_DATA_TYPE, logicalType, columnMetadata.getPhysicalType()); + } + } else { + String maxLengthString = columnMetadata.getLength().toString(); + str = + DataValidationUtil.validateAndParseString( + columnMetadata.getName(), + value, + Optional.of(maxLengthString).map(Integer::parseInt), + insertRowsCurrIndex); + stats.addStrValue(str); + } + return str; + } + /** + * Converts a binary value to its byte array representation. + * + * @param value value to parse + * @param stats column stats to update + * @param columnMetadata column metadata + * @return byte array representation + */ + private static byte[] getBinaryValueForLogicalBinary( + Object value, + RowBufferStats stats, + ColumnMetadata columnMetadata, + final long insertRowsCurrIndex) { + String maxLengthString = columnMetadata.getByteLength().toString(); + byte[] bytes = + DataValidationUtil.validateAndParseBinary( + columnMetadata.getName(), + value, + Optional.of(maxLengthString).map(Integer::parseInt), + insertRowsCurrIndex); + stats.addBinaryValue(bytes); + return bytes; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/RegisterBlobResponse.java b/src/main/java/net/snowflake/ingest/streaming/internal/RegisterBlobResponse.java index 6c4baad66..bcfae3355 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/RegisterBlobResponse.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/RegisterBlobResponse.java @@ -8,7 +8,7 @@ import java.util.List; /** Response to the RegisterBlobRequest */ -class RegisterBlobResponse { +class RegisterBlobResponse extends StreamingIngestResponse { private Long statusCode; private String message; private List blobsStatus; diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/RegisterService.java b/src/main/java/net/snowflake/ingest/streaming/internal/RegisterService.java index fa663d517..0be99b821 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/RegisterService.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/RegisterService.java @@ -4,8 +4,8 @@ package net.snowflake.ingest.streaming.internal; -import static net.snowflake.ingest.utils.Constants.BLOB_UPLOAD_MAX_RETRY_COUNT; import static net.snowflake.ingest.utils.Constants.BLOB_UPLOAD_TIMEOUT_IN_SEC; +import static net.snowflake.ingest.utils.Utils.getStackTrace; import com.codahale.metrics.Timer; import java.util.ArrayList; @@ -16,6 +16,7 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; import net.snowflake.ingest.utils.Logging; import net.snowflake.ingest.utils.Pair; import net.snowflake.ingest.utils.Utils; @@ -23,18 +24,20 @@ /** * Register one or more blobs to the targeted Snowflake table, it will be done using the dedicated * thread in order to maintain ordering per channel + * + * @param type of column data ({@link ParquetChunkData}) */ -class RegisterService { +class RegisterService { private static final Logging logger = new Logging(RegisterService.class); // Reference to the client that owns this register service - private final SnowflakeStreamingIngestClientInternal owningClient; + private final SnowflakeStreamingIngestClientInternal owningClient; // Contains one or more blob metadata that will be registered to Snowflake // The key is the BlobData object, and the value is the BlobMetadata future that will complete // when the blob is uploaded successfully - private final List>> blobsList; + private final List, CompletableFuture>> blobsList; // Lock to protect the read/write access of m_blobsList private final Lock blobsListLock; @@ -47,7 +50,7 @@ class RegisterService { * * @param client */ - RegisterService(SnowflakeStreamingIngestClientInternal client, boolean isTestMode) { + RegisterService(SnowflakeStreamingIngestClientInternal client, boolean isTestMode) { this.owningClient = client; this.blobsList = new ArrayList<>(); this.blobsListLock = new ReentrantLock(); @@ -59,7 +62,7 @@ class RegisterService { * * @param blobs */ - void addBlobs(List>> blobs) { + void addBlobs(List, CompletableFuture>> blobs) { if (!blobs.isEmpty()) { this.blobsListLock.lock(); try { @@ -75,14 +78,14 @@ void addBlobs(List>> * the ordering is maintained across independent blobs in the same channel. * * @param latencyTimerContextMap the map that stores the latency timer for each blob - * @return a list of blob names that has errors during registration + * @return a list of blob names that have errors during registration */ - List registerBlobs(Map latencyTimerContextMap) { - List errorBlobs = new ArrayList<>(); + List> registerBlobs(Map latencyTimerContextMap) { + List> errorBlobs = new ArrayList<>(); if (!this.blobsList.isEmpty()) { // Will skip and try again later if someone else is holding the lock if (this.blobsListLock.tryLock()) { - List>> oldList = null; + List, CompletableFuture>> oldList = null; try { // Create a copy of the list when we have the lock, and then release the lock to unblock // other writers while we work on the old list @@ -92,25 +95,44 @@ List registerBlobs(Map latencyTime this.blobsListLock.unlock(); } - // If no exception, we will register all blobs in the blob list - // If hitting non-timeout exception, we will log it and continue - // If hitting timeout exception, we will register all the previous blobs in the blob list - // and retry until the hitting BLOB_UPLOAD_MAX_RETRY_COUNT + // In order to guarantee fairness between the time spent on waiting blob uploading VS blob + // registering and make sure the delay on server side commit is relatively small: + // 1. If no exception and total time is less than or equal to + // BLOB_UPLOAD_TIMEOUT_IN_SEC * 2, we will wait for all blobs to be uploaded and then + // register them + // 2. If no exception and total time is bigger than BLOB_UPLOAD_TIMEOUT_IN_SEC * 2, we + // will break the waiting and register the processed blob first + // 3. If hitting non-timeout exception, we will skip the current blob and continue on + // processing next blob + // 4. If hitting timeout exception, we will break the waiting if we haven't reached + // BLOB_UPLOAD_TIMEOUT_IN_SEC * BLOB_UPLOAD_MAX_RETRY_COUNT and register the + // processed blob first. Otherwise, we will skip the current blob and continue on processing + // next blob if time out has been reached. int idx = 0; int retry = 0; + logger.logDebug( + "Start loop outer for uploading blobs={}", + oldList.stream().map(blob -> blob.getKey().getPath()).collect(Collectors.toList())); while (idx < oldList.size()) { List blobs = new ArrayList<>(); - while (idx < oldList.size()) { - Pair> futureBlob = + long startTime = System.currentTimeMillis(); + logger.logDebug( + "Start loop inner for uploading blobs, size={}, idx={}", oldList.size(), idx); + while (idx < oldList.size() + && System.currentTimeMillis() - startTime + <= TimeUnit.SECONDS.toMillis(BLOB_UPLOAD_TIMEOUT_IN_SEC * 2)) { + Pair, CompletableFuture> futureBlob = oldList.get(idx); try { logger.logDebug( - "Start waiting on uploading blob={}", futureBlob.getKey().getFilePath()); + "Start waiting on uploading blob={}, idx={}", futureBlob.getKey().getPath(), idx); // Wait for uploading to finish BlobMetadata blob = futureBlob.getValue().get(BLOB_UPLOAD_TIMEOUT_IN_SEC, TimeUnit.SECONDS); logger.logDebug( - "Finish waiting on uploading blob={}", futureBlob.getKey().getFilePath()); + "Finish waiting on uploading blob={}, idx={}", + futureBlob.getKey().getPath(), + idx); if (blob != null) { blobs.add(blob); } @@ -123,18 +145,34 @@ List registerBlobs(Map latencyTime // blobs are generated before these channels got invalidated. // Retry logic for timeout exception only - if (e instanceof TimeoutException && retry < BLOB_UPLOAD_MAX_RETRY_COUNT) { + if (e instanceof TimeoutException + && retry + < this.owningClient.getParameterProvider().getBlobUploadMaxRetryCount()) { + logger.logInfo( + "Retry on waiting for uploading blob={}, idx={}", + futureBlob.getKey().getPath(), + idx); retry++; break; } - logger.logError( - "Building or uploading blob failed={}, exception={}, detail={}, cause={}," - + " detail={}, all channels in the blob will be invalidated", - futureBlob.getKey().getFilePath(), - e, - e.getMessage(), - e.getCause(), - e.getCause() == null ? null : e.getCause().getMessage()); + String errorMessage = + String.format( + "Building or uploading blob failed, client=%s, file=%s, exception=%s," + + " detail=%s, cause=%s, cause_detail=%s, cause_trace=%s all channels in" + + " the blob will be invalidated", + this.owningClient.getName(), + futureBlob.getKey().getPath(), + e, + e.getMessage(), + e.getCause(), + e.getCause() == null ? null : e.getCause().getMessage(), + getStackTrace(e.getCause())); + logger.logError(errorMessage); + if (this.owningClient.getTelemetryService() != null) { + this.owningClient + .getTelemetryService() + .reportClientFailure(this.getClass().getSimpleName(), errorMessage); + } this.owningClient .getFlushService() .invalidateAllChannelsInBlob(futureBlob.getKey().getData()); @@ -145,10 +183,17 @@ List registerBlobs(Map latencyTime } if (blobs.size() > 0 && !isTestMode) { + logger.logInfo( + "Start registering blobs in client={}, totalBlobListSize={}," + + " currentBlobListSize={}, idx={}", + this.owningClient.getName(), + oldList.size(), + blobs.size(), + idx); Timer.Context registerContext = Utils.createTimerContext(this.owningClient.registerLatency); - // Register the blobs, and invalidate any channels that returns a failure status code + // Register the blobs, and invalidate any channels that return a failure status code this.owningClient.registerBlobs(blobs); if (registerContext != null) { @@ -174,7 +219,7 @@ List registerBlobs(Map latencyTime * * @return the blobsList */ - List>> getBlobsList() { + List, CompletableFuture>> getBlobsList() { return this.blobsList; } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/RowBuffer.java b/src/main/java/net/snowflake/ingest/streaming/internal/RowBuffer.java new file mode 100644 index 000000000..02905c02e --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/RowBuffer.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import java.util.List; +import java.util.Map; +import net.snowflake.ingest.streaming.InsertValidationResponse; + +/** + * Interface for the buffer in the Streaming Ingest channel that holds the un-flushed rows, these + * rows will be converted to the underlying format implementation for faster processing + * + * @param type of column data ({@link ParquetChunkData}) + */ +interface RowBuffer { + /** + * Set up the column fields and vectors using the column metadata from the server + * + * @param columns list of column metadata + */ + void setupSchema(List columns); + + /** + * Insert a batch of rows into the row buffer + * + * @param rows input row + * @param startOffsetToken start offset token of the batch + * @param endOffsetToken offset token of the latest row in the batch + * @return insert response that possibly contains errors because of insertion failures + */ + InsertValidationResponse insertRows( + Iterable> rows, String startOffsetToken, String endOffsetToken); + + /** + * Flush the data in the row buffer by taking the ownership of the old vectors and pass all the + * required info back to the flush service to build the blob + * + * @param filePath the name of the file the data will be written in + * @return A ChannelData object that contains the info needed by the flush service to build a blob + */ + ChannelData flush(final String filePath); + + /** + * Close the row buffer and release resources. Note that the caller needs to handle + * synchronization + */ + void close(String name); + + /** + * Get the current buffer size + * + * @return the current buffer size + */ + float getSize(); + + /** + * Create {@link Flusher} implementation to flush the buffered rows to the underlying format + * implementation for faster processing. + * + * @return flusher + */ + Flusher createFlusher(); +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/RowBufferStats.java b/src/main/java/net/snowflake/ingest/streaming/internal/RowBufferStats.java index f490671a2..395123f1f 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/RowBufferStats.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/RowBufferStats.java @@ -4,306 +4,20 @@ package net.snowflake.ingest.streaming.internal; -import com.ibm.icu.lang.UCharacter; -import com.ibm.icu.text.CollationKey; -import com.ibm.icu.text.Collator; -import com.ibm.icu.util.ULocale; +import static net.snowflake.ingest.utils.Constants.EP_NDV_UNKNOWN; + import java.math.BigInteger; import java.nio.charset.StandardCharsets; -import java.util.Arrays; +import java.util.Objects; import net.snowflake.ingest.utils.ErrorCode; import net.snowflake.ingest.utils.SFException; /** Keeps track of the active EP stats, used to generate a file EP info */ class RowBufferStats { - private static class CollationDefinition { - /** The name of this collation */ - private String name; - - /** Derived ICU collation name */ - private String icuCollationString = null; - - /** Defines if a given collation trims strings on the left side */ - private boolean ltrim = false; - - /** Defines if a given collation trims strings on the right side */ - private boolean rtrim = false; - - /** Defines if a given collation applies "lower" before comparison */ - private boolean lower = false; - - /** Defines if a given collation applies "upper" before comparison */ - private boolean upper = false; - - /** If true or null, the collation is case sensitive */ - private Boolean caseSensitive = null; - - /** If true or null, the collation is accent sensitive */ - private Boolean accentSensitive = null; - - /** If true or null, the collation is punctuation sensitive */ - private Boolean punctuationSensitive = null; - - /** - * Defines if a given collation is forced, i.e. goes through the full collation pipeline even if - * not strictly needed (i.e. UTF8) - */ - private boolean forced = false; - - private static String LANG_UTF8 = "utf8"; - private static final String LANG_BIN = "bin"; - private static final String LANG_EMPTY = ""; - - /** Language, e.g. "en_EN". LANG_UTF8 is the default */ - private String language = LANG_UTF8; - - private Collator collator; - - /** Constructor - only called internally */ - private CollationDefinition(String name) { - name = name.toLowerCase(); - this.name = name; - - // Empty collation is UTF8, treat it as such - if (name.isEmpty()) { - name = LANG_UTF8; - } - - /** Language, e.g. "en_EN". LANG_UTF8 is the default */ - String lang = LANG_UTF8; - - // Variables used to construct the ICU string - boolean firstLower = false; - boolean firstLowerIsSet = false; - - // Parse the collation as a sequence of "-" divided options - String[] elements = name.split("-"); - assert (elements.length > 0); // empty string detected earlier - - // Then other options - for (int i = 0; i < elements.length; i++) { - switch (elements[i]) { - // ICU options - case "ci": - caseSensitive = false; - break; - case "cs": - caseSensitive = true; - break; - case "ai": - accentSensitive = false; - break; - case "as": - accentSensitive = true; - break; - case "pi": - punctuationSensitive = false; - break; - case "ps": - punctuationSensitive = true; - break; - case "fu": - firstLower = false; - firstLowerIsSet = true; - break; - case "fl": - firstLower = true; - firstLowerIsSet = true; - break; - // Snowflake-specific options - case "lower": - lower = true; - break; - case "upper": - upper = true; - break; - case "rtrim": - rtrim = true; - break; - case "ltrim": - ltrim = true; - break; - case "trim": - ltrim = rtrim = true; - break; - case "forced": - forced = true; - break; - default: - // Unknown element can be the language identificator, if it's the first one - if (i == 0) { - language = elements[0]; - } else { - throw new SFException( - ErrorCode.INVALID_COLLATION_STRING, name, "Unknown option: " + elements[i]); - } - } - } - - /* Sanity checks */ - if (upper && lower) - throw new SFException( - ErrorCode.INVALID_COLLATION_STRING, - name, - "Options 'upper' and 'lower' are mutually exclusive"); - - if ((language == null - || language.equalsIgnoreCase(LANG_EMPTY) - || language.equalsIgnoreCase(LANG_UTF8) - || language.equalsIgnoreCase(LANG_BIN))) { - // Check we didn't provide any of the ICU-specific options - if (caseSensitive != null) - throw new SFException( - ErrorCode.INVALID_COLLATION_STRING, - name, - "Case sensitivity option not allowed for the UTF8 collation"); - if (accentSensitive != null) - throw new SFException( - ErrorCode.INVALID_COLLATION_STRING, - name, - "Accent sensitivity option not allowed for the UTF8 collation"); - if (firstLowerIsSet) - throw new SFException( - ErrorCode.INVALID_COLLATION_STRING, - name, - "Upper/lower preference option not allowed for the UTF8 collation"); - if (punctuationSensitive != null) - throw new SFException( - ErrorCode.INVALID_COLLATION_STRING, - name, - "Punctuation sensitivity option not allowed for the UTF8 collation"); - } else { - // Set the defaults - // NOTE: firstLower does not have a default value - if (caseSensitive == null) caseSensitive = true; - if (accentSensitive == null) accentSensitive = true; - if (punctuationSensitive == null) punctuationSensitive = true; - - // Construct an ICU string, e.g. "de@colStrength=primary;colCaseFirst=lower" - // This string will be used by COLLATE_TO_BINARY and others. - // See http://userguide.icu-project.org/collation/concepts#TOC-Collator-naming-scheme - - // We start with the language, and then construct a series of ";option=something". - // At the end, we'll switch the first ';' to '@' - String icu = language; - - /* - * From https://www.unicode.org/reports/tr35/tr35-collation.html#Collation_Element : - * 3.4.1 Common settings combinations - * Some commonly used parametric collation settings are available via combinations of LDML settings attributes: - * - “Ignore accents”: strength=primary - * - “Ignore accents” but take case into account: strength=primary caseLevel=on - * - “Ignore case”: strength=secondary - */ - if (!accentSensitive) { - icu += ";colStrength=primary"; - // In ICU, "primary" implies case-insensitive. - // If we're case sensitive, force it with "colCaseLevel" - icu += ";colCaseLevel=" + (caseSensitive ? "yes" : "no"); - } else if (!caseSensitive) { - icu += ";colStrength=secondary"; - } - if (firstLowerIsSet) { - icu += ";colCaseFirst=" + (firstLower ? "lower" : "upper"); - } - if (punctuationSensitive != null) { - icu += ";colAlternate=" + (punctuationSensitive ? "non-ignorable" : "shifted"); - } - - // Fix the first ";" to be "@" - this.icuCollationString = icu.replaceFirst(";", "@"); - } - if (this.icuCollationString != null) { - ULocale locale = new ULocale(icuCollationString); - collator = Collator.getInstance(locale); - } - } - - byte[] performConversion(String input) { - /* - * WARNING: the logic should be in sync with CollationEvaluator.cpp !!! - */ - if (ltrim || rtrim) { - // Apply trim - int inputIdx = 0; - int inputLen = input.length(); - - if (ltrim) { - while (inputLen > 0 && input.charAt(inputIdx) == ' ') { - inputLen--; - inputIdx++; - } - } - if (rtrim) { - while (inputLen > 0 && input.charAt(inputIdx + inputLen - 1) == ' ') { - inputLen--; - } - } - input = input.substring(inputIdx, inputIdx + inputLen); - } - // Handle upper/lower - if (upper) { - input = UCharacter.toUpperCase(input); - } - if (lower) { - input = UCharacter.toLowerCase(input); - } - - if (icuCollationString == null) { - // No need for ICU key generation, convert (modified) input to UTF-8 bytes - try { - return input.getBytes("UTF-8"); - } catch (java.io.UnsupportedEncodingException e) { - throw new SFException(ErrorCode.INTERNAL_ERROR, "Failed encoding string to UTF-8"); - } - } - - // ICU collation is needed - // Perform the conversion - CollationKey cltKey = collator.getCollationKey(input); - byte[] cltKeyBytes = cltKey.toByteArray(); - int cltKeySize = cltKeyBytes.length; - - if (cltKeySize > 0) { - // Get rid of the trailing 0 - if (cltKeyBytes[cltKeySize - 1] != 0) { - throw new SFException( - ErrorCode.INTERNAL_ERROR, "unexpected_non_zero_end_of_collated_key"); - } - cltKeySize -= 1; - - // Check if the produced key is all 0x01 - this can happen when we collate to an empty - // string, - // but the ICU still produces 0x01 dividers between various parts of the collation key. - // It's better to just treat these as an empty string. - int idx; - for (idx = 0; idx < cltKeySize; idx++) { - if (cltKeyBytes[idx] != 0x01) { - break; - } - } - - if (idx == cltKeySize) { - // Mark as an empty string - cltKeySize = 0; - } - - cltKeyBytes = Arrays.copyOf(cltKeyBytes, cltKeySize); - } - // Create an SFBinary - return cltKeyBytes; - } - } - - private String currentMinStrValue; - private String currentMaxStrValue; - private String currentMinColStrValue; - private String currentMaxColStrValue; - private byte[] currentMinColStrValueInBytes; - private byte[] currentMaxColStrValueInBytes; - private byte[] currentMinStrValueInBytes; - private byte[] currentMaxStrValueInBytes; + private final int ordinal; + private byte[] currentMinStrValue; + private byte[] currentMaxStrValue; private BigInteger currentMinIntValue; private BigInteger currentMaxIntValue; private Double currentMinRealValue; @@ -311,33 +25,25 @@ byte[] performConversion(String input) { private long currentNullCount; // for binary or string columns private long currentMaxLength; - private CollationDefinition collationDefinition; private final String collationDefinitionString; - - private static final int MAX_LOB_LEN = 32; + /** Display name is required for the registration endpoint */ + private final String columnDisplayName; /** Creates empty stats */ - RowBufferStats(String collationDefinitionString) { + RowBufferStats(String columnDisplayName, String collationDefinitionString, int ordinal) { + this.columnDisplayName = columnDisplayName; this.collationDefinitionString = collationDefinitionString; - if (collationDefinitionString != null) { - this.collationDefinition = new CollationDefinition(collationDefinitionString); - } + this.ordinal = ordinal; reset(); } - RowBufferStats() { - this(null); + RowBufferStats(String columnDisplayName) { + this(columnDisplayName, null, -1); } void reset() { this.currentMaxStrValue = null; this.currentMinStrValue = null; - this.currentMaxColStrValue = null; - this.currentMinColStrValue = null; - this.currentMaxColStrValueInBytes = null; - this.currentMinColStrValueInBytes = null; - this.currentMinStrValueInBytes = null; - this.currentMaxStrValueInBytes = null; this.currentMaxIntValue = null; this.currentMinIntValue = null; this.currentMaxRealValue = null; @@ -346,16 +52,15 @@ void reset() { this.currentMaxLength = 0; } - byte[] getCollatedBytes(String value) { - if (collationDefinition != null) { - return collationDefinition.performConversion(value); - } - return value.getBytes(StandardCharsets.UTF_8); + /** Create new statistics for the same column, with all calculated values set to empty */ + RowBufferStats forkEmpty() { + return new RowBufferStats( + this.getColumnDisplayName(), this.getCollationDefinitionString(), this.getOrdinal()); } // TODO performance test this vs in place update static RowBufferStats getCombinedStats(RowBufferStats left, RowBufferStats right) { - if (left.getCollationDefinitionString() != right.collationDefinitionString) { + if (!Objects.equals(left.getCollationDefinitionString(), right.collationDefinitionString)) { throw new SFException( ErrorCode.INVALID_COLLATION_STRING, "Tried to combine stats for different collations", @@ -363,7 +68,9 @@ static RowBufferStats getCombinedStats(RowBufferStats left, RowBufferStats right "left=%s, right=%s", left.getCollationDefinitionString(), right.getCollationDefinitionString())); } - RowBufferStats combined = new RowBufferStats(left.getCollationDefinitionString()); + RowBufferStats combined = + new RowBufferStats( + left.columnDisplayName, left.getCollationDefinitionString(), left.getOrdinal()); if (left.currentMinIntValue != null) { combined.addIntValue(left.currentMinIntValue); @@ -376,17 +83,13 @@ static RowBufferStats getCombinedStats(RowBufferStats left, RowBufferStats right } if (left.currentMinStrValue != null) { - combined.addStrValue(left.currentMinStrValue); - combined.addStrValue(left.currentMaxStrValue); - combined.addStrValue(left.currentMinColStrValue); - combined.addStrValue(left.currentMaxColStrValue); + combined.addBinaryValue(left.currentMinStrValue); + combined.addBinaryValue(left.currentMaxStrValue); } if (right.currentMinStrValue != null) { - combined.addStrValue(right.currentMinStrValue); - combined.addStrValue(right.currentMaxStrValue); - combined.addStrValue(right.currentMinColStrValue); - combined.addStrValue(right.currentMaxColStrValue); + combined.addBinaryValue(right.currentMinStrValue); + combined.addBinaryValue(right.currentMaxStrValue); } if (left.currentMinRealValue != null) { @@ -405,109 +108,36 @@ static RowBufferStats getCombinedStats(RowBufferStats left, RowBufferStats right return combined; } - void addStrValue(String inputValue) { - this.setCurrentMaxLength(inputValue.length()); - String value = - inputValue.length() > MAX_LOB_LEN ? inputValue.substring(0, MAX_LOB_LEN) : inputValue; + void addStrValue(String value) { + addBinaryValue(value.getBytes(StandardCharsets.UTF_8)); + } - byte[] valueBytes = value != null ? value.getBytes(StandardCharsets.UTF_8) : null; - byte[] collatedValueBytes = value != null ? getCollatedBytes(value) : null; + void addBinaryValue(byte[] valueBytes) { + this.setCurrentMaxLength(valueBytes.length); // Check if new min/max string if (this.currentMinStrValue == null) { - this.currentMinStrValue = value; - this.currentMinColStrValue = value; - this.currentMinStrValueInBytes = valueBytes; - this.currentMinColStrValueInBytes = collatedValueBytes; - - /* - Snowflake stores the first MAX_LOB_LEN characters of a string. - When truncating the max value, we increment the last max value - byte by one to ensure the max value stat is greater than the actual max value. - */ - if (inputValue.length() > MAX_LOB_LEN) { - byte[] incrementedValueBytes = valueBytes.clone(); - byte[] incrementedCollatedValueBytes = collatedValueBytes.clone(); - incrementedValueBytes[MAX_LOB_LEN - 1]++; - incrementedCollatedValueBytes[MAX_LOB_LEN - 1]++; - String incrementedValue = new String(incrementedValueBytes); - this.currentMaxStrValue = incrementedValue; - this.currentMaxColStrValue = incrementedValue; - this.currentMaxStrValueInBytes = incrementedValueBytes; - this.currentMaxColStrValueInBytes = incrementedCollatedValueBytes; - } else { - this.currentMaxStrValue = value; - this.currentMaxColStrValue = value; - this.currentMaxStrValueInBytes = valueBytes; - this.currentMaxColStrValueInBytes = collatedValueBytes; - } + this.currentMinStrValue = valueBytes; + this.currentMaxStrValue = valueBytes; } else { - // Non-collated comparison - if (compare(currentMinStrValueInBytes, valueBytes) > 0) { - this.currentMinStrValue = value; - this.currentMinStrValueInBytes = valueBytes; - } else if (compare(currentMaxStrValueInBytes, valueBytes) < 0) { - /* - Snowflake stores the first MAX_LOB_LEN characters of a string. - When truncating the max value, we increment the last max value - byte by one to ensure the max value stat is greater than the actual max value. - */ - if (inputValue.length() > MAX_LOB_LEN) { - byte[] incrementedValueBytes = valueBytes.clone(); - byte[] incrementedCollatedValueBytes = collatedValueBytes.clone(); - incrementedValueBytes[MAX_LOB_LEN - 1]++; - incrementedCollatedValueBytes[MAX_LOB_LEN - 1]++; - String incrementedValue = new String(incrementedValueBytes); - this.currentMaxStrValue = incrementedValue; - this.currentMaxStrValueInBytes = incrementedValueBytes; - } else { - this.currentMaxStrValue = value; - this.currentMaxStrValueInBytes = valueBytes; - } - } - - // Collated comparison - if (compare(currentMinColStrValueInBytes, collatedValueBytes) > 0) { - this.currentMinColStrValue = value; - this.currentMinColStrValueInBytes = collatedValueBytes; - } else if (compare(currentMaxColStrValueInBytes, collatedValueBytes) < 0) { - /* - Snowflake stores the first MAX_LOB_LEN characters of a string. - When truncating the max value, we increment the last max value - byte by one to ensure the max value stat is greater than the actual max value. - */ - if (inputValue.length() > MAX_LOB_LEN) { - byte[] incrementedValueBytes = valueBytes.clone(); - byte[] incrementedCollatedValueBytes = collatedValueBytes.clone(); - incrementedValueBytes[MAX_LOB_LEN - 1]++; - incrementedCollatedValueBytes[MAX_LOB_LEN - 1]++; - String incrementedValue = new String(incrementedValueBytes); - this.currentMaxColStrValue = incrementedValue; - this.currentMaxColStrValueInBytes = incrementedCollatedValueBytes; - } else { - this.currentMaxColStrValue = value; - this.currentMaxColStrValueInBytes = collatedValueBytes; - } + // Check if the input is less than the existing min + if (compareUnsigned(currentMinStrValue, valueBytes) > 0) { + this.currentMinStrValue = valueBytes; + // Check if the input is more than the existing max + } else if (compareUnsigned(currentMaxStrValue, valueBytes) < 0) { + this.currentMaxStrValue = valueBytes; } } } - String getCurrentMinStrValue() { + byte[] getCurrentMinStrValue() { return currentMinStrValue; } - String getCurrentMaxStrValue() { + byte[] getCurrentMaxStrValue() { return currentMaxStrValue; } - String getCurrentMinColStrValue() { - return currentMinColStrValue; - } - - String getCurrentMaxColStrValue() { - return currentMaxColStrValue; - } - void addIntValue(BigInteger value) { // Set new min/max value if (this.currentMinIntValue == null) { @@ -572,13 +202,21 @@ long getCurrentMaxLength() { * @return -1 indicating the NDV is unknown */ long getDistinctValues() { - return -1; + return EP_NDV_UNKNOWN; } String getCollationDefinitionString() { return collationDefinitionString; } + String getColumnDisplayName() { + return columnDisplayName; + } + + public int getOrdinal() { + return ordinal; + } + /** * Compares two byte arrays lexicographically. If the two arrays share a common prefix then the * lexicographic comparison is the result of comparing two elements, as if by Byte.compare(byte, @@ -593,12 +231,12 @@ String getCollationDefinitionString() { * second array; and a value greater than 0 if the first array is lexicographically greater * than the second array */ - static int compare(byte[] a, byte[] b) { + static int compareUnsigned(byte[] a, byte[] b) { if (a == b) return 0; for (int mismatchIdx = 0; mismatchIdx < Math.min(a.length, b.length); mismatchIdx++) { if (a[mismatchIdx] != b[mismatchIdx]) { - return Byte.compare(a[mismatchIdx], b[mismatchIdx]); + return Byte.toUnsignedInt(a[mismatchIdx]) - Byte.toUnsignedInt(b[mismatchIdx]); } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelFactory.java b/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelFactory.java index 470b48557..a56b82ed5 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelFactory.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelFactory.java @@ -4,17 +4,19 @@ package net.snowflake.ingest.streaming.internal; +import java.time.ZoneId; +import net.snowflake.ingest.streaming.OffsetTokenVerificationFunction; import net.snowflake.ingest.streaming.OpenChannelRequest; import net.snowflake.ingest.utils.Utils; /** Builds a Streaming Ingest channel for a specific Streaming Ingest client */ class SnowflakeStreamingIngestChannelFactory { - static SnowflakeStreamingIngestChannelBuilder builder(String name) { - return new SnowflakeStreamingIngestChannelBuilder(name); + static SnowflakeStreamingIngestChannelBuilder builder(String name) { + return new SnowflakeStreamingIngestChannelBuilder<>(name); } // Builder class to build a SnowflakeStreamingIngestChannel - static class SnowflakeStreamingIngestChannelBuilder { + static class SnowflakeStreamingIngestChannelBuilder { private String name; private String dbName; private String schemaName; @@ -22,68 +24,81 @@ static class SnowflakeStreamingIngestChannelBuilder { private String offsetToken; private Long channelSequencer; private Long rowSequencer; - private SnowflakeStreamingIngestClientInternal owningClient; + private SnowflakeStreamingIngestClientInternal owningClient; private String encryptionKey; private Long encryptionKeyId; private OpenChannelRequest.OnErrorOption onErrorOption; + private ZoneId defaultTimezone; + private OffsetTokenVerificationFunction offsetTokenVerificationFunction; private SnowflakeStreamingIngestChannelBuilder(String name) { this.name = name; } - SnowflakeStreamingIngestChannelBuilder setDBName(String dbName) { + SnowflakeStreamingIngestChannelBuilder setDBName(String dbName) { this.dbName = dbName; return this; } - SnowflakeStreamingIngestChannelBuilder setSchemaName(String schemaName) { + SnowflakeStreamingIngestChannelBuilder setSchemaName(String schemaName) { this.schemaName = schemaName; return this; } - SnowflakeStreamingIngestChannelBuilder setTableName(String tableName) { + SnowflakeStreamingIngestChannelBuilder setTableName(String tableName) { this.tableName = tableName; return this; } - SnowflakeStreamingIngestChannelBuilder setOffsetToken(String offsetToken) { + SnowflakeStreamingIngestChannelBuilder setOffsetToken(String offsetToken) { this.offsetToken = offsetToken; return this; } - SnowflakeStreamingIngestChannelBuilder setChannelSequencer(Long sequencer) { + SnowflakeStreamingIngestChannelBuilder setChannelSequencer(Long sequencer) { this.channelSequencer = sequencer; return this; } - SnowflakeStreamingIngestChannelBuilder setRowSequencer(Long sequencer) { + SnowflakeStreamingIngestChannelBuilder setRowSequencer(Long sequencer) { this.rowSequencer = sequencer; return this; } - SnowflakeStreamingIngestChannelBuilder setEncryptionKey(String encryptionKey) { + SnowflakeStreamingIngestChannelBuilder setEncryptionKey(String encryptionKey) { this.encryptionKey = encryptionKey; return this; } - SnowflakeStreamingIngestChannelBuilder setEncryptionKeyId(Long encryptionKeyId) { + SnowflakeStreamingIngestChannelBuilder setEncryptionKeyId(Long encryptionKeyId) { this.encryptionKeyId = encryptionKeyId; return this; } - SnowflakeStreamingIngestChannelBuilder setOnErrorOption( + SnowflakeStreamingIngestChannelBuilder setOnErrorOption( OpenChannelRequest.OnErrorOption onErrorOption) { this.onErrorOption = onErrorOption; return this; } - SnowflakeStreamingIngestChannelBuilder setOwningClient( - SnowflakeStreamingIngestClientInternal client) { + SnowflakeStreamingIngestChannelBuilder setDefaultTimezone(ZoneId zoneId) { + this.defaultTimezone = zoneId; + return this; + } + + SnowflakeStreamingIngestChannelBuilder setOwningClient( + SnowflakeStreamingIngestClientInternal client) { this.owningClient = client; return this; } - SnowflakeStreamingIngestChannelInternal build() { + SnowflakeStreamingIngestChannelBuilder setOffsetTokenVerificationFunction( + OffsetTokenVerificationFunction function) { + this.offsetTokenVerificationFunction = function; + return this; + } + + SnowflakeStreamingIngestChannelInternal build() { Utils.assertStringNotNullOrEmpty("channel name", this.name); Utils.assertStringNotNullOrEmpty("table name", this.tableName); Utils.assertStringNotNullOrEmpty("schema name", this.schemaName); @@ -94,7 +109,8 @@ SnowflakeStreamingIngestChannelInternal build() { Utils.assertStringNotNullOrEmpty("encryption key", this.encryptionKey); Utils.assertNotNull("encryption key_id", this.encryptionKeyId); Utils.assertNotNull("on_error option", this.onErrorOption); - return new SnowflakeStreamingIngestChannelInternal( + Utils.assertNotNull("default timezone", this.defaultTimezone); + return new SnowflakeStreamingIngestChannelInternal<>( this.name, this.dbName, this.schemaName, @@ -105,7 +121,10 @@ SnowflakeStreamingIngestChannelInternal build() { this.owningClient, this.encryptionKey, this.encryptionKeyId, - this.onErrorOption); + this.onErrorOption, + this.defaultTimezone, + this.owningClient.getParameterProvider().getBlobFormatVersion(), + this.offsetTokenVerificationFunction); } } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelInternal.java b/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelInternal.java index bd90dfcd5..578f8e6c1 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelInternal.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelInternal.java @@ -5,65 +5,58 @@ package net.snowflake.ingest.streaming.internal; import static net.snowflake.ingest.utils.Constants.INSERT_THROTTLE_MAX_RETRY_COUNT; -import static net.snowflake.ingest.utils.Constants.MAX_CHUNK_SIZE_IN_BYTES; +import static net.snowflake.ingest.utils.Constants.RESPONSE_SUCCESS; +import static net.snowflake.ingest.utils.ParameterProvider.MAX_MEMORY_LIMIT_IN_BYTES_DEFAULT; +import com.google.common.annotations.VisibleForTesting; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; +import javax.annotation.Nullable; +import net.snowflake.ingest.streaming.DropChannelRequest; import net.snowflake.ingest.streaming.InsertValidationResponse; +import net.snowflake.ingest.streaming.OffsetTokenVerificationFunction; import net.snowflake.ingest.streaming.OpenChannelRequest; import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; +import net.snowflake.ingest.utils.Constants; import net.snowflake.ingest.utils.ErrorCode; import net.snowflake.ingest.utils.Logging; import net.snowflake.ingest.utils.SFException; -import org.apache.arrow.memory.BufferAllocator; -import org.apache.arrow.memory.RootAllocator; +import net.snowflake.ingest.utils.Utils; -/** The first version of implementation for SnowflakeStreamingIngestChannel */ -class SnowflakeStreamingIngestChannelInternal implements SnowflakeStreamingIngestChannel { +/** + * The first version of implementation for SnowflakeStreamingIngestChannel + * + * @param type of column data {@link ParquetChunkData}) + */ +class SnowflakeStreamingIngestChannelInternal implements SnowflakeStreamingIngestChannel { private static final Logging logger = new Logging(SnowflakeStreamingIngestChannelInternal.class); - private final String channelName; - private final String dbName; - private final String schemaName; - private final String tableName; - private volatile String offsetToken; - private final AtomicLong rowSequencer; - - // Sequencer for this channel, corresponding to client sequencer at server side because each - // connection to a channel at server side will be seen as a connection from a new client - private final Long channelSequencer; + // this context contains channel immutable identification and encryption attributes + private final ChannelFlushContext channelFlushContext; // Reference to the row buffer - private final ArrowRowBuffer arrowBuffer; - - // Indicates whether the channel is still valid - private volatile boolean isValid; + private final RowBuffer rowBuffer; // Indicates whether the channel is closed private volatile boolean isClosed; // Reference to the client that owns this channel - private final SnowflakeStreamingIngestClientInternal owningClient; - - // Memory allocator - private final BufferAllocator allocator; - - // Data encryption key - private final String encryptionKey; - - // Data encryption key id - private final Long encryptionKeyId; + private final SnowflakeStreamingIngestClientInternal owningClient; - // Indicates whether we're using it as of the any tests - private boolean isTestMode; + // State of the channel that will be shared with its underlying buffer + private final ChannelRuntimeState channelState; - // ON_ERROR option for this channel - private final OpenChannelRequest.OnErrorOption onErrorOption; + // Internal map of column name -> column properties + private final Map tableColumns; /** * Constructor for TESTING ONLY which allows us to set the test mode @@ -76,7 +69,6 @@ class SnowflakeStreamingIngestChannelInternal implements SnowflakeStreamingInges * @param channelSequencer * @param rowSequencer * @param client - * @param isTestMode */ SnowflakeStreamingIngestChannelInternal( String name, @@ -86,59 +78,11 @@ class SnowflakeStreamingIngestChannelInternal implements SnowflakeStreamingInges String offsetToken, Long channelSequencer, Long rowSequencer, - SnowflakeStreamingIngestClientInternal client, + SnowflakeStreamingIngestClientInternal client, String encryptionKey, Long encryptionKeyId, OpenChannelRequest.OnErrorOption onErrorOption, - boolean isTestMode) { - this.channelName = name; - this.dbName = dbName; - this.schemaName = schemaName; - this.tableName = tableName; - this.offsetToken = offsetToken; - this.channelSequencer = channelSequencer; - this.rowSequencer = new AtomicLong(rowSequencer); - this.isValid = true; - this.isClosed = false; - this.owningClient = client; - this.isTestMode = isTestMode; - this.allocator = - isTestMode || owningClient.isTestMode() - ? new RootAllocator() - : this.owningClient - .getAllocator() - .newChildAllocator(name, 0, this.owningClient.getAllocator().getLimit()); - this.arrowBuffer = new ArrowRowBuffer(this); - this.encryptionKey = encryptionKey; - this.encryptionKeyId = encryptionKeyId; - this.onErrorOption = onErrorOption; - logger.logDebug("Channel={} created for table={}", this.channelName, this.tableName); - } - - /** - * Default Constructor - * - * @param name - * @param dbName - * @param schemaName - * @param tableName - * @param offsetToken - * @param channelSequencer - * @param rowSequencer - * @param client - */ - SnowflakeStreamingIngestChannelInternal( - String name, - String dbName, - String schemaName, - String tableName, - String offsetToken, - Long channelSequencer, - Long rowSequencer, - SnowflakeStreamingIngestClientInternal client, - String encryptionKey, - Long encryptionKeyId, - OpenChannelRequest.OnErrorOption onErrorOption) { + ZoneOffset defaultTimezone) { this( name, dbName, @@ -151,7 +95,49 @@ class SnowflakeStreamingIngestChannelInternal implements SnowflakeStreamingInges encryptionKey, encryptionKeyId, onErrorOption, - false); + defaultTimezone, + client.getParameterProvider().getBlobFormatVersion(), + null); + } + + /** Default constructor */ + SnowflakeStreamingIngestChannelInternal( + String name, + String dbName, + String schemaName, + String tableName, + String endOffsetToken, + Long channelSequencer, + Long rowSequencer, + SnowflakeStreamingIngestClientInternal client, + String encryptionKey, + Long encryptionKeyId, + OpenChannelRequest.OnErrorOption onErrorOption, + ZoneId defaultTimezone, + Constants.BdecVersion bdecVersion, + OffsetTokenVerificationFunction offsetTokenVerificationFunction) { + this.isClosed = false; + this.owningClient = client; + this.channelFlushContext = + new ChannelFlushContext( + name, dbName, schemaName, tableName, channelSequencer, encryptionKey, encryptionKeyId); + this.channelState = new ChannelRuntimeState(endOffsetToken, rowSequencer, true); + this.rowBuffer = + AbstractRowBuffer.createRowBuffer( + onErrorOption, + defaultTimezone, + bdecVersion, + getFullyQualifiedName(), + this::collectRowSize, + channelState, + new ClientBufferParameters(owningClient), + offsetTokenVerificationFunction, + owningClient == null ? null : owningClient.getTelemetryService()); + this.tableColumns = new HashMap<>(); + logger.logInfo( + "Channel={} created for table={}", + this.channelFlushContext.getName(), + this.channelFlushContext.getTableName()); } /** @@ -162,8 +148,7 @@ class SnowflakeStreamingIngestChannelInternal implements SnowflakeStreamingInges */ @Override public String getFullyQualifiedName() { - return String.format( - "%s.%s.%s.%s", this.dbName, this.schemaName, this.tableName, this.channelName); + return channelFlushContext.getFullyQualifiedName(); } /** @@ -173,50 +158,32 @@ public String getFullyQualifiedName() { */ @Override public String getName() { - return this.channelName; + return this.channelFlushContext.getName(); } @Override public String getDBName() { - return this.dbName; + return this.channelFlushContext.getDbName(); } @Override public String getSchemaName() { - return this.schemaName; + return this.channelFlushContext.getSchemaName(); } @Override public String getTableName() { - return this.tableName; - } - - String getOffsetToken() { - return this.offsetToken; - } - - void setOffsetToken(String offsetToken) { - this.offsetToken = offsetToken; + return this.channelFlushContext.getTableName(); } Long getChannelSequencer() { - return this.channelSequencer; - } - - long incrementAndGetRowSequencer() { - return this.rowSequencer.incrementAndGet(); - } - - long getRowSequencer() { - return this.rowSequencer.get(); - } - - String getEncryptionKey() { - return this.encryptionKey; + return this.channelFlushContext.getChannelSequencer(); } - Long getEncryptionKeyId() { - return this.encryptionKeyId; + /** @return current state of the channel */ + @VisibleForTesting + ChannelRuntimeState getChannelState() { + return this.channelState; } /** @@ -226,33 +193,39 @@ Long getEncryptionKeyId() { */ @Override public String getFullyQualifiedTableName() { - return String.format("%s.%s.%s", this.dbName, this.schemaName, this.tableName); + return channelFlushContext.getFullyQualifiedTableName(); } /** * Get all the data needed to build the blob during flush * + * @param filePath the name of the file the data will be written in * @return a ChannelData object */ - ChannelData getData() { - return this.arrowBuffer.flush(); + ChannelData getData(final String filePath) { + ChannelData data = this.rowBuffer.flush(filePath); + if (data != null) { + data.setChannelContext(channelFlushContext); + } + return data; } /** @return a boolean to indicate whether the channel is valid or not */ @Override public boolean isValid() { - return this.isValid; + return this.channelState.isValid(); } /** Mark the channel as invalid, and release resources */ - void invalidate() { - this.isValid = false; - this.arrowBuffer.close(); + void invalidate(String message) { + this.channelState.invalidate(); + this.rowBuffer.close("invalidate"); logger.logWarn( - "Channel is invalidated, name={}, channel sequencer={}, row sequencer={}", + "Channel is invalidated, name={}, channel sequencer={}, row sequencer={}, message={}", getFullyQualifiedName(), - channelSequencer, - rowSequencer); + channelFlushContext.getChannelSequencer(), + channelState.getRowSequencer(), + message); } /** @return a boolean to indicate whether the channel is closed or not */ @@ -264,11 +237,11 @@ public boolean isClosed() { /** Mark the channel as closed */ void markClosed() { this.isClosed = true; - logger.logDebug( - "Channel is closed, name={}, channel sequencer={}, row sequencer={}", + logger.logInfo( + "Channel is marked as closed, name={}, channel sequencer={}, row sequencer={}", getFullyQualifiedName(), - channelSequencer, - rowSequencer); + channelFlushContext.getChannelSequencer(), + channelState.getRowSequencer()); } /** @@ -281,12 +254,12 @@ CompletableFuture flush(boolean closing) { // Skip this check for closing because we need to set the channel to closed first and then flush // in case there is any leftover rows if (isClosed() && !closing) { - throw new SFException(ErrorCode.CLOSED_CHANNEL); + throw new SFException(ErrorCode.CLOSED_CHANNEL, getFullyQualifiedName()); } // Simply return if there is no data in the channel, this might not work if we support public // flush API since there could a concurrent insert at the same time - if (this.arrowBuffer.getSize() == 0) { + if (this.rowBuffer.getSize() == 0) { return CompletableFuture.completedFuture(null); } @@ -300,6 +273,11 @@ CompletableFuture flush(boolean closing) { */ @Override public CompletableFuture close() { + return this.close(false); + } + + @Override + public CompletableFuture close(boolean drop) { checkValidation(); if (isClosed()) { @@ -307,36 +285,36 @@ public CompletableFuture close() { } markClosed(); - this.owningClient.removeChannelIfSequencersMatch(this); return flush(true) .thenRunAsync( () -> { - List uncommittedChannels = + List> uncommittedChannels = this.owningClient.verifyChannelsAreFullyCommitted( Collections.singletonList(this)); - this.arrowBuffer.close(); + this.rowBuffer.close("close"); + this.owningClient.removeChannelIfSequencersMatch(this); - // Throw an exception if the channel has any uncommitted rows - if (!uncommittedChannels.isEmpty()) { + // Throw an exception if the channel is invalid or has any uncommitted rows + if (!isValid() || !uncommittedChannels.isEmpty()) { throw new SFException( - ErrorCode.CHANNEL_WITH_UNCOMMITTED_ROWS, + ErrorCode.CHANNELS_WITH_UNCOMMITTED_ROWS, uncommittedChannels.stream() .map(SnowflakeStreamingIngestChannelInternal::getFullyQualifiedName) .collect(Collectors.toList())); } + if (drop) { + DropChannelRequest.DropChannelRequestBuilder builder = + DropChannelRequest.builder(this.getChannelContext().getName()) + .setDBName(this.getDBName()) + .setTableName(this.getTableName()) + .setSchemaName(this.getSchemaName()); + this.owningClient.dropChannel( + new DropChannelVersionRequest(builder, this.getChannelSequencer())); + } }); } - /** - * Get the buffer allocator - * - * @return the buffer allocator - */ - BufferAllocator getAllocator() { - return this.allocator; - } - /** * Setup the column fields and vectors using the column metadata from the server * @@ -345,7 +323,8 @@ BufferAllocator getAllocator() { // TODO: need to verify with the table schema when supporting sub-columns void setupSchema(List columns) { logger.logDebug("Setup schema for channel={}, schema={}", getFullyQualifiedName(), columns); - this.arrowBuffer.setupSchema(columns); + this.rowBuffer.setupSchema(columns); + columns.forEach(c -> tableColumns.putIfAbsent(c.getName(), new ColumnProperties(c))); } /** @@ -364,7 +343,7 @@ void setupSchema(List columns) { */ @Override public InsertValidationResponse insertRow(Map row, String offsetToken) { - return insertRows(Collections.singletonList(row), offsetToken); + return insertRows(Collections.singletonList(row), offsetToken, offsetToken); } /** @@ -374,35 +353,60 @@ public InsertValidationResponse insertRow(Map row, String offset */ /** - * Each row is represented using Map where the key is column name and the value is a row of data + * Insert a batch of rows into the channel, each row is represented using Map where the key is + * column name and the value is a row of data. See {@link + * SnowflakeStreamingIngestChannel#insertRow(Map, String)} for more information about accepted + * values. * * @param rows object data to write - * @param offsetToken offset of last row in the row-set, used for replay in case of failures + * @param startOffsetToken start offset of the batch/row-set + * @param endOffsetToken end offset of the batch/row-set, used for replay in case of failures, * + * It could be null if you don't plan on replaying or can't replay * @return insert response that possibly contains errors because of insertion failures - * @throws SFException when the channel is invalid or closed */ @Override public InsertValidationResponse insertRows( - Iterable> rows, String offsetToken) { - throttleInsertIfNeeded(Runtime.getRuntime()); + Iterable> rows, + @Nullable String startOffsetToken, + @Nullable String endOffsetToken) { + throttleInsertIfNeeded(new MemoryInfoProviderFromRuntime()); checkValidation(); if (isClosed()) { - throw new SFException(ErrorCode.CLOSED_CHANNEL); + throw new SFException(ErrorCode.CLOSED_CHANNEL, getFullyQualifiedName()); } - InsertValidationResponse response = this.arrowBuffer.insertRows(rows, offsetToken); + // We create a shallow copy to protect against concurrent addition/removal of columns, which can + // lead to double counting of null values, for example. Individual mutable values may still be + // concurrently modified (e.g. byte[]). Before validation and EP calculation, we must make sure + // that defensive copies of all mutable objects are created. + final List> rowsCopy = new LinkedList<>(); + rows.forEach(r -> rowsCopy.add(new LinkedHashMap<>(r))); + + InsertValidationResponse response = + this.rowBuffer.insertRows(rowsCopy, startOffsetToken, endOffsetToken); // Start flush task if the chunk size reaches a certain size // TODO: Checking table/chunk level size reduces throughput a lot, we may want to check it only // if a large number of rows are inserted - if (this.arrowBuffer.getSize() >= MAX_CHUNK_SIZE_IN_BYTES) { + if (this.rowBuffer.getSize() + >= this.owningClient.getParameterProvider().getMaxChannelSizeInBytes()) { this.owningClient.setNeedFlush(); } return response; } + /** + * Insert a batch of rows into the channel with the end offset token only, please see {@link + * SnowflakeStreamingIngestChannel#insertRows(Iterable, String, String)} for more information. + */ + @Override + public InsertValidationResponse insertRows( + Iterable> rows, String offsetToken) { + return insertRows(rows, null, offsetToken); + } + /** Collect the row size from row buffer if required */ void collectRowSize(float rowSize) { if (this.owningClient.inputThroughput != null) { @@ -411,7 +415,9 @@ void collectRowSize(float rowSize) { } /** - * Get the latest committed offset token from Snowflake + * Get the latest committed offset token from Snowflake, an exception will be thrown if the + * channel becomes invalid due to errors and the channel needs to be reopened in order to return a + * valid offset token * * @return the latest committed offset token */ @@ -419,62 +425,93 @@ void collectRowSize(float rowSize) { public String getLatestCommittedOffsetToken() { checkValidation(); - return this.owningClient - .getChannelsStatus(Collections.singletonList(this)) - .getChannels() - .get(0) - .getPersistedOffsetToken(); + ChannelsStatusResponse.ChannelStatusResponseDTO response = + this.owningClient.getChannelsStatus(Collections.singletonList(this)).getChannels().get(0); + + if (response.getStatusCode() != RESPONSE_SUCCESS) { + throw new SFException(ErrorCode.CHANNEL_STATUS_INVALID, getName(), response.getStatusCode()); + } + + return response.getPersistedOffsetToken(); } - /** - * Check whether we need to throttle the insert API under the following low memory condition: - *

  • system free_memory/total_memory < INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE - */ - void throttleInsertIfNeeded(Runtime runtime) { - int insertThrottleThresholdInPercentage = - this.owningClient.getParameterProvider().getInsertThrottleThresholdInPercentage(); - if (runtime.freeMemory() * 100 / runtime.totalMemory() < insertThrottleThresholdInPercentage) { - long oldTotalMem = runtime.totalMemory(); - long oldFreeMem = runtime.freeMemory(); - int retry = 0; - - long insertThrottleIntervalInMs = - this.owningClient.getParameterProvider().getInsertThrottleIntervalInMs(); - while (runtime.freeMemory() * 100 / runtime.totalMemory() - < insertThrottleThresholdInPercentage - && retry < INSERT_THROTTLE_MAX_RETRY_COUNT) { - try { - Thread.sleep(insertThrottleIntervalInMs); - retry++; - } catch (InterruptedException e) { - throw new SFException(ErrorCode.INTERNAL_ERROR, "Insert throttle get interrupted"); - } + /** Returns a map of column name -> datatype for the table the channel is bound to */ + @Override + public Map getTableSchema() { + return this.tableColumns; + } + + /** Check whether we need to throttle the insertRows API */ + void throttleInsertIfNeeded(MemoryInfoProvider memoryInfoProvider) { + int retry = 0; + long insertThrottleIntervalInMs = + this.owningClient.getParameterProvider().getInsertThrottleIntervalInMs(); + while ((hasLowRuntimeMemory(memoryInfoProvider) + || (this.owningClient.getFlushService() != null + && this.owningClient.getFlushService().throttleDueToQueuedFlushTasks())) + && retry < INSERT_THROTTLE_MAX_RETRY_COUNT) { + try { + Thread.sleep(insertThrottleIntervalInMs); + retry++; + } catch (InterruptedException e) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "Insert throttle get interrupted"); } + } + if (retry > 0) { + logger.logInfo( + "Insert throttled for a total of {} milliseconds, retryCount={}, client={}, channel={}", + retry * insertThrottleIntervalInMs, + retry, + this.owningClient.getName(), + getFullyQualifiedName()); + } + } + /** Check whether we have a low runtime memory condition */ + private boolean hasLowRuntimeMemory(MemoryInfoProvider memoryInfoProvider) { + int insertThrottleThresholdInBytes = + this.owningClient.getParameterProvider().getInsertThrottleThresholdInBytes(); + int insertThrottleThresholdInPercentage = + this.owningClient.getParameterProvider().getInsertThrottleThresholdInPercentage(); + long maxMemoryLimitInBytes = + this.owningClient.getParameterProvider().getMaxMemoryLimitInBytes(); + long maxMemory = + maxMemoryLimitInBytes == MAX_MEMORY_LIMIT_IN_BYTES_DEFAULT + ? memoryInfoProvider.getMaxMemory() + : maxMemoryLimitInBytes; + long freeMemory = + memoryInfoProvider.getFreeMemory() + + (memoryInfoProvider.getMaxMemory() - memoryInfoProvider.getTotalMemory()); + boolean hasLowRuntimeMemory = + freeMemory < insertThrottleThresholdInBytes + && freeMemory * 100 / maxMemory < insertThrottleThresholdInPercentage; + if (hasLowRuntimeMemory) { logger.logWarn( - "InsertRows throttled due to JVM memory pressure, channel={}, timeInMs={}, max memory={}," - + " old total memory={}, old free memory={}, new total memory={}, new free" - + " memory={}.", - getFullyQualifiedName(), - retry * insertThrottleIntervalInMs, - runtime.maxMemory(), - oldTotalMem, - oldFreeMem, - runtime.totalMemory(), - runtime.freeMemory()); + "Throttled due to memory pressure, client={}, channel={}.", + this.owningClient.getName(), + getFullyQualifiedName()); + Utils.showMemory(); } + return hasLowRuntimeMemory; } /** Check whether the channel is still valid, cleanup and throw an error if not */ private void checkValidation() { if (!isValid()) { this.owningClient.removeChannelIfSequencersMatch(this); - this.arrowBuffer.close(); - throw new SFException(ErrorCode.INVALID_CHANNEL); + this.rowBuffer.close("checkValidation"); + throw new SFException(ErrorCode.INVALID_CHANNEL, getFullyQualifiedName()); } } - OpenChannelRequest.OnErrorOption getOnErrorOption() { - return this.onErrorOption; + /** Returns underlying channel's row buffer implementation. */ + RowBuffer getRowBuffer() { + return rowBuffer; + } + + /** Returns underlying channel's attributes. */ + @VisibleForTesting + public ChannelFlushContext getChannelContext() { + return channelFlushContext; } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestClientInternal.java b/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestClientInternal.java index 8fa3b109b..c23df3e34 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestClientInternal.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestClientInternal.java @@ -5,19 +5,26 @@ package net.snowflake.ingest.streaming.internal; import static net.snowflake.ingest.connection.ServiceResponseHandler.ApiName.STREAMING_CHANNEL_STATUS; +import static net.snowflake.ingest.connection.ServiceResponseHandler.ApiName.STREAMING_DROP_CHANNEL; import static net.snowflake.ingest.connection.ServiceResponseHandler.ApiName.STREAMING_OPEN_CHANNEL; import static net.snowflake.ingest.connection.ServiceResponseHandler.ApiName.STREAMING_REGISTER_BLOB; +import static net.snowflake.ingest.streaming.internal.StreamingIngestUtils.executeWithRetries; +import static net.snowflake.ingest.streaming.internal.StreamingIngestUtils.sleepForRetry; import static net.snowflake.ingest.utils.Constants.CHANNEL_STATUS_ENDPOINT; import static net.snowflake.ingest.utils.Constants.COMMIT_MAX_RETRY_COUNT; import static net.snowflake.ingest.utils.Constants.COMMIT_RETRY_INTERVAL_IN_MS; -import static net.snowflake.ingest.utils.Constants.JDBC_PRIVATE_KEY; +import static net.snowflake.ingest.utils.Constants.DROP_CHANNEL_ENDPOINT; +import static net.snowflake.ingest.utils.Constants.ENABLE_TELEMETRY_TO_SF; +import static net.snowflake.ingest.utils.Constants.MAX_STREAMING_INGEST_API_CHANNEL_RETRY; import static net.snowflake.ingest.utils.Constants.OPEN_CHANNEL_ENDPOINT; import static net.snowflake.ingest.utils.Constants.REGISTER_BLOB_ENDPOINT; -import static net.snowflake.ingest.utils.Constants.RESPONSE_ROW_SEQUENCER_IS_COMMITTED; +import static net.snowflake.ingest.utils.Constants.RESPONSE_ERR_ENQUEUE_TABLE_CHUNK_QUEUE_FULL; +import static net.snowflake.ingest.utils.Constants.RESPONSE_ERR_GENERAL_EXCEPTION_RETRY_REQUEST; import static net.snowflake.ingest.utils.Constants.RESPONSE_SUCCESS; import static net.snowflake.ingest.utils.Constants.SNOWPIPE_STREAMING_JMX_METRIC_PREFIX; import static net.snowflake.ingest.utils.Constants.SNOWPIPE_STREAMING_JVM_MEMORY_AND_THREAD_METRICS_REGISTRY; import static net.snowflake.ingest.utils.Constants.SNOWPIPE_STREAMING_SHARED_METRICS_REGISTRY; +import static net.snowflake.ingest.utils.Constants.STREAMING_INGEST_TELEMETRY_UPLOAD_INTERVAL_IN_SEC; import static net.snowflake.ingest.utils.Constants.USER; import com.codahale.metrics.Histogram; @@ -31,47 +38,56 @@ import com.codahale.metrics.jvm.MemoryUsageGaugeSet; import com.codahale.metrics.jvm.ThreadStatesGaugeSet; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; import java.io.IOException; -import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.spec.InvalidKeySpecException; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; import javax.management.MalformedObjectNameException; import javax.management.ObjectName; +import net.snowflake.client.core.SFSessionProperty; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; import net.snowflake.ingest.connection.IngestResponseException; +import net.snowflake.ingest.connection.OAuthCredential; import net.snowflake.ingest.connection.RequestBuilder; -import net.snowflake.ingest.connection.ServiceResponseHandler; +import net.snowflake.ingest.connection.TelemetryService; +import net.snowflake.ingest.streaming.DropChannelRequest; import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClient; import net.snowflake.ingest.utils.Constants; import net.snowflake.ingest.utils.ErrorCode; import net.snowflake.ingest.utils.HttpUtil; import net.snowflake.ingest.utils.Logging; +import net.snowflake.ingest.utils.Pair; import net.snowflake.ingest.utils.ParameterProvider; import net.snowflake.ingest.utils.SFException; import net.snowflake.ingest.utils.SnowflakeURL; import net.snowflake.ingest.utils.Utils; -import org.apache.arrow.memory.BufferAllocator; -import org.apache.arrow.memory.RootAllocator; -import org.apache.http.client.HttpClient; /** * The first version of implementation for SnowflakeStreamingIngestClient. The client internally * manages a few things: *
  • the channel cache, which contains all the channels that belong to this account *
  • the flush service, which schedules and coordinates the flush to Snowflake tables + * + * @param type of column data ({@link ParquetChunkData}) */ -public class SnowflakeStreamingIngestClientInternal implements SnowflakeStreamingIngestClient { +public class SnowflakeStreamingIngestClientInternal implements SnowflakeStreamingIngestClient { private static final Logging logger = new Logging(SnowflakeStreamingIngestClientInternal.class); @@ -90,17 +106,14 @@ public class SnowflakeStreamingIngestClientInternal implements SnowflakeStreamin // Snowflake role for the client to use private String role; - // Http client to send HTTP request to Snowflake - private final HttpClient httpClient; + // Http client to send HTTP requests to Snowflake + private final CloseableHttpClient httpClient; // Reference to the channel cache - private final ChannelCache channelCache; + private final ChannelCache channelCache; // Reference to the flush service - private final FlushService flushService; - - // Memory allocator - private final BufferAllocator allocator; + private final FlushService flushService; // Indicates whether the client has closed private volatile boolean isClosed; @@ -118,7 +131,7 @@ public class SnowflakeStreamingIngestClientInternal implements SnowflakeStreamin Timer uploadLatency; // Latency for uploading a blob Timer registerLatency; // Latency for registering a blob Meter uploadThroughput; // Throughput for uploading blobs - Meter inputThroughput; // Throughput for inserting into the Arrow buffer + Meter inputThroughput; // Throughput for inserting into the internal buffer // JVM and thread related metrics MetricRegistry jvmMemoryAndThreadMetrics; @@ -126,6 +139,12 @@ public class SnowflakeStreamingIngestClientInternal implements SnowflakeStreamin // The request builder who handles building the HttpRequests we send private RequestBuilder requestBuilder; + // Background thread that uploads telemetry data periodically + private ScheduledExecutorService telemetryWorker; + + // Store original properties for proxy configuration + private final Properties originalProperties; + /** * Constructor * @@ -135,45 +154,108 @@ public class SnowflakeStreamingIngestClientInternal implements SnowflakeStreamin * @param httpClient http client for sending request * @param isTestMode whether we're under test mode * @param requestBuilder http request builder - * @param parameterOverrides parameters we override incase we want to set different values + * @param parameterOverrides parameters we override in case we want to set different values */ SnowflakeStreamingIngestClientInternal( String name, SnowflakeURL accountURL, Properties prop, - HttpClient httpClient, + CloseableHttpClient httpClient, boolean isTestMode, RequestBuilder requestBuilder, Map parameterOverrides) { this.parameterProvider = new ParameterProvider(parameterOverrides, prop); + this.originalProperties = prop; this.name = name; + String accountName = accountURL == null ? null : accountURL.getAccount(); this.isTestMode = isTestMode; - this.httpClient = httpClient == null ? HttpUtil.getHttpClient() : httpClient; - this.channelCache = new ChannelCache(); - this.allocator = new RootAllocator(); + + if (prop != null && !prop.isEmpty()) { + // Check if proxy-related properties are present + boolean hasProxyConfig = + prop.stringPropertyNames().stream() + .anyMatch( + key -> + key.startsWith("http.proxy") + || key.equals("useProxy") + || key.equals("proxyHost") + || key.equals("proxyPort") + || key.equals("nonProxyHosts") + || key.equals("proxyUser") + || key.equals("proxyPassword")); + + if (hasProxyConfig) { + logger.logDebug( + "Creating HTTP client for SnowflakeStreamingIngestClient with proxy configuration for" + + " account: {}, client: {}", + accountName, + name); + } else { + logger.logDebug( + "Creating HTTP client for SnowflakeStreamingIngestClient without proxy configuration" + + " for account: {}, client: {}", + accountName, + name); + } + } else { + logger.logDebug( + "Creating HTTP client for SnowflakeStreamingIngestClient with no properties for account:" + + " {}, client: {}", + accountName, + name); + } + + this.httpClient = (httpClient != null) ? httpClient : HttpUtil.getHttpClient(accountName, prop); + this.channelCache = new ChannelCache<>(); this.isClosed = false; this.requestBuilder = requestBuilder; if (!isTestMode) { // Setup request builder for communication with the server side this.role = prop.getProperty(Constants.ROLE); - try { - KeyPair keyPair = - Utils.createKeyPairFromPrivateKey((PrivateKey) prop.get(JDBC_PRIVATE_KEY)); - this.requestBuilder = new RequestBuilder(accountURL, prop.get(USER).toString(), keyPair); - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - throw new SFException(e, ErrorCode.KEYPAIR_CREATION_FAILURE); + + Object credential = null; + + // Authorization type will be set to jwt by default + if (prop.getProperty(Constants.AUTHORIZATION_TYPE).equals(Constants.JWT)) { + try { + credential = + Utils.createKeyPairFromPrivateKey( + (PrivateKey) prop.get(SFSessionProperty.PRIVATE_KEY.getPropertyKey())); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new SFException(e, ErrorCode.KEYPAIR_CREATION_FAILURE); + } + } else { + credential = + new OAuthCredential( + prop.getProperty(Constants.OAUTH_CLIENT_ID), + prop.getProperty(Constants.OAUTH_CLIENT_SECRET), + prop.getProperty(Constants.OAUTH_REFRESH_TOKEN)); } + this.requestBuilder = + new RequestBuilder( + accountURL, + prop.get(USER).toString(), + credential, + this.httpClient, + String.format("%s_%s", this.name, System.currentTimeMillis())); + + logger.logInfo("Using {} for authorization", this.requestBuilder.getAuthType()); + + // Setup client telemetries if needed + this.setupMetricsForClient(); } - this.flushService = new FlushService(this, this.channelCache, this.isTestMode); - - if (this.parameterProvider.hasEnabledSnowpipeStreamingMetrics()) { - this.registerMetricsForClient(); + try { + this.flushService = new FlushService<>(this, this.channelCache, this.isTestMode); + } catch (Exception e) { + // Need to clean up the resources before throwing any exceptions + cleanUpResources(); + throw e; } - logger.logDebug( + logger.logInfo( "Client created, name={}, account={}. isTestMode={}, parameters={}", name, accountURL == null ? "" : accountURL.getAccount(), @@ -188,17 +270,18 @@ public class SnowflakeStreamingIngestClientInternal implements SnowflakeStreamin * @param accountURL Snowflake account url * @param prop connection properties * @param parameterOverrides map of parameters to override for this client + * @param isTestMode indicates whether it's under test mode */ public SnowflakeStreamingIngestClientInternal( String name, SnowflakeURL accountURL, Properties prop, - Map parameterOverrides) { - this(name, accountURL, prop, null, false, null, parameterOverrides); + Map parameterOverrides, + boolean isTestMode) { + this(name, accountURL, prop, null, isTestMode, null, parameterOverrides); } - /** - * Constructor for TEST ONLY + /*** Constructor for TEST ONLY * * @param name the name of the client */ @@ -206,6 +289,12 @@ public SnowflakeStreamingIngestClientInternal( this(name, null, null, null, true, null, new HashMap<>()); } + // TESTING ONLY - inject the request builder + @VisibleForTesting + public void injectRequestBuilder(RequestBuilder requestBuilder) { + this.requestBuilder = requestBuilder; + } + /** * Get the client name * @@ -238,15 +327,16 @@ public boolean isClosed() { * @return a SnowflakeStreamingIngestChannel object */ @Override - public SnowflakeStreamingIngestChannelInternal openChannel(OpenChannelRequest request) { + public SnowflakeStreamingIngestChannelInternal openChannel(OpenChannelRequest request) { if (isClosed) { throw new SFException(ErrorCode.CLOSED_CLIENT); } logger.logDebug( - "Open channel request start, channel={}, table={}", + "Open channel request start, channel={}, table={}, client={}", request.getChannelName(), - request.getFullyQualifiedTableName()); + request.getFullyQualifiedTableName(), + getName()); try { Map payload = new HashMap<>(); @@ -258,33 +348,43 @@ public SnowflakeStreamingIngestChannelInternal openChannel(OpenChannelRequest re payload.put("schema", request.getSchemaName()); payload.put("write_mode", Constants.WriteMode.CLOUD_STORAGE.name()); payload.put("role", this.role); + if (request.isOffsetTokenProvided()) { + payload.put("offset_token", request.getOffsetToken()); + } OpenChannelResponse response = - ServiceResponseHandler.unmarshallStreamingIngestResponse( - httpClient.execute( - requestBuilder.generateStreamingIngestPostRequest( - payload, OPEN_CHANNEL_ENDPOINT, "open channel")), + executeWithRetries( OpenChannelResponse.class, - STREAMING_OPEN_CHANNEL); + OPEN_CHANNEL_ENDPOINT, + payload, + "open channel", + STREAMING_OPEN_CHANNEL, + httpClient, + requestBuilder); // Check for Snowflake specific response code if (response.getStatusCode() != RESPONSE_SUCCESS) { logger.logDebug( - "Open channel request failed, channel={}, table={}, message={}", + "Open channel request failed, channel={}, table={}, client={}, message={}", request.getChannelName(), request.getFullyQualifiedTableName(), + getName(), response.getMessage()); throw new SFException(ErrorCode.OPEN_CHANNEL_FAILURE, response.getMessage()); } - logger.logDebug( - "Open channel request succeeded, channel={}, table={}", + logger.logInfo( + "Open channel request succeeded, channel={}, table={}, clientSequencer={}," + + " rowSequencer={}, client={}", request.getChannelName(), - request.getFullyQualifiedTableName()); + request.getFullyQualifiedTableName(), + response.getClientSequencer(), + response.getRowSequencer(), + getName()); // Channel is now registered, add it to the in-memory channel pool - SnowflakeStreamingIngestChannelInternal channel = - SnowflakeStreamingIngestChannelFactory.builder(response.getChannelName()) + SnowflakeStreamingIngestChannelInternal channel = + SnowflakeStreamingIngestChannelFactory.builder(response.getChannelName()) .setDBName(response.getDBName()) .setSchemaName(response.getSchemaName()) .setTableName(response.getTableName()) @@ -295,6 +395,8 @@ public SnowflakeStreamingIngestChannelInternal openChannel(OpenChannelRequest re .setEncryptionKey(response.getEncryptionKey()) .setEncryptionKeyId(response.getEncryptionKeyId()) .setOnErrorOption(request.getOnErrorOption()) + .setDefaultTimezone(request.getDefaultTimezone()) + .setOffsetTokenVerificationFunction(request.getOffsetTokenVerificationFunction()) .build(); // Setup the row buffer schema @@ -305,17 +407,103 @@ public SnowflakeStreamingIngestChannelInternal openChannel(OpenChannelRequest re return channel; } catch (IOException | IngestResponseException e) { - throw new SFException(e, ErrorCode.OPEN_CHANNEL_FAILURE); + throw new SFException(e, ErrorCode.OPEN_CHANNEL_FAILURE, e.getMessage()); + } + } + + @Override + public void dropChannel(DropChannelRequest request) { + if (isClosed) { + throw new SFException(ErrorCode.CLOSED_CLIENT); + } + + logger.logDebug( + "Drop channel request start, channel={}, table={}, client={}", + request.getChannelName(), + request.getFullyQualifiedTableName(), + getName()); + + try { + Map payload = new HashMap<>(); + payload.put( + "request_id", this.flushService.getClientPrefix() + "_" + counter.getAndIncrement()); + payload.put("channel", request.getChannelName()); + payload.put("table", request.getTableName()); + payload.put("database", request.getDBName()); + payload.put("schema", request.getSchemaName()); + payload.put("role", this.role); + Long clientSequencer = null; + if (request instanceof DropChannelVersionRequest) { + clientSequencer = ((DropChannelVersionRequest) request).getClientSequencer(); + if (clientSequencer != null) { + payload.put("client_sequencer", clientSequencer); + } + } + + DropChannelResponse response = + executeWithRetries( + DropChannelResponse.class, + DROP_CHANNEL_ENDPOINT, + payload, + "drop channel", + STREAMING_DROP_CHANNEL, + httpClient, + requestBuilder); + + // Check for Snowflake specific response code + if (response.getStatusCode() != RESPONSE_SUCCESS) { + logger.logDebug( + "Drop channel request failed, channel={}, table={}, client={}, message={}", + request.getChannelName(), + request.getFullyQualifiedTableName(), + getName(), + response.getMessage()); + throw new SFException(ErrorCode.DROP_CHANNEL_FAILURE, response.getMessage()); + } + + logger.logInfo( + "Drop channel request succeeded, channel={}, table={}, clientSequencer={} client={}", + request.getChannelName(), + request.getFullyQualifiedTableName(), + clientSequencer, + getName()); + + } catch (IOException | IngestResponseException e) { + throw new SFException(e, ErrorCode.DROP_CHANNEL_FAILURE, e.getMessage()); } } + /** + * Return the latest committed/persisted offset token for all channels + * + * @return map of channel to the latest persisted offset token + */ + @Override + public Map getLatestCommittedOffsetTokens( + List channels) { + List> internalChannels = + channels.stream() + .map(c -> (SnowflakeStreamingIngestChannelInternal) c) + .collect(Collectors.toList()); + List channelsStatus = + getChannelsStatus(internalChannels).getChannels(); + Map result = new HashMap<>(); + for (int idx = 0; idx < channels.size(); idx++) { + result.put( + channels.get(idx).getFullyQualifiedName(), + channelsStatus.get(idx).getPersistedOffsetToken()); + } + return result; + } + /** * Fetch channels status from Snowflake * * @param channels a list of channels that we want to get the status on * @return a ChannelsStatusResponse object */ - ChannelsStatusResponse getChannelsStatus(List channels) { + ChannelsStatusResponse getChannelsStatus( + List> channels) { try { ChannelsStatusRequest request = new ChannelsStatusRequest(); List requestDTOs = @@ -327,22 +515,44 @@ ChannelsStatusResponse getChannelsStatus(List channel = channels.get(idx); + ChannelsStatusResponse.ChannelStatusResponseDTO channelStatus = + response.getChannels().get(idx); + if (channelStatus.getStatusCode() != RESPONSE_SUCCESS) { + String errorMessage = + String.format( + "Channel has failure status_code, name=%s, channel_sequencer=%d, status_code=%d", + channel.getFullyQualifiedName(), + channel.getChannelSequencer(), + channelStatus.getStatusCode()); + logger.logWarn(errorMessage); + if (getTelemetryService() != null) { + getTelemetryService() + .reportClientFailure(this.getClass().getSimpleName(), errorMessage); + } + } + } + return response; } catch (IOException | IngestResponseException e) { - throw new SFException(e, ErrorCode.CHANNEL_STATUS_FAILURE); + throw new SFException(e, ErrorCode.CHANNEL_STATUS_FAILURE, e.getMessage()); } } @@ -352,10 +562,67 @@ ChannelsStatusResponse getChannelsStatus(List blobs) { - logger.logDebug( - "Register blob request start for blob={}, client={}", + for (List blobBatch : partitionBlobListForRegistrationRequest(blobs)) { + this.registerBlobs(blobBatch, 0); + } + } + + /** + * Partition the collection of blobs into sub-lists, so that the total number of chunks in each + * sublist does not exceed the max allowed number of chunks in one registration request. + */ + List> partitionBlobListForRegistrationRequest(List blobs) { + List> result = new ArrayList<>(); + List currentBatch = new ArrayList<>(); + int chunksInCurrentBatch = 0; + int maxChunksInBlobAndRegistrationRequest = + parameterProvider.getMaxChunksInBlobAndRegistrationRequest(); + + for (BlobMetadata blob : blobs) { + if (blob.getChunks().size() > maxChunksInBlobAndRegistrationRequest) { + throw new SFException( + ErrorCode.INTERNAL_ERROR, + String.format( + "Incorrectly generated blob detected - number of chunks in the blob is larger than" + + " the max allowed number of chunks. Please report this bug to Snowflake." + + " bdec=%s chunkCount=%d maxAllowedChunkCount=%d", + blob.getPath(), blob.getChunks().size(), maxChunksInBlobAndRegistrationRequest)); + } + + if (chunksInCurrentBatch + blob.getChunks().size() > maxChunksInBlobAndRegistrationRequest) { + // Newly added BDEC file would exceed the max number of chunks in a single registration + // request. We put chunks collected so far into the result list and create a new batch with + // the current blob + result.add(currentBatch); + currentBatch = new ArrayList<>(); + currentBatch.add(blob); + chunksInCurrentBatch = blob.getChunks().size(); + } else { + // Newly added BDEC can be added to the current batch because it does not exceed the max + // number of chunks in a single registration request, yet. + currentBatch.add(blob); + chunksInCurrentBatch += blob.getChunks().size(); + } + } + + if (!currentBatch.isEmpty()) { + result.add(currentBatch); + } + return result; + } + + /** + * Register the uploaded blobs to a Snowflake table + * + * @param blobs list of uploaded blobs + * @param executionCount Number of times this call has been attempted, used to track retries + */ + void registerBlobs(List blobs, final int executionCount) { + logger.logInfo( + "Register blob request preparing for blob={}, client={}, executionCount={}", blobs.stream().map(BlobMetadata::getPath).collect(Collectors.toList()), - this.name); + this.name, + executionCount); RegisterBlobResponse response = null; try { @@ -366,32 +633,37 @@ void registerBlobs(List blobs) { payload.put("role", this.role); response = - ServiceResponseHandler.unmarshallStreamingIngestResponse( - httpClient.execute( - requestBuilder.generateStreamingIngestPostRequest( - payload, REGISTER_BLOB_ENDPOINT, "register blob")), + executeWithRetries( RegisterBlobResponse.class, - STREAMING_REGISTER_BLOB); + REGISTER_BLOB_ENDPOINT, + payload, + "register blob", + STREAMING_REGISTER_BLOB, + httpClient, + requestBuilder); // Check for Snowflake specific response code if (response.getStatusCode() != RESPONSE_SUCCESS) { logger.logDebug( - "Register blob request failed for blob={}, client={}, message={}", + "Register blob request failed for blob={}, client={}, message={}, executionCount={}", blobs.stream().map(BlobMetadata::getPath).collect(Collectors.toList()), this.name, - response.getMessage()); + response.getMessage(), + executionCount); throw new SFException(ErrorCode.REGISTER_BLOB_FAILURE, response.getMessage()); } } catch (IOException | IngestResponseException e) { - throw new SFException(e, ErrorCode.REGISTER_BLOB_FAILURE); + throw new SFException(e, ErrorCode.REGISTER_BLOB_FAILURE, e.getMessage()); } - logger.logDebug( - "Register blob request succeeded for blob={}, client={}", + logger.logInfo( + "Register blob request returned for blob={}, client={}, executionCount={}", blobs.stream().map(BlobMetadata::getPath).collect(Collectors.toList()), - this.name); + this.name, + executionCount); - // Invalidate any channels that returns a failure status code + // We will retry any blob chunks that were rejected because internal Snowflake queues are full + Set queueFullChunks = new HashSet<>(); response .getBlobsStatus() .forEach( @@ -405,21 +677,114 @@ void registerBlobs(List blobs) { .forEach( channelStatus -> { if (channelStatus.getStatusCode() != RESPONSE_SUCCESS) { - logger.logWarn( - "Channel has been invalidated because of failure" - + " response, name={}, channel sequencer={}," - + " status code={}", - channelStatus.getChannelName(), - channelStatus.getChannelSequencer(), - channelStatus.getStatusCode()); - channelCache.invalidateChannelIfSequencersMatch( - chunkStatus.getDBName(), - chunkStatus.getSchemaName(), - chunkStatus.getTableName(), - channelStatus.getChannelName(), - channelStatus.getChannelSequencer()); + // If the chunk queue is full, we wait and retry the chunks + if ((channelStatus.getStatusCode() + == RESPONSE_ERR_ENQUEUE_TABLE_CHUNK_QUEUE_FULL + || channelStatus.getStatusCode() + == RESPONSE_ERR_GENERAL_EXCEPTION_RETRY_REQUEST) + && executionCount + < MAX_STREAMING_INGEST_API_CHANNEL_RETRY) { + queueFullChunks.add(chunkStatus); + } else { + String errorMessage = + String.format( + "Channel has been invalidated because of failure" + + " response, name=%s, channel_sequencer=%d," + + " status_code=%d, message=%s," + + " executionCount=%d", + channelStatus.getChannelName(), + channelStatus.getChannelSequencer(), + channelStatus.getStatusCode(), + channelStatus.getMessage(), + executionCount); + logger.logWarn(errorMessage); + if (getTelemetryService() != null) { + getTelemetryService() + .reportClientFailure( + this.getClass().getSimpleName(), errorMessage); + } + channelCache.invalidateChannelIfSequencersMatch( + chunkStatus.getDBName(), + chunkStatus.getSchemaName(), + chunkStatus.getTableName(), + channelStatus.getChannelName(), + channelStatus.getChannelSequencer()); + } } }))); + + if (!queueFullChunks.isEmpty()) { + logger.logInfo( + "Retrying registerBlobs request, blobs={}, retried_chunks={}, executionCount={}", + blobs, + queueFullChunks, + executionCount); + List retryBlobs = this.getRetryBlobs(queueFullChunks, blobs); + if (retryBlobs.isEmpty()) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "Failed to retry queue full chunks"); + } + sleepForRetry(executionCount); + this.registerBlobs(retryBlobs, executionCount + 1); + } + } + + /** + * Constructs a new register blobs payload consisting of chunks that were rejected by a prior + * registration attempt + * + * @param queueFullChunks ChunkRegisterStatus values for the chunks that had been rejected + * @param blobs List from the prior registration call + * @return a new List for only chunks matching queueFullChunks + */ + List getRetryBlobs( + Set queueFullChunks, List blobs) { + /* + If a channel returns a RESPONSE_ERR_ENQUEUE_TABLE_CHUNK_QUEUE_FULL statusCode then all channels in the same chunk + will have that statusCode. Here we collect all channels with RESPONSE_ERR_ENQUEUE_TABLE_CHUNK_QUEUE_FULL and use + them to pull out the chunks to retry from blobs + */ + Set> queueFullKeys = + queueFullChunks.stream() + .flatMap( + chunkRegisterStatus -> { + return chunkRegisterStatus.getChannelsStatus().stream() + .map( + channelStatus -> + new Pair( + channelStatus.getChannelName(), + channelStatus.getChannelSequencer())); + }) + .collect(Collectors.toSet()); + List retryBlobs = new ArrayList<>(); + blobs.forEach( + blobMetadata -> { + List relevantChunks = + blobMetadata.getChunks().stream() + .filter( + chunkMetadata -> + chunkMetadata.getChannels().stream() + .map( + channelMetadata -> + new Pair<>( + channelMetadata.getChannelName(), + channelMetadata.getClientSequencer())) + .anyMatch(queueFullKeys::contains)) + .collect(Collectors.toList()); + if (!relevantChunks.isEmpty()) { + retryBlobs.add( + BlobMetadata.createBlobMetadata( + blobMetadata.getPath(), + blobMetadata.getMD5(), + blobMetadata.getVersion(), + relevantChunks, + blobMetadata.getBlobStats(), + // Important to not change the spansMixedTables value in case of retries. The + // correct value is the value that the already uploaded blob has. + blobMetadata.getSpansMixedTables())); + } + }); + + return retryBlobs; } /** Close the client, which will flush first and then release all the resources */ @@ -432,25 +797,31 @@ public void close() throws Exception { isClosed = true; this.channelCache.closeAllChannels(); - // unregister jmx metrics - if (this.metrics != null) { - removeMetricsFromRegistry(); - - // LOG jvm memory and thread metrics at the end - Slf4jReporter.forRegistry(jvmMemoryAndThreadMetrics) - .outputTo(logger.getLogger()) - .build() - .report(); - } - // Flush any remaining rows and cleanup all the resources try { this.flush(true).get(); + + // Report telemetry if needed + reportStreamingIngestTelemetryToSF(); + + // Unregister jmx metrics + if (this.metrics != null) { + Slf4jReporter.forRegistry(metrics).outputTo(logger.getLogger()).build().report(); + removeMetricsFromRegistry(); + } + + // LOG jvm memory and thread metrics at the end + if (this.jvmMemoryAndThreadMetrics != null) { + Slf4jReporter.forRegistry(jvmMemoryAndThreadMetrics) + .outputTo(logger.getLogger()) + .build() + .report(); + } } catch (InterruptedException | ExecutionException e) { throw new SFException(e, ErrorCode.RESOURCE_CLEANUP_FAILURE, "client close"); } finally { this.flushService.shutdown(); - Utils.closeAllocator(this.allocator); + cleanUpResources(); } } @@ -472,17 +843,8 @@ void setNeedFlush() { this.flushService.setNeedFlush(); } - /** - * Get the buffer allocator - * - * @return the buffer allocator - */ - BufferAllocator getAllocator() { - return this.allocator; - } - /** Remove the channel in the channel cache if the channel sequencer matches */ - void removeChannelIfSequencersMatch(SnowflakeStreamingIngestChannelInternal channel) { + void removeChannelIfSequencersMatch(SnowflakeStreamingIngestChannelInternal channel) { this.channelCache.removeChannelIfSequencersMatch(channel); } @@ -492,7 +854,7 @@ boolean isTestMode() { } /** Get the http client */ - HttpClient getHttpClient() { + CloseableHttpClient getHttpClient() { return this.httpClient; } @@ -502,12 +864,12 @@ RequestBuilder getRequestBuilder() { } /** Get the channel cache */ - ChannelCache getChannelCache() { + ChannelCache getChannelCache() { return this.channelCache; } /** Get the flush service */ - FlushService getFlushService() { + FlushService getFlushService() { return this.flushService; } @@ -517,8 +879,8 @@ FlushService getFlushService() { * @param channels a list of channels we want to check against * @return a list of channels that has uncommitted rows */ - List verifyChannelsAreFullyCommitted( - List channels) { + List> verifyChannelsAreFullyCommitted( + List> channels) { if (channels.isEmpty()) { return channels; } @@ -527,16 +889,34 @@ List verifyChannelsAreFullyCommitted( int retry = 0; boolean isTimeout = true; List oldChannelsStatus = new ArrayList<>(); + List> channelsWithError = new ArrayList<>(); do { List channelsStatus = getChannelsStatus(channels).getChannels(); - List tempChannels = new ArrayList<>(); + List> tempChannels = new ArrayList<>(); List tempChannelsStatus = new ArrayList<>(); for (int idx = 0; idx < channelsStatus.size(); idx++) { - if (channelsStatus.get(idx).getStatusCode() != RESPONSE_ROW_SEQUENCER_IS_COMMITTED) { - tempChannels.add(channels.get(idx)); - tempChannelsStatus.add(channelsStatus.get(idx)); + ChannelsStatusResponse.ChannelStatusResponseDTO channelStatus = channelsStatus.get(idx); + SnowflakeStreamingIngestChannelInternal channel = channels.get(idx); + long rowSequencer = channel.getChannelState().getRowSequencer(); + logger.logInfo( + "Get channel status name={}, status={}, clientSequencer={}, rowSequencer={}," + + " startOffsetToken={}, endOffsetToken={}, persistedRowSequencer={}," + + " persistedOffsetToken={}", + channel.getName(), + channelStatus.getStatusCode(), + channel.getChannelSequencer(), + rowSequencer, + channel.getChannelState().getStartOffsetToken(), + channel.getChannelState().getEndOffsetToken(), + channelStatus.getPersistedRowSequencer(), + channelStatus.getPersistedOffsetToken()); + if (channelStatus.getStatusCode() != RESPONSE_SUCCESS) { + channelsWithError.add(channel); + } else if (!channelStatus.getPersistedRowSequencer().equals(rowSequencer)) { + tempChannels.add(channel); + tempChannelsStatus.add(channelStatus); } } @@ -581,11 +961,12 @@ List verifyChannelsAreFullyCommitted( this.name); } + channels.addAll(channelsWithError); return channels; } /** - * Get get ParameterProvider with configurable parameters + * Get ParameterProvider with configurable parameters * * @return ParameterProvider used by the client */ @@ -593,48 +974,84 @@ ParameterProvider getParameterProvider() { return parameterProvider; } - /*** + /** + * Set refresh token, this method is for refresh token renewal without requiring to restart + * client. This method only works when the authorization type is OAuth + * + * @param refreshToken the new refresh token + */ + @Override + public void setRefreshToken(String refreshToken) { + if (requestBuilder != null) { + requestBuilder.setRefreshToken(refreshToken); + } + } + + /** * Registers the performance metrics along with JVM memory and Threads. * - * Latency and throughput metrics are emitted to JMX, jvm memory and thread metrics are logged to Slf4JLogger + *

    Latency and throughput metrics are emitted to JMX, jvm memory and thread metrics are logged + * to Slf4JLogger */ - private void registerMetricsForClient() { + private void setupMetricsForClient() { + // Start the telemetry background worker if needed + if (ENABLE_TELEMETRY_TO_SF) { + this.telemetryWorker = Executors.newSingleThreadScheduledExecutor(); + this.telemetryWorker.scheduleWithFixedDelay( + this::reportStreamingIngestTelemetryToSF, + STREAMING_INGEST_TELEMETRY_UPLOAD_INTERVAL_IN_SEC, + STREAMING_INGEST_TELEMETRY_UPLOAD_INTERVAL_IN_SEC, + TimeUnit.SECONDS); + } + + // Register metrics if needed metrics = new MetricRegistry(); - // Blob histograms - blobSizeHistogram = metrics.histogram(MetricRegistry.name("blob", "size", "histogram")); - blobRowCountHistogram = - metrics.histogram(MetricRegistry.name("blob", "row", "count", "histogram")); - cpuHistogram = metrics.histogram(MetricRegistry.name("cpu", "usage", "histogram")); - - // latency metrics - flushLatency = metrics.timer(MetricRegistry.name("latency", "flush")); - buildLatency = metrics.timer(MetricRegistry.name("latency", "build")); - uploadLatency = metrics.timer(MetricRegistry.name("latency", "upload")); - registerLatency = metrics.timer(MetricRegistry.name("latency", "register")); - - // throughput metrics - uploadThroughput = metrics.meter(MetricRegistry.name("throughput", "upload")); - inputThroughput = metrics.meter(MetricRegistry.name("throughput", "input")); - SharedMetricRegistries.add(SNOWPIPE_STREAMING_SHARED_METRICS_REGISTRY, metrics); - - JmxReporter jmxReporter = - JmxReporter.forRegistry(this.metrics) - .inDomain(SNOWPIPE_STREAMING_JMX_METRIC_PREFIX) - .convertDurationsTo(TimeUnit.SECONDS) - .createsObjectNamesWith( - (ignoreMeterType, jmxDomain, metricName) -> - getObjectName(this.getName(), jmxDomain, metricName)) - .build(); - jmxReporter.start(); - - // add JVM and thread metrics too - jvmMemoryAndThreadMetrics.register( - MetricRegistry.name("jvm", "memory"), new MemoryUsageGaugeSet()); - jvmMemoryAndThreadMetrics.register( - MetricRegistry.name("jvm", "threads"), new ThreadStatesGaugeSet()); - - SharedMetricRegistries.add( - SNOWPIPE_STREAMING_JVM_MEMORY_AND_THREAD_METRICS_REGISTRY, jvmMemoryAndThreadMetrics); + + if (ENABLE_TELEMETRY_TO_SF || this.parameterProvider.hasEnabledSnowpipeStreamingMetrics()) { + // CPU usage metric + cpuHistogram = metrics.histogram(MetricRegistry.name("cpu", "usage", "histogram")); + + // Latency metrics + flushLatency = metrics.timer(MetricRegistry.name("latency", "flush")); + buildLatency = metrics.timer(MetricRegistry.name("latency", "build")); + uploadLatency = metrics.timer(MetricRegistry.name("latency", "upload")); + registerLatency = metrics.timer(MetricRegistry.name("latency", "register")); + + // Throughput metrics + uploadThroughput = metrics.meter(MetricRegistry.name("throughput", "upload")); + inputThroughput = metrics.meter(MetricRegistry.name("throughput", "input")); + + // Blob histogram metrics + blobSizeHistogram = metrics.histogram(MetricRegistry.name("blob", "size", "histogram")); + blobRowCountHistogram = + metrics.histogram(MetricRegistry.name("blob", "row", "count", "histogram")); + } + + if (this.parameterProvider.hasEnabledSnowpipeStreamingMetrics()) { + JmxReporter jmxReporter = + JmxReporter.forRegistry(this.metrics) + .inDomain(SNOWPIPE_STREAMING_JMX_METRIC_PREFIX) + .convertDurationsTo(TimeUnit.SECONDS) + .createsObjectNamesWith( + (ignoreMeterType, jmxDomain, metricName) -> + getObjectName(this.getName(), jmxDomain, metricName)) + .build(); + jmxReporter.start(); + + // Add JVM and thread metrics too + jvmMemoryAndThreadMetrics = new MetricRegistry(); + jvmMemoryAndThreadMetrics.register( + MetricRegistry.name("jvm", "memory"), new MemoryUsageGaugeSet()); + jvmMemoryAndThreadMetrics.register( + MetricRegistry.name("jvm", "threads"), new ThreadStatesGaugeSet()); + + SharedMetricRegistries.add( + SNOWPIPE_STREAMING_JVM_MEMORY_AND_THREAD_METRICS_REGISTRY, jvmMemoryAndThreadMetrics); + } + + if (metrics.getMetrics().size() != 0) { + SharedMetricRegistries.add(SNOWPIPE_STREAMING_SHARED_METRICS_REGISTRY, metrics); + } } /** @@ -646,13 +1063,11 @@ private void registerMetricsForClient() { * @param metricName metric name used while registering the metric. * @return Object Name constructed from above three args */ - static ObjectName getObjectName(String clientName, String jmxDomain, String metricName) { + private static ObjectName getObjectName(String clientName, String jmxDomain, String metricName) { try { - StringBuilder sb = new StringBuilder(jmxDomain).append(":clientName=").append(clientName); - - sb.append(",name=").append(metricName); + String sb = jmxDomain + ":clientName=" + clientName + ",name=" + metricName; - return new ObjectName(sb.toString()); + return new ObjectName(sb); } catch (MalformedObjectNameException e) { logger.logWarn("Could not create Object name for MetricName={}", metricName); throw new SFException(ErrorCode.INTERNAL_ERROR, "Invalid metric name"); @@ -667,4 +1082,89 @@ private void removeMetricsFromRegistry() { SharedMetricRegistries.remove(SNOWPIPE_STREAMING_SHARED_METRICS_REGISTRY); } } + + /** + * Get the Telemetry Service for a given client + * + * @return TelemetryService used by the client + */ + TelemetryService getTelemetryService() { + return this.requestBuilder == null ? null : requestBuilder.getTelemetryService(); + } + + /** Report streaming ingest related telemetries to Snowflake */ + private void reportStreamingIngestTelemetryToSF() { + TelemetryService telemetryService = getTelemetryService(); + if (telemetryService != null) { + telemetryService.reportLatencyInSec( + this.buildLatency, this.uploadLatency, this.registerLatency, this.flushLatency); + telemetryService.reportThroughputBytesPerSecond(this.inputThroughput, this.uploadThroughput); + telemetryService.reportCpuMemoryUsage(this.cpuHistogram); + } + } + + /** Cleanup any resource during client closing or failures */ + private void cleanUpResources() { + if (this.telemetryWorker != null) { + this.telemetryWorker.shutdown(); + } + if (this.requestBuilder != null) { + this.requestBuilder.closeResources(); + } + if (!this.isTestMode) { + HttpUtil.shutdownHttpConnectionManagerDaemonThread(); + } + } + + /** + * Extract proxy properties from the original Properties object + * + * @return Properties containing proxy configuration + */ + Properties getProxyProperties() { + Properties proxyProperties = new Properties(); + + if (this.originalProperties != null) { + + for (String key : this.originalProperties.stringPropertyNames()) { + if (key.equals(SFSessionProperty.USE_PROXY.getPropertyKey()) + || key.equals(SFSessionProperty.PROXY_HOST.getPropertyKey()) + || key.equals(SFSessionProperty.PROXY_PORT.getPropertyKey()) + || key.equals(SFSessionProperty.NON_PROXY_HOSTS.getPropertyKey()) + || key.equals(SFSessionProperty.PROXY_USER.getPropertyKey()) + || key.equals(SFSessionProperty.PROXY_PASSWORD.getPropertyKey())) { + proxyProperties.put(key, this.originalProperties.getProperty(key)); + } + } + + if (!proxyProperties.isEmpty()) { + logger.logTrace( + "Proxy properties extracted: {}", + proxyProperties.keySet().stream() + .map( + k -> + k + + "=" + + (k.toString().toLowerCase().contains("password") + ? "[HIDDEN]" + : proxyProperties.get(k))) + .collect(Collectors.joining(", "))); + } else { + logger.logDebug( + "No proxy properties found in original properties for client: {}", this.name); + } + } else { + logger.logDebug( + "Original properties is null, cannot extract proxy properties for client: {}", this.name); + } + + // If no proxy properties found in original properties, fall back to system properties + if (proxyProperties.isEmpty()) { + logger.logDebug( + "Falling back to system properties for proxy configuration for client: {}", this.name); + return HttpUtil.generateProxyPropertiesForJDBC(); + } + + return proxyProperties; + } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestResponse.java b/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestResponse.java new file mode 100644 index 000000000..1ec01fceb --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestResponse.java @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +abstract class StreamingIngestResponse { + abstract Long getStatusCode(); +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestResponseCode.java b/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestResponseCode.java new file mode 100644 index 000000000..2a76675cc --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestResponseCode.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import com.google.common.annotations.VisibleForTesting; + +public enum StreamingIngestResponseCode { + SUCCESS(0, "Success"), + ERR_TABLE_DOES_NOT_EXIST_NOT_AUTHORIZED( + 4, "The supplied table does not exist or is not authorized"), + ERR_CHANNEL_LIMIT_EXCEEDED_FOR_TABLE( + 5, + "Channel limit exceeded for the given table, please contact Snowflake support to raise this" + + " limit."), + ERR_TABLE_OR_COLUMN_TYPE_NOT_SUPPORTED( + 6, + "Snowpipe Streaming is not supported on the type of table resolved, or the table contains" + + " columns that are either AUTOINCREMENT or IDENTITY columns or a column with a default" + + " value"), + ERR_ENQUEUE_TABLE_CHUNK_QUEUE_FULL( + 7, + "The table has exceeded its limit of outstanding registration requests, please wait before" + + " calling the insertRow or insertRows API"), + ERR_DATABASE_DOES_NOT_EXIST_OR_NOT_AUTHORIZED( + 8, "The supplied database does not exist or is not authorized"), + ERR_SCHEMA_DOES_NOT_EXIST_OR_NOT_AUTHORIZED( + 9, "The supplied schema does not exist or is not authorized"), + ERR_GENERAL_EXCEPTION_RETRY_REQUEST( + 10, "Snowflake experienced a transient exception, please retry the request"), + ERR_MALFORMED_REQUEST_MISSING_BLOBS_IN_REQUEST( + 11, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MISSING_CHUNKS_FOR_BLOB_IN_REQUEST( + 12, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MISSING_CHANNELS_FOR_CHUNK_FOR_BLOB_IN_REQUEST( + 13, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MISSING_BLOB_PATH( + 14, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MISSING_DATABASE_IN_REQUEST( + 15, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MISSING_SCHEMA_IN_REQUEST( + 16, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MISSING_TABLE_IN_REQUEST( + 17, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MISSING_CHANNEL_NAME_IN_REQUEST( + 18, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_CHANNEL_NO_LONGER_EXISTS( + 19, + "The requested channel no longer exists, most likely due to inactivity. Please re-open the" + + " channel"), + ERR_CHANNEL_HAS_INVALID_CLIENT_SEQUENCER( + 20, + "The channel is owned by another writer at this time. Either re-open the channel to gain" + + " exclusive write ownership or close the channel and let the other writer continue"), + ERR_CHANNEL_HAS_INVALID_ROW_SEQUENCER( + 21, "The client provided an out-of-order message, please reopen the channel"), + ERR_OTHER_CHANNEL_IN_CHUNK_HAS_ISSUE( + 22, + "Another channel managed by this Client had an issue which is preventing the current channel" + + " from ingesting data. Please re-open the channel"), + ERR_MALFORMED_REQUEST_DUPLICATE_CHANNEL_IN_CHUNK( + 23, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MISSING_CLIENT_SEQUENCER_IN_REQUEST( + 24, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_CHANNEL_DOES_NOT_EXIST_OR_IS_NOT_AUTHORIZED( + 25, "The channel does not exist or is not authorized"), + ROW_SEQUENCER_IS_COMMITTED(26, "The requested row sequencer is committed"), + ROW_SEQUENCER_IS_NOT_COMMITTED(27, "The requested row sequencer is not committed"), + + ERR_MALFORMED_REQUEST_MISSING_CHUNK_MD5_IN_REQUEST( + 28, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MISSING_EP_INFO_IN_REQUEST( + 29, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_INVALID_CHUNK_LENGTH( + 30, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_INVALID_ROW_COUNT_IN_EP_INFO( + 31, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_INVALID_COLUMN_IN_EP_INFO( + 32, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_INVALID_EP_INFO_GENERIC( + 33, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_INVALID_BLOB_NAME( + 34, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + + ERR_CHANNEL_MUST_BE_REOPENED(36, "The channel must be reopened"), + ERR_MALFORMED_REQUEST_MISSING_ROLE_IN_REQUEST( + 37, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_BLOB_HAS_WRONG_FORMAT_OR_EXTENSION( + 38, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_BLOB_MISSING_MD5( + 39, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MISSING_COLUMN_IN_EP_INFO( + 40, + "A schema change occurred on the table, please re-open the channel and supply the missing" + + " data"), + ERR_DUPLICATE_BLOB_IN_REQUEST(41, "Duplicated blob in request"), + ERR_UNSUPPORTED_BLOB_FILE_VERSION(42, "Unsupported blob file version"), + ERR_OUTDATED_CLIENT_SDK_VERSION( + 43, "Outdated Client SDK. Please update your SDK to the latest version"), + ERR_MALFORMED_REQUEST_UNDEFINED_USER_AGENT_HEADER( + 44, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_MALFORMED_REQUEST_MALFORMED_USER_AGENT_HEADER( + 45, StreamingIngestResponseCode.MALFORMED_REQUEST_STATUS_MESSAGE), + ERR_INTERLEAVING_NOT_SUPPORTED_AT_THIS_TIME( + 46, "Interleaving among tables is not supported at this time"), + ERR_RESOLVED_TABLE_IS_READ_ONLY(47, "Table is read-only"), + ERR_MALFORMED_REQUEST_INVALID_VAL_IN_EP_INFO( + 48, "The request contains invalid column metadata, please contact Snowflake support"), + ERR_INGESTION_ON_TABLE_NOT_ALLOWED( + 49, + "Ingestion into this table is not allowed at this time, please contact Snowflake support"); + + public static final String UNKNOWN_STATUS_MESSAGE = + "Unknown status message. Please contact Snowflake support for further assistance"; + + public static final String MALFORMED_REQUEST_STATUS_MESSAGE = + "The SDK generated a malformed request. Please contact Snowflake support for further " + + " assistance"; + + private final long statusCode; + + private final String message; + + StreamingIngestResponseCode(int statusCode, String message) { + this.statusCode = statusCode; + this.message = message; + } + + @VisibleForTesting + public long getStatusCode() { + return statusCode; + } + + @VisibleForTesting + public String getMessage() { + return message; + } + + public static String getMessageByCode(Long statusCode) { + if (statusCode != null) { + for (StreamingIngestResponseCode code : StreamingIngestResponseCode.values()) { + if (code.statusCode == statusCode) { + return code.message; + } + } + } + return UNKNOWN_STATUS_MESSAGE; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestStage.java b/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestStage.java index 94a871b6f..bd1ca79c7 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestStage.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestStage.java @@ -4,12 +4,14 @@ package net.snowflake.ingest.streaming.internal; -import static net.snowflake.client.core.Constants.CLOUD_STORAGE_CREDENTIALS_EXPIRED; import static net.snowflake.ingest.connection.ServiceResponseHandler.ApiName.STREAMING_CLIENT_CONFIGURE; +import static net.snowflake.ingest.streaming.internal.StreamingIngestUtils.executeWithRetries; import static net.snowflake.ingest.utils.Constants.CLIENT_CONFIGURE_ENDPOINT; import static net.snowflake.ingest.utils.Constants.RESPONSE_SUCCESS; import static net.snowflake.ingest.utils.HttpUtil.generateProxyPropertiesForJDBC; +import static net.snowflake.ingest.utils.Utils.getStackTrace; +import com.google.common.annotations.VisibleForTesting; import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; @@ -20,6 +22,7 @@ import java.util.Optional; import java.util.Properties; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import net.snowflake.client.core.OCSPMode; import net.snowflake.client.jdbc.SnowflakeFileTransferAgent; import net.snowflake.client.jdbc.SnowflakeFileTransferConfig; @@ -27,24 +30,24 @@ import net.snowflake.client.jdbc.SnowflakeSQLException; import net.snowflake.client.jdbc.cloud.storage.StageInfo; import net.snowflake.client.jdbc.internal.apache.commons.io.FileUtils; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; import net.snowflake.client.jdbc.internal.fasterxml.jackson.databind.JsonNode; import net.snowflake.client.jdbc.internal.fasterxml.jackson.databind.ObjectMapper; import net.snowflake.client.jdbc.internal.fasterxml.jackson.databind.node.ObjectNode; import net.snowflake.ingest.connection.IngestResponseException; import net.snowflake.ingest.connection.RequestBuilder; -import net.snowflake.ingest.connection.ServiceResponseHandler; import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.Logging; import net.snowflake.ingest.utils.SFException; import net.snowflake.ingest.utils.Utils; -import org.apache.arrow.util.VisibleForTesting; -import org.apache.http.client.HttpClient; /** Handles uploading files to the Snowflake Streaming Ingest Stage */ class StreamingIngestStage { private static final ObjectMapper mapper = new ObjectMapper(); private static final long REFRESH_THRESHOLD_IN_MS = TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES); - static final int MAX_RETRY_COUNT = 1; + + private static final Logging logger = new Logging(StreamingIngestStage.class); /** * Wrapper class containing SnowflakeFileTransferMetadata and the timestamp at which the metadata @@ -55,7 +58,7 @@ static class SnowflakeFileTransferMetadataWithAge { private final boolean isLocalFS; private final String localLocation; - /* Do do not always know the age of the metadata, so we use the empty + /* Do not always know the age of the metadata, so we use the empty state to record unknown age. */ Optional timestamp; @@ -76,27 +79,55 @@ state to record unknown age. } private SnowflakeFileTransferMetadataWithAge fileTransferMetadataWithAge; - private final HttpClient httpClient; + private final CloseableHttpClient httpClient; private final RequestBuilder requestBuilder; private final String role; private final String clientName; private String clientPrefix; + private final int maxUploadRetries; + // Proxy parameters that we set while calling the Snowflake JDBC to upload the streams private final Properties proxyProperties; StreamingIngestStage( boolean isTestMode, String role, - HttpClient httpClient, + CloseableHttpClient httpClient, + RequestBuilder requestBuilder, + String clientName, + int maxUploadRetries) + throws SnowflakeSQLException, IOException { + this(isTestMode, role, httpClient, requestBuilder, clientName, maxUploadRetries, null); + } + + /** + * Constructor with proxy properties support + * + * @param isTestMode whether we're under test mode + * @param role Snowflake role used by the Client + * @param httpClient http client reference + * @param requestBuilder request builder to build the HTTP request + * @param clientName the client name + * @param maxUploadRetries maximum number of upload retries + * @param proxyProperties proxy properties for HTTP client configuration + */ + StreamingIngestStage( + boolean isTestMode, + String role, + CloseableHttpClient httpClient, RequestBuilder requestBuilder, - String clientName) + String clientName, + int maxUploadRetries, + Properties proxyProperties) throws SnowflakeSQLException, IOException { this.httpClient = httpClient; this.role = role; this.requestBuilder = requestBuilder; this.clientName = clientName; - this.proxyProperties = generateProxyPropertiesForJDBC(); + this.proxyProperties = + proxyProperties != null ? proxyProperties : generateProxyPropertiesForJDBC(); + this.maxUploadRetries = maxUploadRetries; if (!isTestMode) { refreshSnowflakeMetadata(); @@ -116,12 +147,13 @@ state to record unknown age. StreamingIngestStage( boolean isTestMode, String role, - HttpClient httpClient, + CloseableHttpClient httpClient, RequestBuilder requestBuilder, String clientName, - SnowflakeFileTransferMetadataWithAge testMetadata) + SnowflakeFileTransferMetadataWithAge testMetadata, + int maxRetryCount) throws SnowflakeSQLException, IOException { - this(isTestMode, role, httpClient, requestBuilder, clientName); + this(isTestMode, role, httpClient, requestBuilder, clientName, maxRetryCount, null); if (!isTestMode) { throw new SFException(ErrorCode.INTERNAL_ERROR); } @@ -142,8 +174,7 @@ void putRemote(String fullFilePath, byte[] data) throws SnowflakeSQLException, I private void putRemote(String fullFilePath, byte[] data, int retryCount) throws SnowflakeSQLException, IOException { SnowflakeFileTransferMetadataV1 fileTransferMetadataCopy; - if (this.fileTransferMetadataWithAge.fileTransferMetadata.getStageInfo().getStageType() - == StageInfo.StageType.GCS) { + if (this.fileTransferMetadataWithAge.fileTransferMetadata.isForOneFile()) { fileTransferMetadataCopy = this.fetchSignedURL(fullFilePath); } else { // Set file path to be uploaded @@ -182,20 +213,39 @@ private void putRemote(String fullFilePath, byte[] data, int retryCount) .setStreamingIngestClientKey(this.clientPrefix) .setStreamingIngestClientName(this.clientName) .setProxyProperties(this.proxyProperties) + .setDestFileName(fullFilePath) .build()); - } catch (SnowflakeSQLException e) { - if (e.getErrorCode() != CLOUD_STORAGE_CREDENTIALS_EXPIRED || retryCount >= MAX_RETRY_COUNT) { - throw e; - } - this.refreshSnowflakeMetadata(); - this.putRemote(fullFilePath, data, ++retryCount); } catch (Exception e) { - throw new SFException(e, ErrorCode.IO_ERROR); + if (retryCount == 0) { + // for the first exception, we always perform a metadata refresh. + logger.logInfo( + "Stage metadata need to be refreshed due to upload error: {} on first retry attempt", + e.getMessage()); + this.refreshSnowflakeMetadata(); + } + if (retryCount >= maxUploadRetries) { + logger.logError( + "Failed to upload to stage, retry attempts exhausted ({}), client={}, message={}", + maxUploadRetries, + clientName, + e.getMessage()); + throw new SFException(e, ErrorCode.IO_ERROR); + } + retryCount++; + StreamingIngestUtils.sleepForRetry(retryCount); + logger.logInfo( + "Retrying upload, attempt {}/{} msg: {}, stackTrace:{}", + retryCount, + maxUploadRetries, + e.getMessage(), + getStackTrace(e)); + this.putRemote(fullFilePath, data, retryCount); } } SnowflakeFileTransferMetadataWithAge refreshSnowflakeMetadata() throws SnowflakeSQLException, IOException { + logger.logInfo("Refresh Snowflake metadata, client={}", clientName); return refreshSnowflakeMetadata(false); } @@ -225,7 +275,7 @@ synchronized SnowflakeFileTransferMetadataWithAge refreshSnowflakeMetadata(boole JsonNode responseNode = this.parseClientConfigureResponse(response); // Do not change the prefix everytime we have to refresh credentials if (Utils.isNullOrEmpty(this.clientPrefix)) { - this.clientPrefix = responseNode.get("prefix").textValue(); + this.clientPrefix = createClientPrefix(responseNode); } Utils.assertStringNotNullOrEmpty("client prefix", this.clientPrefix); @@ -258,6 +308,22 @@ synchronized SnowflakeFileTransferMetadataWithAge refreshSnowflakeMetadata(boole return this.fileTransferMetadataWithAge; } + /** + * Creates a client-specific prefix that will be also part of the files registered by this client. + * The prefix will include a server-side generated string and the GlobalID of the deployment the + * client is registering blobs to. The latter (deploymentId) is needed in order to guarantee that + * blob filenames are unique across deployments even with replication enabled. + * + * @param response the client/configure response from the server + * @return the client prefix. + */ + private String createClientPrefix(final JsonNode response) { + final String prefix = response.get("prefix").textValue(); + final String deploymentId = + response.has("deployment_id") ? "_" + response.get("deployment_id").longValue() : ""; + return prefix + deploymentId; + } + /** * GCS requires a signed url per file. We need to fetch this from the server for each put * @@ -282,6 +348,20 @@ SnowflakeFileTransferMetadataV1 fetchSignedURL(String fileName) return metadata; } + private static class MapStatusGetter implements Function { + public MapStatusGetter() {} + + public Long apply(T input) { + try { + return ((Integer) ((Map) input).get("status_code")).longValue(); + } catch (Exception e) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "failed to get status_code from response"); + } + } + } + + private static final MapStatusGetter statusGetter = new MapStatusGetter(); + private JsonNode parseClientConfigureResponse(Map response) { JsonNode responseNode = mapper.valueToTree(response); @@ -300,13 +380,17 @@ private JsonNode parseClientConfigureResponse(Map response) { private Map makeClientConfigureCall(Map payload) throws IOException { try { + Map response = - ServiceResponseHandler.unmarshallStreamingIngestResponse( - httpClient.execute( - requestBuilder.generateStreamingIngestPostRequest( - payload, CLIENT_CONFIGURE_ENDPOINT, "client configure")), + executeWithRetries( Map.class, - STREAMING_CLIENT_CONFIGURE); + CLIENT_CONFIGURE_ENDPOINT, + mapper.writeValueAsString(payload), + "client configure", + STREAMING_CLIENT_CONFIGURE, + httpClient, + requestBuilder, + statusGetter); // Check for Snowflake specific response code if (!response.get("status_code").equals((int) RESPONSE_SUCCESS)) { @@ -315,7 +399,7 @@ private Map makeClientConfigureCall(Map payload) } return response; } catch (IngestResponseException e) { - throw new SFException(e, ErrorCode.CLIENT_CONFIGURE_FAILURE); + throw new SFException(e, ErrorCode.CLIENT_CONFIGURE_FAILURE, e.getMessage()); } } @@ -356,7 +440,6 @@ void putLocal(String fullFilePath, byte[] data) { InputStream input = new ByteArrayInputStream(data); try { String stageLocation = this.fileTransferMetadataWithAge.localLocation; - Paths.get(stageLocation, fullFilePath); File destFile = Paths.get(stageLocation, fullFilePath).toFile(); FileUtils.copyInputStreamToFile(input, destFile); } catch (Exception ex) { diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestUtils.java b/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestUtils.java new file mode 100644 index 000000000..56e960064 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/StreamingIngestUtils.java @@ -0,0 +1,157 @@ +package net.snowflake.ingest.streaming.internal; + +import static net.snowflake.ingest.utils.Constants.MAX_STREAMING_INGEST_API_CHANNEL_RETRY; +import static net.snowflake.ingest.utils.Constants.RESPONSE_ERR_GENERAL_EXCEPTION_RETRY_REQUEST; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.Map; +import java.util.function.Function; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.CloseableHttpResponse; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpUriRequest; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import net.snowflake.ingest.connection.IngestResponseException; +import net.snowflake.ingest.connection.RequestBuilder; +import net.snowflake.ingest.connection.ServiceResponseHandler; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.Logging; +import net.snowflake.ingest.utils.SFException; + +public class StreamingIngestUtils { + + private static class DefaultStatusGetter + implements Function { + public DefaultStatusGetter() {} + + public Long apply(T input) { + return input.getStatusCode(); + } + } + + private static final DefaultStatusGetter defaultStatusGetter = new DefaultStatusGetter(); + + private static final Logging LOGGER = new Logging(StreamingIngestUtils.class); + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * How many milliseconds of exponential backoff to sleep before retrying the request again: + * + *

      + *
    • 0 or 1 failure => no sleep + *
    • 2 failures => 1s + *
    • 3 failures => 2s + *
    • 4 or more failures => 4s + *
    + * + * @param executionCount How many unsuccessful attempts have been attempted + * @return Sleep time in ms + */ + static long getSleepForRetryMs(int executionCount) { + if (executionCount < 0) { + throw new IllegalArgumentException( + String.format( + "executionCount must be a non-negative integer, passed: %d", executionCount)); + } else if (executionCount < 2) { + return 0; + } else { + final int effectiveExecutionCount = Math.min(executionCount, 4); + return (1 << (effectiveExecutionCount - 2)) * 1000L; + } + } + + public static void sleepForRetry(int executionCount) { + long sleepForRetryMs = getSleepForRetryMs(executionCount); + if (sleepForRetryMs == 0) { + return; + } + + try { + Thread.sleep(sleepForRetryMs); + } catch (InterruptedException e) { + throw new SFException(ErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } + + static T executeWithRetries( + Class targetClass, + String endpoint, + Map payload, + String message, + ServiceResponseHandler.ApiName apiName, + CloseableHttpClient httpClient, + RequestBuilder requestBuilder) + throws IOException, IngestResponseException { + String payloadInString; + try { + payloadInString = objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + throw new SFException(e, ErrorCode.BUILD_REQUEST_FAILURE, message); + } + return executeWithRetries( + targetClass, endpoint, payloadInString, message, apiName, httpClient, requestBuilder); + } + + static T executeWithRetries( + Class targetClass, + String endpoint, + String payload, + String message, + ServiceResponseHandler.ApiName apiName, + CloseableHttpClient httpClient, + RequestBuilder requestBuilder) + throws IOException, IngestResponseException { + return (T) + executeWithRetries( + targetClass, + endpoint, + payload, + message, + apiName, + httpClient, + requestBuilder, + defaultStatusGetter); + } + + static T executeWithRetries( + Class targetClass, + String endpoint, + String payload, + String message, + ServiceResponseHandler.ApiName apiName, + CloseableHttpClient httpClient, + RequestBuilder requestBuilder, + Function statusGetter) + throws IOException, IngestResponseException { + int retries = 0; + T response; + HttpUriRequest request = + requestBuilder.generateStreamingIngestPostRequest(payload, endpoint, message); + do { + try (CloseableHttpResponse httpResponse = httpClient.execute(request)) { + response = + ServiceResponseHandler.unmarshallStreamingIngestResponse( + httpResponse, targetClass, apiName, httpClient, request, requestBuilder); + } + + if (statusGetter.apply(response) == RESPONSE_ERR_GENERAL_EXCEPTION_RETRY_REQUEST) { + LOGGER.logDebug( + "Retrying request for streaming ingest, endpoint={}, retryCount={}, responseCode={}", + endpoint, + retries, + statusGetter.apply(response)); + retries++; + sleepForRetry(retries); + } else { + return response; + } + } while (retries <= MAX_STREAMING_INGEST_API_CHANNEL_RETRY); + return response; + } + + public static String getShortname(final String fullname) { + final String[] parts = fullname.split("/"); + return parts[parts.length - 1]; + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/TimestampWrapper.java b/src/main/java/net/snowflake/ingest/streaming/internal/TimestampWrapper.java index b0c82369d..a2dc55570 100644 --- a/src/main/java/net/snowflake/ingest/streaming/internal/TimestampWrapper.java +++ b/src/main/java/net/snowflake/ingest/streaming/internal/TimestampWrapper.java @@ -1,79 +1,98 @@ /* - * Copyright (c) 2021 Snowflake Computing Inc. All rights reserved. + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. */ package net.snowflake.ingest.streaming.internal; +import java.math.BigDecimal; import java.math.BigInteger; -import java.util.Objects; -import java.util.Optional; -import net.snowflake.client.jdbc.internal.snowflake.common.core.SFTimestamp; +import java.math.RoundingMode; +import java.time.OffsetDateTime; +import net.snowflake.client.jdbc.internal.snowflake.common.util.Power10; -class TimestampWrapper { - - /** Seconds since the epoch */ - private long epoch; - - /** Fraction of a second since the epoch in nanoseconds */ - private int fraction; - - /** Epoch time in column's scale, e.g. for scale=3 this milliseconds past the epoch */ - private BigInteger timeInScale; - - /** SFTimestamp including timezone information */ - private Optional sfTimestamp = Optional.empty(); - - public TimestampWrapper(long epoch, int fraction, BigInteger timeInScale) { - this.epoch = epoch; - this.fraction = fraction; - this.timeInScale = timeInScale; +/** + * This class represents the outcome of timestamp parsing and validation. It contains methods needed + * to serialize timestamps into Parquet. + */ +public class TimestampWrapper { + + /** Epoch seconds */ + private final long epoch; + + /** Fractional part of the second */ + private final int fraction; + + /** Timezone offset in seconds */ + private final int timezoneOffsetSeconds; + + /** Scale of the timestamp column (0-9) */ + private final int scale; + + /** + * How many bits should be reserver for the timezone part. Needs to be aligned with {@link + * net.snowflake.client.jdbc.internal.snowflake.common.core.SFTimestamp#BITS_FOR_TIMEZONE} + */ + private static final int BITS_FOR_TIMEZONE = 14; + + /** + * Mask of the timezone bits. Needs to be aligned with {@link + * net.snowflake.client.jdbc.internal.snowflake.common.core.SFTimestamp#MASK_OF_TIMEZONE} + */ + private static final int MASK_OF_TIMEZONE = (1 << BITS_FOR_TIMEZONE) - 1; + + /** Create a new instance from {@link OffsetDateTime} and its scale. */ + public TimestampWrapper(OffsetDateTime offsetDateTime, int scale) { + if (scale < 0 || scale > 9) { + throw new IllegalArgumentException( + String.format("Scale must be between 0 and 9, actual: %d", scale)); + } + this.epoch = offsetDateTime.toEpochSecond(); + this.fraction = + offsetDateTime.getNano() / Power10.intTable[9 - scale] * Power10.intTable[9 - scale]; + this.timezoneOffsetSeconds = offsetDateTime.getOffset().getTotalSeconds(); + this.scale = scale; } - public TimestampWrapper( - long epoch, int fraction, BigInteger timeInScale, SFTimestamp sfTimestamp) { - this.epoch = epoch; - this.fraction = fraction; - this.timeInScale = timeInScale; - this.sfTimestamp = Optional.ofNullable(sfTimestamp); + /** + * Convert the timestamp to a binary representation. Needs to be aligned with {@link + * net.snowflake.client.jdbc.internal.snowflake.common.core.SFTimestamp#toBinary}. + */ + public BigInteger toBinary(boolean includeTimezone) { + BigDecimal timeInNs = + BigDecimal.valueOf(epoch).scaleByPowerOfTen(9).add(new BigDecimal(fraction)); + BigDecimal scaledTime = timeInNs.scaleByPowerOfTen(scale - 9); + scaledTime = scaledTime.setScale(0, RoundingMode.DOWN); + BigInteger fcpInt = scaledTime.unscaledValue(); + if (includeTimezone) { + int offsetMin = timezoneOffsetSeconds / 60; + assert offsetMin >= -1440 && offsetMin <= 1440; + offsetMin += 1440; + fcpInt = fcpInt.shiftLeft(14); + fcpInt = fcpInt.add(BigInteger.valueOf(offsetMin & MASK_OF_TIMEZONE)); + } + return fcpInt; } - public long getEpoch() { + /** Get epoch in seconds */ + public long getEpochSecond() { return epoch; } + /** Get fractional part of a second */ public int getFraction() { return fraction; } - public BigInteger getTimeInScale() { - return timeInScale; - } - - public Optional getTimezoneOffset() { - return sfTimestamp.map(t -> t.getTimeZoneOffsetMillis()); - } - - public Optional getTimeZoneIndex() { - return this.getTimezoneOffset().map(t -> (t / 1000 / 60) + 1440); - } - - public Optional getSfTimestamp() { - return sfTimestamp; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - TimestampWrapper that = (TimestampWrapper) o; - return epoch == that.epoch - && fraction == that.fraction - && Objects.equals(timeInScale, that.timeInScale) - && sfTimestamp.equals(that.sfTimestamp); + /** Get timezone offset in seconds */ + public int getTimezoneOffsetSeconds() { + return timezoneOffsetSeconds; } - @Override - public int hashCode() { - return Objects.hash(epoch, fraction, timeInScale, sfTimestamp); + /** + * Get timezone index, 1440 means UTC. Calculation needs to be aligned with {@link + * net.snowflake.client.jdbc.internal.snowflake.common.core.SFTimestamp#toBinary} + */ + public int getTimeZoneIndex() { + return timezoneOffsetSeconds / 60 + 1440; } } diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/serialization/ByteArraySerializer.java b/src/main/java/net/snowflake/ingest/streaming/internal/serialization/ByteArraySerializer.java new file mode 100644 index 000000000..036b9a509 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/serialization/ByteArraySerializer.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal.serialization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; + +/** + * Serialize Java byte arrays as JSON arrays of numbers instead of the default Jackson + * base64-encoding. + */ +public class ByteArraySerializer extends JsonSerializer { + @Override + public void serialize(byte[] value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeStartArray(); + for (byte v : value) { + gen.writeNumber(v); + } + gen.writeEndArray(); + } +} diff --git a/src/main/java/net/snowflake/ingest/streaming/internal/serialization/ZonedDateTimeSerializer.java b/src/main/java/net/snowflake/ingest/streaming/internal/serialization/ZonedDateTimeSerializer.java new file mode 100644 index 000000000..3760362e8 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/streaming/internal/serialization/ZonedDateTimeSerializer.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal.serialization; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import java.time.ZonedDateTime; + +/** Snowflake does not support parsing zones, so serialize it in offset instead */ +public class ZonedDateTimeSerializer extends JsonSerializer { + @Override + public void serialize(ZonedDateTime value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeString(value.toOffsetDateTime().toString()); + } +} diff --git a/src/main/java/net/snowflake/ingest/utils/Constants.java b/src/main/java/net/snowflake/ingest/utils/Constants.java index c91be1a4a..134328668 100644 --- a/src/main/java/net/snowflake/ingest/utils/Constants.java +++ b/src/main/java/net/snowflake/ingest/utils/Constants.java @@ -4,6 +4,9 @@ package net.snowflake.ingest.utils; +import java.util.Arrays; +import org.apache.parquet.hadoop.metadata.CompressionCodecName; + /** Contains all the constants needed for Streaming Ingest */ public class Constants { @@ -20,16 +23,24 @@ public class Constants { public static final String SCHEME = "scheme"; public static final String ACCOUNT_URL = "url"; public static final String ROLE = "role"; + public static final String AUTHORIZATION_TYPE = "authorization_type"; + public static final String JWT = "JWT"; + public static final String OAUTH = "OAuth"; public static final String PRIVATE_KEY = "private_key"; public static final String PRIVATE_KEY_PASSPHRASE = "private_key_passphrase"; - public static final String JDBC_PRIVATE_KEY = "privateKey"; + public static final String OAUTH_CLIENT_ID = "oauth_client_id"; + public static final String OAUTH_CLIENT_SECRET = "oauth_client_secret"; + public static final String OAUTH_REFRESH_TOKEN = "oauth_refresh_token"; + public static final String PRIMARY_FILE_ID_KEY = + "primaryFileId"; // Don't change, should match Parquet Scanner public static final long RESPONSE_SUCCESS = 0L; // Don't change, should match server side - public static final long BLOB_UPLOAD_TIMEOUT_IN_SEC = 5L; - public static final int BLOB_UPLOAD_MAX_RETRY_COUNT = 12; - public static final int INSERT_THROTTLE_MAX_RETRY_COUNT = 10; - public static final long MAX_BLOB_SIZE_IN_BYTES = 512000000L; - public static final long MAX_CHUNK_SIZE_IN_BYTES = 32000000L; - public static final byte BLOB_FORMAT_VERSION = 0; + public static final long RESPONSE_ERR_GENERAL_EXCEPTION_RETRY_REQUEST = + 10L; // Don't change, should match server side + public static final long RESPONSE_ERR_ENQUEUE_TABLE_CHUNK_QUEUE_FULL = + 7L; // Don't change, should match server side + public static final int BLOB_UPLOAD_TIMEOUT_IN_SEC = 5; + public static final int INSERT_THROTTLE_MAX_RETRY_COUNT = 60; + public static final long MAX_BLOB_SIZE_IN_BYTES = 256000000L; public static final int BLOB_TAG_SIZE_IN_BYTES = 4; public static final int BLOB_VERSION_SIZE_IN_BYTES = 1; public static final int BLOB_FILE_SIZE_SIZE_IN_BYTES = 8; @@ -38,29 +49,101 @@ public class Constants { public static final long THREAD_SHUTDOWN_TIMEOUT_IN_SEC = 300L; public static final String BLOB_EXTENSION_TYPE = "bdec"; public static final int MAX_THREAD_COUNT = Integer.MAX_VALUE; - public static final int CPU_IO_TIME_RATIO = 1; public static final String CLIENT_CONFIGURE_ENDPOINT = "/v1/streaming/client/configure/"; - public static final int COMMIT_MAX_RETRY_COUNT = 10; - public static final int COMMIT_RETRY_INTERVAL_IN_MS = 500; - public static final int RESPONSE_ROW_SEQUENCER_IS_COMMITTED = - 26; // Don't change, should match server side - public static final String ENCRYPTION_ALGORITHM = "AES/CTR/PKCS7Padding"; - public static final long ENCRYPTION_ALGORITHM_BLOCK_SIZE_BYTES = 16; + public static final int COMMIT_MAX_RETRY_COUNT = 60; + public static final int COMMIT_RETRY_INTERVAL_IN_MS = 1000; + public static final String ENCRYPTION_ALGORITHM = "AES/CTR/NoPadding"; + public static final int ENCRYPTION_ALGORITHM_BLOCK_SIZE_BYTES = 16; + public static final int MAX_STREAMING_INGEST_API_CHANNEL_RETRY = 3; + public static final int STREAMING_INGEST_TELEMETRY_UPLOAD_INTERVAL_IN_SEC = 10; + public static final long EP_NDV_UNKNOWN = -1L; + public static final int MAX_OAUTH_REFRESH_TOKEN_RETRY = 3; // Channel level constants public static final String CHANNEL_STATUS_ENDPOINT = "/v1/streaming/channels/status/"; public static final String OPEN_CHANNEL_ENDPOINT = "/v1/streaming/channels/open/"; + public static final String DROP_CHANNEL_ENDPOINT = "/v1/streaming/channels/drop/"; public static final String REGISTER_BLOB_ENDPOINT = "/v1/streaming/channels/write/blobs/"; - public static enum WriteMode { + public enum WriteMode { CLOUD_STORAGE, REST_API, } + /** The write mode to generate BDEC file. */ + public enum BdecVersion { + // Unused (previously Arrow) + // ONE(1), + + // Unused (previously Arrow with per column compression. + // TWO(2), + + /** + * Uses Parquet to generate BDEC chunks with {@link + * net.snowflake.ingest.streaming.internal.ParquetRowBuffer} (page-level compression). This + * version is experimental and WIP at the moment. + */ + THREE(3); + + private final byte version; + + BdecVersion(int version) { + if (version > Byte.MAX_VALUE || version < Byte.MIN_VALUE) { + throw new IllegalArgumentException("Version does not fit into the byte data type"); + } + this.version = (byte) version; + } + + public byte toByte() { + return version; + } + + public static BdecVersion fromInt(int val) { + if (val > Byte.MAX_VALUE || val < Byte.MIN_VALUE) { + throw new IllegalArgumentException("Version does not fit into the byte data type"); + } + byte version = (byte) val; + for (BdecVersion eversion : BdecVersion.values()) { + if (eversion.version == version) { + return eversion; + } + } + throw new IllegalArgumentException( + String.format( + "Unsupported BLOB_FORMAT_VERSION = '%d', allowed values are %s", + version, Arrays.asList(BdecVersion.values()))); + } + } + + /** + * Compression algorithm supported by BDEC Parquet Writer. It is a wrapper around Parquet's lib + * CompressionCodecName, but we want to control and allow only specific values of that. + */ + public enum BdecParquetCompression { + GZIP, + ZSTD; + + public CompressionCodecName getCompressionCodec() { + return CompressionCodecName.fromConf(this.name()); + } + + public static BdecParquetCompression fromName(String name) { + for (BdecParquetCompression e : BdecParquetCompression.values()) { + if (e.name().toLowerCase().equals(name.toLowerCase())) { + return e; + } + } + throw new IllegalArgumentException( + String.format( + "Unsupported BDEC_PARQUET_COMPRESSION_ALGORITHM = '%s', allowed values are %s", + name, Arrays.asList(BdecParquetCompression.values()))); + } + } // Parameters public static final boolean DISABLE_BACKGROUND_FLUSH = false; public static final boolean COMPRESS_BLOB_TWICE = false; public static final boolean BLOB_NO_HEADER = true; + public static final boolean ENABLE_TELEMETRY_TO_SF = true; // Metrics public static final String SNOWPIPE_STREAMING_JMX_METRIC_PREFIX = "snowflake.ingest.sdk"; diff --git a/src/main/java/net/snowflake/ingest/utils/Cryptor.java b/src/main/java/net/snowflake/ingest/utils/Cryptor.java index 92c4429ab..ae7199417 100644 --- a/src/main/java/net/snowflake/ingest/utils/Cryptor.java +++ b/src/main/java/net/snowflake/ingest/utils/Cryptor.java @@ -2,6 +2,7 @@ import static net.snowflake.ingest.utils.Constants.ENCRYPTION_ALGORITHM; +import com.google.common.annotations.VisibleForTesting; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.InvalidAlgorithmParameterException; @@ -16,7 +17,6 @@ import javax.crypto.SecretKey; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; -import org.apache.arrow.util.VisibleForTesting; public class Cryptor { @@ -103,7 +103,7 @@ public static byte[] encrypt( // Generate the derived key SecretKey derivedKey = deriveKey(encryptionKey, diversifier); - // Encrypt with zero IV + // Encrypt with the corresponding IV Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM); byte[] ivBytes = ByteBuffer.allocate(2 * Long.BYTES).putLong(0).putLong(iv).array(); cipher.init(Cipher.ENCRYPT_MODE, derivedKey, new IvParameterSpec(ivBytes)); diff --git a/src/main/java/net/snowflake/ingest/utils/ErrorCode.java b/src/main/java/net/snowflake/ingest/utils/ErrorCode.java index 68ab2b569..46c11a485 100644 --- a/src/main/java/net/snowflake/ingest/utils/ErrorCode.java +++ b/src/main/java/net/snowflake/ingest/utils/ErrorCode.java @@ -9,7 +9,7 @@ public enum ErrorCode { INTERNAL_ERROR("0001"), NULL_VALUE("0002"), NULL_OR_EMPTY_STRING("0003"), - INVALID_ROW("0004"), + INVALID_FORMAT_ROW("0004"), UNKNOWN_DATA_TYPE("0005"), REGISTER_BLOB_FAILURE("0006"), OPEN_CHANNEL_FAILURE("0007"), @@ -30,9 +30,19 @@ public enum ErrorCode { KEYPAIR_CREATION_FAILURE("0022"), MD5_HASHING_NOT_AVAILABLE("0023"), CHANNEL_STATUS_FAILURE("0024"), - CHANNEL_WITH_UNCOMMITTED_ROWS("0025"), + CHANNELS_WITH_UNCOMMITTED_ROWS("0025"), INVALID_COLLATION_STRING("0026"), - ENCRYPTION_FAILURE("0027"); + ENCRYPTION_FAILURE("0027"), + CHANNEL_STATUS_INVALID("0028"), + UNSUPPORTED_DATA_TYPE("0029"), + INVALID_VALUE_ROW("0030"), + MAX_ROW_SIZE_EXCEEDED("0031"), + MAKE_URI_FAILURE("0032"), + OAUTH_REFRESH_TOKEN_ERROR("0033"), + INVALID_CONFIG_PARAMETER("0034"), + MAX_BATCH_SIZE_EXCEEDED("0035"), + CRYPTO_PROVIDER_ERROR("0036"), + DROP_CHANNEL_FAILURE("0037"); public static final String errorMessageResource = "net.snowflake.ingest.ingest_error_messages"; diff --git a/src/main/java/net/snowflake/ingest/utils/HttpClientSettingsKey.java b/src/main/java/net/snowflake/ingest/utils/HttpClientSettingsKey.java new file mode 100644 index 000000000..d25641980 --- /dev/null +++ b/src/main/java/net/snowflake/ingest/utils/HttpClientSettingsKey.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2012-2017 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.utils; + +import static net.snowflake.ingest.utils.Utils.isNullOrEmpty; + +import java.io.Serializable; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class defines all parameters needed to create an HttpClient object for the ingest service. + * It is used as the key for the static hashmap of reusable http clients. + */ +public class HttpClientSettingsKey implements Serializable { + + private static final Logger LOGGER = LoggerFactory.getLogger(HttpClientSettingsKey.class); + + private boolean useProxy; + private String proxyHost = ""; + private int proxyPort = 0; + private String nonProxyHosts = ""; + private String proxyUser = ""; + private String proxyPassword = ""; + private String accountName = ""; + + /** Constructor for proxy configuration */ + public HttpClientSettingsKey( + String accountName, + String proxyHost, + int proxyPort, + String nonProxyHosts, + String proxyUser, + String proxyPassword) { + this.useProxy = true; + this.accountName = !isNullOrEmpty(accountName) ? accountName.trim() : ""; + this.proxyHost = !isNullOrEmpty(proxyHost) ? proxyHost.trim() : ""; + this.proxyPort = proxyPort; + this.nonProxyHosts = !isNullOrEmpty(nonProxyHosts) ? nonProxyHosts.trim() : ""; + this.proxyUser = !isNullOrEmpty(proxyUser) ? proxyUser.trim() : ""; + this.proxyPassword = !isNullOrEmpty(proxyPassword) ? proxyPassword.trim() : ""; + + LOGGER.trace( + "Created HttpClientSettingsKey with proxy configuration for account: {}. Host: {}, Port:" + + " {}, User: {}, NonProxyHosts: {}", + this.accountName, + this.proxyHost, + this.proxyPort, + !isNullOrEmpty(this.proxyUser) ? "set" : "not set", + !isNullOrEmpty(this.nonProxyHosts) ? this.nonProxyHosts : "not set"); + } + + /** Constructor for non-proxy configuration */ + public HttpClientSettingsKey(String accountName) { + this.useProxy = false; + this.accountName = !isNullOrEmpty(accountName) ? accountName.trim() : ""; + + LOGGER.debug( + "Created HttpClientSettingsKey without proxy configuration for account: {}", + this.accountName); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + + HttpClientSettingsKey that = (HttpClientSettingsKey) obj; + + return useProxy == that.useProxy + && proxyPort == that.proxyPort + && Objects.equals(accountName, that.accountName) + && Objects.equals(proxyHost, that.proxyHost) + && Objects.equals(nonProxyHosts, that.nonProxyHosts) + && Objects.equals(proxyUser, that.proxyUser) + && Objects.equals(proxyPassword, that.proxyPassword); + } + + @Override + public int hashCode() { + return Objects.hash( + useProxy, accountName, proxyHost, proxyPort, nonProxyHosts, proxyUser, proxyPassword); + } + + public boolean usesProxy() { + return this.useProxy; + } + + public String getAccountName() { + return this.accountName; + } + + public String getProxyHost() { + return this.proxyHost; + } + + public int getProxyPort() { + return this.proxyPort; + } + + public String getProxyUser() { + return this.proxyUser; + } + + public String getProxyPassword() { + return this.proxyPassword; + } + + public String getNonProxyHosts() { + return this.nonProxyHosts; + } + + @Override + public String toString() { + return "HttpClientSettingsKey[" + + "accountName='" + + accountName + + '\'' + + ", useProxy=" + + useProxy + + ", proxyHost='" + + proxyHost + + '\'' + + ", proxyPort=" + + proxyPort + + ", nonProxyHosts='" + + nonProxyHosts + + '\'' + + ", proxyUser='" + + proxyUser + + '\'' + + ", proxyPassword=" + + (proxyPassword.isEmpty() ? "not set" : "set") + + ']'; + } +} diff --git a/src/main/java/net/snowflake/ingest/utils/HttpUtil.java b/src/main/java/net/snowflake/ingest/utils/HttpUtil.java index 9b70c8bf5..ab11ea7ee 100644 --- a/src/main/java/net/snowflake/ingest/utils/HttpUtil.java +++ b/src/main/java/net/snowflake/ingest/utils/HttpUtil.java @@ -7,31 +7,42 @@ import static net.snowflake.ingest.utils.Utils.isNullOrEmpty; import java.security.Security; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; import javax.net.ssl.SSLContext; import net.snowflake.client.core.SFSessionProperty; -import org.apache.http.HttpHost; -import org.apache.http.HttpRequest; -import org.apache.http.HttpResponse; -import org.apache.http.NoHttpResponseException; -import org.apache.http.auth.AuthScope; -import org.apache.http.auth.Credentials; -import org.apache.http.auth.UsernamePasswordCredentials; -import org.apache.http.client.CredentialsProvider; -import org.apache.http.client.HttpClient; -import org.apache.http.client.HttpRequestRetryHandler; -import org.apache.http.client.ServiceUnavailableRetryStrategy; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.protocol.HttpClientContext; -import org.apache.http.conn.ssl.DefaultHostnameVerifier; -import org.apache.http.conn.ssl.SSLConnectionSocketFactory; -import org.apache.http.impl.client.BasicCredentialsProvider; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.conn.DefaultProxyRoutePlanner; -import org.apache.http.protocol.HttpContext; -import org.apache.http.ssl.SSLContexts; +import net.snowflake.client.jdbc.internal.apache.http.HttpHost; +import net.snowflake.client.jdbc.internal.apache.http.HttpRequest; +import net.snowflake.client.jdbc.internal.apache.http.HttpResponse; +import net.snowflake.client.jdbc.internal.apache.http.NoHttpResponseException; +import net.snowflake.client.jdbc.internal.apache.http.auth.AuthScope; +import net.snowflake.client.jdbc.internal.apache.http.auth.Credentials; +import net.snowflake.client.jdbc.internal.apache.http.auth.UsernamePasswordCredentials; +import net.snowflake.client.jdbc.internal.apache.http.client.CredentialsProvider; +import net.snowflake.client.jdbc.internal.apache.http.client.HttpRequestRetryHandler; +import net.snowflake.client.jdbc.internal.apache.http.client.ServiceUnavailableRetryStrategy; +import net.snowflake.client.jdbc.internal.apache.http.client.config.RequestConfig; +import net.snowflake.client.jdbc.internal.apache.http.client.protocol.HttpClientContext; +import net.snowflake.client.jdbc.internal.apache.http.conn.routing.HttpRoute; +import net.snowflake.client.jdbc.internal.apache.http.conn.ssl.DefaultHostnameVerifier; +import net.snowflake.client.jdbc.internal.apache.http.conn.ssl.SSLConnectionSocketFactory; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.BasicCredentialsProvider; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.HttpClientBuilder; +import net.snowflake.client.jdbc.internal.apache.http.impl.conn.DefaultProxyRoutePlanner; +import net.snowflake.client.jdbc.internal.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import net.snowflake.client.jdbc.internal.apache.http.pool.PoolStats; +import net.snowflake.client.jdbc.internal.apache.http.protocol.HttpContext; +import net.snowflake.client.jdbc.internal.apache.http.ssl.SSLContexts; +import net.snowflake.ingest.streaming.internal.StreamingIngestUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,27 +51,187 @@ public class HttpUtil { public static final String USE_PROXY = "http.useProxy"; public static final String PROXY_HOST = "http.proxyHost"; public static final String PROXY_PORT = "http.proxyPort"; - + public static final String NON_PROXY_HOSTS = "http.nonProxyHosts"; public static final String HTTP_PROXY_USER = "http.proxyUser"; public static final String HTTP_PROXY_PASSWORD = "http.proxyPassword"; + private static final String SNOWFLAKE_DOMAIN_NAME = ".snowflakecomputing.com"; private static final String PROXY_SCHEME = "http"; - private static final int MAX_RETRIES = 3; - static final int DEFAULT_CONNECTION_TIMEOUT = 1; // minute - static final int DEFAULT_HTTP_CLIENT_SOCKET_TIMEOUT = 5; // minutes - private static HttpClient httpClient; + private static final String FIRST_FAULT_TIMESTAMP = "FIRST_FAULT_TIMESTAMP"; + private static final Duration TOTAL_RETRY_DURATION = Duration.of(120, ChronoUnit.SECONDS); + private static final Duration RETRY_INTERVAL = Duration.of(3, ChronoUnit.SECONDS); + + /** + * How many times to retry when an IO exception is thrown. Value here is chosen to match the total + * value of {@link HttpUtil.TOTAL_RETRY_DURATION} when exponential backoff of up to 4 seconds per + * retry is used. + */ + private static final int MAX_RETRIES = 10; + + private static final Map httpClientCache = + new ConcurrentHashMap<>(); + + private static PoolingHttpClientConnectionManager connectionManager; + + private static IdleConnectionMonitorThread idleConnectionMonitorThread; + + /** + * This lock is to synchronize on idleConnectionMonitorThread to avoid setting starting a thread + * which was already started. (To avoid {@link IllegalThreadStateException}) + */ + private static final ReentrantLock idleConnectionMonitorThreadLock = new ReentrantLock(true); + + private static final int DEFAULT_CONNECTION_TIMEOUT_MINUTES = 1; + private static final int DEFAULT_HTTP_CLIENT_SOCKET_TIMEOUT_MINUTES = 5; + + /** + * After how many seconds of inactivity should be idle connections evicted from the connection + * pool. + */ + private static final int DEFAULT_EVICT_IDLE_AFTER_SECONDS = 60; + + // Default is 2, but scaling it up to 100 to match with default_max_connections + private static final int DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 100; + + // 100 is close to max partition number we have seen for a kafka topic ingesting into snowflake. + private static final int DEFAULT_MAX_CONNECTIONS = 100; + + // Interval in which we check if there are connections which needs to be closed. + private static final long IDLE_HTTP_CONNECTION_MONITOR_THREAD_INTERVAL_MS = + TimeUnit.SECONDS.toMillis(5); - public static HttpClient getHttpClient() { - if (httpClient == null) { - initHttpClient(); + // Only connections that are currently owned, not checked out, are subject to idle timeouts. + private static final int DEFAULT_IDLE_CONNECTION_TIMEOUT_SECONDS = 30; + + private static final Logger LOGGER = LoggerFactory.getLogger(HttpUtil.class); + + /** + * Get HttpClient with proxy properties support + * + * @param accountName account name to connect to (excluding snowflakecomputing.com domain) + * @param proxyProperties proxy properties, can be null + * @return Instance of CloseableHttpClient + */ + public static CloseableHttpClient getHttpClient(String accountName, Properties proxyProperties) { + + HttpClientSettingsKey key = createHttpClientSettingsKey(accountName, proxyProperties); + + CloseableHttpClient client = httpClientCache.get(key); + // todo: this is for testing phase + if (client != null) { + LOGGER.debug("Reusing existing HTTP client for account: {}, key: {}", accountName, key); + return client; } - return httpClient; + LOGGER.debug( + "No existing HTTP client found for account: {}, key: {}. Creating new HTTP client.", + accountName, + key); + return httpClientCache.computeIfAbsent(key, HttpUtil::buildHttpClient); } - private static final Logger LOGGER = LoggerFactory.getLogger(HttpUtil.class); + /** + * @param accountName account name to connect to (excluding snowflakecomputing.com domain) + * @return Instance of CloseableHttpClient + */ + public static CloseableHttpClient getHttpClient(String accountName) { + return getHttpClient(accountName, null); + } + + /** Create HttpClientSettingsKey from account name and proxy properties */ + private static HttpClientSettingsKey createHttpClientSettingsKey( + String accountName, Properties proxyProperties) { + + if (proxyProperties != null + && proxyProperties.containsKey(SFSessionProperty.USE_PROXY.getPropertyKey())) { + + Boolean useProxy = + Boolean.valueOf( + proxyProperties.getProperty(SFSessionProperty.USE_PROXY.getPropertyKey())); - private static void initHttpClient() { + if (useProxy) { + String proxyHost = + proxyProperties.getProperty(SFSessionProperty.PROXY_HOST.getPropertyKey(), ""); + String proxyPortStr = + proxyProperties.getProperty(SFSessionProperty.PROXY_PORT.getPropertyKey(), "0"); + int proxyPort = 0; + try { + proxyPort = Integer.parseInt(proxyPortStr); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Invalid proxy port: '" + proxyPortStr + "'. Proxy port must be a valid integer.", e); + } + String nonProxyHosts = + proxyProperties.getProperty(SFSessionProperty.NON_PROXY_HOSTS.getPropertyKey(), ""); + String proxyUser = + proxyProperties.getProperty(SFSessionProperty.PROXY_USER.getPropertyKey(), ""); + String proxyPassword = + proxyProperties.getProperty(SFSessionProperty.PROXY_PASSWORD.getPropertyKey(), ""); + + if (shouldBypassProxy(accountName, proxyProperties)) { + LOGGER.debug( + "Account {} matches nonProxyHosts pattern from system properties. Bypassing proxy" + + " despite proxyProperties configuration.", + accountName); + return new HttpClientSettingsKey(accountName); + } + + LOGGER.trace( + "Creating HTTP client settings key with proxy configuration from properties for" + + " account: {}. Proxy host: {}, port: {}, user: {}, nonProxyHosts: {}", + accountName, + proxyHost, + proxyPort, + isNullOrEmpty(proxyUser) ? "not set" : "set", + isNullOrEmpty(nonProxyHosts) ? "not set" : nonProxyHosts); + return new HttpClientSettingsKey( + accountName, proxyHost, proxyPort, nonProxyHosts, proxyUser, proxyPassword); + } else { + LOGGER.debug( + "Creating HTTP client settings key with proxy explicitly disabled via properties for" + + " account: {}", + accountName); + } + } + + // Check system properties for proxy configuration (backward compatibility) + if ("true".equalsIgnoreCase(System.getProperty(USE_PROXY)) + && !shouldBypassProxy(accountName, proxyProperties)) { + String proxyHost = System.getProperty(PROXY_HOST, ""); + String proxyPortStr = System.getProperty(PROXY_PORT, "0"); + int proxyPort = 0; + try { + proxyPort = Integer.parseInt(proxyPortStr); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Invalid proxy port: '" + proxyPortStr + "'. Proxy port must be a valid integer.", e); + } + String nonProxyHosts = System.getProperty(NON_PROXY_HOSTS, ""); + String proxyUser = System.getProperty(HTTP_PROXY_USER, ""); + String proxyPassword = System.getProperty(HTTP_PROXY_PASSWORD, ""); + + LOGGER.trace( + "Creating HTTP client settings key with proxy configuration from system properties for" + + " account: {}. Proxy host: {}, port: {}, user: {}, nonProxyHosts: {}", + accountName, + proxyHost, + proxyPort, + isNullOrEmpty(proxyUser) ? "not set" : "set", + isNullOrEmpty(nonProxyHosts) ? "not set" : nonProxyHosts); + return new HttpClientSettingsKey( + accountName, proxyHost, proxyPort, nonProxyHosts, proxyUser, proxyPassword); + } + + // No proxy configuration + LOGGER.debug( + "Creating HTTP client settings key without proxy configuration for account: {}", + accountName); + return new HttpClientSettingsKey(accountName); + } + + /** Build HttpClient based on the settings key */ + private static CloseableHttpClient buildHttpClient(HttpClientSettingsKey key) { + LOGGER.info("Building new HTTP client"); Security.setProperty("ocsp.enable", "true"); @@ -76,116 +247,190 @@ private static void initHttpClient() { RequestConfig requestConfig = RequestConfig.custom() .setConnectTimeout( - (int) TimeUnit.MILLISECONDS.convert(DEFAULT_CONNECTION_TIMEOUT, TimeUnit.MINUTES)) + (int) + TimeUnit.MILLISECONDS.convert( + DEFAULT_CONNECTION_TIMEOUT_MINUTES, TimeUnit.MINUTES)) .setConnectionRequestTimeout( - (int) TimeUnit.MILLISECONDS.convert(DEFAULT_CONNECTION_TIMEOUT, TimeUnit.MINUTES)) + (int) + TimeUnit.MILLISECONDS.convert( + DEFAULT_CONNECTION_TIMEOUT_MINUTES, TimeUnit.MINUTES)) .setSocketTimeout( (int) TimeUnit.MILLISECONDS.convert( - DEFAULT_HTTP_CLIENT_SOCKET_TIMEOUT, TimeUnit.MINUTES)) + DEFAULT_HTTP_CLIENT_SOCKET_TIMEOUT_MINUTES, TimeUnit.MINUTES)) .build(); - /** - * Use a anonymous class to implement the interface ServiceUnavailableRetryStrategy() The max - * retry time is 3. The interval time is backoff. - */ + + // Below pooling client connection manager uses time_to_live value as -1 which means it will not + // refresh a persisted connection + connectionManager = new PoolingHttpClientConnectionManager(); + connectionManager.setDefaultMaxPerRoute(DEFAULT_MAX_CONNECTIONS_PER_ROUTE); + connectionManager.setMaxTotal(DEFAULT_MAX_CONNECTIONS); + + // Use an anonymous class to implement the interface ServiceUnavailableRetryStrategy() The max + // retry time is 3. The interval time is backoff. HttpClientBuilder clientBuilder = - HttpClients.custom() + HttpClientBuilder.create() + .setConnectionManager(connectionManager) + .evictIdleConnections(DEFAULT_EVICT_IDLE_AFTER_SECONDS, TimeUnit.SECONDS) .setSSLSocketFactory(f) .setServiceUnavailableRetryStrategy(getServiceUnavailableRetryStrategy()) .setRetryHandler(getHttpRequestRetryHandler()) .setDefaultRequestConfig(requestConfig); // proxy settings - if ("true".equalsIgnoreCase(System.getProperty(USE_PROXY))) { - if (System.getProperty(PROXY_PORT) == null) { + if (key.usesProxy()) { + LOGGER.debug( + "Configuring HTTP client with proxy settings. Host: {}, Port: {}", + key.getProxyHost(), + key.getProxyPort()); + + if (isNullOrEmpty(key.getProxyHost())) { throw new IllegalArgumentException( - "proxy port number is not provided, please assign proxy port to http.proxyPort option"); + "proxy host IP is not provided, please assign proxy host IP to http.proxyHost option"); } - if (System.getProperty(PROXY_HOST) == null) { + if (key.getProxyPort() <= 0) { throw new IllegalArgumentException( - "proxy host IP is not provided, please assign proxy host IP to http.proxyHost option"); + "proxy port number is not provided, please assign proxy port to http.proxyPort option"); } - String proxyHost = System.getProperty(PROXY_HOST); - int proxyPort = Integer.parseInt(System.getProperty(PROXY_PORT)); + + String proxyHost = key.getProxyHost(); + int proxyPort = key.getProxyPort(); HttpHost proxy = new HttpHost(proxyHost, proxyPort, PROXY_SCHEME); DefaultProxyRoutePlanner routePlanner = new DefaultProxyRoutePlanner(proxy); clientBuilder = clientBuilder.setRoutePlanner(routePlanner); // Check if proxy username and password are set - final String proxyUser = System.getProperty(HTTP_PROXY_USER); - final String proxyPassword = System.getProperty(HTTP_PROXY_PASSWORD); + final String proxyUser = key.getProxyUser(); + final String proxyPassword = key.getProxyPassword(); if (!isNullOrEmpty(proxyUser) && !isNullOrEmpty(proxyPassword)) { + LOGGER.debug("Configuring HTTP client with proxy authentication credentials"); Credentials credentials = new UsernamePasswordCredentials(proxyUser, proxyPassword); AuthScope authScope = new AuthScope(proxyHost, proxyPort); CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); credentialsProvider.setCredentials(authScope, credentials); clientBuilder = clientBuilder.setDefaultCredentialsProvider(credentialsProvider); + } else { + LOGGER.debug( + "HTTP client configured with proxy but no authentication credentials provided"); } + } else { + LOGGER.debug("Configuring HTTP client without proxy settings"); } - httpClient = clientBuilder.build(); + CloseableHttpClient httpClient = clientBuilder.build(); + initIdleConnectionMonitoringThread(); + LOGGER.trace("Successfully built new HTTP client for key: {}", key); + return httpClient; + } + + /** Starts a daemon thread to monitor idle connections in http connection manager. */ + private static void initIdleConnectionMonitoringThread() { + idleConnectionMonitorThreadLock.lock(); + try { + // Start a new thread only if connectionManager was init before and + // daemon thread was not started before or was closed because of SimpleIngestManager close + if (connectionManager != null + && (idleConnectionMonitorThread == null || idleConnectionMonitorThread.isShutdown())) { + // Monitors in a separate thread where it closes any idle connections + // https://hc.apache.org/httpcomponents-client-4.5.x/current/tutorial/html/connmgmt.html + idleConnectionMonitorThread = new IdleConnectionMonitorThread(connectionManager); + idleConnectionMonitorThread.setDaemon(true); + idleConnectionMonitorThread.start(); + } + } catch (Exception e) { + LOGGER.warn("Unable to start Daemon thread for Http Idle Connection Monitoring", e); + } finally { + idleConnectionMonitorThreadLock.unlock(); + } } private static ServiceUnavailableRetryStrategy getServiceUnavailableRetryStrategy() { return new ServiceUnavailableRetryStrategy() { - private int executionCount = 0; final int REQUEST_TIMEOUT = 408; + final int TOO_MANY_REQUESTS = 429; + final int SERVER_ERRORS = 500; @Override public boolean retryRequest( final HttpResponse response, final int executionCount, final HttpContext context) { - this.executionCount = executionCount; - int statusCode = response.getStatusLine().getStatusCode(); - if (executionCount == MAX_RETRIES + 1) { - LOGGER.info("Reached the max retry time, not retrying anymore"); - return false; + Object firstFault = context.getAttribute(FIRST_FAULT_TIMESTAMP); + long totalRetryDurationSoFarInSeconds = 0; + if (firstFault == null) { + context.setAttribute(FIRST_FAULT_TIMESTAMP, Instant.now()); + } else { + Instant firstFaultInstant = (Instant) firstFault; + Instant now = Instant.now(); + totalRetryDurationSoFarInSeconds = Duration.between(firstFaultInstant, now).getSeconds(); + + if (totalRetryDurationSoFarInSeconds > TOTAL_RETRY_DURATION.getSeconds()) { + LOGGER.info( + String.format( + "Reached the max retry time of %d seconds, not retrying anymore", + TOTAL_RETRY_DURATION.getSeconds())); + return false; + } } + + int statusCode = response.getStatusLine().getStatusCode(); boolean needNextRetry = - (statusCode == REQUEST_TIMEOUT || statusCode >= 500) - && executionCount < MAX_RETRIES + 1; + (statusCode == REQUEST_TIMEOUT + || statusCode == TOO_MANY_REQUESTS + || statusCode >= SERVER_ERRORS); if (needNextRetry) { - long interval = (1 << executionCount) * 1000; - LOGGER.warn( + long interval = getRetryInterval(); + LOGGER.info( "In retryRequest for service unavailability with statusCode:{} and uri:{}", statusCode, getRequestUriFromContext(context)); - LOGGER.info("Sleep time in millisecond: {}, retryCount: {}", interval, executionCount); + LOGGER.info( + "Sleep time in millisecond: {}, retryCount: {}, total retry duration: {}s / {}s", + interval, + executionCount, + totalRetryDurationSoFarInSeconds, + TOTAL_RETRY_DURATION.getSeconds()); } return needNextRetry; } @Override - // The waiting time is backoff, and is - // the exponential of the executionCount. public long getRetryInterval() { - long interval = (1 << executionCount) * 1000; // milliseconds - return interval; + return RETRY_INTERVAL.toMillis(); } }; } /** - * Retry handler logic. Retry if No response from Service exception. (NoHttpResponseException) + * Retry handler logic. Retry at most {@link HttpUtil.MAX_RETRIES} times if any of the following + * exceptions is thrown by request execution: + * + *
      + *
    • No response from Service exception (NoHttpResponseException) + *
    • javax.net.ssl.SSLException: Connection reset. + *
    * * @return retryHandler to add to http client. */ - private static HttpRequestRetryHandler getHttpRequestRetryHandler() { + static HttpRequestRetryHandler getHttpRequestRetryHandler() { return (exception, executionCount, httpContext) -> { final String requestURI = getRequestUriFromContext(httpContext); if (executionCount > MAX_RETRIES) { LOGGER.info("Max retry exceeded for requestURI:{}", requestURI); return false; } - if (exception instanceof NoHttpResponseException) { + if (exception instanceof NoHttpResponseException + || exception instanceof javax.net.ssl.SSLException + || exception instanceof java.net.SocketException + || exception instanceof java.net.UnknownHostException) { LOGGER.info( - "Retrying request which caused No HttpResponse Exception with " - + "URI:{}, retryCount:{} and maxRetryCount:{}", + "Retrying request which caused {} with " + "URI:{}, retryCount:{} and maxRetryCount:{}", + exception.getClass().getName(), requestURI, executionCount, MAX_RETRIES); + StreamingIngestUtils.sleepForRetry(executionCount); return true; } - LOGGER.info("No retry for URI:{} with exception", requestURI, exception); + LOGGER.info("No retry for URI:{} with exception {}", requestURI, exception.toString()); return false; }; } @@ -205,6 +450,8 @@ private static String getRequestUriFromContext(final HttpContext httpContext) { public static Properties generateProxyPropertiesForJDBC() { Properties proxyProperties = new Properties(); if (Boolean.parseBoolean(System.getProperty(USE_PROXY))) { + LOGGER.debug("Generating proxy properties for JDBC from system properties"); + if (isNullOrEmpty(System.getProperty(PROXY_PORT))) { throw new IllegalArgumentException( "proxy port number is not provided, please assign proxy port to http.proxyPort option"); @@ -221,14 +468,202 @@ public static Properties generateProxyPropertiesForJDBC() { proxyProperties.put( SFSessionProperty.PROXY_PORT.getPropertyKey(), System.getProperty(PROXY_PORT)); + LOGGER.debug( + "Generated proxy properties - Host: {}, Port: {}", + System.getProperty(PROXY_HOST), + System.getProperty(PROXY_PORT)); + // Check if proxy username and password are set final String proxyUser = System.getProperty(HTTP_PROXY_USER); final String proxyPassword = System.getProperty(HTTP_PROXY_PASSWORD); if (!isNullOrEmpty(proxyUser) && !isNullOrEmpty(proxyPassword)) { proxyProperties.put(SFSessionProperty.PROXY_USER.getPropertyKey(), proxyUser); proxyProperties.put(SFSessionProperty.PROXY_PASSWORD.getPropertyKey(), proxyPassword); + LOGGER.debug("Added proxy authentication credentials to generated properties"); + } else { + LOGGER.debug("No proxy authentication credentials found in system properties"); + } + + // Check if http.nonProxyHosts was set + final String nonProxyHosts = System.getProperty(NON_PROXY_HOSTS); + if (!isNullOrEmpty(nonProxyHosts)) { + proxyProperties.put(SFSessionProperty.NON_PROXY_HOSTS.getPropertyKey(), nonProxyHosts); + LOGGER.debug("Added nonProxyHosts to generated properties: {}", nonProxyHosts); } + } else { + LOGGER.debug( + "System property {} is not set to true, generating empty proxy properties", USE_PROXY); } return proxyProperties; } + + /** Thread to monitor expired and idle connection, if found clear it and return it back to pool */ + private static class IdleConnectionMonitorThread extends Thread { + + private final PoolingHttpClientConnectionManager connectionManager; + private volatile boolean shutdown; + + public IdleConnectionMonitorThread(PoolingHttpClientConnectionManager connectionManager) { + super(); + this.connectionManager = connectionManager; + } + + @Override + public void run() { + try { + LOGGER.debug("Starting Idle Connection Monitor Thread "); + synchronized (this) { + while (!shutdown) { + wait(IDLE_HTTP_CONNECTION_MONITOR_THREAD_INTERVAL_MS); + + StringBuilder sb = new StringBuilder(); + + sb.append( + createPoolStatsInfo("Total Pool Stats = ", connectionManager.getTotalStats())); + Set routes = connectionManager.getRoutes(); + + if (routes != null) { + for (HttpRoute route : routes) { + sb.append(createPoolStatsForRoute(connectionManager, route)); + } + } + + LOGGER.debug("[IdleConnectionMonitorThread] Pool Stats:\n" + sb); + + // Close expired connections + connectionManager.closeExpiredConnections(); + // Optionally, close connections + // that have been idle longer than 30 sec + connectionManager.closeIdleConnections( + DEFAULT_IDLE_CONNECTION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } + } catch (InterruptedException ex) { + LOGGER.warn("Terminating Idle Connection Monitor Thread "); + } + } + + private void shutdown() { + if (!shutdown) { + LOGGER.debug("Shutdown Idle Connection Monitor Thread "); + shutdown = true; + synchronized (this) { + notifyAll(); + } + } + } + + private boolean isShutdown() { + return this.shutdown; + } + } + + /** Shuts down the daemon thread. */ + public static void shutdownHttpConnectionManagerDaemonThread() { + idleConnectionMonitorThreadLock.lock(); + try { + if (idleConnectionMonitorThread != null) { + idleConnectionMonitorThread.shutdown(); + idleConnectionMonitorThread = null; + } + } finally { + idleConnectionMonitorThreadLock.unlock(); + } + } + + /** Close all cached HTTP clients and clear the cache */ + public static void closeAllHttpClients() { + // for use in test + LOGGER.info( + "Closing all cached HTTP clients. Total clients in cache: {}", httpClientCache.size()); + for (Map.Entry entry : httpClientCache.entrySet()) { + try { + LOGGER.debug("Closing HTTP client for key: {}", entry.getKey()); + entry.getValue().close(); + } catch (Exception e) { + LOGGER.warn("Error closing HTTP client for key: {}", entry.getKey(), e); + } + } + httpClientCache.clear(); + LOGGER.info("All cached HTTP clients closed and cache cleared"); + shutdownHttpConnectionManagerDaemonThread(); + } + + /** Create Pool stats for a route */ + private static String createPoolStatsForRoute( + PoolingHttpClientConnectionManager connectionManager, HttpRoute route) { + PoolStats routeStats = connectionManager.getStats(route); + return createPoolStatsInfo( + String.format("Pool Stats for route %s = ", route.getTargetHost().toURI()), routeStats); + } + + /** Returns a string with a title and pool stats. */ + private static String createPoolStatsInfo(String title, PoolStats poolStats) { + if (poolStats != null) { + return title + poolStats + "\n"; + } + return title; + } + + /** + * Changes the account name to the format accountName.snowflakecomputing.com then returns a + * boolean to indicate if we should go through a proxy or not. This is a convenience method for + * existing tests that calls the two-parameter version with null properties. + */ + public static Boolean shouldBypassProxy(String accountName) { + return shouldBypassProxy(accountName, null); + } + + /** + * Changes the account name to the format accountName.snowflakecomputing.com then returns a + * boolean to indicate if we should go through a proxy or not. Checks both system properties and + * provided proxy properties for non-proxy hosts configuration. + */ + public static Boolean shouldBypassProxy(String accountName, Properties proxyProperties) { + String targetHost = accountName + SNOWFLAKE_DOMAIN_NAME; + + // Check system properties first + String systemNonProxyHosts = System.getProperty(NON_PROXY_HOSTS); + if (systemNonProxyHosts != null && isInNonProxyHosts(targetHost, systemNonProxyHosts)) { + LOGGER.info( + "Account {} ({}) matches system nonProxyHosts pattern. Bypassing proxy.", + accountName, + targetHost); + return true; + } + + // Check SFSessionProperty.NON_PROXY_HOSTS from proxyProperties if available + if (proxyProperties != null) { + String sessionNonProxyHosts = + proxyProperties.getProperty(SFSessionProperty.NON_PROXY_HOSTS.getPropertyKey()); + if (sessionNonProxyHosts != null && isInNonProxyHosts(targetHost, sessionNonProxyHosts)) { + LOGGER.info( + "Account {} ({}) matches session nonProxyHosts pattern. Bypassing proxy.", + accountName, + targetHost); + return true; + } + } + + LOGGER.debug( + "Account {} ({}) does not match any nonProxyHosts pattern.", accountName, targetHost); + return false; + } + + /** + * The target hostname input is compared with the hosts in the '|' separated list provided by the + * nonProxyHosts parameter using regex. The nonProxyHosts will be used as our Patterns, so we need + * to replace the '.' and '*' characters since those are special regex constructs that mean 'any + * character,' and 'repeat 0 or more times.' + */ + private static Boolean isInNonProxyHosts(String targetHost, String nonProxyHosts) { + String escapedNonProxyHosts = nonProxyHosts.replace(".", "\\.").replace("*", ".*"); + String[] nonProxyHostsArray = escapedNonProxyHosts.split("\\|"); + for (String pattern : nonProxyHostsArray) { + if (Pattern.compile(pattern).matcher(targetHost).matches()) { + return true; + } + } + return false; + } } diff --git a/src/main/java/net/snowflake/ingest/utils/Logging.java b/src/main/java/net/snowflake/ingest/utils/Logging.java index b44f2c852..1bd6c6b1a 100644 --- a/src/main/java/net/snowflake/ingest/utils/Logging.java +++ b/src/main/java/net/snowflake/ingest/utils/Logging.java @@ -71,6 +71,12 @@ public void logDebug(String format, Object... vars) { } } + public void logWarn(String format, Throwable e) { + if (log.isWarnEnabled()) { + log.warn(format, e); + } + } + public void logWarn(String format, Object... vars) { if (log.isWarnEnabled()) { log.warn(format, vars); @@ -108,7 +114,7 @@ public Logger getLogger() { * @return log message wrapped by snowflake tag */ private static String logMessage(String msg) { - return "\n".concat(msg).replaceAll("\n", "\n" + SF_LOG_TAG + " "); + return SF_LOG_TAG + " " + msg; } /** diff --git a/src/main/java/net/snowflake/ingest/utils/ParameterProvider.java b/src/main/java/net/snowflake/ingest/utils/ParameterProvider.java index 01400f118..7967f1fdd 100644 --- a/src/main/java/net/snowflake/ingest/utils/ParameterProvider.java +++ b/src/main/java/net/snowflake/ingest/utils/ParameterProvider.java @@ -3,35 +3,82 @@ import java.util.HashMap; import java.util.Map; import java.util.Properties; +import java.util.concurrent.TimeUnit; /** Utility class to provide configurable constants */ public class ParameterProvider { - public static final String BUFFER_FLUSH_INTERVAL_IN_MILLIS_MAP_KEY = + public static final String BUFFER_FLUSH_INTERVAL_IN_MILLIS = "STREAMING_INGEST_CLIENT_SDK_BUFFER_FLUSH_INTERVAL_IN_MILLIS".toLowerCase(); - public static final String BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_MAP_KEY = + public static final String BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS = "STREAMING_INGEST_CLIENT_SDK_BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS".toLowerCase(); - public static final String INSERT_THROTTLE_INTERVAL_IN_MILLIS_MAP_KEY = + public static final String INSERT_THROTTLE_INTERVAL_IN_MILLIS = "STREAMING_INGEST_CLIENT_SDK_INSERT_THROTTLE_INTERVAL_IN_MILLIS".toLowerCase(); - public static final String INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_MAP_KEY = + public static final String INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE = "STREAMING_INGEST_CLIENT_SDK_INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE".toLowerCase(); - public static final String ENABLE_SNOWPIPE_STREAMING_METRICS_MAP_KEY = + public static final String INSERT_THROTTLE_THRESHOLD_IN_BYTES = + "STREAMING_INGEST_CLIENT_SDK_INSERT_THROTTLE_THRESHOLD_IN_BYTES".toLowerCase(); + public static final String ENABLE_SNOWPIPE_STREAMING_METRICS = "ENABLE_SNOWPIPE_STREAMING_JMX_METRICS".toLowerCase(); + public static final String BLOB_FORMAT_VERSION = "BLOB_FORMAT_VERSION".toLowerCase(); + public static final String IO_TIME_CPU_RATIO = "IO_TIME_CPU_RATIO".toLowerCase(); + public static final String BLOB_UPLOAD_MAX_RETRY_COUNT = + "BLOB_UPLOAD_MAX_RETRY_COUNT".toLowerCase(); + public static final String MAX_MEMORY_LIMIT_IN_BYTES = "MAX_MEMORY_LIMIT_IN_BYTES".toLowerCase(); + public static final String ENABLE_PARQUET_INTERNAL_BUFFERING = + "ENABLE_PARQUET_INTERNAL_BUFFERING".toLowerCase(); + // This should not be needed once we have the ability to track size at table/chunk level + public static final String MAX_CHANNEL_SIZE_IN_BYTES = "MAX_CHANNEL_SIZE_IN_BYTES".toLowerCase(); + public static final String MAX_CHUNK_SIZE_IN_BYTES = "MAX_CHUNK_SIZE_IN_BYTES".toLowerCase(); + public static final String MAX_ALLOWED_ROW_SIZE_IN_BYTES = + "MAX_ALLOWED_ROW_SIZE_IN_BYTES".toLowerCase(); + public static final String MAX_CHUNKS_IN_BLOB_AND_REGISTRATION_REQUEST = + "MAX_CHUNKS_IN_BLOB_AND_REGISTRATION_REQUEST".toLowerCase(); + + public static final String MAX_CLIENT_LAG = "MAX_CLIENT_LAG".toLowerCase(); + + public static final String BDEC_PARQUET_COMPRESSION_ALGORITHM = + "BDEC_PARQUET_COMPRESSION_ALGORITHM".toLowerCase(); // Default values - public static final long BUFFER_FLUSH_INTERVAL_IN_MILLIS_DEFAULT = 1000; public static final long BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_DEFAULT = 100; - public static final long INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT = 500; - public static final long INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_DEFAULT = 10; + public static final long INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT = 1000; + public static final int INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_DEFAULT = 10; + public static final int INSERT_THROTTLE_THRESHOLD_IN_BYTES_DEFAULT = 200 * 1024 * 1024; // 200MB public static final boolean SNOWPIPE_STREAMING_METRICS_DEFAULT = false; + public static final Constants.BdecVersion BLOB_FORMAT_VERSION_DEFAULT = + Constants.BdecVersion.THREE; + public static final int IO_TIME_CPU_RATIO_DEFAULT = 2; + public static final int BLOB_UPLOAD_MAX_RETRY_COUNT_DEFAULT = 24; + public static final long MAX_MEMORY_LIMIT_IN_BYTES_DEFAULT = -1L; + public static final long MAX_CHANNEL_SIZE_IN_BYTES_DEFAULT = 32000000L; + public static final long MAX_CHUNK_SIZE_IN_BYTES_DEFAULT = 128000000L; + + // Lag related parameters + public static final long MAX_CLIENT_LAG_DEFAULT = 1000; // 1 second + static final long MAX_CLIENT_LAG_MS_MIN = TimeUnit.SECONDS.toMillis(1); + static final long MAX_CLIENT_LAG_MS_MAX = TimeUnit.MINUTES.toMillis(10); + + public static final long MAX_ALLOWED_ROW_SIZE_IN_BYTES_DEFAULT = 64 * 1024 * 1024; // 64 MB + public static final int MAX_CHUNKS_IN_BLOB_AND_REGISTRATION_REQUEST_DEFAULT = 100; + + public static final Constants.BdecParquetCompression BDEC_PARQUET_COMPRESSION_ALGORITHM_DEFAULT = + Constants.BdecParquetCompression.GZIP; + + /* Parameter that enables using internal Parquet buffers for buffering of rows before serializing. + It reduces memory consumption compared to using Java Objects for buffering.*/ + public static final boolean ENABLE_PARQUET_INTERNAL_BUFFERING_DEFAULT = false; /** Map of parameter name to parameter value. This will be set by client/configure API Call. */ private final Map parameterMap = new HashMap<>(); + // Cached buffer flush interval - avoid parsing each time for quick lookup + private Long cachedBufferFlushIntervalMs = -1L; + /** * Constructor. Takes properties from profile file and properties from client constructor and * resolves final parameter value * - * @param parameterOverrides Map of parameter name -> value + * @param parameterOverrides Map of parameter name to value * @param props Properties from profile file */ public ParameterProvider(Map parameterOverrides, Properties props) { @@ -54,6 +101,7 @@ private void updateValue( this.parameterMap.put(key, props.getOrDefault(key, defaultValue)); } } + /** * Sets parameter values by first checking 1. parameterOverrides 2. props 3. default value * @@ -61,73 +109,302 @@ private void updateValue( * @param props Properties file provided to client constructor */ private void setParameterMap(Map parameterOverrides, Properties props) { - this.updateValue( - BUFFER_FLUSH_INTERVAL_IN_MILLIS_MAP_KEY, - BUFFER_FLUSH_INTERVAL_IN_MILLIS_DEFAULT, - parameterOverrides, - props); + // BUFFER_FLUSH_INTERVAL_IN_MILLIS is deprecated and disallowed + if ((parameterOverrides != null + && parameterOverrides.containsKey(BUFFER_FLUSH_INTERVAL_IN_MILLIS)) + || (props != null && props.containsKey(BUFFER_FLUSH_INTERVAL_IN_MILLIS))) { + throw new IllegalArgumentException( + String.format( + "%s is deprecated, please use %s instead", + BUFFER_FLUSH_INTERVAL_IN_MILLIS, MAX_CLIENT_LAG)); + } this.updateValue( - BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_MAP_KEY, + BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS, BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_DEFAULT, parameterOverrides, props); this.updateValue( - INSERT_THROTTLE_INTERVAL_IN_MILLIS_MAP_KEY, + INSERT_THROTTLE_INTERVAL_IN_MILLIS, INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT, parameterOverrides, props); this.updateValue( - INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_MAP_KEY, + INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE, INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_DEFAULT, parameterOverrides, props); this.updateValue( - ENABLE_SNOWPIPE_STREAMING_METRICS_MAP_KEY, + INSERT_THROTTLE_THRESHOLD_IN_BYTES, + INSERT_THROTTLE_THRESHOLD_IN_BYTES_DEFAULT, + parameterOverrides, + props); + + this.updateValue( + ENABLE_SNOWPIPE_STREAMING_METRICS, SNOWPIPE_STREAMING_METRICS_DEFAULT, parameterOverrides, props); + + this.updateValue(BLOB_FORMAT_VERSION, BLOB_FORMAT_VERSION_DEFAULT, parameterOverrides, props); + getBlobFormatVersion(); // to verify parsing the configured value + + this.updateValue(IO_TIME_CPU_RATIO, IO_TIME_CPU_RATIO_DEFAULT, parameterOverrides, props); + + this.updateValue( + BLOB_UPLOAD_MAX_RETRY_COUNT, + BLOB_UPLOAD_MAX_RETRY_COUNT_DEFAULT, + parameterOverrides, + props); + + this.updateValue( + MAX_MEMORY_LIMIT_IN_BYTES, MAX_MEMORY_LIMIT_IN_BYTES_DEFAULT, parameterOverrides, props); + + this.updateValue( + ENABLE_PARQUET_INTERNAL_BUFFERING, + ENABLE_PARQUET_INTERNAL_BUFFERING_DEFAULT, + parameterOverrides, + props); + + this.updateValue( + MAX_CHANNEL_SIZE_IN_BYTES, MAX_CHANNEL_SIZE_IN_BYTES_DEFAULT, parameterOverrides, props); + + this.updateValue( + MAX_CHUNK_SIZE_IN_BYTES, MAX_CHUNK_SIZE_IN_BYTES_DEFAULT, parameterOverrides, props); + + this.updateValue(MAX_CLIENT_LAG, MAX_CLIENT_LAG_DEFAULT, parameterOverrides, props); + + this.updateValue( + MAX_CHUNKS_IN_BLOB_AND_REGISTRATION_REQUEST, + MAX_CHUNKS_IN_BLOB_AND_REGISTRATION_REQUEST_DEFAULT, + parameterOverrides, + props); + + this.updateValue( + BDEC_PARQUET_COMPRESSION_ALGORITHM, + BDEC_PARQUET_COMPRESSION_ALGORITHM_DEFAULT, + parameterOverrides, + props); } /** @return Longest interval in milliseconds between buffer flushes */ - public long getBufferFlushIntervalInMs() { - return (long) - this.parameterMap.getOrDefault( - BUFFER_FLUSH_INTERVAL_IN_MILLIS_MAP_KEY, BUFFER_FLUSH_INTERVAL_IN_MILLIS_DEFAULT); + public long getCachedMaxClientLagInMs() { + if (cachedBufferFlushIntervalMs != -1L) { + return cachedBufferFlushIntervalMs; + } + + cachedBufferFlushIntervalMs = getMaxClientLagInMs(); + return cachedBufferFlushIntervalMs; + } + + private long getMaxClientLagInMs() { + Object val = this.parameterMap.getOrDefault(MAX_CLIENT_LAG, MAX_CLIENT_LAG_DEFAULT); + long computedLag; + if (val instanceof String) { + String maxLag = (String) val; + String[] lagParts = maxLag.split(" "); + if (lagParts.length > 2) { + throw new IllegalArgumentException( + String.format("Failed to parse MAX_CLIENT_LAG = '%s'", maxLag)); + } + + // Compute the actual value + try { + computedLag = Long.parseLong(lagParts[0]); + } catch (Throwable t) { + throw new IllegalArgumentException( + String.format("Failed to parse MAX_CLIENT_LAG = '%s'", lagParts[0]), t); + } + + // Compute the time unit if needed + if (lagParts.length == 2) { + switch (lagParts[1].toLowerCase()) { + case "second": + case "seconds": + computedLag = computedLag * TimeUnit.SECONDS.toMillis(1); + break; + case "minute": + case "minutes": + computedLag = computedLag * TimeUnit.SECONDS.toMillis(60); + break; + default: + throw new IllegalArgumentException( + String.format("Invalid time unit supplied = '%s", lagParts[1])); + } + } + } else { + computedLag = (long) val; + } + + if (!(computedLag >= MAX_CLIENT_LAG_MS_MIN && computedLag <= MAX_CLIENT_LAG_MS_MAX)) { + throw new IllegalArgumentException( + String.format( + "Lag falls outside of allowed time range. Minimum (milliseconds) = %s, Maximum" + + " (milliseconds) = %s", + MAX_CLIENT_LAG_MS_MIN, MAX_CLIENT_LAG_MS_MAX)); + } + + return computedLag; } /** @return Time in milliseconds between checks to see if the buffer should be flushed */ public long getBufferFlushCheckIntervalInMs() { - return (long) + Object val = this.parameterMap.getOrDefault( - BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_MAP_KEY, - BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_DEFAULT); + BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS, BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_DEFAULT); + if (val instanceof String) { + return Long.parseLong(val.toString()); + } + return (long) val; } /** @return Duration in milliseconds to delay data insertion to the buffer when throttled */ public long getInsertThrottleIntervalInMs() { - return (long) + Object val = this.parameterMap.getOrDefault( - INSERT_THROTTLE_INTERVAL_IN_MILLIS_MAP_KEY, INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT); + INSERT_THROTTLE_INTERVAL_IN_MILLIS, INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT); + if (val instanceof String) { + return Long.parseLong(val.toString()); + } + return (long) val; } /** @return Percent of free total memory at which we throttle row inserts */ public int getInsertThrottleThresholdInPercentage() { - return ((Long) - this.parameterMap.getOrDefault( - INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_MAP_KEY, - INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_DEFAULT)) - .intValue(); + Object val = + this.parameterMap.getOrDefault( + INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE, + INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_DEFAULT); + if (val instanceof String) { + return Integer.parseInt(val.toString()); + } + return (int) val; + } + + /** @return Absolute size in bytes of free total memory at which we throttle row inserts */ + public int getInsertThrottleThresholdInBytes() { + Object val = + this.parameterMap.getOrDefault( + INSERT_THROTTLE_THRESHOLD_IN_BYTES, INSERT_THROTTLE_THRESHOLD_IN_BYTES_DEFAULT); + if (val instanceof String) { + return Integer.parseInt(val.toString()); + } + return (int) val; } /** @return true if jmx metrics are enabled for a client */ public boolean hasEnabledSnowpipeStreamingMetrics() { - return (Boolean) + Object val = + this.parameterMap.getOrDefault( + ENABLE_SNOWPIPE_STREAMING_METRICS, SNOWPIPE_STREAMING_METRICS_DEFAULT); + if (val instanceof String) { + return Boolean.parseBoolean(val.toString()); + } + return (boolean) val; + } + + /** @return Blob format version */ + public Constants.BdecVersion getBlobFormatVersion() { + Object val = this.parameterMap.getOrDefault(BLOB_FORMAT_VERSION, BLOB_FORMAT_VERSION_DEFAULT); + if (val instanceof Constants.BdecVersion) { + return (Constants.BdecVersion) val; + } + if (val instanceof String) { + try { + val = Integer.parseInt((String) val); + } catch (Throwable t) { + throw new IllegalArgumentException( + String.format("Failed to parse BLOB_FORMAT_VERSION = '%s'", val), t); + } + } + return Constants.BdecVersion.fromInt((int) val); + } + + /** + * @return the IO_TIME/CPU ratio that we will use to determine the number of buildAndUpload + * threads + */ + public int getIOTimeCpuRatio() { + Object val = this.parameterMap.getOrDefault(IO_TIME_CPU_RATIO, IO_TIME_CPU_RATIO_DEFAULT); + if (val instanceof String) { + return Integer.parseInt(val.toString()); + } + return (int) val; + } + + /** @return the max retry count when waiting for a blob upload task to finish */ + public int getBlobUploadMaxRetryCount() { + Object val = this.parameterMap.getOrDefault( - ENABLE_SNOWPIPE_STREAMING_METRICS_MAP_KEY, SNOWPIPE_STREAMING_METRICS_DEFAULT); + BLOB_UPLOAD_MAX_RETRY_COUNT, BLOB_UPLOAD_MAX_RETRY_COUNT_DEFAULT); + if (val instanceof String) { + return Integer.parseInt(val.toString()); + } + return (int) val; + } + + /** @return The max memory limit in bytes */ + public long getMaxMemoryLimitInBytes() { + Object val = + this.parameterMap.getOrDefault( + MAX_MEMORY_LIMIT_IN_BYTES, MAX_MEMORY_LIMIT_IN_BYTES_DEFAULT); + return (val instanceof String) ? Long.parseLong(val.toString()) : (long) val; + } + + /** @return Return whether memory optimization for Parquet is enabled. */ + public boolean getEnableParquetInternalBuffering() { + Object val = + this.parameterMap.getOrDefault( + ENABLE_PARQUET_INTERNAL_BUFFERING, ENABLE_PARQUET_INTERNAL_BUFFERING_DEFAULT); + return (val instanceof String) ? Boolean.parseBoolean(val.toString()) : (boolean) val; + } + + /** @return The max channel size in bytes */ + public long getMaxChannelSizeInBytes() { + Object val = + this.parameterMap.getOrDefault( + MAX_CHANNEL_SIZE_IN_BYTES, MAX_CHANNEL_SIZE_IN_BYTES_DEFAULT); + return (val instanceof String) ? Long.parseLong(val.toString()) : (long) val; + } + + /** @return The max chunk size in bytes that could avoid OOM at server side */ + public long getMaxChunkSizeInBytes() { + Object val = + this.parameterMap.getOrDefault(MAX_CHUNK_SIZE_IN_BYTES, MAX_CHUNK_SIZE_IN_BYTES_DEFAULT); + return (val instanceof String) ? Long.parseLong(val.toString()) : (long) val; + } + + public long getMaxAllowedRowSizeInBytes() { + Object val = + this.parameterMap.getOrDefault( + MAX_ALLOWED_ROW_SIZE_IN_BYTES, MAX_ALLOWED_ROW_SIZE_IN_BYTES_DEFAULT); + return (val instanceof String) ? Long.parseLong(val.toString()) : (long) val; + } + + /** + * @return The max number of chunks that can be put into a single BDEC or blob registration + * request. + */ + public int getMaxChunksInBlobAndRegistrationRequest() { + Object val = + this.parameterMap.getOrDefault( + MAX_CHUNKS_IN_BLOB_AND_REGISTRATION_REQUEST, + MAX_CHUNKS_IN_BLOB_AND_REGISTRATION_REQUEST_DEFAULT); + return (val instanceof String) ? Integer.parseInt(val.toString()) : (int) val; + } + + /** @return BDEC compression algorithm */ + public Constants.BdecParquetCompression getBdecParquetCompressionAlgorithm() { + Object val = + this.parameterMap.getOrDefault( + BDEC_PARQUET_COMPRESSION_ALGORITHM, BDEC_PARQUET_COMPRESSION_ALGORITHM_DEFAULT); + if (val instanceof Constants.BdecParquetCompression) { + return (Constants.BdecParquetCompression) val; + } + return Constants.BdecParquetCompression.fromName((String) val); } @Override diff --git a/src/main/java/net/snowflake/ingest/utils/SnowflakeURL.java b/src/main/java/net/snowflake/ingest/utils/SnowflakeURL.java index 533b6db8f..cfcc73056 100644 --- a/src/main/java/net/snowflake/ingest/utils/SnowflakeURL.java +++ b/src/main/java/net/snowflake/ingest/utils/SnowflakeURL.java @@ -64,7 +64,7 @@ private SnowflakeURL(SnowflakeURLBuilder builder) { */ public SnowflakeURL(String urlStr) { Pattern pattern = - Pattern.compile("^(https?://)?((([\\w\\d]+)(\\" + ".[\\w\\d-]+){2,})(:(\\d+))?)/?$"); + Pattern.compile("^(https?://)?((([\\w\\d-]+)(\\.[\\w\\d-]+){2,})(:(\\d+))?)/?$"); Matcher matcher = pattern.matcher(urlStr.trim().toLowerCase()); diff --git a/src/main/java/net/snowflake/ingest/utils/Utils.java b/src/main/java/net/snowflake/ingest/utils/Utils.java index 31a6f021e..45f67a83d 100644 --- a/src/main/java/net/snowflake/ingest/utils/Utils.java +++ b/src/main/java/net/snowflake/ingest/utils/Utils.java @@ -4,42 +4,90 @@ package net.snowflake.ingest.utils; -import static net.snowflake.ingest.utils.Constants.JDBC_PRIVATE_KEY; -import static net.snowflake.ingest.utils.Constants.ROLE; -import static net.snowflake.ingest.utils.Constants.SSL; import static net.snowflake.ingest.utils.Constants.USER; import com.codahale.metrics.Timer; -import com.fasterxml.jackson.databind.node.ObjectNode; +import io.netty.util.internal.PlatformDependent; import java.io.StringReader; +import java.lang.management.BufferPoolMXBean; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryUsage; +import java.lang.reflect.InvocationTargetException; import java.security.KeyFactory; import java.security.KeyPair; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; +import java.security.Provider; import java.security.PublicKey; import java.security.Security; import java.security.interfaces.RSAPrivateCrtKey; import java.security.spec.InvalidKeySpecException; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.RSAPublicKeySpec; +import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Properties; -import net.snowflake.client.jdbc.internal.org.bouncycastle.asn1.pkcs.PrivateKeyInfo; -import net.snowflake.client.jdbc.internal.org.bouncycastle.jce.provider.BouncyCastleProvider; -import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.PEMParser; -import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; -import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder; -import net.snowflake.client.jdbc.internal.org.bouncycastle.operator.InputDecryptorProvider; -import net.snowflake.client.jdbc.internal.org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; -import org.apache.arrow.memory.BufferAllocator; +import net.snowflake.client.core.SFSessionProperty; import org.apache.commons.codec.binary.Base64; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder; +import org.bouncycastle.operator.InputDecryptorProvider; +import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; /** Contains Ingest related utility functions */ public class Utils { private static final Logging logger = new Logging(Utils.class); + private static final String DEFAULT_SECURITY_PROVIDER_NAME = + "org.bouncycastle.jce.provider.BouncyCastleProvider"; + + /** provider name */ + private static final String BOUNCY_CASTLE_PROVIDER = "BC"; + /** provider name for FIPS */ + private static final String BOUNCY_CASTLE_FIPS_PROVIDER = "BCFIPS"; + + static { + // Add Bouncy Castle to the security provider. This is required to + // verify the signature on OCSP response and attached certificates. + if (Security.getProvider(BOUNCY_CASTLE_PROVIDER) == null + && Security.getProvider(BOUNCY_CASTLE_FIPS_PROVIDER) == null) { + Security.addProvider(instantiateSecurityProvider()); + } + } + + public static Provider getProvider() { + final Provider bcProvider = Security.getProvider(BOUNCY_CASTLE_PROVIDER); + if (bcProvider != null) { + return bcProvider; + } + final Provider bcFipsProvider = Security.getProvider(BOUNCY_CASTLE_FIPS_PROVIDER); + if (bcFipsProvider != null) { + return bcFipsProvider; + } + throw new SFException(ErrorCode.INTERNAL_ERROR, "No security provider found"); + } + + private static Provider instantiateSecurityProvider() { + try { + logger.logInfo("Adding security provider {}", DEFAULT_SECURITY_PROVIDER_NAME); + Class klass = Class.forName(DEFAULT_SECURITY_PROVIDER_NAME); + return (Provider) klass.getDeclaredConstructor().newInstance(); + } catch (ExceptionInInitializerError + | ClassNotFoundException + | NoSuchMethodException + | InstantiationException + | IllegalAccessException + | IllegalArgumentException + | InvocationTargetException + | SecurityException ex) { + throw new SFException( + ErrorCode.CRYPTO_PROVIDER_ERROR, DEFAULT_SECURITY_PROVIDER_NAME, ex.getMessage()); + } + } + /** * Assert when the String is null or Empty * @@ -70,10 +118,9 @@ public static void assertNotNull(String name, Object value) throws SFException { * Create a Properties for snowflake connection * * @param inputProp input property map - * @param sslEnabled if ssl is enabled * @return a Properties instance */ - public static Properties createProperties(Properties inputProp, boolean sslEnabled) { + public static Properties createProperties(Properties inputProp) { Properties properties = new Properties(); // decrypt rsa key @@ -91,69 +138,104 @@ public static Properties createProperties(Properties inputProp, boolean sslEnabl case Constants.PRIVATE_KEY_PASSPHRASE: privateKeyPassphrase = val; break; - case Constants.USER: - properties.put(USER, val); - break; - case Constants.ROLE: - properties.put(ROLE, val); - break; default: - properties.put(key, val); + // Preserve case for proxy-related properties that HttpUtil expects + if (isProxyRelatedProperty(key)) { + properties.put(key, val); + } else { + properties.put(key.toLowerCase(), val); + } } } if (!privateKeyPassphrase.isEmpty()) { - properties.put(JDBC_PRIVATE_KEY, parseEncryptedPrivateKey(privateKey, privateKeyPassphrase)); + properties.put( + SFSessionProperty.PRIVATE_KEY.getPropertyKey(), + parseEncryptedPrivateKey(privateKey, privateKeyPassphrase)); } else if (!privateKey.isEmpty()) { - properties.put(JDBC_PRIVATE_KEY, parsePrivateKey(privateKey)); + properties.put(SFSessionProperty.PRIVATE_KEY.getPropertyKey(), parsePrivateKey(privateKey)); } - // set ssl - if (sslEnabled) { - properties.put(SSL, "on"); - } else { - properties.put(SSL, "off"); + // Use JWT if authorization type not specified + if (!properties.containsKey(Constants.AUTHORIZATION_TYPE)) { + properties.put(Constants.AUTHORIZATION_TYPE, Constants.JWT); } - if (!properties.containsKey(JDBC_PRIVATE_KEY)) { - throw new SFException(ErrorCode.MISSING_CONFIG, "private_key"); + String authType = properties.get(Constants.AUTHORIZATION_TYPE).toString(); + if (authType.equals(Constants.JWT)) { + if (!properties.containsKey(SFSessionProperty.PRIVATE_KEY.getPropertyKey())) { + throw new SFException(ErrorCode.MISSING_CONFIG, Constants.PRIVATE_KEY); + } + } else if (authType.equals(Constants.OAUTH)) { + if (!properties.containsKey(Constants.OAUTH_CLIENT_ID)) { + throw new SFException(ErrorCode.MISSING_CONFIG, Constants.OAUTH_CLIENT_ID); + } + if (!properties.containsKey(Constants.OAUTH_CLIENT_SECRET)) { + throw new SFException(ErrorCode.MISSING_CONFIG, Constants.OAUTH_CLIENT_SECRET); + } + if (!properties.containsKey(Constants.OAUTH_REFRESH_TOKEN)) { + throw new SFException(ErrorCode.MISSING_CONFIG, Constants.OAUTH_REFRESH_TOKEN); + } + } else { + throw new SFException( + ErrorCode.INVALID_CONFIG_PARAMETER, + String.format("authorization_type, should be %s or %s", Constants.JWT, Constants.OAUTH)); } if (!properties.containsKey(USER)) { throw new SFException(ErrorCode.MISSING_CONFIG, "user"); } + if (!properties.containsKey(Constants.ACCOUNT_URL)) { + if (!properties.containsKey(Constants.HOST)) { + throw new SFException(ErrorCode.MISSING_CONFIG, "host"); + } + if (!properties.containsKey(Constants.SCHEME)) { + throw new SFException(ErrorCode.MISSING_CONFIG, "scheme"); + } + if (!properties.containsKey(Constants.PORT)) { + throw new SFException(ErrorCode.MISSING_CONFIG, "port"); + } + + properties.put( + Constants.ACCOUNT_URL, + Utils.constructAccountUrl( + properties.get(Constants.SCHEME).toString(), + properties.get(Constants.HOST).toString(), + Integer.parseInt(properties.get(Constants.PORT).toString()))); + } + + if (!properties.containsKey(Constants.ROLE)) { + logger.logInfo("Snowflake role is not provided, the default user role will be applied."); + } + + /** + * Behavior change in JDBC release 3.13.25 + * + * @see Snowflake + * Documentation Release Notes + */ + properties.put(SFSessionProperty.ALLOW_UNDERSCORES_IN_HOST.getPropertyKey(), "true"); + return properties; } + /** Check if a property key is proxy-related and should preserve its case */ + private static boolean isProxyRelatedProperty(String key) { + return key.equals(SFSessionProperty.USE_PROXY.getPropertyKey()) + || key.equals(SFSessionProperty.PROXY_HOST.getPropertyKey()) + || key.equals(SFSessionProperty.PROXY_PORT.getPropertyKey()) + || key.equals(SFSessionProperty.PROXY_USER.getPropertyKey()) + || key.equals(SFSessionProperty.PROXY_PASSWORD.getPropertyKey()) + || key.equals(SFSessionProperty.NON_PROXY_HOSTS.getPropertyKey()) + || key.equals(SFSessionProperty.PROXY_PROTOCOL.getPropertyKey()); + } + /** Construct account url from input schema, host and port */ public static String constructAccountUrl(String scheme, String host, int port) { return String.format("%s://%s:%d", scheme, host, port); } - /** Get the properties out from a json node */ - public static Properties getPropertiesFromJson(ObjectNode json) { - Properties props = new Properties(); - Optional.ofNullable(json.get(Constants.USER)) - .ifPresent(u -> props.put(Constants.USER, u.asText())); - Optional.ofNullable(json.get(Constants.PRIVATE_KEY)) - .ifPresent(u -> props.put(Constants.PRIVATE_KEY, u.asText())); - Optional.ofNullable(json.get(Constants.ROLE)).ifPresent(u -> props.put(ROLE, u.asText())); - Optional.ofNullable(json.get(Constants.PRIVATE_KEY_PASSPHRASE)) - .ifPresent(u -> props.put(Constants.PRIVATE_KEY_PASSPHRASE, u.asText())); - - Optional.ofNullable(json.get(Constants.SCHEME)) - .ifPresent(u -> props.put(Constants.SCHEME, u.asText())); - Optional.ofNullable(json.get(Constants.HOST)) - .ifPresent(u -> props.put(Constants.HOST, u.asText())); - Optional.ofNullable(json.get(Constants.PORT)) - .ifPresent(u -> props.put(Constants.PORT, u.asInt())); - Optional.ofNullable(json.get(Constants.ACCOUNT_URL)) - .ifPresent(u -> props.put(Constants.ACCOUNT_URL, u.asText())); - - return props; - } - /** * Parse an unencrypted private key * @@ -165,7 +247,6 @@ public static PrivateKey parsePrivateKey(String key) { key = key.replaceAll("-+[A-Za-z ]+-+", ""); key = key.replaceAll("\\s", ""); - java.security.Security.addProvider(new BouncyCastleProvider()); byte[] encoded = Base64.decodeBase64(key); try { KeyFactory kf = KeyFactory.getInstance("RSA"); @@ -198,7 +279,6 @@ public static PrivateKey parseEncryptedPrivateKey(String key, String passphrase) } builder.append("\n-----END ENCRYPTED PRIVATE KEY-----"); key = builder.toString(); - Security.addProvider(new BouncyCastleProvider()); try { PEMParser pemParser = new PEMParser(new StringReader(key)); PKCS8EncryptedPrivateKeyInfo encryptedPrivateKeyInfo = @@ -207,7 +287,7 @@ public static PrivateKey parseEncryptedPrivateKey(String key, String passphrase) InputDecryptorProvider pkcs8Prov = new JceOpenSSLPKCS8DecryptorProviderBuilder().build(passphrase.toCharArray()); JcaPEMKeyConverter converter = - new JcaPEMKeyConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME); + new JcaPEMKeyConverter().setProvider(Utils.getProvider().getName()); PrivateKeyInfo decryptedPrivateKeyInfo = encryptedPrivateKeyInfo.decryptPrivateKeyInfo(pkcs8Prov); return converter.getPrivateKey(decryptedPrivateKeyInfo); @@ -266,18 +346,57 @@ public static byte[] toByteArray(int value) { }; } - /** Utility function to check whether a streing is null or empty */ + /** Utility function to check whether a string is null or empty */ public static boolean isNullOrEmpty(String string) { return string == null || string.isEmpty(); } - /** Release any outstanding memory and then close the buffer allocator */ - public static void closeAllocator(BufferAllocator alloc) { - for (BufferAllocator childAlloc : alloc.getChildAllocators()) { - childAlloc.releaseBytes(childAlloc.getAllocatedMemory()); - childAlloc.close(); + /** Util function to show memory usage info and debug memory issue in the SDK */ + public static void showMemory() { + List pools = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class); + for (BufferPoolMXBean pool : pools) { + logger.logInfo( + "Pool name={}, pool count={}, memory used={}, total capacity={}", + pool.getName(), + pool.getCount(), + pool.getMemoryUsed(), + pool.getTotalCapacity()); + } + + Runtime runtime = Runtime.getRuntime(); + logger.logInfo( + "Max direct memory={}, max runtime memory={}, total runtime memory={}, free runtime" + + " memory={}", + PlatformDependent.maxDirectMemory(), + runtime.maxMemory(), + runtime.totalMemory(), + runtime.freeMemory()); + + MemoryUsage nonHeapMem = ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage(); + logger.logInfo( + "Non-heap memory usage max={}, used={}, committed={}", + nonHeapMem.getMax(), + nonHeapMem.getUsed(), + nonHeapMem.getCommitted()); + + MemoryUsage heapMem = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage(); + logger.logInfo( + "Heap memory usage max={}, used={}, committed={}", + heapMem.getMax(), + heapMem.getUsed(), + heapMem.getCommitted()); + } + + /** Return the stack trace for a given exception */ + public static String getStackTrace(Throwable e) { + if (e == null) { + return null; + } + + StringBuilder stackTrace = new StringBuilder(); + for (StackTraceElement element : e.getStackTrace()) { + stackTrace.append(System.lineSeparator()).append(element.toString()); } - alloc.releaseBytes(alloc.getAllocatedMemory()); - alloc.close(); + return stackTrace.toString(); } } diff --git a/src/main/java/org/apache/parquet/hadoop/BdecParquetReader.java b/src/main/java/org/apache/parquet/hadoop/BdecParquetReader.java new file mode 100644 index 000000000..1a92a8cd4 --- /dev/null +++ b/src/main/java/org/apache/parquet/hadoop/BdecParquetReader.java @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package org.apache.parquet.hadoop; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; +import org.apache.hadoop.conf.Configuration; +import org.apache.parquet.ParquetReadOptions; +import org.apache.parquet.hadoop.api.InitContext; +import org.apache.parquet.hadoop.api.ReadSupport; +import org.apache.parquet.io.DelegatingSeekableInputStream; +import org.apache.parquet.io.InputFile; +import org.apache.parquet.io.SeekableInputStream; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.io.api.Converter; +import org.apache.parquet.io.api.GroupConverter; +import org.apache.parquet.io.api.PrimitiveConverter; +import org.apache.parquet.io.api.RecordMaterializer; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.MessageType; + +/** + * BDEC specific parquet reader. + * + *

    Resides in parquet package because, it uses {@link InternalParquetRecordReader} that is + * package private. + */ +public class BdecParquetReader implements AutoCloseable { + private final InternalParquetRecordReader> reader; + + /** + * @param data buffer where the data that has to be read resides. + * @throws IOException + */ + public BdecParquetReader(byte[] data) throws IOException { + ParquetReadOptions options = ParquetReadOptions.builder().build(); + ParquetFileReader fileReader = ParquetFileReader.open(new BdecInputFile(data), options); + reader = new InternalParquetRecordReader<>(new BdecReadSupport(), options.getRecordFilter()); + reader.initialize(fileReader, options); + } + + /** + * Reads the current row, i.e. list of values. + * + * @return current row + * @throws IOException + */ + public List read() throws IOException { + try { + return reader.nextKeyValue() ? reader.getCurrentValue() : null; + } catch (InterruptedException e) { + throw new IOException(e); + } + } + + /** + * Close the reader. + * + * @throws IOException + */ + @Override + public void close() throws IOException { + reader.close(); + } + + /** + * Reads the input data using Parquet reader and writes them using a Parquet Writer. + * + * @param data input data to be read first and then written with outputWriter + * @param outputWriter output parquet writer + */ + public static void readFileIntoWriter(byte[] data, BdecParquetWriter outputWriter) { + try (BdecParquetReader reader = new BdecParquetReader(data)) { + for (List record = reader.read(); record != null; record = reader.read()) { + outputWriter.writeRow(record); + } + } catch (IOException e) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "Failed to merge parquet files", e); + } + } + + private static class BdecInputFile implements InputFile { + private final byte[] data; + + private BdecInputFile(byte[] data) { + this.data = data; + } + + @Override + public long getLength() { + return data.length; + } + + @Override + public SeekableInputStream newStream() { + return new BdecSeekableInputStream(new BdecByteArrayInputStream(data)); + } + } + + private static class BdecSeekableInputStream extends DelegatingSeekableInputStream { + private final BdecByteArrayInputStream stream; + + public BdecSeekableInputStream(BdecByteArrayInputStream stream) { + super(stream); + this.stream = stream; + } + + @Override + public long getPos() { + return stream.getPos(); + } + + @Override + public void seek(long newPos) { + stream.seek(newPos); + } + } + + private static class BdecByteArrayInputStream extends ByteArrayInputStream { + public BdecByteArrayInputStream(byte[] buf) { + super(buf); + } + + long getPos() { + return pos; + } + + void seek(long newPos) { + pos = (int) newPos; + } + } + + private static class BdecReadSupport extends ReadSupport> { + @Override + public RecordMaterializer> prepareForRead( + Configuration conf, Map metaData, MessageType schema, ReadContext context) { + return new BdecRecordMaterializer(schema); + } + + @Override + public ReadContext init(InitContext context) { + return new ReadContext(context.getFileSchema()); + } + } + + private static class BdecRecordMaterializer extends RecordMaterializer> { + public final BdecRecordConverter root; + + public BdecRecordMaterializer(MessageType schema) { + this.root = new BdecRecordConverter(schema); + } + + @Override + public List getCurrentRecord() { + return root.getCurrentRecord(); + } + + @Override + public GroupConverter getRootConverter() { + return root; + } + } + + private static class BdecRecordConverter extends GroupConverter { + private final Converter[] converters; + private final int fieldNumber; + private Object[] record; + + public BdecRecordConverter(GroupType schema) { + this.converters = new Converter[schema.getFieldCount()]; + this.fieldNumber = schema.getFields().size(); + for (int i = 0; i < fieldNumber; i++) { + converters[i] = new BdecPrimitiveConverter(i); + } + } + + @Override + public Converter getConverter(int fieldIndex) { + return converters[fieldIndex]; + } + + List getCurrentRecord() { + return Arrays.asList(record); + } + + @Override + public void start() { + record = new Object[fieldNumber]; + } + + @Override + public void end() {} + + private class BdecPrimitiveConverter extends PrimitiveConverter { + protected final int index; + + public BdecPrimitiveConverter(int index) { + this.index = index; + } + + @Override + public void addBinary(Binary value) { + record[index] = value.getBytes(); + } + + @Override + public void addBoolean(boolean value) { + record[index] = value; + } + + @Override + public void addDouble(double value) { + record[index] = value; + } + + @Override + public void addFloat(float value) { + record[index] = value; + } + + @Override + public void addInt(int value) { + record[index] = value; + } + + @Override + public void addLong(long value) { + record[index] = value; + } + } + } +} diff --git a/src/main/java/org/apache/parquet/hadoop/BdecParquetWriter.java b/src/main/java/org/apache/parquet/hadoop/BdecParquetWriter.java new file mode 100644 index 000000000..8b71cfd0e --- /dev/null +++ b/src/main/java/org/apache/parquet/hadoop/BdecParquetWriter.java @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package org.apache.parquet.hadoop; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; +import org.apache.hadoop.conf.Configuration; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.ParquetProperties; +import org.apache.parquet.column.values.factory.DefaultV1ValuesWriterFactory; +import org.apache.parquet.crypto.FileEncryptionProperties; +import org.apache.parquet.hadoop.api.WriteSupport; +import org.apache.parquet.io.DelegatingPositionOutputStream; +import org.apache.parquet.io.OutputFile; +import org.apache.parquet.io.ParquetEncodingException; +import org.apache.parquet.io.PositionOutputStream; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.io.api.RecordConsumer; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; + +/** + * BDEC specific parquet writer. + * + *

    Resides in parquet package because, it uses {@link InternalParquetRecordWriter} and {@link + * CodecFactory} that are package private. + */ +public class BdecParquetWriter implements AutoCloseable { + private final InternalParquetRecordWriter> writer; + private final CodecFactory codecFactory; + + /** + * Creates a BDEC specific parquet writer. + * + * @param stream output + * @param schema row schema + * @param extraMetaData extra metadata + * @param channelName name of the channel that is using the writer + * @throws IOException + */ + public BdecParquetWriter( + ByteArrayOutputStream stream, + MessageType schema, + Map extraMetaData, + String channelName, + long maxChunkSizeInBytes, + Constants.BdecParquetCompression bdecParquetCompression) + throws IOException { + OutputFile file = new ByteArrayOutputFile(stream, maxChunkSizeInBytes); + ParquetProperties encodingProps = createParquetProperties(); + Configuration conf = new Configuration(); + WriteSupport> writeSupport = + new BdecWriteSupport(schema, extraMetaData, channelName); + WriteSupport.WriteContext writeContext = writeSupport.init(conf); + + ParquetFileWriter fileWriter = + new ParquetFileWriter( + file, + schema, + ParquetFileWriter.Mode.CREATE, + Constants.MAX_BLOB_SIZE_IN_BYTES * 2, + ParquetWriter.MAX_PADDING_SIZE_DEFAULT, + encodingProps.getColumnIndexTruncateLength(), + encodingProps.getStatisticsTruncateLength(), + encodingProps.getPageWriteChecksumEnabled(), + (FileEncryptionProperties) null); + fileWriter.start(); + + /* + Internally parquet writer initialises CodecFactory with the configured page size. + We set the page size to the max chunk size that is quite big in general. + CodecFactory allocates a byte buffer of that size on heap during initialisation. + + If we use Parquet writer for buffering, there will be one writer per channel on each flush. + The memory will be allocated for each writer at the beginning even if we don't write anything with each writer, + which is the case when we enable parquet writer buffering. + Hence, to avoid huge memory allocations, we have to internally initialise CodecFactory with `ParquetWriter.DEFAULT_PAGE_SIZE` as it usually happens. + To get code access to this internal initialisation, we have to move the BdecParquetWriter class in the parquet.hadoop package. + */ + codecFactory = new CodecFactory(conf, ParquetWriter.DEFAULT_PAGE_SIZE); + @SuppressWarnings("deprecation") // Parquet does not support the new one now + CodecFactory.BytesCompressor compressor = + codecFactory.getCompressor(bdecParquetCompression.getCompressionCodec()); + writer = + new InternalParquetRecordWriter<>( + fileWriter, + writeSupport, + schema, + writeContext.getExtraMetaData(), + Constants.MAX_BLOB_SIZE_IN_BYTES * 2, + compressor, + true, + encodingProps); + } + + public void writeRow(List row) { + try { + writer.write(row); + } catch (InterruptedException | IOException e) { + throw new SFException(ErrorCode.INTERNAL_ERROR, "parquet row write failed", e); + } + } + + @Override + public void close() throws IOException { + try { + writer.close(); + } catch (InterruptedException e) { + throw new IOException(e); + } finally { + codecFactory.release(); + } + } + + private static ParquetProperties createParquetProperties() { + /** + * There are two main limitations on the server side that we have to overcome by tweaking + * Parquet limits: + * + *

    1. Scanner supports only the case when the row number in pages is the same. Remember that + * a page has data from only one column. + * + *

    2. Scanner supports only 1 row group per Parquet file. + * + *

    We can't guarantee that each page will have the same number of rows, because we have no + * internal control over Parquet lib. That's why to satisfy 1., we will generate one page per + * column. + * + *

    To satisfy 1. and 2., we will disable a check that decides when to flush buffered rows to + * row groups and pages. The check happens after a configurable amount of row counts and by + * setting it to Integer.MAX_VALUE, Parquet lib will never perform the check and flush all + * buffered rows to one row group on close(). The same check decides when to flush a rowgroup to + * page. So by disabling it, we are not checking any page limits as well and flushing all + * buffered data of the rowgroup to one page. + * + *

    TODO: Remove the enforcements of single row group SNOW-738040 and single page (per column) + * SNOW-737331. TODO: Revisit block and page size estimate after limitation (1) is removed + * SNOW-738614 * + */ + return ParquetProperties.builder() + // PARQUET_2_0 uses Encoding.DELTA_BYTE_ARRAY for byte arrays (e.g. SF sb16) + // server side does not support it TODO: SNOW-657238 + .withWriterVersion(ParquetProperties.WriterVersion.PARQUET_1_0) + .withValuesWriterFactory(new DefaultV1ValuesWriterFactory()) + // the dictionary encoding (Encoding.*_DICTIONARY) is not supported by server side + // scanner yet + .withDictionaryEncoding(false) + .withPageRowCountLimit(Integer.MAX_VALUE) + .withMinRowCountForPageSizeCheck(Integer.MAX_VALUE) + .build(); + } + + /** + * A parquet specific file output implementation. + * + *

    This class is implemented as parquet library API requires, mostly to create our {@link + * ByteArrayDelegatingPositionOutputStream} implementation. + */ + private static class ByteArrayOutputFile implements OutputFile { + private final ByteArrayOutputStream stream; + private final long maxChunkSizeInBytes; + + private ByteArrayOutputFile(ByteArrayOutputStream stream, long maxChunkSizeInBytes) { + this.stream = stream; + this.maxChunkSizeInBytes = maxChunkSizeInBytes; + } + + @Override + public PositionOutputStream create(long blockSizeHint) throws IOException { + stream.reset(); + return new ByteArrayDelegatingPositionOutputStream(stream); + } + + @Override + public PositionOutputStream createOrOverwrite(long blockSizeHint) throws IOException { + return create(blockSizeHint); + } + + @Override + public boolean supportsBlockSize() { + return false; + } + + @Override + public long defaultBlockSize() { + return maxChunkSizeInBytes; + } + } + + /** + * A parquet specific output stream implementation. + * + *

    This class is implemented as parquet library API requires, mostly to wrap our BDEC output + * {@link ByteArrayOutputStream}. + */ + private static class ByteArrayDelegatingPositionOutputStream + extends DelegatingPositionOutputStream { + private final ByteArrayOutputStream stream; + + public ByteArrayDelegatingPositionOutputStream(ByteArrayOutputStream stream) { + super(stream); + this.stream = stream; + } + + @Override + public long getPos() { + return stream.size(); + } + } + + /** + * A parquet specific write support implementation. + * + *

    This class is implemented as parquet library API requires, mostly to serialize user column + * values depending on type into Parquet {@link RecordConsumer} in {@link + * BdecWriteSupport#write(List)}. + */ + private static class BdecWriteSupport extends WriteSupport> { + MessageType schema; + RecordConsumer recordConsumer; + Map extraMetadata; + private final String channelName; + + // TODO SNOW-672156: support specifying encodings and compression + BdecWriteSupport(MessageType schema, Map extraMetadata, String channelName) { + this.schema = schema; + this.extraMetadata = extraMetadata; + this.channelName = channelName; + } + + @Override + public WriteContext init(Configuration config) { + return new WriteContext(schema, extraMetadata); + } + + @Override + public void prepareForWrite(RecordConsumer recordConsumer) { + this.recordConsumer = recordConsumer; + } + + @Override + public void write(List values) { + List cols = schema.getColumns(); + if (values.size() != cols.size()) { + throw new ParquetEncodingException( + "Invalid input data in channel '" + + channelName + + "'. Expecting " + + cols.size() + + " columns. Input had " + + values.size() + + " columns (" + + cols + + ") : " + + values); + } + + recordConsumer.startMessage(); + for (int i = 0; i < cols.size(); ++i) { + Object val = values.get(i); + // val.length() == 0 indicates a NULL value. + if (val != null) { + String fieldName = cols.get(i).getPath()[0]; + recordConsumer.startField(fieldName, i); + PrimitiveType.PrimitiveTypeName typeName = + cols.get(i).getPrimitiveType().getPrimitiveTypeName(); + switch (typeName) { + case BOOLEAN: + recordConsumer.addBoolean((boolean) val); + break; + case FLOAT: + recordConsumer.addFloat((float) val); + break; + case DOUBLE: + recordConsumer.addDouble((double) val); + break; + case INT32: + recordConsumer.addInteger((int) val); + break; + case INT64: + recordConsumer.addLong((long) val); + break; + case BINARY: + Binary binVal = + val instanceof String + ? Binary.fromString((String) val) + : Binary.fromConstantByteArray((byte[]) val); + recordConsumer.addBinary(binVal); + break; + case FIXED_LEN_BYTE_ARRAY: + Binary binary = Binary.fromConstantByteArray((byte[]) val); + recordConsumer.addBinary(binary); + break; + default: + throw new ParquetEncodingException( + "Unsupported column type: " + cols.get(i).getPrimitiveType()); + } + recordConsumer.endField(fieldName, i); + } + } + recordConsumer.endMessage(); + } + } +} diff --git a/src/main/resources/net/snowflake/ingest/ingest_error_messages.properties b/src/main/resources/net/snowflake/ingest/ingest_error_messages.properties index edf73a09c..268afd051 100644 --- a/src/main/resources/net/snowflake/ingest/ingest_error_messages.properties +++ b/src/main/resources/net/snowflake/ingest/ingest_error_messages.properties @@ -6,7 +6,7 @@ 0001=Ingest client internal error: {0}. 0002=Required value is null, Key: {0}. 0003=Required value is empty, Key: {0}. -0004=The given row cannot be converted to Arrow format: {0}. {1} +0004=The given row cannot be converted to the internal format: {0}. {1} 0005=Unknown data type for logical: {0}, physical: {1}. 0006=Register blob request failed: {0}. 0007=Open channel request failed: {0}. @@ -15,8 +15,8 @@ 0010=Missing {0} in config file. 0011=Failed to upload blob. 0012=Failed to cleanup resources during {0}. -0013=Channel is invalid and might contain uncommitted rows, please consider reopening the channel to restart. -0014=Channel is closed, please reopen the channel to restart. +0013=Channel {0} is invalid and might contain uncommitted rows, please consider reopening the channel to restart. +0014=Channel {0} is closed, please reopen the channel to restart. 0015=Invalid Snowflake URL, URL format: 'https://..snowflakecomputing.com:443', 'https://' and ':443' are optional. 0016=Client is closed, please recreate to restart. 0017=Invalid private key, private key should be a valid PEM RSA private key. @@ -30,5 +30,12 @@ 0025=One or more channels {0} might contain uncommitted rows due to server side errors, please consider reopening the channels to replay the data loading by using the latest persistent offset token. 0026=Invalid collation string: {0}. {1} 0027=Failure during data encryption. - - +0028=Get channel status indicates Channel {0} is invalid with status code {1}, please reopen the channel. +0029=Data type not supported: {0} +0030=The given row cannot be converted to the internal format due to invalid value: {0} +0031=The given row exceeds the maximum allowed row size {0} +0032=URI builder fail to build url: {0} +0033=OAuth token refresh failure: {0} +0034=Invalid config parameter: {0} +0035=Too large batch of rows passed to insertRows, the batch size cannot exceed {0} bytes, recommended batch size for optimal performance and memory utilization is {1} bytes. We recommend splitting large batches into multiple smaller ones and call insertRows for each smaller batch separately. +0036=Failed to load {0}. If you use FIPS, import BouncyCastleFipsProvider in the application: {1} \ No newline at end of file diff --git a/src/test/java/net/snowflake/ingest/SimpleIngestIT.java b/src/test/java/net/snowflake/ingest/SimpleIngestIT.java index 02c32aca2..20a27eafb 100644 --- a/src/test/java/net/snowflake/ingest/SimpleIngestIT.java +++ b/src/test/java/net/snowflake/ingest/SimpleIngestIT.java @@ -5,12 +5,11 @@ import static net.snowflake.ingest.connection.RequestBuilder.JAVA_USER_AGENT; import static net.snowflake.ingest.connection.RequestBuilder.OS_INFO_USER_AGENT_FORMAT; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URL; +import java.time.Instant; import java.util.Collections; import java.util.HashSet; import java.util.Random; @@ -20,18 +19,14 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import net.snowflake.ingest.connection.ClientStatusResponse; -import net.snowflake.ingest.connection.ConfigureClientResponse; +import net.snowflake.client.jdbc.internal.apache.http.Header; +import net.snowflake.client.jdbc.internal.apache.http.HttpHeaders; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpPost; +import net.snowflake.ingest.connection.HistoryRangeResponse; import net.snowflake.ingest.connection.HistoryResponse; import net.snowflake.ingest.connection.IngestResponse; -import net.snowflake.ingest.connection.IngestResponseException; -import net.snowflake.ingest.connection.InsertFilesClientInfo; import net.snowflake.ingest.utils.StagedFileWrapper; -import org.apache.http.Header; -import org.apache.http.HttpHeaders; -import org.apache.http.client.methods.HttpPost; import org.junit.After; -import org.junit.Assert; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; @@ -40,6 +35,7 @@ public class SimpleIngestIT { private final String TEST_FILE_NAME = "test1.csv"; private final String TEST_FILE_NAME_2 = "test2.csv"; + private final String PIPE_NAME_PREFIX = "ingest_sdk_test_pipe_"; private String testFilePath = null; private String testFilePath_2 = null; @@ -55,6 +51,10 @@ public class SimpleIngestIT { // the object mapper we use for deserialization static ObjectMapper mapper = new ObjectMapper(); + private final Random RAND = new Random(); + + private final Long RAND_NUM = Math.abs(RAND.nextLong()); + /** Create test table and pipe */ @Before public void beforeAll() throws Exception { @@ -67,19 +67,28 @@ public void beforeAll() throws Exception { testFilePath_2 = resource.getFile(); // create stage, pipe, and table - Random rand = new Random(); - Long num = Math.abs(rand.nextLong()); + tableName = "ingest_sdk_test_table_" + RAND_NUM; + + pipeName = PIPE_NAME_PREFIX + RAND_NUM; + + pipeWithPatternName = "ingest_sdk_test_pipe_pattern_" + RAND_NUM; + + stageName = "ingest_sdk_test_stage_" + RAND_NUM; - tableName = "ingest_sdk_test_table_" + num; + stageWithPatternName = "ingest_sdk_test_stage_pattern" + RAND_NUM; - pipeName = "ingest_sdk_test_pipe_" + num; + String databaseName = TestUtils.getDatabase(); - pipeWithPatternName = "ingest_sdk_test_pipe_pattern_" + num; + String schemaName = TestUtils.getSchema(); - stageName = "ingest_sdk_test_stage_" + num; + TestUtils.executeQuery("create database if not exists " + databaseName); - stageWithPatternName = "ingest_sdk_test_stage_pattern" + num; + TestUtils.executeQuery("create schema if not exists " + schemaName); + + TestUtils.executeQuery("use database " + databaseName); + + TestUtils.executeQuery("use schema " + schemaName); TestUtils.executeQuery("create or replace table " + tableName + " (str string, num int)"); @@ -87,13 +96,7 @@ public void beforeAll() throws Exception { TestUtils.executeQuery("create or replace stage " + stageWithPatternName); - TestUtils.executeQuery( - "create or replace pipe " - + pipeName - + " as copy into " - + tableName - + " from @" - + stageName); + createPipe(tableName, stageName, pipeName); TestUtils.executeQuery( "create or replace pipe " @@ -121,6 +124,7 @@ public void afterAll() { /** ingest test example ingest a simple file and check load history. */ @Test + @Ignore // SNOW-957347: re-enable after fix public void testSimpleIngest() throws Exception { // put TestUtils.executeQuery("put file://" + testFilePath + " @" + stageName); @@ -128,23 +132,7 @@ public void testSimpleIngest() throws Exception { // create ingest manager SimpleIngestManager manager = TestUtils.getManager(pipeName); - // create a file wrapper - StagedFileWrapper myFile = new StagedFileWrapper(TEST_FILE_NAME, null); - - // get an insert response after we submit - IngestResponse insertResponse = manager.ingestFile(myFile, null); - - assertEquals("SUCCESS", insertResponse.getResponseCode()); - - // Get history and ensure that the expected file has been ingested - getHistoryAndAssertLoad(manager, TEST_FILE_NAME); - - IngestResponse insertResponseSkippedFiles = manager.ingestFile(myFile, null, true); - - assertEquals("SUCCESS", insertResponseSkippedFiles.getResponseCode()); - assertEquals(1, insertResponseSkippedFiles.getSkippedFiles().size()); - assertEquals( - TEST_FILE_NAME, insertResponseSkippedFiles.getSkippedFiles().stream().findFirst().get()); + testAndVerifySimpleIngestionForOnePipe(manager); } /** ingest test example ingest a simple file and check load history. */ @@ -202,13 +190,20 @@ private void getHistoryAndAssertLoad(SimpleIngestManager manager, String test_fi Future result = service.submit( () -> { + String startTime = Instant.ofEpochMilli(System.currentTimeMillis()).toString(); String beginMark = null; while (true) { try { Thread.sleep(5000); + + String endTime = Instant.ofEpochMilli(System.currentTimeMillis()).toString(); HistoryResponse response = manager.getHistory(null, null, beginMark); + HistoryRangeResponse rangeResponse = + manager.getHistoryRange(null, startTime, endTime); + + assertEquals(response.files.size(), rangeResponse.files.size()); if (response != null && response.getNextBeginMark() != null) { beginMark = response.getNextBeginMark(); @@ -302,6 +297,70 @@ public void testUserAgentSuffixForInsertFileAPI() throws Exception { verifyDefaultUserAgent(noUserAgentUsed.getAllHeaders(), false, null); } + /** + * Creates multiple pipes with same stage and table to verify behavior of HttpUtil and BG thread + * for ConnectionPoolingManager + * + *

    Creates the thread only two times since the manager was closed only once. + */ + @Test + @Ignore // SNOW-957347: re-enable after fix + public void testMultipleSimpleIngestManagers() throws Exception { + // put + TestUtils.executeQuery("put file://" + testFilePath + " @" + stageName); + + // create ingest manager + SimpleIngestManager manager = TestUtils.getManager(pipeName); + + manager.close(); + + final String pipeName2 = PIPE_NAME_PREFIX + "" + (RAND_NUM + 1); + createPipe(tableName, stageName, pipeName2); + + SimpleIngestManager manager2 = TestUtils.getManager(pipeName2); + + testAndVerifySimpleIngestionForOnePipe(manager2); + + final String pipeName3 = PIPE_NAME_PREFIX + "" + (RAND_NUM + 2); + createPipe(tableName, stageName, pipeName3); + + // creating one more SimpleIngestManager + SimpleIngestManager manager3 = TestUtils.getManager(pipeName3); + testAndVerifySimpleIngestionForOnePipe(manager3); + } + + private static void createPipe( + final String tableName, final String stageName, final String pipeName) throws Exception { + TestUtils.executeQuery( + "create or replace pipe " + + pipeName + + " as copy into " + + tableName + + " from @" + + stageName); + } + + private void testAndVerifySimpleIngestionForOnePipe(final SimpleIngestManager simpleIngestManager) + throws Exception { + // create a file wrapper + StagedFileWrapper myFile = new StagedFileWrapper(TEST_FILE_NAME, null); + + // get an insert response after we submit + IngestResponse insertResponse = simpleIngestManager.ingestFile(myFile, null); + + assertEquals("SUCCESS", insertResponse.getResponseCode()); + + // Get history and ensure that the expected file has been ingested + getHistoryAndAssertLoad(simpleIngestManager, TEST_FILE_NAME); + + IngestResponse insertResponseSkippedFiles = simpleIngestManager.ingestFile(myFile, null, true); + + assertEquals("SUCCESS", insertResponseSkippedFiles.getResponseCode()); + assertEquals(1, insertResponseSkippedFiles.getSkippedFiles().size()); + assertEquals( + TEST_FILE_NAME, insertResponseSkippedFiles.getSkippedFiles().stream().findFirst().get()); + } + private void verifyDefaultUserAgent( final Header[] headers, final boolean verifyAdditionalUserAgentInfo, @@ -328,181 +387,4 @@ private void verifyDefaultUserAgent( } } } - - @Ignore - @Test - public void testConfigureClientHappyCase() throws Exception { - final String userAgentSuffix = "kafka-provider/NONE"; - SimpleIngestManager manager = TestUtils.getManager(pipeName, userAgentSuffix); - ConfigureClientResponse configureClientResponse = manager.configureClient(null); - assertEquals(0L, configureClientResponse.getClientSequencer().longValue()); - } - - @Ignore - @Test - public void testConfigureClientNoPipeFound() throws Exception { - final String userAgentSuffix = "kafka-provider/NONE"; - SimpleIngestManager manager = TestUtils.getManager("nopipe", userAgentSuffix); - try { - manager.configureClient(null); - } catch (IngestResponseException exception) { - assertEquals(404, exception.getErrorCode()); - assertEquals( - "Specified object does not exist or not authorized. Pipe not found", - exception.getErrorBody().getMessage()); - } - } - - @Ignore - @Test - public void testGetClientStatusHappyCase() throws Exception { - final String userAgentSuffix = "kafka-provider/NONE"; - SimpleIngestManager manager = TestUtils.getManager(pipeName, userAgentSuffix); - manager.configureClient(null); - ClientStatusResponse clientStatusResponse = manager.getClientStatus(null); - assertEquals(0L, clientStatusResponse.getClientSequencer().longValue()); - assertNull(clientStatusResponse.getOffsetToken()); - } - - @Ignore - @Test - public void testGetClientStatusNoPipeFound() throws Exception { - final String userAgentSuffix = "kafka-provider/NONE"; - SimpleIngestManager manager = TestUtils.getManager("nopipe", userAgentSuffix); - try { - manager.getClientStatus(null); - } catch (IngestResponseException exception) { - assertEquals(404, exception.getErrorCode()); - assertEquals( - "Specified object does not exist or not authorized. Pipe not found", - exception.getErrorBody().getMessage()); - } - } - - @Ignore - @Test - public void testIngestFilesWithClientInfo() throws Exception { - - // first lets call configure client API - final String userAgentSuffix = "kafka-provider/NONE"; - SimpleIngestManager manager = TestUtils.getManager(pipeName, userAgentSuffix); - ConfigureClientResponse configureClientResponse = manager.configureClient(null); - assertEquals(0L, configureClientResponse.getClientSequencer().longValue()); - - // put - TestUtils.executeQuery("put file://" + testFilePath + " @" + stageName); - - // create a file wrapper - StagedFileWrapper myFile = new StagedFileWrapper(TEST_FILE_NAME, null); - - final String offsetToken = "1"; - InsertFilesClientInfo clientInfo = - new InsertFilesClientInfo(configureClientResponse.getClientSequencer(), offsetToken); - - // get an insert response after we submit - IngestResponse insertResponse = - manager.ingestFiles(Collections.singletonList(myFile), null, false, clientInfo); - - assertEquals("SUCCESS", insertResponse.getResponseCode()); - - // Get history and ensure that the expected file has been ingested - getHistoryAndAssertLoad(manager, TEST_FILE_NAME); - - // Get client status since we added offsetToken too - ClientStatusResponse clientStatusResponse = manager.getClientStatus(null); - assertEquals(0L, clientStatusResponse.getClientSequencer().longValue()); - assertNotNull(clientStatusResponse.getOffsetToken()); - assertEquals(offsetToken, clientStatusResponse.getOffsetToken()); - } - - @Ignore - @Test - public void testIngestFilesWithClientInfoWithOldClientSequencer() throws Exception { - - // first lets call configure client API - final String userAgentSuffix = "kafka-provider/NONE"; - SimpleIngestManager manager = TestUtils.getManager(pipeName, userAgentSuffix); - ConfigureClientResponse configureClientResponse = manager.configureClient(null); - assertEquals(0L, configureClientResponse.getClientSequencer().longValue()); - final long oldClientSequencer = configureClientResponse.getClientSequencer(); - configureClientResponse = manager.configureClient(null); - assertEquals(1L, configureClientResponse.getClientSequencer().longValue()); - - // put - TestUtils.executeQuery("put file://" + testFilePath + " @" + stageName); - - // create a file wrapper - StagedFileWrapper myFile = new StagedFileWrapper(TEST_FILE_NAME, null); - - final String offsetToken = "1"; - // Passing in an old clientSequencer - InsertFilesClientInfo clientInfo = new InsertFilesClientInfo(oldClientSequencer, offsetToken); - - // get an insert response after we submit - try { - manager.ingestFiles(Collections.singletonList(myFile), null, false, clientInfo); - Assert.fail( - "The insertFiles API should return 400 and SDK should throw IngestResponseException"); - } catch (IngestResponseException ex) { - assertEquals(400, ex.getErrorCode()); - assertTrue(ex.getErrorBody().getCode().equalsIgnoreCase("091129")); - } - - // Get client status since we added offsetToken too - ClientStatusResponse clientStatusResponse = manager.getClientStatus(null); - assertEquals( - configureClientResponse.getClientSequencer(), clientStatusResponse.getClientSequencer()); - assertNull(clientStatusResponse.getOffsetToken()); - - // lets call insertFiles with new clientSequencer - // Passing in a new clientSequencer - clientInfo = new InsertFilesClientInfo(clientStatusResponse.getClientSequencer(), offsetToken); - - // get an insert response after we submit - try { - IngestResponse insertResponse = - manager.ingestFiles(Collections.singletonList(myFile), null, false, clientInfo); - assertEquals("SUCCESS", insertResponse.getResponseCode()); - - // Get history and ensure that the expected file has been ingested - getHistoryAndAssertLoad(manager, TEST_FILE_NAME); - - // Get client status since we added offsetToken too (During second attempt) - clientStatusResponse = manager.getClientStatus(null); - assertNotNull(clientStatusResponse.getOffsetToken()); - assertEquals(offsetToken, clientStatusResponse.getOffsetToken()); - } catch (IngestResponseException ex) { - Assert.fail( - "The insertFiles API should be successful second time after updaing clientSequencer"); - } - } - - @Ignore - @Test - public void testIngestFilesWithClientInfoWithNoClientSequencer() throws Exception { - // first lets call configure client API - final String userAgentSuffix = "kafka-provider/NONE"; - SimpleIngestManager manager = TestUtils.getManager(pipeName, userAgentSuffix); - - // put - TestUtils.executeQuery("put file://" + testFilePath + " @" + stageName); - - // create a file wrapper - StagedFileWrapper myFile = new StagedFileWrapper(TEST_FILE_NAME, null); - - final String offsetToken = "1"; - // Passing in an old clientSequencer - InsertFilesClientInfo clientInfo = new InsertFilesClientInfo(0L, offsetToken); - - // get an insert response after we submit - try { - manager.ingestFiles(Collections.singletonList(myFile), null, false, clientInfo); - Assert.fail( - "The insertFiles API should return 400 since client/configure was not called and SDK" - + " should throw IngestResponseException"); - } catch (IngestResponseException ex) { - assertEquals(400, ex.getErrorCode()); - assertTrue(ex.getErrorBody().getCode().equalsIgnoreCase("091128")); - } - } } diff --git a/src/test/java/net/snowflake/ingest/TestUtils.java b/src/test/java/net/snowflake/ingest/TestUtils.java index 95dd01d2d..ba14ff610 100644 --- a/src/test/java/net/snowflake/ingest/TestUtils.java +++ b/src/test/java/net/snowflake/ingest/TestUtils.java @@ -13,8 +13,12 @@ import static net.snowflake.ingest.utils.Constants.SSL; import static net.snowflake.ingest.utils.Constants.USER; import static net.snowflake.ingest.utils.Constants.WAREHOUSE; +import static net.snowflake.ingest.utils.ParameterProvider.BLOB_FORMAT_VERSION; import java.io.IOException; +import java.math.BigDecimal; +import java.net.URI; +import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -26,19 +30,30 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; +import java.sql.SQLException; import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import java.util.Properties; +import java.util.Random; +import java.util.function.Supplier; +import net.snowflake.client.jdbc.internal.apache.http.client.utils.URIBuilder; import net.snowflake.client.jdbc.internal.fasterxml.jackson.databind.ObjectMapper; import net.snowflake.client.jdbc.internal.fasterxml.jackson.databind.node.ObjectNode; -import net.snowflake.client.jdbc.internal.org.bouncycastle.jce.provider.BouncyCastleProvider; +import net.snowflake.ingest.streaming.InsertValidationResponse; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; +import net.snowflake.ingest.utils.Constants; import net.snowflake.ingest.utils.Utils; import org.apache.commons.codec.binary.Base64; +import org.junit.Assert; public class TestUtils { // profile path, follow readme for the format private static final String PROFILE_PATH = "profile.json"; + private static final String OAUTH_INTEGRATION = "OAUTH_INTEGRATION"; + private static final ObjectMapper mapper = new ObjectMapper(); private static ObjectNode profile = null; @@ -71,11 +86,16 @@ public class TestUtils { private static int port = 0; - private static Connection conn = null; + // Keep separate test connections for snowpipe and snowpipe streaming so that session state is + // isolated + private static Connection snowpipeConn = null; + + private static Connection streamingConn = null; private static String dummyUser = "user"; private static int dummyPort = 443; private static String dummyHost = "snowflake.qa1.int.snowflakecomputing.com"; + private static String dummyScheme = "http"; /** * load all login info from profile @@ -83,7 +103,8 @@ public class TestUtils { * @throws IOException if can't read profile */ private static void init() throws Exception { - Path path = Paths.get(PROFILE_PATH); + String testProfilePath = getTestProfilePath(); + Path path = Paths.get(testProfilePath); if (Files.exists(path)) { profile = (ObjectNode) mapper.readTree(new String(Files.readAllBytes(path))); @@ -101,8 +122,6 @@ private static void init() throws Exception { role = Optional.ofNullable(profile.get(ROLE)).map(r -> r.asText()).orElse("DEFAULT_ROLE"); privateKeyPem = profile.get(PRIVATE_KEY).asText(); - java.security.Security.addProvider(new BouncyCastleProvider()); - byte[] encoded = Base64.decodeBase64(privateKeyPem); KeyFactory kf = KeyFactory.getInstance("RSA"); @@ -113,6 +132,7 @@ private static void init() throws Exception { user = dummyUser; port = dummyPort; host = dummyHost; + scheme = dummyScheme; KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); kpg.initialize(2048); keyPair = kpg.generateKeyPair(); @@ -121,6 +141,15 @@ private static void init() throws Exception { } } + /** @return profile path that will be used for tests. */ + private static String getTestProfilePath() { + String testProfilePath = + System.getProperty("testProfilePath") != null + ? System.getProperty("testProfilePath") + : PROFILE_PATH; + return testProfilePath; + } + public static String getUser() throws Exception { if (profile == null) { init(); @@ -128,6 +157,13 @@ public static String getUser() throws Exception { return user; } + public static String getAccount() throws Exception { + if (profile == null) { + init(); + } + return account; + } + public static String getAccountURL() throws Exception { if (profile == null) { init(); @@ -136,6 +172,14 @@ public static String getAccountURL() throws Exception { return Utils.constructAccountUrl(scheme, host, port); } + public static String getRole() throws Exception { + if (profile == null) { + init(); + } + + return role; + } + public static String getWarehouse() throws Exception { if (profile == null) { init(); @@ -164,7 +208,22 @@ public static KeyPair getKeyPair() throws Exception { return keyPair; } - public static Properties getProperties() throws Exception { + public static String getDatabase() throws Exception { + if (profile == null) { + init(); + } + return database; + } + + public static String getSchema() throws Exception { + if (profile == null) { + init(); + } + return schema; + } + + public static Properties getProperties(Constants.BdecVersion bdecVersion, boolean useDefaultRole) + throws Exception { if (profile == null) { init(); } @@ -177,8 +236,11 @@ public static Properties getProperties() throws Exception { props.put(SCHEMA, schema); props.put(WAREHOUSE, warehouse); props.put(PRIVATE_KEY, privateKeyPem); - props.put(ROLE, role); + if (!useDefaultRole) { + props.put(ROLE, role); + } props.put(ACCOUNT_URL, getAccountURL()); + props.put(BLOB_FORMAT_VERSION, bdecVersion.toByte()); return props; } @@ -189,7 +251,24 @@ public static Properties getProperties() throws Exception { * @throws Exception */ public static Connection getConnection() throws Exception { - if (conn != null) return conn; + return getConnection(false); + } + + /** + * Create snowflake jdbc connection + * + * @param isStreamingConnection: is true will return a separate connection for streaming ingest + * tests + * @return jdbc connection + * @throws Exception + */ + public static Connection getConnection(boolean isStreamingConnection) throws Exception { + if (!isStreamingConnection && snowpipeConn != null && !snowpipeConn.isClosed()) { + return snowpipeConn; + } + if (isStreamingConnection && streamingConn != null && !streamingConn.isClosed()) { + return streamingConn; + } if (profile == null) init(); // check first to see if we have the Snowflake JDBC @@ -206,10 +285,14 @@ public static Connection getConnection() throws Exception { props.put("client_session_keep_alive", "true"); props.put("privateKey", privateKey); - conn = DriverManager.getConnection(connectString, props); - + if (isStreamingConnection) { + streamingConn = DriverManager.getConnection(connectString, props); + // fire off the connection + return streamingConn; + } + snowpipeConn = DriverManager.getConnection(connectString, props); // fire off the connection - return conn; + return snowpipeConn; } /** @@ -279,4 +362,147 @@ public static SimpleIngestManager getManagerUsingBuilderPattern( .setUserAgentSuffix(userAgentSuffix) .build(); } + + /** + * Given a channel and expected offset, this method waits up to 60 seconds until the last + * committed offset is equal to the passed offset + */ + public static void waitForOffset(SnowflakeStreamingIngestChannel channel, String expectedOffset) + throws InterruptedException { + int counter = 0; + String lastCommittedOffset = null; + while (counter < 600) { + String currentOffset = channel.getLatestCommittedOffsetToken(); + if (expectedOffset.equals(currentOffset)) { + return; + } + lastCommittedOffset = currentOffset; + counter++; + Thread.sleep(100); + } + Assert.fail( + String.format( + "Timeout exceeded while waiting for offset %s. Last committed offset: %s", + expectedOffset, lastCommittedOffset)); + } + + public static void waitChannelFlushed(SnowflakeStreamingIngestChannel channel, int numberOfRows) { + String latestCommittedOffsetToken = null; + for (int i = 1; i < 40; i++) { + latestCommittedOffsetToken = channel.getLatestCommittedOffsetToken(); + if (latestCommittedOffsetToken != null + && latestCommittedOffsetToken.equals(Integer.toString(numberOfRows - 1))) { + return; + } + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + throw new RuntimeException( + "Interrupted waitChannelFlushed for " + numberOfRows + " rows", e); + } + } + Assert.fail( + "Row sequencer not updated before timeout, latestCommittedOffsetToken: " + + latestCommittedOffsetToken); + } + + /** Verify the insert validation response and throw the exception if needed */ + public static void verifyInsertValidationResponse(InsertValidationResponse response) { + if (response.hasErrors()) { + throw response.getInsertErrors().get(0).getException(); + } + } + + public static void verifyTableRowCount( + int rowNumber, Connection jdbcConnection, String database, String schema, String tableName) { + try { + ResultSet resultCount = + jdbcConnection + .createStatement() + .executeQuery( + String.format("select count(*) from %s.%s.%s", database, schema, tableName)); + resultCount.next(); + Assert.assertEquals(rowNumber, resultCount.getLong(1)); + } catch (SQLException e) { + throw new RuntimeException("Cannot verifyTableRowCount for " + tableName, e); + } + } + + /** + * Creates a string from a certain number of concatenated strings e.g. buildString("ab", 2) => + * abab + */ + public static String buildString(String str, int count) { + StringBuilder sb = new StringBuilder(count); + for (int i = 0; i < count; i++) { + sb.append(str); + } + return sb.toString(); + } + + public static Map getRandomRow(Random r, boolean nullable) { + Map row = new HashMap<>(); + + row.put("num_2_1", nullOrIfNullable(nullable, r, () -> r.nextInt(100) / 10.0)); + row.put("num_4_2", nullOrIfNullable(nullable, r, () -> r.nextInt(10000) / 100.0)); + row.put( + "num_9_4", nullOrIfNullable(nullable, r, () -> r.nextInt(1000000000) / Math.pow(10, 4))); + row.put( + "num_18_7", + nullOrIfNullable(nullable, r, () -> nextLongOfPrecision(r, 18) / Math.pow(10, 7))); + row.put( + "num_38_15", + nullOrIfNullable( + nullable, + r, + () -> + new BigDecimal( + "" + nextLongOfPrecision(r, 18) + "." + Math.abs(nextLongOfPrecision(r, 15))))); + + row.put("num_float", nullOrIfNullable(nullable, r, () -> nextFloat(r))); + row.put("str", nullOrIfNullable(nullable, r, () -> nextString(r))); + row.put("bin", nullOrIfNullable(nullable, r, () -> nextBytes(r))); + + return row; + } + + public static URIBuilder getBaseURIBuilder() { + return new URIBuilder().setScheme(scheme).setHost(host).setPort(port); + } + + public static URI getTokenRequestURI() { + URI tokenRequestURI = null; + try { + tokenRequestURI = getBaseURIBuilder().setPath("/oauth/token-request").build(); + } catch (URISyntaxException e) { + throw new RuntimeException("Fail to construct token request uri", e); + } + + return tokenRequestURI; + } + + private static T nullOrIfNullable(boolean nullable, Random r, Supplier value) { + return !nullable ? value.get() : (r.nextBoolean() ? value.get() : null); + } + + private static long nextLongOfPrecision(Random r, int precision) { + return r.nextLong() % Math.round(Math.pow(10, precision)); + } + + private static String nextString(Random r) { + return new String(nextBytes(r)); + } + + private static byte[] nextBytes(Random r) { + byte[] bin = new byte[128]; + r.nextBytes(bin); + for (int i = 0; i < bin.length; i++) { + bin[i] = (byte) (Math.abs(bin[i]) % 25 + 97); // ascii letters + } + return bin; + } + + private static double nextFloat(Random r) { + return (r.nextLong() % Math.round(Math.pow(10, 10))) / 100000d; + } } diff --git a/src/test/java/net/snowflake/ingest/connection/MockOAuthClient.java b/src/test/java/net/snowflake/ingest/connection/MockOAuthClient.java new file mode 100644 index 000000000..f1c3b2409 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/connection/MockOAuthClient.java @@ -0,0 +1,39 @@ +package net.snowflake.ingest.connection; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; + +/** Mock implementation of {@link OAuthClient}, only use for test */ +public class MockOAuthClient implements OAuthClient { + private final AtomicReference oAuthCredential; + + private int futureRefreshFailCount = 0; + + public MockOAuthClient() { + OAuthCredential mockOAuthCredential = + new OAuthCredential("CLIENT_ID", "CLIENT_SECRET", "REFRESH_TOKEN"); + oAuthCredential = new AtomicReference<>(mockOAuthCredential); + oAuthCredential.get().setExpiresIn(600); + } + + @Override + public AtomicReference getoAuthCredentialRef() { + return oAuthCredential; + } + + @Override + public void refreshToken() { + if (futureRefreshFailCount == 0) { + oAuthCredential.get().setAccessToken(UUID.randomUUID().toString()); + return; + } + futureRefreshFailCount--; + throw new SFException(ErrorCode.OAUTH_REFRESH_TOKEN_ERROR); + } + + public void setFutureRefreshFailCount(int futureRefreshFailCount) { + this.futureRefreshFailCount = futureRefreshFailCount; + } +} diff --git a/src/test/java/net/snowflake/ingest/connection/SecurityManagerTest.java b/src/test/java/net/snowflake/ingest/connection/SecurityManagerTest.java index a7570f4c7..51d1d6490 100644 --- a/src/test/java/net/snowflake/ingest/connection/SecurityManagerTest.java +++ b/src/test/java/net/snowflake/ingest/connection/SecurityManagerTest.java @@ -1,186 +1,138 @@ package net.snowflake.ingest.connection; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; -import java.security.KeyFactory; import java.security.KeyPair; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; -import java.util.Arrays; -import java.util.Base64; -import org.junit.Assert; +import java.security.KeyPairGenerator; +import java.util.concurrent.TimeUnit; +import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.utils.Constants; +import org.junit.Before; import org.junit.Test; +/** SecurityManager - tests functionally of security manager */ public class SecurityManagerTest { - private String expectedPublicKeyFp = "SHA256:yVUGJrOo4BN1Cza+m2zNzvQbk/4rICTydzSNvuiyy9Q="; - - /** BASE64 ENCODED PUBLIC KEY */ - private String storedPublicKey = - "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtRPru42llC40VdmWnc8r\n" - + "7TI/AFemZw4Lh1HRnPIFRxwhOE/yxHHxFGuPLUouyHWM9rVT9N9eo6PTOB8TCnGw\n" - + "fwTW2jloSbjtycDdM3+UrBUpX7x/Ufhcwoeu0O3NR5pAhGJRVKCvSpmrD3k2l2vZ\n" - + "sRL0230IPGxeDB8m2Wia8QCKKou7AkSsmQ3/9kcKowLGf2axPHty2QSXx4NKvwe0\n" - + "B1NnLcQTBc6Z83Lym3gKn8YSINk1ZoO9G5oQKr64wnuQIOlXjcXD8BAEYKbv7VkG\n" - + "1vsikqixFpPfWrGUlzhoWWTcn4awzRFaX81ZzhAtfA/laqVvrqN/+O6Cc1k614kV\n" - + "GQIDAQAB\n"; - - /** BASE64 ENCODED PRIVATE KEY */ - private String storedPrivateKey = - "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC1E+u7jaWULjRV\n" - + "2ZadzyvtMj8AV6ZnDguHUdGc8gVHHCE4T/LEcfEUa48tSi7IdYz2tVP0316jo9M4\n" - + "HxMKcbB/BNbaOWhJuO3JwN0zf5SsFSlfvH9R+FzCh67Q7c1HmkCEYlFUoK9KmasP\n" - + "eTaXa9mxEvTbfQg8bF4MHybZaJrxAIoqi7sCRKyZDf/2RwqjAsZ/ZrE8e3LZBJfH\n" - + "g0q/B7QHU2ctxBMFzpnzcvKbeAqfxhIg2TVmg70bmhAqvrjCe5Ag6VeNxcPwEARg\n" - + "pu/tWQbW+yKSqLEWk99asZSXOGhZZNyfhrDNEVpfzVnOEC18D+VqpW+uo3/47oJz\n" - + "WTrXiRUZAgMBAAECggEAd/XnkMwJKr7law6IaqmqJyzHchmfIty6JH6+yCPJ/U8U\n" - + "bvMAGMaHeQi8xLtFfQXrSjHcmfg1AWHx91cWzS9+RtfU4qNvhI+f8K31nT1jKBGo\n" - + "5ETDcHGCOlmbJcy01z/IiCt+R/tfxaNCITEjSnNnt5igYJxXjXgZYhxtJ1DWfNvJ\n" - + "0PEPRF+Wuo1y77EW4ACzAKdZriLuJ+ynakYIZc8TC+w2ZiSDHx0ZEQ2YmV7m7A11\n" - + "db1bAw4X7Z7ECvx9VaZKmvj3v9xa8BIcJtYw8YJSHL0f3g/xz+qAqDVw5X7zofdO\n" - + "FEwx96/o14lWnohVjruaGEQvMsNKNz/ONQ1cUaTsFQKBgQDlik5hjo7AEx8mlu5q\n" - + "vXt9iAqSSSI56bunJofbfRLihI9Z3e10v5pvdj9CwB/gJYtOJ4rYLo4KGY7AMq7r\n" - + "q4ObVGF3lIIdBQldy0B+w2jHaemNlg5shQKqC4dA26MlOKO0iIvS1HIazkRGk1sg\n" - + "p8NVaG7jMvGyzD75UsdPRFoBxwKBgQDJ838J+R2OjAdbcDKblfpc/O+IU5chxNsw\n" - + "ojlA4NLbROr9RBNj04r565g1kU0vbj14Cj9Ocifb9yZNQJPIPfyVVg8LIrt9YciC\n" - + "GnvUSdX/558+O7Y/HwqXlzGt3TRnHpdH34qO/CSUEl5kDP/TmW3sw6VNYZa1QwhW\n" - + "VldKgWYyHwKBgDzUomEIPpx4dNDtPtHa1Vc3LlYGO6PNZYWumGJ6iv6s0rCmN7+w\n" - + "52SSmcE+2TO1v20+3XTdIZdbnpEg3WpnUcFgY1QlbzXxl8Hbk4QElUgDsXlsQvZP\n" - + "aZ1W4Mk3a8z5bajyZtvAoVypPT7W3leRHhsMSha78YHIzweUAG3pV1ERAoGATEg4\n" - + "nVjG7FhCUyyvQQvGtScph3IjrTLBpL4yKCqEGyUOKjpzpIp8fWibZuiKojbe6x/b\n" - + "x9Lg8XqKsjWJXOLlLLeEGS22ambsKRC943M8bVxdT1GYxoEALECFGGps5+KrPA/Z\n" - + "M6dUXcYOd3Zdj9zto7hHEVKibbdzR8F3WYJFSvsCgYBj1TqNONCQRJvo298bJtm9\n" - + "RXzYQvjHXnNNnbEL+B+B/r2jBOM/t8WBLJVNghQDhJY+DMMdptxkvNge3XNenDtY\n" - + "25UsnB7XefRE0tDe6yLWKbONT33+WGZjCBCXpQ90Avvi9npFetwG9Q8GSP7VdbTc\n" - + "tLwfZra8DDXs5Dz9Gion+A==\n"; - - private String expectedPublicKeyFp2 = "SHA256:MDcEdlQsgzIs7UBLHV6CB9GJLqqW/AqGsMcAPrWVxuA="; - - private String storedPublicKey2 = - "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwwtuB1ZFqe9jF8TjvwoH\n" - + "oGO2KRxSu8GRDuI93/g1dJKve/SsmNB+SPQ402tEmnejg6QMlyFOvh+bbEqYooXN\n" - + "6lCaFSk7DAx7aNqr1yU/Jpwzbal0H74PhOAw4u1iyBCf08r8aQHLYkOUF2DcggUI\n" - + "WCKrBnpEC6vK8aZRWGwpgXB46CkousWXrmKBqbEBJusj2/Fgrk2CZ/OGY/vlzh6A\n" - + "7TpucviZUF3bsmsEs//63XpTSQsL785uixJbnQye8HDN4iyjvK09dHruIfSVPZ2N\n" - + "7xPdw7Nvyf+gRBmu2HWCPFpOc7a0XPNarlQPXPLbGz47dIZNEW+8p2jdw1D2PZ3h\n" - + "vwIDAQAB\n"; - - private String storedPrivateKey2 = - "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDDC24HVkWp72MX\n" - + "xOO/CgegY7YpHFK7wZEO4j3f+DV0kq979KyY0H5I9DjTa0Sad6ODpAyXIU6+H5ts\n" - + "Spiihc3qUJoVKTsMDHto2qvXJT8mnDNtqXQfvg+E4DDi7WLIEJ/TyvxpActiQ5QX\n" - + "YNyCBQhYIqsGekQLq8rxplFYbCmBcHjoKSi6xZeuYoGpsQEm6yPb8WCuTYJn84Zj\n" - + "++XOHoDtOm5y+JlQXduyawSz//rdelNJCwvvzm6LEludDJ7wcM3iLKO8rT10eu4h\n" - + "9JU9nY3vE93Ds2/J/6BEGa7YdYI8Wk5ztrRc81quVA9c8tsbPjt0hk0Rb7ynaN3D\n" - + "UPY9neG/AgMBAAECggEBAL9JLGXBtJzPDC45iOrJWWVxpSt4faNqWWtxcyF++l4T\n" - + "pks5UTSl9dRywHCImUWs5A6tCzQFFIbd1L5GAqAR/js5RYRPZXuRmk7hdvqPqvmg\n" - + "48c/E4Y2Dl5QyWElU2XG+Bjs0NPjUKZUhJ7Q/jH94YsepQC7VJTlrSmF5e2EVsh7\n" - + "3wwAd+fRNSvWmMoVsjw1gX8Cen8rCEZZyPSwaK1pYBQga0abEZ6BdQQ0O3yTZQga\n" - + "Q3z+vC+hm3cqpAmhWupYxqdA/EBNz/v0GVe8BGbEYAnm/4LPpQilt1Z1ngNqdUoT\n" - + "zF4PXnYQ/fFOR90nbjWoJLJYCDd+QW4XXwxwOIs/BsECgYEA+USiF2F+lG7y6dhk\n" - + "fLAi8UROAjYv+/wWSBnOV0JCD0A6Ik5bOXvXs72/NCHdBGq93mbYhzo7ar9Fm0QE\n" - + "mtwfnfhVNRk+SSq/+hzCOmZrdZpu2nMzBDJtR2TartOwfijE/NNZGA1V7QOmR02u\n" - + "WINyaqvDNNPVIt+D6qreXcZDslECgYEAyE/pWfLjJfWBXtOruXxBcJKq5Arx0YSv\n" - + "i7VaiOg9zBtLg4imKvRJqjs6Y7rGap3qA+HguEl4TQHvhIfhlAxWJ2XVB0ijTAiF\n" - + "aMTmBmxCJq0V9raScJPbSHAN54T2f0x+yh5/q7IFFKF2Hn3qrmarSAw02G+KTH6M\n" - + "lFFqJ+Dcvw8CgYEA7aMX4LBqq3nGjVdmHVUSSu7ya7tbLaDbYStxAtFBBycVBQWs\n" - + "hHXjYxD/SuUJvx9AGdn0jZ7fbFojMu26ciRu4/wOx4tkTP67fOeT53ci9UAgdJQk\n" - + "y9iDQ/ALZ2abOPsHKX0X0A1OoKG9EPcmwm22U6midSeKZy+tpLf3PHE6srECgYAa\n" - + "yj0+T3K7t+r2gL69zvV9ldAPMbuHtwQ3XijemJjzPE9MJzF6GzPi9Yronak9xyLu\n" - + "I/6HByR0wCaFhhrQTxoSqNbl43wbhiQ5j+PnxgDO5WVDmsVZEx1HwdzKMwk4m0V1\n" - + "yMBweR2e1b1TdKm3a3nK5/8FV12av24TxBO7g6JiVwKBgDnaedWvgJt+tJrkFn9h\n" - + "ZE2VVGC4FjFDpCMxIebvBz5Kbs+lknpdcda8+DMwXFDf8OR3lt/3KzJqleUhrTTz\n" - + "AQz56Xdi9VEnVs3rsgvX9VnaWcRpa4GT5EIj+I2M9t+D8XCfMMs1S56Pnn5oGkqv\n" - + "FBzmMRnskqK6d75B8EG5BGi0\n"; + // generate our keys using RSA + private static final String ALGORITHM = "RSA"; + private KeyPair keyPair; + + @Before + public void setup() throws Exception { + // first we need to create a keypair + KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM); + // we need a 2048 bit RSA key for this test + keyGen.initialize(2048); + // generate the actual keys + keyPair = keyGen.generateKeyPair(); + } - @Test - public void validatePublicKeyFp() throws NoSuchAlgorithmException, InvalidKeySpecException { + /** Test instantiate jwt manager with invalid params */ + @Test(expected = IllegalArgumentException.class) + public void testJWTManagerInstantiate() { + SecurityManager jwtManager = new JWTManager(null, null, null, 3, TimeUnit.SECONDS, null); + } - PublicKey pubKey = loadPublicKey(storedPublicKey); - PrivateKey priKey = loadPrivateKey(storedPrivateKey); + /** Test instantiate OAuth manager with invalid params */ + @Test(expected = IllegalArgumentException.class) + public void testOAuthManagerInstantiate() { + SecurityManager oAuthManager = new OAuthManager("account", "user", null, null, null); + } - KeyPair keypair = new KeyPair(pubKey, priKey); + /** Test get token type, should exactly match the request */ + @Test + public void testGetTokenType() throws Exception { + SecurityManager jwtManager = + new JWTManager("account", "user", keyPair, 3, TimeUnit.SECONDS, null); + assertEquals(jwtManager.getTokenType(), "KEYPAIR_JWT"); + + SecurityManager oAuthManager = + new OAuthManager(TestUtils.getAccount(), TestUtils.getUser(), new MockOAuthClient(), 0.8); + assertEquals(oAuthManager.getTokenType(), "OAUTH"); + } + + /** Evaluates whether or not we are actually renewing jwt tokens */ + @Test + public void testRegenerateJWTToken() throws InterruptedException { + // create the security manager; + SecurityManager manager = new JWTManager("account", "user", keyPair, 3, TimeUnit.SECONDS, null); - String accountName = "accountName"; - String userName = "userName"; - SecurityManager securityManager = new SecurityManager(accountName, userName, keypair); - String publicKeyFp = securityManager.getPublicKeyFingerPrint(); - assertTrue(publicKeyFp.equals(expectedPublicKeyFp)); + // grab a token + String token = manager.getToken(); - PublicKey pubKey2 = loadPublicKey(storedPublicKey2); - PrivateKey priKey2 = loadPrivateKey(storedPrivateKey2); + // if we immediately request the same token, it should be exactly the same + assertEquals( + "Two token requests prior to renewals should be the same", token, manager.getToken()); - KeyPair keypair2 = new KeyPair(pubKey2, priKey2); + // sleep for enough time for the thread renewal thread to run + Thread.sleep(6000); - SecurityManager securityManager2 = new SecurityManager(accountName, userName, keypair2); - String publicKeyFp2 = securityManager2.getPublicKeyFingerPrint(); - assertTrue(publicKeyFp2.equals(expectedPublicKeyFp2)); + // ascertain that we have overwritten the token + assertNotEquals("The renewal thread should have reset the token", token, manager.getToken()); } + /** Check if OAuth token do refresh */ @Test - public void testParseAccount() throws NoSuchAlgorithmException, InvalidKeySpecException { - PublicKey pubKey = loadPublicKey(storedPublicKey); - PrivateKey priKey = loadPrivateKey(storedPrivateKey); + public void testRefreshOAuthToken() throws Exception { + + // Set update threshold ratio to 5e-3, access token should be refreshed after 3 seconds + SecurityManager securityManager = + new OAuthManager(TestUtils.getAccount(), TestUtils.getUser(), new MockOAuthClient(), 5e-3); - KeyPair keypair = new KeyPair(pubKey, priKey); + String token = securityManager.getToken(); - String accountName = "accountName"; - String userName = "userName"; - SecurityManager securityManager = new SecurityManager(accountName, userName, keypair); - Assert.assertEquals(accountName.toUpperCase(), securityManager.getAccount()); + // if we immediately request the same token, it should be exactly the same + assertEquals( + "Two token requests prior to renewals should be the same", + token, + securityManager.getToken()); + + // sleep for enough time for the thread renewal thread to run + Thread.sleep(6000); + + // ascertain that we have overwritten the token + assertNotEquals( + "The renewal thread should have reset the token", token, securityManager.getToken()); } - @Test - public void testParseAccount_dotInAccountName() - throws NoSuchAlgorithmException, InvalidKeySpecException { - PublicKey pubKey = loadPublicKey(storedPublicKey); - PrivateKey priKey = loadPrivateKey(storedPrivateKey); - - KeyPair keypair = new KeyPair(pubKey, priKey); - - String accountName = "accountName.extra"; - String userName = "userName"; - String trimmedAccountName = "accountName"; - SecurityManager securityManager = new SecurityManager(accountName, userName, keypair); - Assert.assertEquals(trimmedAccountName.toUpperCase(), securityManager.getAccount()); + /** Test behavior of getting token after refresh failed */ + @Test(expected = SecurityException.class) + public void testGetJWTTokenFail() { + SecurityManager manager = new JWTManager("account", "user", keyPair, 3, TimeUnit.SECONDS, null); + manager.setRefreshFailed(true); + String token = manager.getToken(); } - /** - * Converts encodedBase64 publicKey back to the RSA scheme PublicKey object. - * - *

    - * - * @param base64PublicKey - * @return - * @throws NoSuchAlgorithmException - * @throws InvalidKeySpecException - */ - private PublicKey loadPublicKey(String base64PublicKey) - throws NoSuchAlgorithmException, InvalidKeySpecException { - byte[] data = Base64.getMimeDecoder().decode(base64PublicKey); - X509EncodedKeySpec spec = new X509EncodedKeySpec(data); - KeyFactory factory = KeyFactory.getInstance("RSA"); - return factory.generatePublic(spec); + /** Test behavior of getting token after refresh failed */ + @Test(expected = SecurityException.class) + public void testGetOAuthTokenFail() throws Exception { + MockOAuthClient mockOAuthClient = new MockOAuthClient(); + + SecurityManager manager = + new OAuthManager(TestUtils.getAccount(), TestUtils.getUser(), mockOAuthClient, 0.8); + manager.setRefreshFailed(true); + String token = manager.getToken(); } - /** - * Converts privateKey in encodedBase64 in String to RSA scheme PrivateKey. - * - * @param base64PrivateKey - * @return - * @throws NoSuchAlgorithmException - * @throws InvalidKeySpecException - */ - private PrivateKey loadPrivateKey(String base64PrivateKey) - throws NoSuchAlgorithmException, InvalidKeySpecException { - byte[] clear = Base64.getMimeDecoder().decode(base64PrivateKey); - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(clear); - KeyFactory factory = KeyFactory.getInstance("RSA"); - PrivateKey privateKey = factory.generatePrivate(keySpec); - Arrays.fill(clear, (byte) 0); - return privateKey; + /** Test refresh oauth token fail */ + @Test(expected = SecurityException.class) + public void testOAuthRefreshFail() throws Exception { + MockOAuthClient mockOAuthClient = new MockOAuthClient(); + mockOAuthClient.setFutureRefreshFailCount(Constants.MAX_OAUTH_REFRESH_TOKEN_RETRY); + + SecurityManager securityManager = + new OAuthManager(TestUtils.getAccount(), TestUtils.getUser(), mockOAuthClient, 0.8); + securityManager.close(); + } + + /** Test retry, should success */ + @Test + public void testOAuthRefreshRetry() throws Exception { + MockOAuthClient mockOAuthClient = new MockOAuthClient(); + mockOAuthClient.setFutureRefreshFailCount(Constants.MAX_OAUTH_REFRESH_TOKEN_RETRY - 1); + + SecurityManager securityManager = + new OAuthManager(TestUtils.getAccount(), TestUtils.getUser(), mockOAuthClient, 0.8); + securityManager.close(); } } diff --git a/src/test/java/net/snowflake/ingest/connection/TelemetryServiceTest.java b/src/test/java/net/snowflake/ingest/connection/TelemetryServiceTest.java new file mode 100644 index 000000000..91a8449a7 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/connection/TelemetryServiceTest.java @@ -0,0 +1,90 @@ +package net.snowflake.ingest.connection; + +import com.codahale.metrics.Histogram; +import com.codahale.metrics.Meter; +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.Timer; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import org.junit.Test; +import org.mockito.Mockito; + +public class TelemetryServiceTest { + @Test + public void testReportLatencyInSec() { + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + + TelemetryService telemetryService = + Mockito.spy( + new TelemetryService(httpClient, "testReportLatencyInSec", "snowflake.dev.local:8082")); + Mockito.doNothing().when(telemetryService).send(Mockito.any(), Mockito.any()); + MetricRegistry metrics = new MetricRegistry(); + Timer flushLatency = metrics.timer(MetricRegistry.name("latency", "flush")); + Timer buildLatency = metrics.timer(MetricRegistry.name("latency", "build")); + Timer uploadLatency = metrics.timer(MetricRegistry.name("latency", "upload")); + Timer registerLatency = metrics.timer(MetricRegistry.name("latency", "register")); + + // Make sure there is no exception thrown + telemetryService.reportLatencyInSec(buildLatency, uploadLatency, registerLatency, flushLatency); + } + + @Test + public void testReportClientFailure() { + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + + TelemetryService telemetryService = + Mockito.spy( + new TelemetryService( + httpClient, "testReportClientFailure", "snowflake.dev.local:8082")); + Mockito.doNothing().when(telemetryService).send(Mockito.any(), Mockito.any()); + + // Make sure there is no exception thrown + telemetryService.reportClientFailure("testReportClientFailure", "exception"); + } + + @Test + public void testReportThroughputBytesPerSecond() { + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + + TelemetryService telemetryService = + Mockito.spy( + new TelemetryService( + httpClient, "testReportThroughputBytesPerSecond", "snowflake.dev.local:8082")); + Mockito.doNothing().when(telemetryService).send(Mockito.any(), Mockito.any()); + MetricRegistry metrics = new MetricRegistry(); + Meter uploadThroughput = metrics.meter(MetricRegistry.name("throughput", "upload")); + Meter inputThroughput = metrics.meter(MetricRegistry.name("throughput", "input")); + + // Make sure there is no exception thrown + telemetryService.reportThroughputBytesPerSecond(uploadThroughput, inputThroughput); + } + + @Test + public void testReportCpuMemoryUsage() { + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + + TelemetryService telemetryService = + Mockito.spy( + new TelemetryService( + httpClient, "testReportCpuMemoryUsage", "snowflake.dev.local:8082")); + Mockito.doNothing().when(telemetryService).send(Mockito.any(), Mockito.any()); + MetricRegistry metrics = new MetricRegistry(); + Histogram cpuHistogram = metrics.histogram(MetricRegistry.name("cpu", "usage", "histogram")); + + // Make sure there is no exception thrown + telemetryService.reportCpuMemoryUsage(cpuHistogram); + } + + @Test + public void testReportBatchOffsetMismatch() { + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + + TelemetryService telemetryService = + Mockito.spy( + new TelemetryService( + httpClient, "testReportClientFailure", "snowflake.dev.local:8082")); + Mockito.doNothing().when(telemetryService).send(Mockito.any(), Mockito.any()); + + // Make sure there is no exception thrown + telemetryService.reportBatchOffsetMismatch("channel", "0", "1", "2", 1); + } +} diff --git a/src/test/java/net/snowflake/ingest/connection/TestKeyRenewal.java b/src/test/java/net/snowflake/ingest/connection/TestKeyRenewal.java deleted file mode 100644 index 351de1e21..000000000 --- a/src/test/java/net/snowflake/ingest/connection/TestKeyRenewal.java +++ /dev/null @@ -1,48 +0,0 @@ -package net.snowflake.ingest.connection; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; - -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; -import java.util.concurrent.TimeUnit; -import org.junit.Test; - -/** - * TestKeyRenewal - tests whether or not the SecurityManager is successfully resetting the the JWT - * Token after the specified timespan - */ -public class TestKeyRenewal { - // generate our keys using RSA - private static final String ALGORITHM = "RSA"; - - /** Evaluates whether or not we are actually renewing tokens */ - @Test - public void doesRegenerateToken() - throws NoSuchProviderException, NoSuchAlgorithmException, InterruptedException { - // first we need to create a keypair - KeyPairGenerator keyGen = KeyPairGenerator.getInstance(ALGORITHM); - // we need a 2048 bit RSA key for this test - keyGen.initialize(2048); - // generate the actual keys - KeyPair keyPair = keyGen.generateKeyPair(); - - // create the security manager; - SecurityManager manager = new SecurityManager("account", "user", keyPair, 3, TimeUnit.SECONDS); - - // grab a token - String token = manager.getToken(); - - // if we immediately request the same token, it should be exactly the same - assertEquals( - "Two token requests prior to renewals should be the same", token, manager.getToken()); - - // sleep for enough time for the thread renewal thread to run - Thread.sleep(6000); - - // ascertain that we have overwritten the token - assertNotEquals("The renewal thread should have reset the token", token, manager.getToken()); - } -} diff --git a/src/test/java/net/snowflake/ingest/connection/UserAgentTest.java b/src/test/java/net/snowflake/ingest/connection/UserAgentTest.java new file mode 100644 index 000000000..396dfa9ae --- /dev/null +++ b/src/test/java/net/snowflake/ingest/connection/UserAgentTest.java @@ -0,0 +1,20 @@ +package net.snowflake.ingest.connection; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; +import net.snowflake.ingest.SimpleIngestManager; +import org.junit.Assert; +import org.junit.Test; + +public class UserAgentTest { + @Test + public void testDefaultSdkVersionMatchesProjectVersion() throws IOException { + Properties properties = new Properties(); + try (InputStream is = + SimpleIngestManager.class.getClassLoader().getResourceAsStream("project.properties")) { + properties.load(is); + Assert.assertEquals(RequestBuilder.DEFAULT_VERSION, properties.getProperty("version")); + } + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/BinaryStringUtilsTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/BinaryStringUtilsTest.java new file mode 100644 index 000000000..f836574b3 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/BinaryStringUtilsTest.java @@ -0,0 +1,107 @@ +package net.snowflake.ingest.streaming.internal; + +import static net.snowflake.ingest.streaming.internal.BinaryStringUtils.truncateBytesAsHex; +import static net.snowflake.ingest.streaming.internal.BinaryStringUtils.unicodeCharactersCount; + +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; +import org.junit.Assert; +import org.junit.Test; + +public class BinaryStringUtilsTest { + + @Test + public void testUnicodeCodePointsCalculation() { + Assert.assertEquals(1, unicodeCharactersCount("🍞")); + } + + @Test + public void testTruncation() throws DecoderException { + // Test empty input + Assert.assertEquals("", truncateBytesAsHex(new byte[0], false)); + Assert.assertEquals("", truncateBytesAsHex(new byte[0], true)); + + // Test basic case + Assert.assertEquals("aa", truncateBytesAsHex(Hex.decodeHex("aa"), false)); + Assert.assertEquals("aa", truncateBytesAsHex(Hex.decodeHex("aa"), true)); + + // Test exactly 32 bytes + Assert.assertEquals( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + truncateBytesAsHex( + Hex.decodeHex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + false)); + Assert.assertEquals( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + truncateBytesAsHex( + Hex.decodeHex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + true)); + + Assert.assertEquals( + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + truncateBytesAsHex( + Hex.decodeHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + false)); + + Assert.assertEquals( + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + truncateBytesAsHex( + Hex.decodeHex("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + true)); + + // Test 1 truncate up + Assert.assertEquals( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + truncateBytesAsHex( + Hex.decodeHex( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + false)); + + Assert.assertEquals( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab", + truncateBytesAsHex( + Hex.decodeHex( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + true)); + + // Test one overflow + Assert.assertEquals( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaafffffffffffffffffffffffffffffffaaff", + truncateBytesAsHex( + Hex.decodeHex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaafffffffffffffffffffffffffffffffaaffffffff"), + false)); + Assert.assertEquals( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaafffffffffffffffffffffffffffffffab00", + truncateBytesAsHex( + Hex.decodeHex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaafffffffffffffffffffffffffffffffaaffffffff"), + true)); + + // Test many overflow + Assert.assertEquals( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaafffffffffffffffffffffffffffffffffff", + truncateBytesAsHex( + Hex.decodeHex( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaafffffffffffffffffffffffffffffffffffffffffffffffffffff"), + false)); + Assert.assertEquals( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaab00000000000000000000000000000000000", + truncateBytesAsHex( + Hex.decodeHex( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaafffffffffffffffffffffffffffffffffffffffffffffffffffff"), + true)); + + // Test infinity + Assert.assertEquals( + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + truncateBytesAsHex( + Hex.decodeHex( + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffcccccccccccc"), + false)); + Assert.assertEquals( + "Z", + truncateBytesAsHex( + Hex.decodeHex( + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffcccccccccccc"), + true)); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/ChannelCacheTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/ChannelCacheTest.java index 8f98bf76d..ea88d618c 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/ChannelCacheTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/ChannelCacheTest.java @@ -1,5 +1,7 @@ package net.snowflake.ingest.streaming.internal; +import static java.time.ZoneOffset.UTC; + import java.util.Iterator; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -9,11 +11,11 @@ import org.junit.Test; public class ChannelCacheTest { - ChannelCache cache; - SnowflakeStreamingIngestClientInternal client; - SnowflakeStreamingIngestChannelInternal channel1; - SnowflakeStreamingIngestChannelInternal channel2; - SnowflakeStreamingIngestChannelInternal channel3; + ChannelCache cache; + SnowflakeStreamingIngestClientInternal client; + SnowflakeStreamingIngestChannelInternal channel1; + SnowflakeStreamingIngestChannelInternal channel2; + SnowflakeStreamingIngestChannelInternal channel3; String dbName = "db"; String schemaName = "schema"; String table1Name = "table1"; @@ -21,10 +23,10 @@ public class ChannelCacheTest { @Before public void setup() { - cache = new ChannelCache(); - client = new SnowflakeStreamingIngestClientInternal("client"); + cache = new ChannelCache<>(); + client = new SnowflakeStreamingIngestClientInternal<>("client"); channel1 = - new SnowflakeStreamingIngestChannelInternal( + new SnowflakeStreamingIngestChannelInternal<>( "channel1", dbName, schemaName, @@ -36,9 +38,9 @@ public void setup() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); channel2 = - new SnowflakeStreamingIngestChannelInternal( + new SnowflakeStreamingIngestChannelInternal<>( "channel2", dbName, schemaName, @@ -50,9 +52,9 @@ public void setup() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); channel3 = - new SnowflakeStreamingIngestChannelInternal( + new SnowflakeStreamingIngestChannelInternal<>( "channel3", dbName, schemaName, @@ -64,7 +66,7 @@ public void setup() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); cache.addChannel(channel1); cache.addChannel(channel2); cache.addChannel(channel3); @@ -75,10 +77,10 @@ public void testAddChannel() { String channelName = "channel"; String tableName = "table"; - ChannelCache cache = new ChannelCache(); + ChannelCache cache = new ChannelCache<>(); Assert.assertEquals(0, cache.getSize()); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( channelName, dbName, schemaName, @@ -90,13 +92,13 @@ public void testAddChannel() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); cache.addChannel(channel); Assert.assertEquals(1, cache.getSize()); Assert.assertTrue(channel == cache.iterator().next().getValue().get(channelName)); - SnowflakeStreamingIngestChannelInternal channelDup = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestChannelInternal channelDup = + new SnowflakeStreamingIngestChannelInternal<>( channelName, dbName, schemaName, @@ -108,13 +110,13 @@ public void testAddChannel() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); cache.addChannel(channelDup); // The old channel should be invalid now Assert.assertTrue(!channel.isValid()); Assert.assertTrue(channelDup.isValid()); Assert.assertEquals(1, cache.getSize()); - ConcurrentHashMap channels = + ConcurrentHashMap> channels = cache.iterator().next().getValue(); Assert.assertEquals(1, channels.size()); Assert.assertTrue(channelDup == channels.get(channelName)); @@ -124,11 +126,18 @@ public void testAddChannel() { @Test public void testIterator() { Assert.assertEquals(2, cache.getSize()); - Iterator>> + Iterator< + Map.Entry< + String, + ConcurrentHashMap>>> iter = cache.iterator(); - Map.Entry> + Map.Entry< + String, + ConcurrentHashMap>> firstTable = iter.next(); - Map.Entry> + Map.Entry< + String, + ConcurrentHashMap>> secondTable = iter.next(); Assert.assertFalse(iter.hasNext()); if (firstTable.getKey().equals(channel1.getFullyQualifiedTableName())) { @@ -147,10 +156,13 @@ public void testIterator() { @Test public void testCloseAllChannels() { cache.closeAllChannels(); - Iterator>> + Iterator< + Map.Entry< + String, + ConcurrentHashMap>>> iter = cache.iterator(); while (iter.hasNext()) { - for (SnowflakeStreamingIngestChannelInternal channel : iter.next().getValue().values()) { + for (SnowflakeStreamingIngestChannelInternal channel : iter.next().getValue().values()) { Assert.assertTrue(channel.isClosed()); } } @@ -166,8 +178,8 @@ public void testRemoveChannel() { cache.removeChannelIfSequencersMatch(channel2); Assert.assertEquals(1, cache.getSize()); - SnowflakeStreamingIngestChannelInternal channel3Dup = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestChannelInternal channel3Dup = + new SnowflakeStreamingIngestChannelInternal<>( "channel3", dbName, schemaName, @@ -179,7 +191,7 @@ public void testRemoveChannel() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); cache.removeChannelIfSequencersMatch(channel3Dup); // Verify that remove the same channel with a different channel sequencer is a no op Assert.assertEquals(1, cache.getSize()); diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/ChannelDataTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/ChannelDataTest.java index 69af1e8c6..5b545d697 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/ChannelDataTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/ChannelDataTest.java @@ -1,6 +1,7 @@ package net.snowflake.ingest.streaming.internal; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; import net.snowflake.ingest.utils.ErrorCode; @@ -13,7 +14,7 @@ public class ChannelDataTest { @Test public void testGetCombinedColumnStatsMapNulls() { Map left = new HashMap<>(); - RowBufferStats leftStats1 = new RowBufferStats(); + RowBufferStats leftStats1 = new RowBufferStats("COL1"); left.put("one", leftStats1); leftStats1.addIntValue(new BigInteger("10")); @@ -42,12 +43,12 @@ public void testGetCombinedColumnStatsMapNulls() { @Test public void testGetCombinedColumnStatsMapMissingColumn() { Map left = new HashMap<>(); - RowBufferStats leftStats1 = new RowBufferStats(); + RowBufferStats leftStats1 = new RowBufferStats("COL1"); left.put("one", leftStats1); leftStats1.addIntValue(new BigInteger("10")); Map right = new HashMap<>(); - RowBufferStats rightStats1 = new RowBufferStats(); + RowBufferStats rightStats1 = new RowBufferStats("COL1"); right.put("foo", rightStats1); rightStats1.addIntValue(new BigInteger("11")); @@ -77,10 +78,10 @@ public void testGetCombinedColumnStatsMap() { Map left = new HashMap<>(); Map right = new HashMap<>(); - RowBufferStats leftStats1 = new RowBufferStats(); - RowBufferStats rightStats1 = new RowBufferStats(); - RowBufferStats leftStats2 = new RowBufferStats(); - RowBufferStats rightStats2 = new RowBufferStats(); + RowBufferStats leftStats1 = new RowBufferStats("COL1"); + RowBufferStats rightStats1 = new RowBufferStats("COL1"); + RowBufferStats leftStats2 = new RowBufferStats("COL1"); + RowBufferStats rightStats2 = new RowBufferStats("COL1"); left.put("one", leftStats1); left.put("two", leftStats2); @@ -112,8 +113,10 @@ public void testGetCombinedColumnStatsMap() { Assert.assertNull(oneCombined.getCurrentMinRealValue()); Assert.assertNull(oneCombined.getCurrentMaxRealValue()); - Assert.assertEquals("10", twoCombined.getCurrentMinStrValue()); - Assert.assertEquals("17", twoCombined.getCurrentMaxStrValue()); + Assert.assertArrayEquals( + "10".getBytes(StandardCharsets.UTF_8), twoCombined.getCurrentMinStrValue()); + Assert.assertArrayEquals( + "17".getBytes(StandardCharsets.UTF_8), twoCombined.getCurrentMaxStrValue()); Assert.assertEquals(-1, twoCombined.getDistinctValues()); Assert.assertNull(twoCombined.getCurrentMinIntValue()); Assert.assertNull(twoCombined.getCurrentMaxIntValue()); diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/ColumnMetadataBuilder.java b/src/test/java/net/snowflake/ingest/streaming/internal/ColumnMetadataBuilder.java new file mode 100644 index 000000000..01fd44918 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/ColumnMetadataBuilder.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2022 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +/** Helper class to build ColumnMetadata */ +public class ColumnMetadataBuilder { + private String name; + private String type; + private String logicalType; + private String physicalType; + private Integer precision; + private Integer scale; + private Integer byteLength; + private Integer length; + private boolean nullable; + private String collation; + + private Integer ordinal; + + /** + * Returns a new ColumnMetadata builder + * + * @return builder + */ + public static ColumnMetadataBuilder newBuilder() { + ColumnMetadataBuilder mb = new ColumnMetadataBuilder(); + mb.name = "testCol"; + mb.byteLength = 14; + mb.length = 11; + mb.scale = 0; + mb.precision = 4; + return mb; + } + + /** + * Set name + * + * @param name column name + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder name(String name) { + this.name = name; + return this; + } + + /** + * Set type + * + * @param type type (as defined in ColumnMetadata) + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder type(String type) { + this.type = type; + return this; + } + + /** + * Set column logical type + * + * @param logicalType logical type + * @return columnMetadata object + */ + public ColumnMetadataBuilder logicalType(String logicalType) { + this.logicalType = logicalType; + return this; + } + + /** + * Set column physical type + * + * @param physicalType physical type + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder physicalType(String physicalType) { + this.physicalType = physicalType; + return this; + } + + /** + * Set column precision + * + * @param precision precision + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder precision(int precision) { + this.precision = precision; + return this; + } + + /** + * Set column scale + * + * @param scale scale + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder scale(int scale) { + this.scale = scale; + return this; + } + + /** + * Set column length in bytes + * + * @param byteLength length in bytes + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder byteLength(int byteLength) { + this.byteLength = byteLength; + return this; + } + + /** + * Set column length (in chars) + * + * @param length length + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder length(int length) { + this.length = length; + return this; + } + + /** + * Set column nullability + * + * @param nullable nullable + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder nullable(boolean nullable) { + this.nullable = nullable; + return this; + } + + /** + * Set column collation + * + * @param collation collation + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder collation(String collation) { + this.collation = collation; + return this; + } + + /** + * Set column ordinal + * + * @param ordinal ordinal + * @return columnMetadataBuilder object + */ + public ColumnMetadataBuilder ordinal(int ordinal) { + this.ordinal = ordinal; + return this; + } + + /** + * Build a columnMetadata object from the set values + * + * @return columnMetadata object + */ + public ColumnMetadata build() { + ColumnMetadata colMetadata = new ColumnMetadata(); + colMetadata.setName(name); + colMetadata.setType(type); + colMetadata.setPhysicalType(physicalType); + colMetadata.setNullable(nullable); + colMetadata.setLogicalType(logicalType); + colMetadata.setByteLength(byteLength); + colMetadata.setLength(length); + colMetadata.setScale(scale); + colMetadata.setPrecision(precision); + colMetadata.setCollation(collation); + colMetadata.setOrdinal(ordinal); + return colMetadata; + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/DataValidationUtilTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/DataValidationUtilTest.java index 01ff6c7c6..8ab22619f 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/DataValidationUtilTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/DataValidationUtilTest.java @@ -1,281 +1,638 @@ package net.snowflake.ingest.streaming.internal; -import static net.snowflake.ingest.streaming.internal.DataValidationUtil.MAX_BIGINTEGER; +import static java.time.ZoneOffset.UTC; +import static net.snowflake.ingest.TestUtils.buildString; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.BYTES_16_MB; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.BYTES_8_MB; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.isAllowedSemiStructuredType; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseArray; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseBigDecimal; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseBinary; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseBoolean; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseDate; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseObject; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseReal; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseString; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseTime; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseTimestamp; +import static net.snowflake.ingest.streaming.internal.DataValidationUtil.validateAndParseVariant; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.math.BigDecimal; import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Arrays; +import java.util.Collections; +import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.function.Function; -import javax.xml.bind.DatatypeConverter; +import java.util.TimeZone; import net.snowflake.ingest.utils.ErrorCode; import net.snowflake.ingest.utils.SFException; +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Hex; import org.junit.Assert; import org.junit.Test; public class DataValidationUtilTest { private static final ObjectMapper objectMapper = new ObjectMapper(); - private static final Object[] goodIntegersValue10 = - new Object[] {10D, 10F, 10L, new BigInteger("10"), 10, "10", "1e1", "1.0e1"}; - - private static final Object[] trueBooleanInput = - new Object[] {true, "true", "True", "TruE", "t", "yes", "YeS", "y", "on", "1", 1.1}; - private static final Object[] falseBooleanInput = - new Object[] {false, "false", "False", "FalsE", "f", "no", "NO", "n", "off", "0", 0}; - - private void expectError(ErrorCode expectedError, Function func, Object args) { + private void expectErrorCodeAndMessage( + ErrorCode expectedErrorCode, String expectedExceptionMessage, Runnable action) { try { - func.apply(args); + action.run(); Assert.fail("Expected Exception"); } catch (SFException e) { - Assert.assertEquals(expectedError.getMessageCode(), e.getVendorCode()); + assertEquals(expectedErrorCode.getMessageCode(), e.getVendorCode()); + if (expectedExceptionMessage != null) assertEquals(expectedExceptionMessage, e.getMessage()); } catch (Exception e) { + e.printStackTrace(); Assert.fail("Invalid error through"); } } + private void expectError(ErrorCode expectedErrorCode, Runnable action) { + expectErrorCodeAndMessage(expectedErrorCode, null, action); + } + @Test - public void testValidateAndParseShort() { - short e = 12; - Assert.assertEquals(e, DataValidationUtil.validateAndParseShort("12")); - Assert.assertEquals(e, DataValidationUtil.validateAndParseShort(e)); - Assert.assertEquals(Short.MAX_VALUE, DataValidationUtil.validateAndParseShort(Short.MAX_VALUE)); - Assert.assertEquals(Short.MIN_VALUE, DataValidationUtil.validateAndParseShort(Short.MIN_VALUE)); + public void testValidateAndParseDate() { + assertEquals(9, validateAndParseDate("COL", LocalDate.of(1970, 1, 10), 0)); + assertEquals(9, validateAndParseDate("COL", LocalDateTime.of(1970, 1, 10, 1, 0), 0)); + assertEquals( + 9, + validateAndParseDate( + "COL", + OffsetDateTime.of(1970, 1, 10, 1, 0, 34, 123456789, ZoneOffset.of("-07:00")), + 0)); + assertEquals( + 9, + validateAndParseDate( + "COL", + OffsetDateTime.of(1970, 1, 10, 1, 0, 34, 123456789, ZoneOffset.of("+07:00")), + 0)); + assertEquals( + 9, + validateAndParseDate( + "COL", + ZonedDateTime.of(1970, 1, 10, 1, 0, 34, 123456789, ZoneId.of("America/Los_Angeles")), + 0)); + assertEquals( + 9, + validateAndParseDate( + "COL", ZonedDateTime.of(1970, 1, 10, 1, 0, 34, 123456789, ZoneId.of("Asia/Tokyo")), 0)); + assertEquals(19380, validateAndParseDate("COL", Instant.ofEpochMilli(1674478926000L), 0)); + + assertEquals(-923, validateAndParseDate("COL", "1967-06-23", 0)); + assertEquals(-923, validateAndParseDate("COL", " 1967-06-23 \t\n", 0)); + assertEquals(-923, validateAndParseDate("COL", "1967-06-23T01:01:01", 0)); + assertEquals(18464, validateAndParseDate("COL", "2020-07-21", 0)); + assertEquals(18464, validateAndParseDate("COL", "2020-07-21T23:31:00", 0)); + assertEquals(18464, validateAndParseDate("COL", "2020-07-21T23:31:00+07:00", 0)); + assertEquals(18464, validateAndParseDate("COL", "2020-07-21T23:31:00-07:00", 0)); + assertEquals( + 18464, validateAndParseDate("COL", "2020-07-21T23:31:00-07:00[America/Los_Angeles]", 0)); + assertEquals(18464, validateAndParseDate("COL", "2020-07-21T23:31:00+09:00[Asia/Tokyo]", 0)); + + // Test integer-stored date + assertEquals(19380, validateAndParseDate("COL", "1674478926", 0)); + assertEquals(19380, validateAndParseDate("COL", "1674478926000", 0)); + assertEquals(19380, validateAndParseDate("COL", "1674478926000000", 0)); + assertEquals(19380, validateAndParseDate("COL", "1674478926000000000", 0)); + + // Time input is not supported + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseDate("COL", "20:57:01", 0)); - // Expect errors - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseShort, "howdy"); + // Test values out of range + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseDate("COL", LocalDateTime.of(10000, 2, 2, 2, 2), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseDate("COL", LocalDateTime.of(-10000, 2, 2, 2, 2), 0)); + + // Test forbidden values + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", new Object(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", LocalTime.now(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", OffsetTime.now(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", new java.util.Date(), 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", false, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseDate("COL", "", 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseDate("COL", "foo", 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseDate("COL", "1.0", 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", 'c', 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", 1, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", 1L, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", 1.25, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseDate("COL", BigInteger.valueOf(1), 0)); expectError( - ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseShort, Short.MAX_VALUE + 1); + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseDate("COL", BigDecimal.valueOf(1.25), 0)); } @Test public void testValidateAndParseTime() { - Map metadata = new HashMap<>(); - metadata.put(ArrowRowBuffer.COLUMN_SCALE, "0"); - Assert.assertEquals( - new BigInteger("1595289600"), - DataValidationUtil.validateAndParseTime("1595289600", metadata)); - - metadata.put(ArrowRowBuffer.COLUMN_SCALE, "0"); - Assert.assertEquals( - new BigInteger("1595289600"), - DataValidationUtil.validateAndParseTime("2020-07-21", metadata)); - - Assert.assertEquals( - new BigInteger("1595374380"), - DataValidationUtil.validateAndParseTime("2020-07-21 23:33:00", metadata)); - - metadata.put(ArrowRowBuffer.COLUMN_SCALE, "3"); - Assert.assertEquals( - new BigInteger("1595289600000"), - DataValidationUtil.validateAndParseTime("1595289600", metadata)); - - Assert.assertEquals( - new BigInteger("1595289600000"), - DataValidationUtil.validateAndParseTime("2020-07-21", metadata)); - - Assert.assertEquals( - new BigInteger("1595374380000"), - DataValidationUtil.validateAndParseTime("2020-07-21 23:33:00", metadata)); - - Assert.assertEquals( - new BigInteger("1595374380123"), - DataValidationUtil.validateAndParseTime("2020-07-21 23:33:00.123", metadata)); - } + // Test local time + assertEquals(46920, validateAndParseTime("COL", "13:02", 0, 0).longValueExact()); + assertEquals(46920, validateAndParseTime("COL", " 13:02 \t\n", 0, 0).longValueExact()); + assertEquals(46926, validateAndParseTime("COL", "13:02:06", 0, 0).longValueExact()); + assertEquals(469260, validateAndParseTime("COL", "13:02:06", 1, 0).longValueExact()); + assertEquals(46926000000000L, validateAndParseTime("COL", "13:02:06", 9, 0).longValueExact()); - @Test - public void testValidateAndPareTimestampNtzSb16() { - Map metadata = new HashMap<>(); - metadata.put(ArrowRowBuffer.COLUMN_SCALE, "3"); - Assert.assertEquals( - new TimestampWrapper(1, 123000000, BigInteger.valueOf(1123)), - DataValidationUtil.validateAndParseTimestampNtzSb16("1.123", metadata)); - metadata.put(ArrowRowBuffer.COLUMN_SCALE, "9"); - Assert.assertEquals( - new TimestampWrapper(1, 123, BigInteger.valueOf(1000000123)), - DataValidationUtil.validateAndParseTimestampNtzSb16("1.000000123", metadata)); - - Assert.assertEquals( - new TimestampWrapper(1609462800, 123000000, new BigInteger("1609462800123000000")), - DataValidationUtil.validateAndParseTimestampNtzSb16("2021-01-01 01:00:00.123", metadata)); - - // Expect errors - try { - DataValidationUtil.validateAndParseTimestampNtzSb16("honk", metadata); - Assert.fail("Expected Exception"); - } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); - } - metadata.put(ArrowRowBuffer.COLUMN_SCALE, "1"); - try { - DataValidationUtil.validateAndParseTimestampNtzSb16("1.23", metadata); - Assert.fail("Expected Exception"); - } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); - } - } + assertEquals(46926, validateAndParseTime("COL", "13:02:06.1234", 0, 0).longValueExact()); + assertEquals(469261, validateAndParseTime("COL", "13:02:06.1234", 1, 0).longValueExact()); + assertEquals( + 46926123400000L, validateAndParseTime("COL", "13:02:06.1234", 9, 0).longValueExact()); - @Test - public void testValidateAndPareTimestampTz() { - Map metadata = new HashMap<>(); - metadata.put(ArrowRowBuffer.COLUMN_SCALE, "3"); - TimestampWrapper result = - DataValidationUtil.validateAndParseTimestampTz("2021-01-01 01:00:00.123 +0100", metadata); - Assert.assertEquals(1609459200, result.getEpoch()); - Assert.assertEquals(123000000, result.getFraction()); - Assert.assertEquals(Optional.of(3600000), result.getTimezoneOffset()); - Assert.assertEquals(Optional.of(1500), result.getTimeZoneIndex()); - - // Expect errors - try { - DataValidationUtil.validateAndParseTimestampNtzSb16("honk", metadata); - Assert.fail("Expected Exception"); - } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); - } - metadata.put(ArrowRowBuffer.COLUMN_SCALE, "1"); - try { - DataValidationUtil.validateAndParseTimestampTz("2021-01-01 01:00:00.123 +0100", metadata); - Assert.fail("Expected Exception"); - } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); - } + assertEquals(46926, validateAndParseTime("COL", "13:02:06.123456789", 0, 0).longValueExact()); + assertEquals(469261, validateAndParseTime("COL", "13:02:06.123456789", 1, 0).longValueExact()); + assertEquals( + 46926123456789L, validateAndParseTime("COL", "13:02:06.123456789", 9, 0).longValueExact()); + + // Test that offset time does not make any difference + assertEquals( + 46926123456789L, + validateAndParseTime("COL", "13:02:06.123456789+09:00", 9, 0).longValueExact()); + assertEquals( + 46926123456789L, + validateAndParseTime("COL", "13:02:06.123456789-09:00", 9, 0).longValueExact()); + + // Test integer-stored time and scale guessing + assertEquals(46926L, validateAndParseTime("COL", "1674478926", 0, 0).longValueExact()); + assertEquals(46926L, validateAndParseTime("COL", "1674478926123", 0, 0).longValueExact()); + assertEquals(46926L, validateAndParseTime("COL", "1674478926123456", 0, 0).longValueExact()); + assertEquals(46926L, validateAndParseTime("COL", "1674478926123456789", 0, 0).longValueExact()); + + assertEquals(469260L, validateAndParseTime("COL", "1674478926", 1, 0).longValueExact()); + assertEquals(469261L, validateAndParseTime("COL", "1674478926123", 1, 0).longValueExact()); + assertEquals(469261L, validateAndParseTime("COL", "1674478926123456", 1, 0).longValueExact()); + assertEquals( + 469261L, validateAndParseTime("COL", "1674478926123456789", 1, 0).longValueExact()); + + assertEquals(46926000000000L, validateAndParseTime("COL", "1674478926", 9, 0).longValueExact()); + assertEquals( + 46926123000000L, validateAndParseTime("COL", "1674478926123", 9, 0).longValueExact()); + assertEquals( + 46926123456000L, validateAndParseTime("COL", "1674478926123456", 9, 0).longValueExact()); + assertEquals( + 46926123456789L, validateAndParseTime("COL", "1674478926123456789", 9, 0).longValueExact()); + + // Test Java objects + assertEquals( + 46926123456789L, + validateAndParseTime("COL", LocalTime.of(13, 2, 6, 123456789), 9, 0).longValueExact()); + assertEquals( + 46926123456789L, + validateAndParseTime( + "COL", OffsetTime.of(13, 2, 6, 123456789, ZoneOffset.of("+09:00")), 9, 0) + .longValueExact()); + + // Dates and timestamps are forbidden + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseTime("COL", "2023-01-19", 9, 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseTime("COL", "2023-01-19T14:23:55.878137", 9, 0)); + + // Test forbidden values + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", LocalDate.now(), 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", LocalDateTime.now(), 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTime("COL", OffsetDateTime.now(), 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", ZonedDateTime.now(), 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", Instant.now(), 3, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", new Date(), 3, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", 1.5f, 3, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", 1.5, 3, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseTime("COL", "1.5", 3, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseTime("COL", "1.0", 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", new Object(), 3, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", false, 3, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseTime("COL", "", 3, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseTime("COL", "foo", 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTime("COL", java.sql.Time.valueOf("20:57:00"), 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTime("COL", java.sql.Date.valueOf("2010-11-03"), 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTime("COL", java.sql.Timestamp.valueOf("2010-11-03 20:57:00"), 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", BigInteger.ZERO, 3, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", BigDecimal.ZERO, 3, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseTime("COL", 'c', 3, 0)); } @Test - public void testValidateAndParseBigInteger() { - for (Object input : goodIntegersValue10) { - Assert.assertEquals( - new BigInteger("10"), DataValidationUtil.validateAndParseBigInteger(input)); - } - Assert.assertEquals( - new BigInteger("-1000"), DataValidationUtil.validateAndParseBigInteger("-1e3")); - - Assert.assertEquals( - BigInteger.valueOf(10).pow(37), - DataValidationUtil.validateAndParseBigInteger(BigInteger.valueOf(10).pow(37))); - Assert.assertEquals( - BigInteger.valueOf(-1).multiply(BigInteger.valueOf(10).pow(37)), - DataValidationUtil.validateAndParseBigInteger( - BigInteger.valueOf(-1).multiply(BigInteger.valueOf(10).pow(37)))); - - // Expect errors - // Too big - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseBigInteger, - BigInteger.valueOf(10).pow(38)); - // Too small - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseBigInteger, - BigInteger.valueOf(-1).multiply(BigInteger.valueOf(10).pow(38))); - // Decimal - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseBigInteger, 1.1D); - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseBigInteger, 1.1F); - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseBigInteger, "1.1"); + public void testValidateAndParseTimestamp() throws ParseException { + TimestampWrapper wrapper = + DataValidationUtil.validateAndParseTimestamp( + "COL", "2021-01-01T01:00:00.123+01:00", 4, UTC, false, 0); + assertEquals(1609459200, wrapper.getEpochSecond()); + assertEquals(123000000, wrapper.getFraction()); + assertEquals(3600, wrapper.getTimezoneOffsetSeconds()); + assertEquals(1500, wrapper.getTimeZoneIndex()); + + wrapper = validateAndParseTimestamp("COL", " 2021-01-01T01:00:00.123 \t\n", 9, UTC, true, 0); + Assert.assertEquals(1609462800, wrapper.getEpochSecond()); + Assert.assertEquals(123000000, wrapper.getFraction()); + Assert.assertEquals(new BigInteger("1609462800123000000"), wrapper.toBinary(false)); + + // Test integer-stored time and scale guessing + SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + df.setTimeZone(TimeZone.getTimeZone("UTC")); + assertEquals( + BigInteger.valueOf(df.parse("1971-01-01 00:00:00.001").getTime()) + .multiply(BigInteger.valueOf(1000000)), + validateAndParseTimestamp("COL", "31536000001", 9, UTC, true, 0).toBinary(false)); + + assertEquals( + BigInteger.valueOf(df.parse("2969-05-02 23:59:59.999").getTime()) + .multiply(BigInteger.valueOf(1000000)), + validateAndParseTimestamp("COL", "31535999999999", 9, UTC, true, 0).toBinary(false)); + + assertEquals( + BigInteger.valueOf(df.parse("1971-01-01 00:00:00.000").getTime()) + .multiply(BigInteger.valueOf(1000000)), + validateAndParseTimestamp("COL", "31536000000000", 9, UTC, true, 0).toBinary(false)); + + assertEquals( + BigInteger.valueOf(df.parse("2969-05-02 23:59:59.999").getTime()) + .multiply(BigInteger.valueOf(1000000)), + validateAndParseTimestamp("COL", "31535999999999", 9, UTC, true, 0).toBinary(false)); + + assertEquals( + BigInteger.valueOf(df.parse("1971-01-01 00:00:00.000").getTime()) + .multiply(BigInteger.valueOf(1000000)), + validateAndParseTimestamp("COL", "31536000000000000", 9, UTC, true, 0).toBinary(false)); + + // Time input is not supported + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseTimestamp("COL", "20:57:01", 3, UTC, false, 0)); + + // Test values out of range + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> + validateAndParseTimestamp( + "COL", LocalDateTime.of(10000, 2, 2, 2, 2), 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseTimestamp("COL", LocalDateTime.of(0, 2, 2, 2, 2), 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseTimestamp("COL", LocalDateTime.of(-1, 2, 2, 2, 2), 3, UTC, false, 0)); + + // Test forbidden values + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", LocalTime.now(), 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", OffsetTime.now(), 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", new Date(), 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", 1.5f, 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", 1.5, 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseTimestamp("COL", "1.5", 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseTimestamp("COL", "1.0", 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", new Object(), 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", false, 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseTimestamp("COL", "", 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseTimestamp("COL", "foo", 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> + validateAndParseTimestamp("COL", java.sql.Time.valueOf("20:57:00"), 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> + validateAndParseTimestamp( + "COL", java.sql.Date.valueOf("2010-11-03"), 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> + validateAndParseTimestamp( + "COL", java.sql.Timestamp.valueOf("2010-11-03 20:57:00"), 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", BigInteger.ZERO, 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", BigDecimal.ZERO, 3, UTC, false, 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseTimestamp("COL", 'c', 3, UTC, false, 0)); } @Test public void testValidateAndParseBigDecimal() { - Assert.assertEquals(new BigDecimal("1"), DataValidationUtil.validateAndParseBigDecimal("1")); - Assert.assertEquals( + assertEquals(new BigDecimal("1"), validateAndParseBigDecimal("COL", "1", 0)); + assertEquals(new BigDecimal("1"), validateAndParseBigDecimal("COL", " 1 \t\n ", 0)); + assertEquals( + new BigDecimal("1000").toBigInteger(), + validateAndParseBigDecimal("COL", "1e3", 0).toBigInteger()); + assertEquals( + new BigDecimal("1000").toBigInteger(), + validateAndParseBigDecimal("COL", " 1e3 \t\n", 0).toBigInteger()); + assertEquals( new BigDecimal("1000").toBigInteger(), - DataValidationUtil.validateAndParseBigDecimal("1e3").toBigInteger()); - Assert.assertEquals( + validateAndParseBigDecimal("COL", "1e3", 0).toBigInteger()); + assertEquals( new BigDecimal("-1000").toBigInteger(), - DataValidationUtil.validateAndParseBigDecimal("-1e3").toBigInteger()); - Assert.assertEquals( + validateAndParseBigDecimal("COL", "-1e3", 0).toBigInteger()); + assertEquals( new BigDecimal("1").toBigInteger(), - DataValidationUtil.validateAndParseBigDecimal("1e0").toBigInteger()); - Assert.assertEquals( + validateAndParseBigDecimal("COL", "1e0", 0).toBigInteger()); + assertEquals( new BigDecimal("-1").toBigInteger(), - DataValidationUtil.validateAndParseBigDecimal("-1e0").toBigInteger()); - Assert.assertEquals( + validateAndParseBigDecimal("COL", "-1e0", 0).toBigInteger()); + assertEquals( new BigDecimal("123").toBigInteger(), - DataValidationUtil.validateAndParseBigDecimal("1.23e2").toBigInteger()); - Assert.assertEquals(new BigDecimal("1"), DataValidationUtil.validateAndParseBigDecimal(1)); - Assert.assertEquals(new BigDecimal("1.0"), DataValidationUtil.validateAndParseBigDecimal(1D)); - Assert.assertEquals(new BigDecimal("1"), DataValidationUtil.validateAndParseBigDecimal(1L)); - Assert.assertEquals(new BigDecimal("1.0"), DataValidationUtil.validateAndParseBigDecimal(1F)); - Assert.assertEquals( + validateAndParseBigDecimal("COL", "1.23e2", 0).toBigInteger()); + assertEquals( + new BigDecimal("123.4").toBigInteger(), + validateAndParseBigDecimal("COL", "1.234e2", 0).toBigInteger()); + assertEquals( + new BigDecimal("0.1234").toBigInteger(), + validateAndParseBigDecimal("COL", "1.234e-1", 0).toBigInteger()); + assertEquals( + new BigDecimal("0.1234").toBigInteger(), + validateAndParseBigDecimal("COL", "1234e-5", 0).toBigInteger()); + assertEquals( + new BigDecimal("0.1234").toBigInteger(), + validateAndParseBigDecimal("COL", "1234E-5", 0).toBigInteger()); + assertEquals(new BigDecimal("1"), validateAndParseBigDecimal("COL", 1, 0)); + assertEquals(new BigDecimal("1.0"), validateAndParseBigDecimal("COL", 1D, 0)); + assertEquals(new BigDecimal("1"), validateAndParseBigDecimal("COL", 1L, 0)); + assertEquals(new BigDecimal("1.0"), validateAndParseBigDecimal("COL", 1F, 0)); + assertEquals( BigDecimal.valueOf(10).pow(37), - DataValidationUtil.validateAndParseBigDecimal(BigDecimal.valueOf(10).pow(37))); - Assert.assertEquals( + validateAndParseBigDecimal("COL", BigDecimal.valueOf(10).pow(37), 0)); + assertEquals( BigDecimal.valueOf(-1).multiply(BigDecimal.valueOf(10).pow(37)), - DataValidationUtil.validateAndParseBigDecimal( - BigInteger.valueOf(-1).multiply(BigInteger.valueOf(10).pow(37)))); + validateAndParseBigDecimal( + "COL", BigInteger.valueOf(-1).multiply(BigInteger.valueOf(10).pow(37)), 0)); - // Expect errors - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseBigDecimal, "honk"); + // Test forbidden values + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseBigDecimal("COL", "honk", 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseBigDecimal("COL", "0x22", 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBigDecimal("COL", true, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBigDecimal("COL", false, 0)); expectError( - ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseBigDecimal, MAX_BIGINTEGER); + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBigDecimal("COL", new Object(), 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBigDecimal("COL", 'a', 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBigDecimal("COL", new byte[4], 0)); } @Test public void testValidateAndParseString() { - Assert.assertEquals( - "honk", DataValidationUtil.validateAndParseString("honk", Optional.empty())); + assertEquals("honk", validateAndParseString("COL", "honk", Optional.empty(), 0)); - // Check max String length - StringBuilder longBuilder = new StringBuilder(); - for (int i = 0; i < DataValidationUtil.MAX_STRING_LENGTH + 1; i++) { - longBuilder.append("a"); - } - String tooLong = longBuilder.toString(); + // Check max byte length + String maxString = buildString("a", BYTES_16_MB); + assertEquals(maxString, validateAndParseString("COL", maxString, Optional.empty(), 0)); - try { - DataValidationUtil.validateAndParseString(tooLong, Optional.empty()); - Assert.fail("Expected error for String too long"); - } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); - } + // max byte length - 1 should also succeed + String maxStringMinusOne = buildString("a", BYTES_16_MB - 1); + assertEquals( + maxStringMinusOne, validateAndParseString("COL", maxStringMinusOne, Optional.empty(), 0)); - try { - DataValidationUtil.validateAndParseString("123", Optional.of(2)); - Assert.fail("Expected error for String too long"); - } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); - } + // max byte length + 1 should fail + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseString("COL", maxString + "a", Optional.empty(), 0)); + + // Test that max character length validation counts characters and not bytes + assertEquals("a", validateAndParseString("COL", "a", Optional.of(1), 0)); + assertEquals("č", validateAndParseString("COL", "č", Optional.of(1), 0)); + assertEquals("❄", validateAndParseString("COL", "❄", Optional.of(1), 0)); + assertEquals("🍞", validateAndParseString("COL", "🍞", Optional.of(1), 0)); + + // Test max character length rejection + expectError( + ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseString("COL", "a🍞", Optional.of(1), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseString("COL", "12345", Optional.of(4), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseString("COL", false, Optional.of(4), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseString("COL", 12345, Optional.of(4), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseString("COL", 1.2345, Optional.of(4), 0)); + + // Test that invalid UTF-8 strings cannot be ingested + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseString("COL", "foo\uD800bar", Optional.empty(), 0)); + + // Test unsupported values + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseString("COL", new Object(), Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseString("COL", new byte[] {}, Optional.of(4), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseString("COL", new char[] {}, Optional.of(4), 0)); } @Test public void testValidateAndParseVariant() throws Exception { + assertEquals("1", validateAndParseVariant("COL", 1, 0)); + assertEquals("1", validateAndParseVariant("COL", "1", 0)); + assertEquals("1", validateAndParseVariant("COL", " 1 ", 0)); String stringVariant = "{\"key\":1}"; - Assert.assertEquals(stringVariant, DataValidationUtil.validateAndParseVariant(stringVariant)); - JsonNode nodeVariant = objectMapper.readTree(stringVariant); - Assert.assertEquals(stringVariant, DataValidationUtil.validateAndParseVariant(nodeVariant)); + assertEquals(stringVariant, validateAndParseVariant("COL", stringVariant, 0)); + assertEquals(stringVariant, validateAndParseVariant("COL", " " + stringVariant + " \t\n", 0)); - char[] data = new char[20000000]; - Arrays.fill(data, 'a'); - String stringVal = new String(data); - try { - DataValidationUtil.validateAndParseVariant(stringVal); - Assert.fail("Expected INVALID_ROW error"); - } catch (SFException err) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), err.getVendorCode()); - } + // Test custom serializers + assertEquals( + "[-128,0,127]", + validateAndParseVariant("COL", new byte[] {Byte.MIN_VALUE, 0, Byte.MAX_VALUE}, 0)); + assertEquals( + "\"2022-09-28T03:04:12.123456789-07:00\"", + validateAndParseVariant( + "COL", + ZonedDateTime.of(2022, 9, 28, 3, 4, 12, 123456789, ZoneId.of("America/Los_Angeles")), + 0)); + + // Test valid JSON tokens + assertEquals("null", validateAndParseVariant("COL", null, 0)); + assertEquals("null", validateAndParseVariant("COL", "null", 0)); + assertEquals("true", validateAndParseVariant("COL", true, 0)); + assertEquals("true", validateAndParseVariant("COL", "true", 0)); + assertEquals("false", validateAndParseVariant("COL", false, 0)); + assertEquals("false", validateAndParseVariant("COL", "false", 0)); + assertEquals("{}", validateAndParseVariant("COL", "{}", 0)); + assertEquals("[]", validateAndParseVariant("COL", "[]", 0)); + assertEquals("[\"foo\",1,null]", validateAndParseVariant("COL", "[\"foo\",1,null]", 0)); + assertEquals("\"\"", validateAndParseVariant("COL", "\"\"", 0)); + + // Test missing values are null instead of empty string + assertNull(validateAndParseVariant("COL", "", 0)); + assertNull(validateAndParseVariant("COL", " ", 0)); + + // Test that invalid UTF-8 strings cannot be ingested + expectError( + ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseVariant("COL", "\"foo\uD800bar\"", 0)); + + // Test forbidden values + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseVariant("COL", "{null}", 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseVariant("COL", "}{", 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseVariant("COL", readTree("{}"), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseVariant("COL", new Object(), 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseVariant("COL", "foo", 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseVariant("COL", new Date(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseVariant("COL", Collections.singletonList(new Object()), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> + validateAndParseVariant( + "COL", + Collections.singletonList(Collections.singletonMap("foo", new Object())), + 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseVariant("COL", Collections.singletonMap(new Object(), "foo"), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseVariant("COL", Collections.singletonMap("foo", new Object()), 0)); + } + + @Test + public void testValidateAndParseArray() throws Exception { + assertEquals("[1]", validateAndParseArray("COL", 1, 0)); + assertEquals("[1]", validateAndParseArray("COL", "1", 0)); + assertEquals("[1]", validateAndParseArray("COL", " 1 ", 0)); + assertEquals("[1,2,3]", validateAndParseArray("COL", "[1, 2, 3]", 0)); + assertEquals("[1,2,3]", validateAndParseArray("COL", " [1, 2, 3] \t\n", 0)); + int[] intArray = new int[] {1, 2, 3}; + assertEquals("[1,2,3]", validateAndParseArray("COL", intArray, 0)); + + String[] stringArray = new String[] {"a", "b", "c"}; + assertEquals("[\"a\",\"b\",\"c\"]", validateAndParseArray("COL", stringArray, 0)); + + Object[] objectArray = new Object[] {1, 2, 3}; + assertEquals("[1,2,3]", validateAndParseArray("COL", objectArray, 0)); + + Object[] ObjectArrayWithNull = new Object[] {1, null, 3}; + assertEquals("[1,null,3]", validateAndParseArray("COL", ObjectArrayWithNull, 0)); + + Object[][] nestedArray = new Object[][] {{1, 2, 3}, null, {4, 5, 6}}; + assertEquals("[[1,2,3],null,[4,5,6]]", validateAndParseArray("COL", nestedArray, 0)); + + List intList = Arrays.asList(1, 2, 3); + assertEquals("[1,2,3]", validateAndParseArray("COL", intList, 0)); + + List objectList = Arrays.asList(1, 2, 3); + assertEquals("[1,2,3]", validateAndParseArray("COL", objectList, 0)); + + List nestedList = Arrays.asList(Arrays.asList(1, 2, 3), 2, 3); + assertEquals("[[1,2,3],2,3]", validateAndParseArray("COL", nestedList, 0)); + + // Test null values + assertEquals("[null]", validateAndParseArray("COL", "", 0)); + assertEquals("[null]", validateAndParseArray("COL", " ", 0)); + assertEquals("[null]", validateAndParseArray("COL", "null", 0)); + assertEquals("[null]", validateAndParseArray("COL", null, 0)); + + // Test that invalid UTF-8 strings cannot be ingested + expectError( + ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseArray("COL", "\"foo\uD800bar\"", 0)); + + // Test forbidden values + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseArray("COL", readTree("[]"), 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseArray("COL", new Object(), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseArray("COL", "foo", 0)); // invalid JSO)N + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseArray("COL", new Date(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseArray("COL", Collections.singletonList(new Object()), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> + validateAndParseArray( + "COL", + Collections.singletonList(Collections.singletonMap("foo", new Object())), + 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseArray("COL", Collections.singletonMap(new Object(), "foo"), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseArray("COL", Collections.singletonMap("foo", new Object()), 0)); } @Test public void testValidateAndParseObject() throws Exception { String stringObject = "{\"key\":1}"; - Assert.assertEquals(stringObject, DataValidationUtil.validateAndParseObject(stringObject)); - JsonNode nodeObject = objectMapper.readTree(stringObject); - Assert.assertEquals(stringObject, DataValidationUtil.validateAndParseObject(nodeObject)); + assertEquals(stringObject, validateAndParseObject("COL", stringObject, 0)); + assertEquals(stringObject, validateAndParseObject("COL", " " + stringObject + " \t\n", 0)); String badObject = "foo"; try { - DataValidationUtil.validateAndParseObject(badObject); + validateAndParseObject("COL", badObject, 0); Assert.fail("Expected INVALID_ROW error"); } catch (SFException err) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), err.getVendorCode()); + assertEquals(ErrorCode.INVALID_VALUE_ROW.getMessageCode(), err.getVendorCode()); } char[] data = new char[20000000]; @@ -285,237 +642,608 @@ public void testValidateAndParseObject() throws Exception { mapVal.put("key", stringVal); String tooLargeObject = objectMapper.writeValueAsString(mapVal); try { - DataValidationUtil.validateAndParseObject(tooLargeObject); + validateAndParseObject("COL", tooLargeObject, 0); Assert.fail("Expected INVALID_ROW error"); } catch (SFException err) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), err.getVendorCode()); - } - } - - @Test - public void testValidateAndParseInteger() { - for (Object input : goodIntegersValue10) { - Assert.assertEquals(10, DataValidationUtil.validateAndParseInteger(input)); + assertEquals(ErrorCode.INVALID_VALUE_ROW.getMessageCode(), err.getVendorCode()); } - // Bad inputs - // Double - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseInteger, 10.1D); + // Test that invalid UTF-8 strings cannot be ingested expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - Double.valueOf(Integer.MAX_VALUE) + 1); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - Double.valueOf(Integer.MIN_VALUE) - 1); + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseObject("COL", "{\"foo\": \"foo\uD800bar\"}", 0)); - // Float - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseInteger, 10.1F); + // Test forbidden values expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - Float.valueOf(Integer.MAX_VALUE) * 2); + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseObject("COL", readTree("{}"), 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseObject("COL", "[]", 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseObject("COL", "1", 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseObject("COL", 1, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseObject("COL", 1.5, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseObject("COL", false, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseObject("COL", new Object(), 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseObject("COL", "foo", 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseObject("COL", new Date(), 0)); expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - Float.valueOf(Integer.MIN_VALUE) * 2); - - // Long + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseObject("COL", Collections.singletonList(new Object()), 0)); expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - Long.valueOf(Integer.MAX_VALUE) + 1); + ErrorCode.INVALID_FORMAT_ROW, + () -> + validateAndParseObject( + "COL", + Collections.singletonList(Collections.singletonMap("foo", new Object())), + 0)); expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - Long.valueOf(Integer.MIN_VALUE) - 1); - - // BigInteger - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - BigInteger.valueOf(Integer.MAX_VALUE).add(new BigInteger("1"))); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - BigInteger.valueOf(Integer.MIN_VALUE).add(new BigInteger("-1"))); - - // String + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseObject("COL", Collections.singletonMap(new Object(), "foo"), 0)); expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - "Honk goes the noble goose"); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - BigInteger.valueOf(Integer.MAX_VALUE).add(new BigInteger("1")).toString()); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseInteger, - BigInteger.valueOf(Integer.MIN_VALUE).add(new BigInteger("-1")).toString()); + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseObject("COL", Collections.singletonMap("foo", new Object()), 0)); } @Test - public void testValidateAndParseLong() { - for (Object input : goodIntegersValue10) { - Assert.assertEquals(10, DataValidationUtil.validateAndParseLong(input)); - } + public void testTooLargeVariant() { + char[] stringContent = new char[16 * 1024 * 1024 - 16]; // {"a":"11","b":""} + Arrays.fill(stringContent, 'c'); - // Bad inputs - // Double - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseLong, 10.1D); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseLong, - Double.valueOf(Long.MAX_VALUE) * 2); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseLong, - Double.valueOf(Long.MIN_VALUE) * 2); - - // Float - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseLong, 10.1F); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseLong, - Float.valueOf(Long.MAX_VALUE) * 2); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseLong, - Float.valueOf(Long.MIN_VALUE) * 2); - - // BigInteger - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseLong, - BigInteger.valueOf(Long.MAX_VALUE).add(new BigInteger("1"))); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseLong, - BigInteger.valueOf(Long.MIN_VALUE).add(new BigInteger("-1"))); - - // String - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseLong, - "Honk goes the noble goose"); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseLong, - BigInteger.valueOf(Long.MAX_VALUE).add(new BigInteger("1")).toString()); - expectError( - ErrorCode.INVALID_ROW, - DataValidationUtil::validateAndParseLong, - BigInteger.valueOf(Long.MIN_VALUE).add(new BigInteger("-1")).toString()); + // {"a":"11","b":""} + Map m = new HashMap<>(); + m.put("a", "11"); + m.put("b", new String(stringContent)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseVariant("COL", m, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseArray("COL", m, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseObject("COL", m, 0)); } @Test - public void testValidateAndParseDate() { - Assert.assertEquals(-923, DataValidationUtil.validateAndParseDate("1967-06-23")); - Assert.assertEquals(-923, DataValidationUtil.validateAndParseDate("1967-06-23 01:01:01")); - Assert.assertEquals(18464, DataValidationUtil.validateAndParseDate("2020-07-21")); - Assert.assertEquals(18464, DataValidationUtil.validateAndParseDate("2020-07-21 23:31:00")); - Assert.assertEquals(12341, DataValidationUtil.validateAndParseDate(12341)); - } + public void testTooLargeMultiByteSemiStructuredValues() { + // Variant max size is not in characters, but in bytes + char[] stringContent = new char[9 * 1024 * 1024]; // 8MB < value < 16MB + Arrays.fill(stringContent, 'Č'); - @Test - public void testGetStringValue() throws Exception { - Assert.assertEquals("123", DataValidationUtil.getStringValue("123")); - Assert.assertEquals("123", DataValidationUtil.getStringValue(123)); - Assert.assertEquals("123", DataValidationUtil.getStringValue(new BigDecimal("123"))); - Assert.assertEquals("123", DataValidationUtil.getStringValue(new BigInteger("123"))); - Assert.assertEquals("123.0", DataValidationUtil.getStringValue(123f)); - Assert.assertEquals("123.0", DataValidationUtil.getStringValue(123d)); - Assert.assertEquals("123", DataValidationUtil.getStringValue(123l)); + Map m = new HashMap<>(); + m.put("a", new String(stringContent)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type VARIANT, rowIndex:0, reason:" + + " Variant too long: length=18874376 maxLength=16777152", + () -> validateAndParseVariant("COL", m, 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type ARRAY, rowIndex:0, reason:" + + " Array too large. length=18874378 maxLength=16777152", + () -> validateAndParseArray("COL", m, 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type OBJECT, rowIndex:0, reason:" + + " Object too large. length=18874376 maxLength=16777152", + () -> validateAndParseObject("COL", m, 0)); } @Test - public void testGetTimestampInScale() throws Exception { - Assert.assertEquals( - new BigInteger("123"), DataValidationUtil.getTimeInScale("123.000000000", 0)); - Assert.assertEquals(new BigInteger("12301"), DataValidationUtil.getTimeInScale("123.01", 2)); - } + public void testValidVariantType() { + // Test primitive types + Assert.assertTrue(isAllowedSemiStructuredType((byte) 1)); + Assert.assertTrue(isAllowedSemiStructuredType((short) 1)); + Assert.assertTrue(isAllowedSemiStructuredType(1)); + Assert.assertTrue(isAllowedSemiStructuredType(1L)); + Assert.assertTrue(isAllowedSemiStructuredType(1.25f)); + Assert.assertTrue(isAllowedSemiStructuredType(1.25d)); + Assert.assertTrue(isAllowedSemiStructuredType(false)); + Assert.assertTrue(isAllowedSemiStructuredType('c')); - @Test - public void testValidateAndParseBinary() throws Exception { + // Test boxed primitive types + Assert.assertTrue(isAllowedSemiStructuredType(Byte.valueOf((byte) 1))); + Assert.assertTrue(isAllowedSemiStructuredType(Short.valueOf((short) 1))); + Assert.assertTrue(isAllowedSemiStructuredType(Integer.valueOf(1))); + Assert.assertTrue(isAllowedSemiStructuredType(Long.valueOf(1L))); + Assert.assertTrue(isAllowedSemiStructuredType(Float.valueOf(1.25f))); + Assert.assertTrue(isAllowedSemiStructuredType(Double.valueOf(1.25d))); + Assert.assertTrue(isAllowedSemiStructuredType(Boolean.valueOf(false))); + Assert.assertTrue(isAllowedSemiStructuredType(Character.valueOf('c'))); + + // Test primitive arrays + Assert.assertTrue(isAllowedSemiStructuredType(new byte[] {1})); + Assert.assertTrue(isAllowedSemiStructuredType(new short[] {1})); + Assert.assertTrue(isAllowedSemiStructuredType(new int[] {1})); + Assert.assertTrue(isAllowedSemiStructuredType(new long[] {1L})); + Assert.assertTrue(isAllowedSemiStructuredType(new float[] {1.25f})); + Assert.assertTrue(isAllowedSemiStructuredType(new double[] {1.25d})); + Assert.assertTrue(isAllowedSemiStructuredType(new boolean[] {false})); + Assert.assertTrue(isAllowedSemiStructuredType(new char[] {'c'})); + + // Test primitive lists + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList((byte) 1))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList((short) 1))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(1))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(1L))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(1.25f))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(1.25d))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(false))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList('c'))); + + // Test additional numeric types and their collections + Assert.assertTrue(isAllowedSemiStructuredType(new BigInteger("1"))); + Assert.assertTrue(isAllowedSemiStructuredType(new BigInteger[] {new BigInteger("1")})); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(new BigInteger("1")))); + Assert.assertTrue(isAllowedSemiStructuredType(new BigDecimal("1.25"))); + Assert.assertTrue(isAllowedSemiStructuredType(new BigDecimal[] {new BigDecimal("1.25")})); Assert.assertTrue( - Arrays.equals( - "honk".getBytes(StandardCharsets.UTF_8), - DataValidationUtil.validateAndParseBinary("honk".getBytes(StandardCharsets.UTF_8)))); + isAllowedSemiStructuredType(Collections.singletonList(new BigDecimal("1.25")))); + + // Test strings + Assert.assertTrue(isAllowedSemiStructuredType("foo")); + Assert.assertTrue(isAllowedSemiStructuredType(new String[] {"foo"})); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList("foo"))); + + // Test date/time objects and their collections + Assert.assertTrue(isAllowedSemiStructuredType(LocalTime.now())); + Assert.assertTrue(isAllowedSemiStructuredType(OffsetTime.now())); + Assert.assertTrue(isAllowedSemiStructuredType(LocalDate.now())); + Assert.assertTrue(isAllowedSemiStructuredType(LocalDateTime.now())); + Assert.assertTrue(isAllowedSemiStructuredType(ZonedDateTime.now())); + Assert.assertTrue(isAllowedSemiStructuredType(OffsetDateTime.now())); + Assert.assertTrue(isAllowedSemiStructuredType(new LocalTime[] {LocalTime.now()})); + Assert.assertTrue(isAllowedSemiStructuredType(new OffsetTime[] {OffsetTime.now()})); + Assert.assertTrue(isAllowedSemiStructuredType(new LocalDate[] {LocalDate.now()})); + Assert.assertTrue(isAllowedSemiStructuredType(new LocalDateTime[] {LocalDateTime.now()})); + Assert.assertTrue(isAllowedSemiStructuredType(new ZonedDateTime[] {ZonedDateTime.now()})); + Assert.assertTrue(isAllowedSemiStructuredType(new OffsetDateTime[] {OffsetDateTime.now()})); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(LocalTime.now()))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(OffsetTime.now()))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(LocalDate.now()))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(LocalDateTime.now()))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(ZonedDateTime.now()))); + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonList(OffsetDateTime.now()))); + + // Test mixed collections + Assert.assertTrue( + isAllowedSemiStructuredType( + new Object[] { + 1, + false, + new BigInteger("1"), + LocalDateTime.now(), + new Object[] {new Object[] {new Object[] {LocalDateTime.now(), false}}} + })); + Assert.assertFalse( + isAllowedSemiStructuredType( + new Object[] { + 1, + false, + new BigInteger("1"), + LocalDateTime.now(), + new Object[] {new Object[] {new Object[] {new Object(), false}}} + })); Assert.assertTrue( - Arrays.equals( - DatatypeConverter.parseHexBinary("12"), - DataValidationUtil.validateAndParseBinary("12"))); + isAllowedSemiStructuredType( + Arrays.asList( + new BigInteger("1"), + "foo", + false, + Arrays.asList(13, Arrays.asList(Arrays.asList(false, 'c')))))); + Assert.assertFalse( + isAllowedSemiStructuredType( + Arrays.asList( + new BigInteger("1"), + "foo", + false, + Arrays.asList(13, Arrays.asList(Arrays.asList(new Object(), 'c')))))); + // Test maps + Assert.assertTrue(isAllowedSemiStructuredType(Collections.singletonMap("foo", "bar"))); + Assert.assertFalse(isAllowedSemiStructuredType(Collections.singletonMap(new Object(), "foo"))); + Assert.assertFalse(isAllowedSemiStructuredType(Collections.singletonMap("foo", new Object()))); Assert.assertTrue( - Arrays.equals( - DatatypeConverter.parseHexBinary("12"), DataValidationUtil.validateAndParseBinary(12))); + isAllowedSemiStructuredType( + Collections.singletonMap( + "foo", + new Object[] { + 1, + false, + new BigInteger("1"), + LocalDateTime.now(), + new Object[] {new Object[] {new Object[] {LocalDateTime.now(), false}}} + }))); + Assert.assertFalse( + isAllowedSemiStructuredType( + Collections.singletonMap( + "foo", + new Object[] { + 1, + false, + new BigInteger("1"), + LocalDateTime.now(), + new Object[] {new Object[] {new Object[] {new Object(), false}}} + }))); + Assert.assertTrue( + isAllowedSemiStructuredType( + Collections.singletonMap( + "foo", + Arrays.asList( + new BigInteger("1"), + "foo", + false, + Arrays.asList(13, Arrays.asList(Arrays.asList(false, 'c'))))))); + Assert.assertFalse( + isAllowedSemiStructuredType( + Collections.singletonMap( + "foo", + Arrays.asList( + new BigInteger("1"), + "foo", + false, + Arrays.asList(13, Arrays.asList(Arrays.asList(new Object(), 'c'))))))); + } + + @Test + public void testValidateAndParseBinary() throws DecoderException { + byte[] maxAllowedArray = new byte[BYTES_8_MB]; + byte[] maxAllowedArrayMinusOne = new byte[BYTES_8_MB - 1]; + + assertArrayEquals( + "honk".getBytes(StandardCharsets.UTF_8), + validateAndParseBinary( + "COL", "honk".getBytes(StandardCharsets.UTF_8), Optional.empty(), 0)); - expectError(ErrorCode.INVALID_ROW, DataValidationUtil::validateAndParseBinary, 123); + assertArrayEquals( + new byte[] {-1, 0, 1}, + validateAndParseBinary("COL", new byte[] {-1, 0, 1}, Optional.empty(), 0)); + assertArrayEquals( + Hex.decodeHex("1234567890abcdef"), // pragma: allowlist secret NOT A SECRET + validateAndParseBinary( + "COL", + "1234567890abcdef", // pragma: allowlist secret NOT A SECRET + Optional.empty(), + 0)); // pragma: allowlist secret NOT A SECRET + assertArrayEquals( + Hex.decodeHex("1234567890abcdef"), // pragma: allowlist secret NOT A SECRET + validateAndParseBinary( + "COL", + " 1234567890abcdef \t\n", + Optional.empty(), + 0)); // pragma: allowlist secret NOT A SECRET + + assertArrayEquals( + maxAllowedArray, validateAndParseBinary("COL", maxAllowedArray, Optional.empty(), 0)); + assertArrayEquals( + maxAllowedArrayMinusOne, + validateAndParseBinary("COL", maxAllowedArrayMinusOne, Optional.empty(), 0)); + + // Too large arrays should be rejected + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseBinary("COL", new byte[1], Optional.of(0), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseBinary("COL", new byte[BYTES_8_MB + 1], Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseBinary("COL", new byte[8], Optional.of(7), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseBinary("COL", "aabb", Optional.of(1), 0)); + + // unsupported data types should fail + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseBinary("COL", "000", Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, + () -> validateAndParseBinary("COL", "abcg", Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseBinary("COL", "c", Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> + validateAndParseBinary( + "COL", Arrays.asList((byte) 1, (byte) 2, (byte) 3), Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBinary("COL", 1, Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBinary("COL", 12, Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseBinary("COL", 1.5, Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseBinary("COL", BigInteger.ONE, Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseBinary("COL", false, Optional.empty(), 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, + () -> validateAndParseBinary("COL", new Object(), Optional.empty(), 0)); } @Test public void testValidateAndParseReal() throws Exception { // From number types - Assert.assertEquals(1.23d, DataValidationUtil.validateAndParseReal(1.23f), 0); - Assert.assertEquals(1.23d, DataValidationUtil.validateAndParseReal(1.23), 0); - Assert.assertEquals(1.23d, DataValidationUtil.validateAndParseReal(1.23d), 0); - Assert.assertEquals(1.23d, DataValidationUtil.validateAndParseReal(new BigDecimal("1.23")), 0); + assertEquals(1.23d, validateAndParseReal("COL", 1.23f, 0), 0); + assertEquals(1.23d, validateAndParseReal("COL", 1.23, 0), 0); + assertEquals(1.23d, validateAndParseReal("COL", 1.23d, 0), 0); + assertEquals(1.23d, validateAndParseReal("COL", new BigDecimal("1.23"), 0), 0); + assertEquals(Double.NaN, validateAndParseReal("COL", "Nan", 0), 0); + assertEquals(Double.POSITIVE_INFINITY, validateAndParseReal("COL", "inF", 0), 0); + assertEquals(Double.NEGATIVE_INFINITY, validateAndParseReal("COL", "-inF", 0), 0); + assertEquals(Double.NEGATIVE_INFINITY, validateAndParseReal("COL", " -inF \t\n", 0), 0); // From string - Assert.assertEquals(1.23d, DataValidationUtil.validateAndParseReal("1.23"), 0); - Assert.assertEquals(123d, DataValidationUtil.validateAndParseReal("1.23E2"), 0); - Assert.assertEquals(123d, DataValidationUtil.validateAndParseReal("1.23e2"), 0); + assertEquals(1.23d, validateAndParseReal("COL", " 1.23 \t\n", 0), 0); + assertEquals(1.23d, validateAndParseReal("COL", "1.23", 0), 0); + assertEquals(123d, validateAndParseReal("COL", "1.23E2", 0), 0); + assertEquals(123d, validateAndParseReal("COL", "1.23e2", 0), 0); - // Error states - try { - DataValidationUtil.validateAndParseReal("honk"); - Assert.fail("Expected invalid row error"); - } catch (SFException err) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), err.getVendorCode()); - } + // Test forbidden values + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseReal("COL", "foo", 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseReal("COL", 'c', 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseReal("COL", new Object(), 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseReal("COL", false, 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseReal("COL", true, 0)); } @Test - public void testValidateAndParseBoolean() throws Exception { - for (Object input : trueBooleanInput) { - Assert.assertEquals(1, DataValidationUtil.validateAndParseBoolean(input)); + public void testValidateAndParseBoolean() { + + for (Object input : + Arrays.asList( + true, + "true", + "True", + "TruE", + "t", + "yes", + "YeS", + "y", + "on", + "1", + " true \t\n", + 1.1, + -1.1, + -10, + 10)) { + assertEquals(1, validateAndParseBoolean("COL", input, 0)); } - for (Object input : falseBooleanInput) { - Assert.assertEquals(0, DataValidationUtil.validateAndParseBoolean(input)); + int rowIndex = 0; + for (Object input : + Arrays.asList(false, "false", "False", "FalsE", "f", "no", "NO", "n", "off", "0", 0)) { + assertEquals(0, validateAndParseBoolean("COL", input, rowIndex)); + rowIndex += 1; } - // Error states - try { - DataValidationUtil.validateAndParseBoolean("honk"); - Assert.fail("Expected invalid row error"); - } catch (SFException err) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), err.getVendorCode()); - } + // Test forbidden values + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBoolean("COL", new Object(), 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBoolean("COL", 't', 0)); + expectError(ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBoolean("COL", 'f', 0)); + expectError( + ErrorCode.INVALID_FORMAT_ROW, () -> validateAndParseBoolean("COL", new int[] {}, 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseBoolean("COL", "foobar", 0)); + expectError(ErrorCode.INVALID_VALUE_ROW, () -> validateAndParseBoolean("COL", "", 0)); } + /** + * Tests that exception message are constructed correctly when ingesting forbidden Java type, as + * well a value of an allowed type, but in invalid format + */ @Test - public void testConvertStringToBoolean() throws Exception { - for (Object input : trueBooleanInput) { - if (input instanceof String) { - Assert.assertEquals(true, DataValidationUtil.convertStringToBoolean((String) input)); - } - } + public void testExceptionMessages() { + // BOOLEAN + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type BOOLEAN, rowIndex:0. Allowed" + + " Java types: boolean, Number, String", + () -> validateAndParseBoolean("COL", new Object(), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type BOOLEAN, rowIndex:0, reason:" + + " Not a valid boolean, see" + + " https://docs.snowflake.com/en/sql-reference/data-types-logical.html#conversion-to-boolean" + + " for the list of supported formats", + () -> validateAndParseBoolean("COL", "abc", 0)); - for (Object input : falseBooleanInput) { - if (input instanceof String) { - Assert.assertEquals(false, DataValidationUtil.convertStringToBoolean((String) input)); - } - } + // TIME + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type TIME, rowIndex:0. Allowed" + + " Java types: String, LocalTime, OffsetTime", + () -> validateAndParseTime("COL", new Object(), 10, 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type TIME, rowIndex:0, reason:" + + " Not a valid time, see" + + " https://docs.snowflake.com/en/user-guide/data-load-snowpipe-streaming-overview for" + + " the list of supported formats", + () -> validateAndParseTime("COL", "abc", 10, 0)); + // DATE + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type DATE, rowIndex:0. Allowed" + + " Java types: String, LocalDate, LocalDateTime, ZonedDateTime, OffsetDateTime", + () -> validateAndParseDate("COL", new Object(), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type DATE, rowIndex:0, reason:" + + " Not a valid value, see" + + " https://docs.snowflake.com/en/user-guide/data-load-snowpipe-streaming-overview for" + + " the list of supported formats", + () -> validateAndParseDate("COL", "abc", 0)); + + // TIMESTAMP_NTZ + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type TIMESTAMP, rowIndex:0." + + " Allowed Java types: String, LocalDate, LocalDateTime, ZonedDateTime," + + " OffsetDateTime", + () -> validateAndParseTimestamp("COL", new Object(), 3, UTC, true, 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type TIMESTAMP, rowIndex:0," + + " reason: Not a valid value, see" + + " https://docs.snowflake.com/en/user-guide/data-load-snowpipe-streaming-overview for" + + " the list of supported formats", + () -> validateAndParseTimestamp("COL", "abc", 3, UTC, true, 0)); + + // TIMESTAMP_LTZ + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type TIMESTAMP, rowIndex:0." + + " Allowed Java types: String, LocalDate, LocalDateTime, ZonedDateTime," + + " OffsetDateTime", + () -> validateAndParseTimestamp("COL", new Object(), 3, UTC, false, 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type TIMESTAMP, rowIndex:0," + + " reason: Not a valid value, see" + + " https://docs.snowflake.com/en/user-guide/data-load-snowpipe-streaming-overview for" + + " the list of supported formats", + () -> validateAndParseTimestamp("COL", "abc", 3, UTC, false, 0)); + + // TIMESTAMP_TZ + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type TIMESTAMP, rowIndex:0." + + " Allowed Java types: String, LocalDate, LocalDateTime, ZonedDateTime," + + " OffsetDateTime", + () -> validateAndParseTimestamp("COL", new Object(), 3, UTC, false, 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type TIMESTAMP, rowIndex:0," + + " reason: Not a valid value, see" + + " https://docs.snowflake.com/en/user-guide/data-load-snowpipe-streaming-overview for" + + " the list of supported formats", + () -> validateAndParseTimestamp("COL", "abc", 3, UTC, false, 0)); + + // NUMBER + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type NUMBER, rowIndex:0. Allowed" + + " Java types: int, long, byte, short, float, double, BigDecimal, BigInteger, String", + () -> validateAndParseBigDecimal("COL", new Object(), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type NUMBER, rowIndex:0, reason:" + + " Not a valid number", + () -> validateAndParseBigDecimal("COL", "abc", 0)); + + // REAL + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type REAL, rowIndex:0. Allowed" + + " Java types: Number, String", + () -> validateAndParseReal("COL", new Object(), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type REAL, rowIndex:0, reason:" + + " Not a valid decimal number", + () -> validateAndParseReal("COL", "abc", 0)); + + // STRING + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type STRING, rowIndex:0. Allowed" + + " Java types: String, Number, boolean, char", + () -> validateAndParseString("COL", new Object(), Optional.empty(), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type STRING, rowIndex:0, reason:" + + " String too long: length=3 characters maxLength=2 characters", + () -> validateAndParseString("COL", "abc", Optional.of(2), 0)); + + // BINARY + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type BINARY, rowIndex:0. Allowed" + + " Java types: byte[], String", + () -> validateAndParseBinary("COL", new Object(), Optional.empty(), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type BINARY, rowIndex:0, reason:" + + " Binary too long: length=2 maxLength=1", + () -> validateAndParseBinary("COL", new byte[] {1, 2}, Optional.of(1), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type BINARY, rowIndex:0, reason:" + + " Not a valid hex string", + () -> validateAndParseBinary("COL", "ghi", Optional.empty(), 0)); + + // VARIANT + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type VARIANT, rowIndex:0. Allowed" + + " Java types: String, Primitive data types and their arrays, java.time.*, List," + + " Map, T[]", + () -> validateAndParseVariant("COL", new Object(), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type VARIANT, rowIndex:0, reason:" + + " Not a valid JSON", + () -> validateAndParseVariant("COL", "][", 0)); + + // ARRAY + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type ARRAY, rowIndex:0. Allowed" + + " Java types: String, Primitive data types and their arrays, java.time.*, List," + + " Map, T[]", + () -> validateAndParseArray("COL", new Object(), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type ARRAY, rowIndex:0, reason:" + + " Not a valid JSON", + () -> validateAndParseArray("COL", "][", 0)); + + // OBJECT + expectErrorCodeAndMessage( + ErrorCode.INVALID_FORMAT_ROW, + "The given row cannot be converted to the internal format: Object of type java.lang.Object" + + " cannot be ingested into Snowflake column COL of type OBJECT, rowIndex:0. Allowed" + + " Java types: String, Primitive data types and their arrays, java.time.*, List," + + " Map, T[]", + () -> validateAndParseObject("COL", new Object(), 0)); + expectErrorCodeAndMessage( + ErrorCode.INVALID_VALUE_ROW, + "The given row cannot be converted to the internal format due to invalid value: Value" + + " cannot be ingested into Snowflake column COL of type OBJECT, rowIndex:0, reason:" + + " Not a valid JSON", + () -> validateAndParseObject("COL", "}{", 0)); + } + + private JsonNode readTree(String value) { try { - DataValidationUtil.convertStringToBoolean("honk"); - Assert.fail("Expected error"); - } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); - } catch (Exception e) { - Assert.fail(); + return objectMapper.readTree(value); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); } } } diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/FileColumnPropertiesTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/FileColumnPropertiesTest.java new file mode 100644 index 000000000..11e128ef7 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/FileColumnPropertiesTest.java @@ -0,0 +1,32 @@ +package net.snowflake.ingest.streaming.internal; + +import org.junit.Assert; +import org.junit.Test; + +public class FileColumnPropertiesTest { + + @Test + public void testFileColumnPropertiesConstructor() { + // Test simple construction + RowBufferStats stats = new RowBufferStats("COL", null, 1); + stats.addStrValue("bcd"); + stats.addStrValue("abcde"); + FileColumnProperties props = new FileColumnProperties(stats); + Assert.assertEquals(1, props.getColumnOrdinal()); + Assert.assertEquals("6162636465", props.getMinStrValue()); + Assert.assertNull(props.getMinStrNonCollated()); + Assert.assertEquals("626364", props.getMaxStrValue()); + Assert.assertNull(props.getMaxStrNonCollated()); + + // Test that truncation is performed + stats = new RowBufferStats("COL", null, 1); + stats.addStrValue("aßßßßßßßßßßßßßßßß"); + Assert.assertEquals(33, stats.getCurrentMinStrValue().length); + props = new FileColumnProperties(stats); + Assert.assertEquals(1, props.getColumnOrdinal()); + Assert.assertNull(props.getMinStrNonCollated()); + Assert.assertNull(props.getMaxStrNonCollated()); + Assert.assertEquals(32 * 2, props.getMinStrValue().length()); + Assert.assertEquals(32 * 2, props.getMaxStrValue().length()); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/FlushServiceTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/FlushServiceTest.java index a14b3c1ae..28d1206a2 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/FlushServiceTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/FlushServiceTest.java @@ -4,11 +4,14 @@ import static net.snowflake.ingest.utils.Constants.BLOB_CHUNK_METADATA_LENGTH_SIZE_IN_BYTES; import static net.snowflake.ingest.utils.Constants.BLOB_EXTENSION_TYPE; import static net.snowflake.ingest.utils.Constants.BLOB_FILE_SIZE_SIZE_IN_BYTES; -import static net.snowflake.ingest.utils.Constants.BLOB_FORMAT_VERSION; import static net.snowflake.ingest.utils.Constants.BLOB_NO_HEADER; import static net.snowflake.ingest.utils.Constants.BLOB_TAG_SIZE_IN_BYTES; import static net.snowflake.ingest.utils.Constants.BLOB_VERSION_SIZE_IN_BYTES; +import static net.snowflake.ingest.utils.ParameterProvider.MAX_CHUNK_SIZE_IN_BYTES_DEFAULT; +import com.codahale.metrics.Histogram; +import com.codahale.metrics.Meter; +import com.codahale.metrics.Timer; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -20,116 +23,376 @@ import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Calendar; +import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.TimeUnit; import javax.crypto.BadPaddingException; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; -import net.snowflake.client.jdbc.SnowflakeConnectionV1; -import net.snowflake.client.jdbc.internal.org.bouncycastle.jce.provider.BouncyCastleProvider; import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.utils.Constants; import net.snowflake.ingest.utils.Cryptor; import net.snowflake.ingest.utils.ErrorCode; import net.snowflake.ingest.utils.ParameterProvider; import net.snowflake.ingest.utils.SFException; -import org.apache.arrow.memory.BufferAllocator; -import org.apache.arrow.memory.RootAllocator; -import org.apache.arrow.vector.FieldVector; -import org.apache.arrow.vector.IntVector; -import org.apache.arrow.vector.VarCharVector; -import org.apache.arrow.vector.VectorSchemaRoot; -import org.apache.arrow.vector.util.Text; import org.junit.Assert; -import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; public class FlushServiceTest { - private SnowflakeStreamingIngestClientInternal client; - private ChannelCache channelCache; - private SnowflakeConnectionV1 conn; - private SnowflakeStreamingIngestChannelInternal channel1; - private SnowflakeStreamingIngestChannelInternal channel2; - private SnowflakeStreamingIngestChannelInternal channel3; - private StreamingIngestStage stage; + public FlushServiceTest() { + this.testContextFactory = ParquetTestContext.createFactory(); + } - private final BufferAllocator allocator = new RootAllocator(); + private abstract static class TestContextFactory { + private final String name; - @Before - public void setup() { - java.security.Security.addProvider(new BouncyCastleProvider()); + TestContextFactory(String name) { + this.name = name; + } - ParameterProvider parameterProvider = new ParameterProvider(); - client = Mockito.mock(SnowflakeStreamingIngestClientInternal.class); - Mockito.when(client.getParameterProvider()).thenReturn(parameterProvider); - stage = Mockito.mock(StreamingIngestStage.class); - Mockito.when(stage.getClientPrefix()).thenReturn("client_prefix"); - channelCache = new ChannelCache(); - Mockito.when(client.getChannelCache()).thenReturn(channelCache); - conn = Mockito.mock(SnowflakeConnectionV1.class); - channel1 = - new SnowflakeStreamingIngestChannelInternal( - "channel1", - "db1", - "schema1", - "table1", - "offset1", - 0L, - 0L, - client, - "key", - 1234L, - OpenChannelRequest.OnErrorOption.CONTINUE, - true); + abstract TestContext create(); - channel2 = - new SnowflakeStreamingIngestChannelInternal( - "channel2", - "db1", - "schema1", - "table1", - "offset2", - 10L, - 100L, - client, - "key", - 1234L, - OpenChannelRequest.OnErrorOption.CONTINUE, - true); + @Override + public String toString() { + return name; + } + } - channel3 = - new SnowflakeStreamingIngestChannelInternal( - "channel3", - "db2", - "schema1", - "table2", - "offset3", - 0L, - 0L, - client, - "key", - 1234L, - OpenChannelRequest.OnErrorOption.CONTINUE, - true); - channelCache.addChannel(channel1); - channelCache.addChannel(channel2); - channelCache.addChannel(channel3); + private abstract static class TestContext implements AutoCloseable { + SnowflakeStreamingIngestClientInternal client; + ChannelCache channelCache; + final Map> channels = new HashMap<>(); + FlushService flushService; + StreamingIngestStage stage; + ParameterProvider parameterProvider; + RegisterService registerService; + + final List> channelData = new ArrayList<>(); + + TestContext() { + stage = Mockito.mock(StreamingIngestStage.class); + Mockito.when(stage.getClientPrefix()).thenReturn("client_prefix"); + parameterProvider = new ParameterProvider(); + client = Mockito.mock(SnowflakeStreamingIngestClientInternal.class); + Mockito.when(client.getParameterProvider()).thenReturn(parameterProvider); + channelCache = new ChannelCache<>(); + Mockito.when(client.getChannelCache()).thenReturn(channelCache); + registerService = Mockito.spy(new RegisterService(client, client.isTestMode())); + flushService = Mockito.spy(new FlushService<>(client, channelCache, stage, true)); + } + + ChannelData flushChannel(String name) { + SnowflakeStreamingIngestChannelInternal channel = channels.get(name); + ChannelData channelData = channel.getRowBuffer().flush(name + "_snowpipe_streaming.bdec"); + channelData.setChannelContext(channel.getChannelContext()); + this.channelData.add(channelData); + return channelData; + } + + BlobMetadata buildAndUpload() throws Exception { + List>> blobData = Collections.singletonList(channelData); + return flushService.buildAndUpload("file_name", blobData); + } + + abstract SnowflakeStreamingIngestChannelInternal createChannel( + String name, + String dbName, + String schemaName, + String tableName, + String offsetToken, + Long channelSequencer, + Long rowSequencer, + String encryptionKey, + Long encryptionKeyId, + OpenChannelRequest.OnErrorOption onErrorOption, + ZoneId defaultTimezone); + + ChannelBuilder channelBuilder(String name) { + return new ChannelBuilder(name); + } + + class ChannelBuilder { + private final String name; + private String dbName = "db1"; + private String schemaName = "schema1"; + private String tableName = "table1"; + private String offsetToken = "offset1"; + private Long channelSequencer = 0L; + private Long rowSequencer = 0L; + private String encryptionKey = "key"; + private Long encryptionKeyId = 0L; + private OpenChannelRequest.OnErrorOption onErrorOption = + OpenChannelRequest.OnErrorOption.CONTINUE; + + private ChannelBuilder(String name) { + this.name = name; + } + + ChannelBuilder setDBName(String dbName) { + this.dbName = dbName; + return this; + } + + ChannelBuilder setSchemaName(String schemaName) { + this.schemaName = schemaName; + return this; + } + + ChannelBuilder setTableName(String tableName) { + this.tableName = tableName; + return this; + } + + ChannelBuilder setOffsetToken(String offsetToken) { + this.offsetToken = offsetToken; + return this; + } + + ChannelBuilder setChannelSequencer(Long sequencer) { + this.channelSequencer = sequencer; + return this; + } + + ChannelBuilder setRowSequencer(Long sequencer) { + this.rowSequencer = sequencer; + return this; + } + + ChannelBuilder setEncryptionKey(String encryptionKey) { + this.encryptionKey = encryptionKey; + return this; + } + + ChannelBuilder setEncryptionKeyId(Long encryptionKeyId) { + this.encryptionKeyId = encryptionKeyId; + return this; + } + + SnowflakeStreamingIngestChannelInternal buildAndAdd() { + SnowflakeStreamingIngestChannelInternal channel = + createChannel( + name, + dbName, + schemaName, + tableName, + offsetToken, + channelSequencer, + rowSequencer, + encryptionKey, + encryptionKeyId, + onErrorOption, + ZoneOffset.UTC); + channels.put(name, channel); + channelCache.addChannel(channel); + return channel; + } + } + } + + private static class RowSetBuilder { + private final List> rows = new ArrayList<>(); + + private Map lastRow() { + return rows.get(rows.size() - 1); + } + + RowSetBuilder addColumn(String column, Object value) { + lastRow().put(column, value); + return this; + } + + RowSetBuilder newRow() { + if (rows.isEmpty() || !lastRow().isEmpty()) { + rows.add(new HashMap<>()); + } + return this; + } + + List> build() { + return rows; + } + + static RowSetBuilder newBuilder() { + return new RowSetBuilder().newRow(); + } + } + + private static class ParquetTestContext extends TestContext>> { + + SnowflakeStreamingIngestChannelInternal>> createChannel( + String name, + String dbName, + String schemaName, + String tableName, + String offsetToken, + Long channelSequencer, + Long rowSequencer, + String encryptionKey, + Long encryptionKeyId, + OpenChannelRequest.OnErrorOption onErrorOption, + ZoneId defaultTimezone) { + return new SnowflakeStreamingIngestChannelInternal<>( + name, + dbName, + schemaName, + tableName, + offsetToken, + channelSequencer, + rowSequencer, + client, + encryptionKey, + encryptionKeyId, + onErrorOption, + defaultTimezone, + Constants.BdecVersion.THREE, + null); + } + + @Override + public void close() {} + + static TestContextFactory>> createFactory() { + return new TestContextFactory>>("Parquet") { + @Override + TestContext>> create() { + return new ParquetTestContext(); + } + }; + } + } + + TestContextFactory>> testContextFactory; + + private SnowflakeStreamingIngestChannelInternal>> addChannel( + TestContext>> testContext, int tableId, long encryptionKeyId) { + return testContext + .channelBuilder("channel" + UUID.randomUUID()) + .setDBName("db1") + .setSchemaName("PUBLIC") + .setTableName("table" + tableId) + .setOffsetToken("offset1") + .setChannelSequencer(0L) + .setRowSequencer(0L) + .setEncryptionKey("key") + .setEncryptionKeyId(encryptionKeyId) + .buildAndAdd(); + } + + private SnowflakeStreamingIngestChannelInternal addChannel1(TestContext testContext) { + return testContext + .channelBuilder("channel1") + .setDBName("db1") + .setSchemaName("schema1") + .setTableName("table1") + .setOffsetToken("offset1") + .setChannelSequencer(0L) + .setRowSequencer(0L) + .setEncryptionKey("key") + .setEncryptionKeyId(1L) + .buildAndAdd(); + } + + private SnowflakeStreamingIngestChannelInternal addChannel2(TestContext testContext) { + return testContext + .channelBuilder("channel2") + .setDBName("db1") + .setSchemaName("schema1") + .setTableName("table1") + .setOffsetToken("offset2") + .setChannelSequencer(10L) + .setRowSequencer(100L) + .setEncryptionKey("key") + .setEncryptionKeyId(1L) + .buildAndAdd(); + } + + private SnowflakeStreamingIngestChannelInternal addChannel3(TestContext testContext) { + return testContext + .channelBuilder("channel3") + .setDBName("db2") + .setSchemaName("schema1") + .setTableName("table2") + .setOffsetToken("offset3") + .setChannelSequencer(0L) + .setRowSequencer(0L) + .setEncryptionKey("key3") + .setEncryptionKeyId(3L) + .buildAndAdd(); + } + + private SnowflakeStreamingIngestChannelInternal addChannel4(TestContext testContext) { + return testContext + .channelBuilder("channel4") + .setDBName("db1") + .setSchemaName("schema1") + .setTableName("table1") + .setOffsetToken("offset2") + .setChannelSequencer(10L) + .setRowSequencer(100L) + .setEncryptionKey("key4") + .setEncryptionKeyId(4L) + .buildAndAdd(); + } + + private static ColumnMetadata createTestIntegerColumn(String name) { + ColumnMetadata colInt = new ColumnMetadata(); + colInt.setOrdinal(1); + colInt.setName(name); + colInt.setPhysicalType("SB4"); + colInt.setNullable(true); + colInt.setLogicalType("FIXED"); + colInt.setPrecision(2); + colInt.setScale(0); + return colInt; + } + + private static ColumnMetadata createTestTextColumn(String name) { + ColumnMetadata colChar = new ColumnMetadata(); + colChar.setOrdinal(1); + colChar.setName(name); + colChar.setPhysicalType("LOB"); + colChar.setNullable(true); + colChar.setLogicalType("TEXT"); + colChar.setByteLength(14); + colChar.setLength(11); + colChar.setScale(0); + return colChar; + } + + private static ColumnMetadata createLargeTestTextColumn(String name) { + ColumnMetadata colChar = new ColumnMetadata(); + colChar.setOrdinal(1); + colChar.setName(name); + colChar.setPhysicalType("LOB"); + colChar.setNullable(true); + colChar.setLogicalType("TEXT"); + colChar.setByteLength(14000000); + colChar.setLength(11000000); + colChar.setScale(0); + return colChar; } @Test public void testGetFilePath() { - FlushService flushService = new FlushService(client, channelCache, stage, false); + TestContext testContext = testContextFactory.create(); + FlushService flushService = testContext.flushService; Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); String clientPrefix = "honk"; - String outputString = flushService.getFilePath(calendar, clientPrefix); + String outputString = flushService.getBlobPath(calendar, clientPrefix); Path outputPath = Paths.get(outputString); Assert.assertTrue(outputPath.getFileName().toString().contains(clientPrefix)); Assert.assertTrue( @@ -159,7 +422,9 @@ public void testGetFilePath() { @Test public void testFlush() throws Exception { - FlushService flushService = Mockito.spy(new FlushService(client, channelCache, stage, false)); + TestContext testContext = testContextFactory.create(); + FlushService flushService = testContext.flushService; + Mockito.when(flushService.isTestMode()).thenReturn(false); // Nothing to flush flushService.flush(false).get(); @@ -183,41 +448,263 @@ public void testFlush() throws Exception { Assert.assertTrue(flushService.lastFlushTime > 0); } + @Test + public void testBlobCreation() throws Exception { + TestContext testContext = testContextFactory.create(); + SnowflakeStreamingIngestChannelInternal channel1 = addChannel1(testContext); + SnowflakeStreamingIngestChannelInternal channel2 = addChannel2(testContext); + SnowflakeStreamingIngestChannelInternal channel4 = addChannel4(testContext); + String colName1 = "testBlobCreation1"; + String colName2 = "testBlobCreation2"; + + List schema = + Arrays.asList(createTestIntegerColumn(colName1), createTestTextColumn(colName2)); + channel1.getRowBuffer().setupSchema(schema); + channel2.getRowBuffer().setupSchema(schema); + channel4.getRowBuffer().setupSchema(schema); + + List> rows1 = + RowSetBuilder.newBuilder() + .addColumn(colName1, 11) + .addColumn(colName2, "bob") + .newRow() + .addColumn(colName1, 22) + .addColumn(colName2, "bob") + .build(); + + channel1.insertRows(rows1, "offset1"); + channel2.insertRows(rows1, "offset2"); + channel4.insertRows(rows1, "offset4"); + + FlushService flushService = testContext.flushService; + + // Force = true flushes + flushService.flush(true).get(); + Mockito.verify(flushService, Mockito.atLeast(2)).buildAndUpload(Mockito.any(), Mockito.any()); + } + + @Test + public void testBlobSplitDueToDifferentSchema() throws Exception { + TestContext testContext = testContextFactory.create(); + SnowflakeStreamingIngestChannelInternal channel1 = addChannel1(testContext); + SnowflakeStreamingIngestChannelInternal channel2 = addChannel2(testContext); + String colName1 = "testBlobSplitDueToDifferentSchema1"; + String colName2 = "testBlobSplitDueToDifferentSchema2"; + String colName3 = "testBlobSplitDueToDifferentSchema3"; + + List schema1 = + Arrays.asList(createTestIntegerColumn(colName1), createTestTextColumn(colName2)); + List schema2 = + Arrays.asList( + createTestIntegerColumn(colName1), + createTestTextColumn(colName2), + createTestIntegerColumn(colName3)); + channel1.getRowBuffer().setupSchema(schema1); + channel2.getRowBuffer().setupSchema(schema2); + + List> rows1 = + RowSetBuilder.newBuilder() + .addColumn(colName1, 11) + .addColumn(colName2, "bob") + .newRow() + .addColumn(colName1, 22) + .addColumn(colName2, "bob") + .build(); + + List> rows2 = + RowSetBuilder.newBuilder() + .addColumn(colName1, 11) + .addColumn(colName2, "bob") + .addColumn(colName3, 11) + .newRow() + .addColumn(colName1, 22) + .addColumn(colName2, "bob") + .addColumn(colName3, 22) + .build(); + + channel1.insertRows(rows1, "offset1"); + channel2.insertRows(rows2, "offset2"); + + FlushService flushService = testContext.flushService; + + // Force = true flushes + flushService.flush(true).get(); + Mockito.verify(flushService, Mockito.atLeast(2)).buildAndUpload(Mockito.any(), Mockito.any()); + } + + @Test + public void testBlobSplitDueToChunkSizeLimit() throws Exception { + TestContext testContext = testContextFactory.create(); + SnowflakeStreamingIngestChannelInternal channel1 = addChannel1(testContext); + SnowflakeStreamingIngestChannelInternal channel2 = addChannel2(testContext); + String colName1 = "testBlobSplitDueToChunkSizeLimit1"; + String colName2 = "testBlobSplitDueToChunkSizeLimit2"; + int rowSize = 10000000; + String largeData = new String(new char[rowSize]); + + List schema = + Arrays.asList(createTestIntegerColumn(colName1), createLargeTestTextColumn(colName2)); + channel1.getRowBuffer().setupSchema(schema); + channel2.getRowBuffer().setupSchema(schema); + + RowSetBuilder builder = RowSetBuilder.newBuilder(); + RowSetBuilder.newBuilder().addColumn(colName1, 11).addColumn(colName2, largeData); + + for (int idx = 0; idx <= MAX_CHUNK_SIZE_IN_BYTES_DEFAULT / (2 * rowSize); idx++) { + builder.addColumn(colName1, 11).addColumn(colName2, largeData).newRow(); + } + + List> rows = builder.build(); + + channel1.insertRows(rows, "offset1"); + channel2.insertRows(rows, "offset2"); + + FlushService flushService = testContext.flushService; + + // Force = true flushes + flushService.flush(true).get(); + Mockito.verify(flushService, Mockito.times(2)).buildAndUpload(Mockito.any(), Mockito.any()); + } + + @Test + public void testBlobSplitDueToNumberOfChunks() throws Exception { + for (int rowCount : Arrays.asList(0, 1, 30, 111, 159, 287, 1287, 1599, 4496)) { + runTestBlobSplitDueToNumberOfChunks(rowCount); + } + } + + /** + * Insert rows in batches of 3 into each table and assert that the expected number of blobs is + * generated. + * + * @param numberOfRows How many rows to insert + */ + public void runTestBlobSplitDueToNumberOfChunks(int numberOfRows) throws Exception { + int channelsPerTable = 3; + int expectedBlobs = + (int) + Math.ceil( + (double) numberOfRows + / channelsPerTable + / ParameterProvider.MAX_CHUNKS_IN_BLOB_AND_REGISTRATION_REQUEST_DEFAULT); + + final TestContext>> testContext = testContextFactory.create(); + + for (int i = 0; i < numberOfRows; i++) { + SnowflakeStreamingIngestChannelInternal>> channel = + addChannel(testContext, i / channelsPerTable, 1); + channel.setupSchema(Collections.singletonList(createLargeTestTextColumn("C1"))); + channel.insertRow(Collections.singletonMap("C1", i), ""); + } + + FlushService>> flushService = testContext.flushService; + flushService.flush(true).get(); + + ArgumentCaptor>>>>> blobDataCaptor = + ArgumentCaptor.forClass(List.class); + Mockito.verify(flushService, Mockito.times(expectedBlobs)) + .buildAndUpload(Mockito.any(), blobDataCaptor.capture()); + + // 1. list => blobs; 2. list => chunks; 3. list => channels; 4. list => rows, 5. list => columns + List>>>>> allUploadedBlobs = + blobDataCaptor.getAllValues(); + + Assert.assertEquals(numberOfRows, getRows(allUploadedBlobs).size()); + } + + @Test + public void testBlobSplitDueToNumberOfChunksWithLeftoverChannels() throws Exception { + final TestContext>> testContext = testContextFactory.create(); + + for (int i = 0; i < 99; i++) { // 19 simple chunks + SnowflakeStreamingIngestChannelInternal>> channel = + addChannel(testContext, i, 1); + channel.setupSchema(Collections.singletonList(createLargeTestTextColumn("C1"))); + channel.insertRow(Collections.singletonMap("C1", i), ""); + } + + // 20th chunk would contain multiple channels, but there are some with different encryption key + // ID, so they spill to a new blob + SnowflakeStreamingIngestChannelInternal>> channel1 = + addChannel(testContext, 99, 1); + channel1.setupSchema(Collections.singletonList(createLargeTestTextColumn("C1"))); + channel1.insertRow(Collections.singletonMap("C1", 0), ""); + + SnowflakeStreamingIngestChannelInternal>> channel2 = + addChannel(testContext, 99, 2); + channel2.setupSchema(Collections.singletonList(createLargeTestTextColumn("C1"))); + channel2.insertRow(Collections.singletonMap("C1", 0), ""); + + SnowflakeStreamingIngestChannelInternal>> channel3 = + addChannel(testContext, 99, 2); + channel3.setupSchema(Collections.singletonList(createLargeTestTextColumn("C1"))); + channel3.insertRow(Collections.singletonMap("C1", 0), ""); + + FlushService>> flushService = testContext.flushService; + flushService.flush(true).get(); + + ArgumentCaptor>>>>> blobDataCaptor = + ArgumentCaptor.forClass(List.class); + Mockito.verify(flushService, Mockito.atLeast(2)) + .buildAndUpload(Mockito.any(), blobDataCaptor.capture()); + + // 1. list => blobs; 2. list => chunks; 3. list => channels; 4. list => rows, 5. list => columns + List>>>>> allUploadedBlobs = + blobDataCaptor.getAllValues(); + + Assert.assertEquals(102, getRows(allUploadedBlobs).size()); + } + + private List> getRows(List>>>>> blobs) { + List> result = new ArrayList<>(); + blobs.forEach( + chunks -> + chunks.forEach( + channels -> + channels.forEach( + chunkData -> + result.addAll(((ParquetChunkData) chunkData.getVectors()).rows)))); + return result; + } + @Test public void testBuildAndUpload() throws Exception { - FlushService flushService = Mockito.spy(new FlushService(client, channelCache, stage, true)); - - List> blobData = new ArrayList<>(); - List chunkData = new ArrayList<>(); - - // Construct fields - ChannelData channel1Data = new ChannelData(); - ChannelData channel2Data = new ChannelData(); - - FieldVector vector1 = new VarCharVector("vector1", allocator); - FieldVector vector2 = new IntVector("vector2", allocator); - FieldVector vector3 = new VarCharVector("vector3", allocator); - FieldVector vector4 = new IntVector("vector4", allocator); - List vectorList1 = new ArrayList<>(); - vectorList1.add(vector1); - vectorList1.add(vector2); - List vectorList2 = new ArrayList<>(); - vectorList2.add(vector3); - vectorList2.add(vector4); - - VectorSchemaRoot vectorRoot1 = new VectorSchemaRoot(vectorList1); - vectorRoot1.setRowCount(2); - VectorSchemaRoot vectorRoot2 = new VectorSchemaRoot(vectorList2); - vectorRoot2.setRowCount(1); - - channel1Data.setVectors(vectorRoot1); - channel2Data.setVectors(vectorRoot2); + long expectedBuildLatencyMs = 100; + long expectedUploadLatencyMs = 200; + + TestContext testContext = testContextFactory.create(); + SnowflakeStreamingIngestChannelInternal channel1 = addChannel1(testContext); + SnowflakeStreamingIngestChannelInternal channel2 = addChannel2(testContext); + String colName1 = "testBuildAndUpload1"; + String colName2 = "testBuildAndUpload2"; + + List schema = + Arrays.asList(createTestIntegerColumn(colName1), createTestTextColumn(colName2)); + channel1.getRowBuffer().setupSchema(schema); + channel2.getRowBuffer().setupSchema(schema); + + List> rows1 = + RowSetBuilder.newBuilder() + .addColumn(colName1, 11) + .addColumn(colName2, "bob") + .newRow() + .addColumn(colName1, 22) + .addColumn(colName2, "bob") + .build(); + List> rows2 = + RowSetBuilder.newBuilder().addColumn(colName1, null).addColumn(colName2, "toby").build(); + + channel1.insertRows(rows1, "offset1"); + channel2.insertRows(rows2, "offset2"); + + ChannelData channel1Data = testContext.flushChannel(channel1.getName()); + ChannelData channel2Data = testContext.flushChannel(channel2.getName()); Map eps1 = new HashMap<>(); Map eps2 = new HashMap<>(); - RowBufferStats stats1 = new RowBufferStats(); - RowBufferStats stats2 = new RowBufferStats(); + RowBufferStats stats1 = new RowBufferStats("COL1"); + RowBufferStats stats2 = new RowBufferStats("COL1"); eps1.put("one", stats1); eps2.put("one", stats2); @@ -231,60 +718,49 @@ public void testBuildAndUpload() throws Exception { channel1Data.setColumnEps(eps1); channel2Data.setColumnEps(eps2); - chunkData.add(channel1Data); - chunkData.add(channel2Data); - blobData.add(chunkData); - - // Populate test row data - ((VarCharVector) vector1).setSafe(0, new Text("alice")); - ((VarCharVector) vector1).setSafe(1, new Text("bob")); - ((IntVector) vector2).setSafe(0, 11); - ((IntVector) vector2).setSafe(1, 22); - - ((VarCharVector) vector3).setSafe(0, new Text("toby")); - ((IntVector) vector4).setNull(0); - - vector1.setValueCount(2); - vector2.setValueCount(2); - vector3.setValueCount(1); - vector4.setValueCount(1); - channel1Data.setRowSequencer(0L); - channel1Data.setOffsetToken("offset1"); channel1Data.setBufferSize(100); - channel1Data.setChannel(channel1); - channel2Data.setRowSequencer(10L); - channel2Data.setOffsetToken("offset2"); channel2Data.setBufferSize(100); - channel2Data.setChannel(channel2); - BlobMetadata blobMetadata = flushService.buildAndUpload("file_name", blobData); + // set client timers + + SnowflakeStreamingIngestClientInternal client = testContext.client; + client.buildLatency = this.setupTimer(expectedBuildLatencyMs); + client.uploadLatency = this.setupTimer(expectedUploadLatencyMs); + client.uploadThroughput = Mockito.mock(Meter.class); + client.blobSizeHistogram = Mockito.mock(Histogram.class); + client.blobRowCountHistogram = Mockito.mock(Histogram.class); + + BlobMetadata blobMetadata = testContext.buildAndUpload(); EpInfo expectedChunkEpInfo = - ArrowRowBuffer.buildEpInfoFromStats(3, ChannelData.getCombinedColumnStatsMap(eps1, eps2)); + AbstractRowBuffer.buildEpInfoFromStats( + 3, ChannelData.getCombinedColumnStatsMap(eps1, eps2)); ChannelMetadata expectedChannel1Metadata = ChannelMetadata.builder() - .setOwningChannel(channel1) + .setOwningChannelFromContext(channel1.getChannelContext()) .setRowSequencer(1L) .setOffsetToken("offset1") .build(); ChannelMetadata expectedChannel2Metadata = ChannelMetadata.builder() - .setOwningChannel(channel2) + .setOwningChannelFromContext(channel2.getChannelContext()) .setRowSequencer(10L) .setOffsetToken("offset2") .build(); ChunkMetadata expectedChunkMetadata = ChunkMetadata.builder() - .setOwningTable(channel1) + .setOwningTableFromChannelContext(channel1.getChannelContext()) .setChunkStartOffset(0L) .setChunkLength(248) .setChannelList(Arrays.asList(expectedChannel1Metadata, expectedChannel2Metadata)) .setChunkMD5("md5") .setEncryptionKeyId(1234L) .setEpInfo(expectedChunkEpInfo) + .setFirstInsertTimeInMs(1L) + .setLastInsertTimeInMs(2L) .build(); // Check FlushService.upload called with correct arguments @@ -292,14 +768,20 @@ public void testBuildAndUpload() throws Exception { final ArgumentCaptor blobCaptor = ArgumentCaptor.forClass(byte[].class); final ArgumentCaptor> metadataCaptor = ArgumentCaptor.forClass(List.class); - Mockito.verify(flushService) - .upload(nameCaptor.capture(), blobCaptor.capture(), metadataCaptor.capture()); + Mockito.verify(testContext.flushService) + .upload( + nameCaptor.capture(), + blobCaptor.capture(), + metadataCaptor.capture(), + ArgumentMatchers.any()); Assert.assertEquals("file_name", nameCaptor.getValue()); ChunkMetadata metadataResult = metadataCaptor.getValue().get(0); List channelMetadataResult = metadataResult.getChannels(); Assert.assertEquals(BlobBuilder.computeMD5(blobCaptor.getValue()), blobMetadata.getMD5()); + Assert.assertEquals(expectedBuildLatencyMs, blobMetadata.getBlobStats().getBuildDurationMs()); + Assert.assertEquals(expectedUploadLatencyMs, blobMetadata.getBlobStats().getUploadDurationMs()); Assert.assertEquals( expectedChunkEpInfo.getRowCount(), metadataResult.getEpInfo().getRowCount()); @@ -316,12 +798,12 @@ public void testBuildAndUpload() throws Exception { Assert.assertEquals(2, metadataResult.getChannels().size()); // Two channels on the table Assert.assertEquals("channel1", channelMetadataResult.get(0).getChannelName()); - Assert.assertEquals("offset1", channelMetadataResult.get(0).getOffsetToken()); + Assert.assertEquals("offset1", channelMetadataResult.get(0).getEndOffsetToken()); Assert.assertEquals(0L, (long) channelMetadataResult.get(0).getRowSequencer()); Assert.assertEquals(0L, (long) channelMetadataResult.get(0).getClientSequencer()); Assert.assertEquals("channel2", channelMetadataResult.get(1).getChannelName()); - Assert.assertEquals("offset2", channelMetadataResult.get(1).getOffsetToken()); + Assert.assertEquals("offset2", channelMetadataResult.get(1).getEndOffsetToken()); Assert.assertEquals(10L, (long) channelMetadataResult.get(1).getRowSequencer()); Assert.assertEquals(10L, (long) channelMetadataResult.get(1).getClientSequencer()); @@ -333,66 +815,41 @@ public void testBuildAndUpload() throws Exception { (int) (metadataResult.getChunkStartOffset() + metadataResult.getChunkLength()))); Assert.assertEquals(md5, metadataResult.getChunkMD5()); - try { - // Close allocator to make sure no memory leak - allocator.close(); - } catch (Exception e) { - Assert.fail(String.format("Allocator close failure. Caused by %s", e.getMessage())); - } + testContext.close(); } @Test public void testBuildErrors() throws Exception { - // build should error if we try to group channels for different tables together - FlushService flushService = Mockito.spy(new FlushService(client, channelCache, stage, true)); - - List> channelData = new ArrayList<>(); - List channelData1 = new ArrayList<>(); + TestContext testContext = testContextFactory.create(); + SnowflakeStreamingIngestChannelInternal channel1 = addChannel1(testContext); + SnowflakeStreamingIngestChannelInternal channel3 = addChannel3(testContext); + String colName1 = "testBuildErrors1"; + String colName2 = "testBuildErrors2"; - // Construct fields - ChannelData data1 = new ChannelData(); - ChannelData data2 = new ChannelData(); + List schema = + Arrays.asList(createTestIntegerColumn(colName1), createTestTextColumn(colName2)); + channel1.getRowBuffer().setupSchema(schema); + channel3.getRowBuffer().setupSchema(schema); - FieldVector vector1 = new VarCharVector("vector1", allocator); - FieldVector vector3 = new IntVector("vector3", allocator); - vector1.allocateNew(); - vector3.allocateNew(); - List vectorList1 = new ArrayList<>(); - vectorList1.add(vector1); - List vectorList2 = new ArrayList<>(); - vectorList1.add(vector3); + List> rows1 = + RowSetBuilder.newBuilder().addColumn(colName1, 0).addColumn(colName2, "alice").build(); + List> rows2 = + RowSetBuilder.newBuilder().addColumn(colName1, 0).addColumn(colName2, 111).build(); - VectorSchemaRoot vectorRoot1 = new VectorSchemaRoot(vectorList1); - vectorRoot1.setRowCount(2); - VectorSchemaRoot vectorRoot2 = new VectorSchemaRoot(vectorList2); - vectorRoot2.setRowCount(1); + channel1.insertRows(rows1, "offset1"); + channel3.insertRows(rows2, "offset2"); - data1.setVectors(vectorRoot1); - data2.setVectors(vectorRoot2); - - channelData1.add(data1); - channelData1.add(data2); - channelData.add(channelData1); - - // Populate test row data - ((VarCharVector) vector1).setSafe(0, new Text("alice")); - ((IntVector) vector3).setSafe(0, 111); - - vector1.setValueCount(2); - vector3.setValueCount(3); + ChannelData data1 = testContext.flushChannel(channel1.getName()); + ChannelData data2 = testContext.flushChannel(channel3.getName()); data1.setRowSequencer(0L); - data1.setOffsetToken("offset1"); data1.setBufferSize(100); - data1.setChannel(channel1); data2.setRowSequencer(10L); - data2.setOffsetToken("offset3"); data2.setBufferSize(100); - data2.setChannel(channel3); try { - flushService.buildAndUpload("file_name", channelData); + testContext.buildAndUpload(); Assert.fail("Expected SFException"); } catch (SFException err) { Assert.assertEquals(ErrorCode.INVALID_DATA_IN_CHUNK.getMessageCode(), err.getVendorCode()); @@ -400,16 +857,16 @@ public void testBuildErrors() throws Exception { } @Test - public void testInvalidateChannels() throws Exception { + public void testInvalidateChannels() { // Create a new Client in order to not interfere with other tests - SnowflakeStreamingIngestClientInternal client = + SnowflakeStreamingIngestClientInternal client = Mockito.mock(SnowflakeStreamingIngestClientInternal.class); ParameterProvider parameterProvider = new ParameterProvider(); - ChannelCache channelCache = new ChannelCache(); + ChannelCache channelCache = new ChannelCache<>(); Mockito.when(client.getChannelCache()).thenReturn(channelCache); Mockito.when(client.getParameterProvider()).thenReturn(parameterProvider); - SnowflakeStreamingIngestChannelInternal channel1 = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestChannelInternal channel1 = + new SnowflakeStreamingIngestChannelInternal<>( "channel1", "db1", "schema1", @@ -421,10 +878,10 @@ public void testInvalidateChannels() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + ZoneOffset.UTC); - SnowflakeStreamingIngestChannelInternal channel2 = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestChannelInternal channel2 = + new SnowflakeStreamingIngestChannelInternal<>( "channel2", "db1", "schema1", @@ -436,40 +893,29 @@ public void testInvalidateChannels() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + ZoneOffset.UTC); channelCache.addChannel(channel1); channelCache.addChannel(channel2); - List> blobData = new ArrayList<>(); - List innerData = new ArrayList<>(); + List>> blobData = new ArrayList<>(); + List> innerData = new ArrayList<>(); blobData.add(innerData); - ChannelData channel1Data = new ChannelData(); - ChannelData channel2Data = new ChannelData(); - channel1Data.setChannel(channel1); - channel2Data.setChannel(channel1); - - FieldVector vector1 = new VarCharVector("vector1", allocator); - FieldVector vector2 = new IntVector("vector2", allocator); - FieldVector vector3 = new VarCharVector("vector3", allocator); - FieldVector vector4 = new IntVector("vector4", allocator); - List vectorList1 = new ArrayList<>(); - vectorList1.add(vector1); - vectorList1.add(vector2); - List vectorList2 = new ArrayList<>(); - vectorList2.add(vector3); - vectorList2.add(vector4); - - VectorSchemaRoot vectorRoot1 = new VectorSchemaRoot(vectorList1); - VectorSchemaRoot vectorRoot2 = new VectorSchemaRoot(vectorList2); - - channel1Data.setVectors(vectorRoot1); - channel2Data.setVectors(vectorRoot2); + ChannelData channel1Data = new ChannelData<>(); + ChannelData channel2Data = new ChannelData<>(); + channel1Data.setChannelContext(channel1.getChannelContext()); + channel2Data.setChannelContext(channel1.getChannelContext()); + + channel1Data.setVectors(new StubChunkData()); + channel2Data.setVectors(new StubChunkData()); innerData.add(channel1Data); innerData.add(channel2Data); - FlushService flushService = new FlushService(client, channelCache, stage, false); + StreamingIngestStage stage = Mockito.mock(StreamingIngestStage.class); + Mockito.when(stage.getClientPrefix()).thenReturn("client_prefix"); + FlushService flushService = + new FlushService<>(client, channelCache, stage, false); flushService.invalidateAllChannelsInBlob(blobData); Assert.assertFalse(channel1.isValid()); @@ -478,6 +924,9 @@ public void testInvalidateChannels() throws Exception { @Test public void testBlobBuilder() throws Exception { + TestContext testContext = testContextFactory.create(); + SnowflakeStreamingIngestChannelInternal channel1 = addChannel1(testContext); + ObjectMapper mapper = new ObjectMapper(); List chunksMetadataList = new ArrayList<>(); List chunksDataList = new ArrayList<>(); @@ -489,34 +938,39 @@ public void testBlobBuilder() throws Exception { Map eps1 = new HashMap<>(); - RowBufferStats stats1 = new RowBufferStats(); + RowBufferStats stats1 = new RowBufferStats("COL1"); eps1.put("one", stats1); stats1.addIntValue(new BigInteger("10")); stats1.addIntValue(new BigInteger("15")); - EpInfo epInfo = ArrowRowBuffer.buildEpInfoFromStats(2, eps1); + EpInfo epInfo = AbstractRowBuffer.buildEpInfoFromStats(2, eps1); ChannelMetadata channelMetadata = ChannelMetadata.builder() - .setOwningChannel(channel1) + .setOwningChannelFromContext(channel1.getChannelContext()) .setRowSequencer(0L) .setOffsetToken("offset1") .build(); ChunkMetadata chunkMetadata = ChunkMetadata.builder() - .setOwningTable(channel1) + .setOwningTableFromChannelContext(channel1.getChannelContext()) .setChunkStartOffset(0L) .setChunkLength(dataSize) - .setChannelList(Arrays.asList(channelMetadata)) + .setUncompressedChunkLength(dataSize * 2) + .setChannelList(Collections.singletonList(channelMetadata)) .setChunkMD5("md5") .setEncryptionKeyId(1234L) .setEpInfo(epInfo) + .setFirstInsertTimeInMs(1L) + .setLastInsertTimeInMs(2L) .build(); chunksMetadataList.add(chunkMetadata); - byte[] blob = BlobBuilder.build(chunksMetadataList, chunksDataList, checksum, dataSize); + final Constants.BdecVersion bdecVersion = ParameterProvider.BLOB_FORMAT_VERSION_DEFAULT; + byte[] blob = + BlobBuilder.buildBlob(chunksMetadataList, chunksDataList, checksum, dataSize, bdecVersion); // Read the blob byte array back to valid the behavior InputStream input = new ByteArrayInputStream(blob); @@ -528,7 +982,7 @@ public void testBlobBuilder() throws Exception { Arrays.copyOfRange(blob, offset, offset += BLOB_TAG_SIZE_IN_BYTES), StandardCharsets.UTF_8)); Assert.assertEquals( - BLOB_FORMAT_VERSION, + bdecVersion.toByte(), Arrays.copyOfRange(blob, offset, offset += BLOB_VERSION_SIZE_IN_BYTES)[0]); long totalSize = ByteBuffer.wrap(Arrays.copyOfRange(blob, offset, offset += BLOB_FILE_SIZE_SIZE_IN_BYTES)) @@ -554,6 +1008,9 @@ public void testBlobBuilder() throws Exception { Assert.assertEquals(chunkMetadata.getTableName(), map.get("table")); Assert.assertEquals(chunkMetadata.getSchemaName(), map.get("schema")); Assert.assertEquals(chunkMetadata.getDBName(), map.get("database")); + Assert.assertEquals(chunkMetadata.getChunkLength(), map.get("chunk_length")); + Assert.assertEquals( + chunkMetadata.getUncompressedChunkLength(), map.get("chunk_length_uncompressed")); Assert.assertEquals( Long.toString(chunkMetadata.getChunkStartOffset() - offset), map.get("chunk_start_offset").toString()); @@ -569,7 +1026,8 @@ public void testBlobBuilder() throws Exception { @Test public void testShutDown() throws Exception { - FlushService flushService = new FlushService(client, channelCache, stage, false); + TestContext testContext = testContextFactory.create(); + FlushService flushService = testContext.flushService; Assert.assertFalse(flushService.buildUploadWorkers.isShutdown()); Assert.assertFalse(flushService.registerWorker.isShutdown()); @@ -596,4 +1054,13 @@ public void testEncryptionDecryption() Assert.assertArrayEquals(data, decryptedData); } + + private Timer setupTimer(long expectedLatencyMs) { + Timer.Context timerContext = Mockito.mock(Timer.Context.class); + Mockito.when(timerContext.stop()).thenReturn(TimeUnit.MILLISECONDS.toNanos(expectedLatencyMs)); + Timer timer = Mockito.mock(Timer.class); + Mockito.when(timer.time()).thenReturn(timerContext); + + return timer; + } } diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/ManyTablesIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/ManyTablesIT.java new file mode 100644 index 000000000..d32adfe3f --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/ManyTablesIT.java @@ -0,0 +1,98 @@ +package net.snowflake.ingest.streaming.internal; + +import static net.snowflake.ingest.utils.Constants.ROLE; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Collections; +import java.util.Map; +import java.util.Properties; +import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClient; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClientFactory; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.ParameterProvider; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +/** + * Verified that ingestion work when we ingest into large number of tables from the same client and + * blobs and registration requests have to be cut, so they don't contain large number of chunks + */ +public class ManyTablesIT { + + private static final int TABLES_COUNT = 20; + private static final int TOTAL_ROWS_COUNT = 200_000; + private String dbName; + private SnowflakeStreamingIngestClient client; + private Connection connection; + private SnowflakeStreamingIngestChannel[] channels; + private String[] offsetTokensPerChannel; + + @Before + public void setUp() throws Exception { + Properties props = TestUtils.getProperties(Constants.BdecVersion.THREE, false); + props.put(ParameterProvider.MAX_CHUNKS_IN_BLOB_AND_REGISTRATION_REQUEST, 2); + if (props.getProperty(ROLE).equals("DEFAULT_ROLE")) { + props.setProperty(ROLE, "ACCOUNTADMIN"); + } + client = SnowflakeStreamingIngestClientFactory.builder("client1").setProperties(props).build(); + connection = TestUtils.getConnection(true); + dbName = String.format("sdk_it_many_tables_db_%d", System.nanoTime()); + + channels = new SnowflakeStreamingIngestChannel[TABLES_COUNT]; + offsetTokensPerChannel = new String[TABLES_COUNT]; + connection.createStatement().execute(String.format("create database %s;", dbName)); + + String[] tableNames = new String[TABLES_COUNT]; + for (int i = 0; i < tableNames.length; i++) { + tableNames[i] = String.format("table_%d", i); + connection.createStatement().execute(String.format("create table table_%d(c int);", i)); + channels[i] = + client.openChannel( + OpenChannelRequest.builder(String.format("channel-%d", i)) + .setDBName(dbName) + .setSchemaName("public") + .setTableName(tableNames[i]) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.ABORT) + .build()); + } + } + + @After + public void tearDown() throws Exception { + connection.createStatement().execute(String.format("drop database %s;", dbName)); + client.close(); + connection.close(); + } + + @Test + public void testIngestionIntoManyTables() throws InterruptedException, SQLException { + for (int i = 0; i < TOTAL_ROWS_COUNT; i++) { + Map row = Collections.singletonMap("c", i); + String offset = String.valueOf(i); + int channelId = i % channels.length; + channels[channelId].insertRow(row, offset); + offsetTokensPerChannel[channelId] = offset; + } + + for (int i = 0; i < channels.length; i++) { + TestUtils.waitForOffset(channels[i], offsetTokensPerChannel[i]); + } + + int totalRowsCount = 0; + ResultSet rs = + connection + .createStatement() + .executeQuery(String.format("show tables in database %s;", dbName)); + while (rs.next()) { + totalRowsCount += rs.getInt("rows"); + } + Assert.assertEquals(TOTAL_ROWS_COUNT, totalRowsCount); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/OAuthBasicTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/OAuthBasicTest.java new file mode 100644 index 000000000..614f606a9 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/OAuthBasicTest.java @@ -0,0 +1,121 @@ +package net.snowflake.ingest.streaming.internal; + +import java.util.Properties; +import java.util.UUID; +import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.connection.MockOAuthClient; +import net.snowflake.ingest.connection.OAuthManager; +import net.snowflake.ingest.connection.RequestBuilder; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClient; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClientFactory; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; +import org.junit.Assert; +import org.junit.Test; + +/** + * This test only contains basic construction of client using OAuth authentication. Further + * integration test would be added in dew. + */ +public class OAuthBasicTest { + + /** Create client with invalid authorization type, this should fail. */ + @Test + public void invalidAuthType() throws Exception { + Properties props = TestUtils.getProperties(Constants.BdecVersion.THREE, false); + props.put(Constants.AUTHORIZATION_TYPE, "INVALID_AUTH_TYPE"); + SFException e = + Assert.assertThrows( + SFException.class, + () -> + SnowflakeStreamingIngestClientFactory.builder("MY_CLIENT") + .setProperties(props) + .build()); + Assert.assertEquals(e.getVendorCode(), ErrorCode.INVALID_CONFIG_PARAMETER.getMessageCode()); + } + + /** Create client with missing config, this should fail. */ + @Test + public void missingOAuthParam() throws Exception { + Properties props = TestUtils.getProperties(Constants.BdecVersion.THREE, false); + props.put(Constants.AUTHORIZATION_TYPE, Constants.OAUTH); + + // Missing oauth_client_id + SFException e = + Assert.assertThrows( + SFException.class, + () -> + SnowflakeStreamingIngestClientFactory.builder("MY_CLIENT") + .setProperties(props) + .build()); + Assert.assertEquals( + e.getMessage(), + new SFException(ErrorCode.MISSING_CONFIG, Constants.OAUTH_CLIENT_ID).getMessage()); + + // Missing oauth_client_secret + props.put(Constants.OAUTH_CLIENT_ID, "MOCK_CLIENT_ID"); + e = + Assert.assertThrows( + SFException.class, + () -> + SnowflakeStreamingIngestClientFactory.builder("MY_CLIENT") + .setProperties(props) + .build()); + Assert.assertEquals( + e.getMessage(), + new SFException(ErrorCode.MISSING_CONFIG, Constants.OAUTH_CLIENT_SECRET).getMessage()); + + // Missing oauth_refresh_token + props.put(Constants.OAUTH_CLIENT_SECRET, "MOCK_CLIENT_SECRET"); + e = + Assert.assertThrows( + SFException.class, + () -> + SnowflakeStreamingIngestClientFactory.builder("MY_CLIENT") + .setProperties(props) + .build()); + Assert.assertEquals( + e.getMessage(), + new SFException(ErrorCode.MISSING_CONFIG, Constants.OAUTH_REFRESH_TOKEN).getMessage()); + } + + /** Create client with mock credential, should fail when refreshing token */ + @Test(expected = SecurityException.class) + public void testCreateOAuthClient() throws Exception { + Properties props = TestUtils.getProperties(Constants.BdecVersion.THREE, false); + props.remove(Constants.PRIVATE_KEY); + props.put(Constants.AUTHORIZATION_TYPE, Constants.OAUTH); + props.put(Constants.OAUTH_CLIENT_ID, "MOCK_CLIENT_ID"); + props.put(Constants.OAUTH_CLIENT_SECRET, "MOCK_CLIENT_SECRET"); + props.put(Constants.OAUTH_REFRESH_TOKEN, "MOCK_REFRESH_TOKEN"); + SnowflakeStreamingIngestClient client = + SnowflakeStreamingIngestClientFactory.builder("MY_CLIENT").setProperties(props).build(); + } + + @Test + public void testSetRefreshToken() throws Exception { + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>("TEST_CLIENT"); + MockOAuthClient mockOAuthClient = new MockOAuthClient(); + + OAuthManager oAuthManager = + new OAuthManager(TestUtils.getAccount(), TestUtils.getUser(), mockOAuthClient, 0.8); + RequestBuilder requestBuilder = + new RequestBuilder( + "MOCK_ACCOUNTNAME", + "MOCK_USERNAME", + "MOCK_CREDENTIAL", + "https", + "MOCK_HOST_NAME", + 443, + null, + oAuthManager, + null, + null); + client.injectRequestBuilder(requestBuilder); + + String newToken = UUID.randomUUID().toString(); + client.setRefreshToken(newToken); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/OpenManyChannelsIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/OpenManyChannelsIT.java new file mode 100644 index 000000000..b27a76396 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/OpenManyChannelsIT.java @@ -0,0 +1,194 @@ +package net.snowflake.ingest.streaming.internal; + +import static net.snowflake.ingest.utils.Constants.ROLE; + +import java.sql.Connection; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClient; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClientFactory; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.SFException; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +/** Tries to open several thousand channels into the same table from multiple threads in parallel */ +@Ignore("Will be reimplemented in dew: SNOW-807102") +public class OpenManyChannelsIT { + private static final int THREAD_COUNT = 20; + private static final int CHANNELS_PER_THREAD = 250; + private static final String SCHEMA_NAME = "PUBLIC"; + private static final String TABLE_NAME = "T1"; + + private String databaseName; + + private Connection conn; + + private SnowflakeStreamingIngestClient client; + + @Before + public void setUp() throws Exception { + databaseName = + String.format("SDK_DATATYPE_COMPATIBILITY_IT_%s", RandomStringUtils.randomNumeric(9)); + conn = TestUtils.getConnection(true); + conn.createStatement().execute(String.format("create or replace database %s;", databaseName)); + conn.createStatement() + .execute( + String.format( + "create or replace table %s.%s.%s (col int)", + databaseName, SCHEMA_NAME, TABLE_NAME)); + Properties props = TestUtils.getProperties(Constants.BdecVersion.THREE, false); + if (props.getProperty(ROLE).equals("DEFAULT_ROLE")) { + props.setProperty(ROLE, "ACCOUNTADMIN"); + } + client = SnowflakeStreamingIngestClientFactory.builder("client1").setProperties(props).build(); + } + + /** + * Reopens the same channel from multiple threads, asserts each channel has a unique client + * sequencer. At the end it verifies that only the channel with highest client sequencer can + * ingest data + */ + @Test + public void reopenSameChannel() throws Exception { + String channelName = "CHANNEL"; + List threads = new ArrayList<>(); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + ConcurrentHashMap clientSequencerToChannelMap = + new ConcurrentHashMap<>(); + + for (int i = 0; i < THREAD_COUNT; i++) { + Thread t = + new Thread( + () -> { + for (int j = 0; j < CHANNELS_PER_THREAD; j++) { + OpenChannelRequest openChannelRequest = + OpenChannelRequest.builder(channelName) + .setDBName(databaseName) + .setSchemaName(SCHEMA_NAME) + .setTableName(TABLE_NAME) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.ABORT) + .build(); + try { + SnowflakeStreamingIngestChannel channel = + client.openChannel(openChannelRequest); + Long channelSequencer = + ((SnowflakeStreamingIngestChannelInternal) channel) + .getChannelSequencer(); + clientSequencerToChannelMap.put(channelSequencer.intValue(), channel); + } catch (Exception e) { + exceptions.add(e); + break; + } + } + }); + t.start(); + threads.add(t); + } + + for (Thread t : threads) { + t.join(); + } + + if (!exceptions.isEmpty()) { + for (Exception e : exceptions) { + e.printStackTrace(); + } + Assert.fail(String.format("Exceptions thrown: %d", exceptions.size())); + } + + // Verify that each reopened channel received its own sequencer + Assert.assertEquals(THREAD_COUNT * CHANNELS_PER_THREAD, clientSequencerToChannelMap.size()); + + // Verify that ingestion into to the channel with the highest client sequencer works + int highestSequencer = + clientSequencerToChannelMap.keySet().stream().max(Comparator.naturalOrder()).get(); + Assert.assertEquals(THREAD_COUNT * CHANNELS_PER_THREAD - 1, highestSequencer); + String offsetToken = UUID.randomUUID().toString(); + SnowflakeStreamingIngestChannel lastOpenedChannel = + clientSequencerToChannelMap.get(highestSequencer); + lastOpenedChannel.insertRow(new HashMap<>(), offsetToken); + TestUtils.waitForOffset(lastOpenedChannel, offsetToken); + + // Verify that ingestion into all other channels does not work + for (Map.Entry entry : + clientSequencerToChannelMap.entrySet()) { + if (entry.getKey() != highestSequencer) { + try { + entry.getValue().insertRow(new HashMap<>(), UUID.randomUUID().toString()); + Assert.fail("Ingestion into channel with non-latest client sequencer should fail"); + } catch (SFException e) { + // all good, expected exception has been thrown + } + } + } + } + + /** Opens many channels in parallel, checks that each has client sequencer 0 */ + @Test + public void testOpenManyDifferentChannels() throws Exception { + List threads = new ArrayList<>(); + List exceptions = Collections.synchronizedList(new ArrayList<>()); + for (int i = 0; i < THREAD_COUNT; i++) { + final int threadId = i; + Thread t = + new Thread( + () -> { + for (int j = 0; j < CHANNELS_PER_THREAD; j++) { + OpenChannelRequest openChannelRequest = + OpenChannelRequest.builder(String.format("CHANNEL-%s-%s", threadId, j)) + .setDBName(databaseName) + .setSchemaName(SCHEMA_NAME) + .setTableName(TABLE_NAME) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.ABORT) + .build(); + try { + SnowflakeStreamingIngestChannel channel = + client.openChannel(openChannelRequest); + Long channelSequencer = + ((SnowflakeStreamingIngestChannelInternal) channel) + .getChannelSequencer(); + Assert.assertEquals(0L, channelSequencer.longValue()); + } catch (Exception e) { + exceptions.add(e); + break; + } + } + }); + t.start(); + threads.add(t); + } + + for (Thread t : threads) { + t.join(); + } + + if (!exceptions.isEmpty()) { + for (Exception e : exceptions) { + e.printStackTrace(); + } + Assert.fail(String.format("Exceptions thrown: %d", exceptions.size())); + } + } + + @After + public void tearDown() throws Exception { + conn.createStatement().execute(String.format("drop database %s;", databaseName)); + client.close(); + conn.close(); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/ParameterProviderTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/ParameterProviderTest.java index 292ef36d2..86cece9c7 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/ParameterProviderTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/ParameterProviderTest.java @@ -1,41 +1,65 @@ package net.snowflake.ingest.streaming.internal; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Properties; +import net.snowflake.ingest.utils.Constants; import net.snowflake.ingest.utils.ParameterProvider; import org.junit.Assert; import org.junit.Test; public class ParameterProviderTest { + private Map getStartingParameterMap() { + Map parameterMap = new HashMap<>(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, 1000L); + parameterMap.put(ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS, 4L); + parameterMap.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE, 6); + parameterMap.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_BYTES, 1024); + parameterMap.put(ParameterProvider.INSERT_THROTTLE_INTERVAL_IN_MILLIS, 7L); + parameterMap.put(ParameterProvider.IO_TIME_CPU_RATIO, 10); + parameterMap.put(ParameterProvider.BLOB_UPLOAD_MAX_RETRY_COUNT, 100); + parameterMap.put(ParameterProvider.MAX_MEMORY_LIMIT_IN_BYTES, 1000L); + parameterMap.put(ParameterProvider.MAX_CHANNEL_SIZE_IN_BYTES, 1000000L); + parameterMap.put(ParameterProvider.BDEC_PARQUET_COMPRESSION_ALGORITHM, "gzip"); + return parameterMap; + } + @Test public void withValuesSet() { Properties prop = new Properties(); - Map parameterMap = new HashMap<>(); - parameterMap.put(ParameterProvider.BUFFER_FLUSH_INTERVAL_IN_MILLIS_MAP_KEY, 3L); - parameterMap.put(ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_MAP_KEY, 4L); - parameterMap.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_MAP_KEY, 6L); - parameterMap.put(ParameterProvider.INSERT_THROTTLE_INTERVAL_IN_MILLIS_MAP_KEY, 7L); + Map parameterMap = getStartingParameterMap(); ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); - Assert.assertEquals(3, parameterProvider.getBufferFlushIntervalInMs()); - Assert.assertEquals(4, parameterProvider.getBufferFlushCheckIntervalInMs()); + Assert.assertEquals(1000L, parameterProvider.getCachedMaxClientLagInMs()); + Assert.assertEquals(4L, parameterProvider.getBufferFlushCheckIntervalInMs()); Assert.assertEquals(6, parameterProvider.getInsertThrottleThresholdInPercentage()); - Assert.assertEquals(7, parameterProvider.getInsertThrottleIntervalInMs()); + Assert.assertEquals(1024, parameterProvider.getInsertThrottleThresholdInBytes()); + Assert.assertEquals(7L, parameterProvider.getInsertThrottleIntervalInMs()); + Assert.assertEquals(10, parameterProvider.getIOTimeCpuRatio()); + Assert.assertEquals(100, parameterProvider.getBlobUploadMaxRetryCount()); + Assert.assertEquals(1000L, parameterProvider.getMaxMemoryLimitInBytes()); + Assert.assertEquals(1000000L, parameterProvider.getMaxChannelSizeInBytes()); + Assert.assertEquals( + Constants.BdecParquetCompression.GZIP, + parameterProvider.getBdecParquetCompressionAlgorithm()); } @Test public void withNullProps() { Map parameterMap = new HashMap<>(); - parameterMap.put(ParameterProvider.BUFFER_FLUSH_INTERVAL_IN_MILLIS_MAP_KEY, 3L); - parameterMap.put(ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_MAP_KEY, 4L); - parameterMap.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_MAP_KEY, 6L); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, 3000L); + parameterMap.put(ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS, 4L); + parameterMap.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE, 6); + parameterMap.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_BYTES, 1024); ParameterProvider parameterProvider = new ParameterProvider(parameterMap, null); - Assert.assertEquals(3, parameterProvider.getBufferFlushIntervalInMs()); + Assert.assertEquals(3000, parameterProvider.getCachedMaxClientLagInMs()); Assert.assertEquals(4, parameterProvider.getBufferFlushCheckIntervalInMs()); Assert.assertEquals(6, parameterProvider.getInsertThrottleThresholdInPercentage()); + Assert.assertEquals(1024, parameterProvider.getInsertThrottleThresholdInBytes()); Assert.assertEquals( ParameterProvider.INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT, parameterProvider.getInsertThrottleIntervalInMs()); @@ -44,14 +68,16 @@ public void withNullProps() { @Test public void withNullParameterMap() { Properties props = new Properties(); - props.put(ParameterProvider.BUFFER_FLUSH_INTERVAL_IN_MILLIS_MAP_KEY, 3L); - props.put(ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_MAP_KEY, 4L); - props.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_MAP_KEY, 6L); + props.put(ParameterProvider.MAX_CLIENT_LAG, 3000L); + props.put(ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS, 4L); + props.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE, 6); + props.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_BYTES, 1024); ParameterProvider parameterProvider = new ParameterProvider(null, props); - Assert.assertEquals(3, parameterProvider.getBufferFlushIntervalInMs()); + Assert.assertEquals(3000, parameterProvider.getCachedMaxClientLagInMs()); Assert.assertEquals(4, parameterProvider.getBufferFlushCheckIntervalInMs()); Assert.assertEquals(6, parameterProvider.getInsertThrottleThresholdInPercentage()); + Assert.assertEquals(1024, parameterProvider.getInsertThrottleThresholdInBytes()); Assert.assertEquals( ParameterProvider.INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT, parameterProvider.getInsertThrottleIntervalInMs()); @@ -62,14 +88,16 @@ public void withNullInputs() { ParameterProvider parameterProvider = new ParameterProvider(null, null); Assert.assertEquals( - ParameterProvider.BUFFER_FLUSH_INTERVAL_IN_MILLIS_DEFAULT, - parameterProvider.getBufferFlushIntervalInMs()); + ParameterProvider.MAX_CLIENT_LAG_DEFAULT, parameterProvider.getCachedMaxClientLagInMs()); Assert.assertEquals( ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_DEFAULT, parameterProvider.getBufferFlushCheckIntervalInMs()); Assert.assertEquals( ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_DEFAULT, parameterProvider.getInsertThrottleThresholdInPercentage()); + Assert.assertEquals( + ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_BYTES_DEFAULT, + parameterProvider.getInsertThrottleThresholdInBytes()); Assert.assertEquals( ParameterProvider.INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT, parameterProvider.getInsertThrottleIntervalInMs()); @@ -80,16 +108,233 @@ public void withDefaultValues() { ParameterProvider parameterProvider = new ParameterProvider(); Assert.assertEquals( - ParameterProvider.BUFFER_FLUSH_INTERVAL_IN_MILLIS_DEFAULT, - parameterProvider.getBufferFlushIntervalInMs()); + ParameterProvider.MAX_CLIENT_LAG_DEFAULT, parameterProvider.getCachedMaxClientLagInMs()); Assert.assertEquals( ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_DEFAULT, parameterProvider.getBufferFlushCheckIntervalInMs()); Assert.assertEquals( ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE_DEFAULT, parameterProvider.getInsertThrottleThresholdInPercentage()); + Assert.assertEquals( + ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_BYTES_DEFAULT, + parameterProvider.getInsertThrottleThresholdInBytes()); Assert.assertEquals( ParameterProvider.INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT, parameterProvider.getInsertThrottleIntervalInMs()); + Assert.assertEquals( + ParameterProvider.IO_TIME_CPU_RATIO_DEFAULT, parameterProvider.getIOTimeCpuRatio()); + Assert.assertEquals( + ParameterProvider.BLOB_UPLOAD_MAX_RETRY_COUNT_DEFAULT, + parameterProvider.getBlobUploadMaxRetryCount()); + Assert.assertEquals( + ParameterProvider.MAX_MEMORY_LIMIT_IN_BYTES_DEFAULT, + parameterProvider.getMaxMemoryLimitInBytes()); + Assert.assertEquals( + ParameterProvider.MAX_CHANNEL_SIZE_IN_BYTES_DEFAULT, + parameterProvider.getMaxChannelSizeInBytes()); + Assert.assertEquals( + ParameterProvider.BDEC_PARQUET_COMPRESSION_ALGORITHM_DEFAULT, + parameterProvider.getBdecParquetCompressionAlgorithm()); + } + + @Test + public void testMaxClientLagEnabled() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "2 second"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals(2000, parameterProvider.getCachedMaxClientLagInMs()); + // call again to trigger caching logic + Assert.assertEquals(2000, parameterProvider.getCachedMaxClientLagInMs()); + } + + @Test + public void testMaxClientLagEnabledPluralTimeUnit() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "2 seconds"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals(2000, parameterProvider.getCachedMaxClientLagInMs()); + } + + @Test + public void testMaxClientLagEnabledMinuteTimeUnit() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "1 minute"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals(60000, parameterProvider.getCachedMaxClientLagInMs()); + } + + @Test + public void testMaxClientLagEnabledMinuteTimeUnitPluralTimeUnit() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "2 minutes"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals(120000, parameterProvider.getCachedMaxClientLagInMs()); + } + + @Test + public void testMaxClientLagEnabledDefaultValue() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals( + ParameterProvider.MAX_CLIENT_LAG_DEFAULT, parameterProvider.getCachedMaxClientLagInMs()); + } + + @Test + public void testMaxClientLagEnabledDefaultUnit() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "3000"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals(3000, parameterProvider.getCachedMaxClientLagInMs()); + } + + @Test + public void testMaxClientLagEnabledLongInput() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, 3000L); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals(3000, parameterProvider.getCachedMaxClientLagInMs()); + } + + @Test + public void testMaxClientLagEnabledMissingUnitTimeUnitSupplied() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, " year"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + try { + parameterProvider.getCachedMaxClientLagInMs(); + Assert.fail("Should not have succeeded"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().startsWith("Failed to parse")); + } + } + + @Test + public void testMaxClientLagEnabledInvalidTimeUnit() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "1 year"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + try { + parameterProvider.getCachedMaxClientLagInMs(); + Assert.fail("Should not have succeeded"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().startsWith("Invalid time unit")); + } + } + + @Test + public void testMaxClientLagEnabledInvalidUnit() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "banana minute"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + try { + parameterProvider.getCachedMaxClientLagInMs(); + Assert.fail("Should not have succeeded"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().startsWith("Failed to parse")); + } + } + + @Test + public void testMaxClientLagEnabledThresholdBelow() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "0 second"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + try { + parameterProvider.getCachedMaxClientLagInMs(); + Assert.fail("Should not have succeeded"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().startsWith("Lag falls outside")); + } + } + + @Test + public void testMaxClientLagEnabledThresholdAbove() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "11 minutes"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + try { + parameterProvider.getCachedMaxClientLagInMs(); + Assert.fail("Should not have succeeded"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().startsWith("Lag falls outside")); + } + } + + @Test + public void testMaxClientLagEnableEmptyInput() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, ""); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + try { + parameterProvider.getCachedMaxClientLagInMs(); + Assert.fail("Should not have succeeded"); + } catch (IllegalArgumentException e) { + Assert.assertEquals(e.getCause().getClass(), NumberFormatException.class); + } + } + + @Test + public void testMaxChunksInBlobAndRegistrationRequest() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put("max_chunks_in_blob_and_registration_request", 1); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals(1, parameterProvider.getMaxChunksInBlobAndRegistrationRequest()); + } + + @Test + public void testValidCompressionAlgorithmsAndWithUppercaseLowerCase() { + List gzipValues = Arrays.asList("GZIP", "gzip", "Gzip", "gZip"); + gzipValues.forEach( + v -> { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.BDEC_PARQUET_COMPRESSION_ALGORITHM, v); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals( + Constants.BdecParquetCompression.GZIP, + parameterProvider.getBdecParquetCompressionAlgorithm()); + }); + List zstdValues = Arrays.asList("ZSTD", "zstd", "Zstd", "zStd"); + zstdValues.forEach( + v -> { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.BDEC_PARQUET_COMPRESSION_ALGORITHM, v); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + Assert.assertEquals( + Constants.BdecParquetCompression.ZSTD, + parameterProvider.getBdecParquetCompressionAlgorithm()); + }); + } + + @Test + public void testInvalidCompressionAlgorithm() { + Properties prop = new Properties(); + Map parameterMap = getStartingParameterMap(); + parameterMap.put(ParameterProvider.BDEC_PARQUET_COMPRESSION_ALGORITHM, "invalid_comp"); + ParameterProvider parameterProvider = new ParameterProvider(parameterMap, prop); + try { + parameterProvider.getBdecParquetCompressionAlgorithm(); + Assert.fail("Should not have succeeded"); + } catch (IllegalArgumentException e) { + Assert.assertEquals( + "Unsupported BDEC_PARQUET_COMPRESSION_ALGORITHM = 'invalid_comp', allowed values are" + + " [GZIP, ZSTD]", + e.getMessage()); + } } } diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/ParquetTypeGeneratorTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/ParquetTypeGeneratorTest.java new file mode 100644 index 000000000..3356ab277 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/ParquetTypeGeneratorTest.java @@ -0,0 +1,474 @@ +package net.snowflake.ingest.streaming.internal; + +import java.util.Map; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.junit.Assert; +import org.junit.Test; + +public class ParquetTypeGeneratorTest { + private static final int COL_ORDINAL = 11; + + @Test + public void buildFieldFixedSB1() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("FIXED") + .physicalType("SB1") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(4) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.INT32) + .expectedLogicalTypeAnnotation( + LogicalTypeAnnotation.decimalType(testCol.getScale(), testCol.getPrecision())) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.FIXED.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB1.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldFixedSB2() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("FIXED") + .physicalType("SB2") + .nullable(false) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(4) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.INT32) + .expectedLogicalTypeAnnotation( + LogicalTypeAnnotation.decimalType(testCol.getScale(), testCol.getPrecision())) + .expectedRepetition(Type.Repetition.REQUIRED) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.FIXED.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB2.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldFixedSB4() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("FIXED") + .physicalType("SB4") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(4) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.INT32) + .expectedLogicalTypeAnnotation( + LogicalTypeAnnotation.decimalType(testCol.getScale(), testCol.getPrecision())) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.FIXED.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB4.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldFixedSB8() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("FIXED") + .physicalType("SB8") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(8) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.INT64) + .expectedLogicalTypeAnnotation( + LogicalTypeAnnotation.decimalType(testCol.getScale(), testCol.getPrecision())) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.FIXED.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB8.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldFixedSB16() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("FIXED") + .physicalType("SB16") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(16) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY) + .expectedLogicalTypeAnnotation( + LogicalTypeAnnotation.decimalType(testCol.getScale(), testCol.getPrecision())) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.FIXED.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB16.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldLobVariant() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("VARIANT") + .physicalType("LOB") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(0) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.BINARY) + .expectedLogicalTypeAnnotation(LogicalTypeAnnotation.stringType()) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.VARIANT.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.LOB.getOrdinal()) + .assertMatches(); + + Assert.assertEquals("1", typeInfo.getMetadata().get(COL_ORDINAL + ":obj_enc")); + } + + @Test + public void buildFieldTimestampNtzSB8() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("TIMESTAMP_NTZ") + .physicalType("SB8") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(8) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.INT64) + .expectedLogicalTypeAnnotation(LogicalTypeAnnotation.decimalType(testCol.getScale(), 18)) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.TIMESTAMP_NTZ.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB8.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldTimestampNtzSB16() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("TIMESTAMP_NTZ") + .physicalType("SB16") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(16) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY) + .expectedLogicalTypeAnnotation(LogicalTypeAnnotation.decimalType(testCol.getScale(), 38)) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.TIMESTAMP_NTZ.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB16.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldTimestampTzSB8() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("TIMESTAMP_TZ") + .physicalType("SB8") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(8) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.INT64) + .expectedLogicalTypeAnnotation(LogicalTypeAnnotation.decimalType(testCol.getScale(), 18)) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.TIMESTAMP_TZ.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB8.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldTimestampTzSB16() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("TIMESTAMP_TZ") + .physicalType("SB16") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(16) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY) + .expectedLogicalTypeAnnotation(LogicalTypeAnnotation.decimalType(testCol.getScale(), 38)) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.TIMESTAMP_TZ.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB16.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldDate() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("DATE") + .physicalType("SB8") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(0) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.INT32) + .expectedLogicalTypeAnnotation(LogicalTypeAnnotation.dateType()) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.DATE.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB8.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldTimeSB4() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("TIME") + .physicalType("SB4") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(4) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.INT32) + .expectedLogicalTypeAnnotation(LogicalTypeAnnotation.decimalType(testCol.getScale(), 9)) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.TIME.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB4.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldTimeSB8() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("TIME") + .physicalType("SB8") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(8) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.INT64) + .expectedLogicalTypeAnnotation(LogicalTypeAnnotation.decimalType(testCol.getScale(), 18)) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.TIME.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB8.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldBoolean() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("BOOLEAN") + .physicalType("BINARY") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(0) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.BOOLEAN) + .expectedLogicalTypeAnnotation(null) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.BOOLEAN.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.BINARY.getOrdinal()) + .assertMatches(); + } + + @Test + public void buildFieldRealSB16() { + ColumnMetadata testCol = + createColumnMetadataBuilder() + .logicalType("REAL") + .physicalType("SB16") + .nullable(true) + .build(); + + ParquetTypeGenerator.ParquetTypeInfo typeInfo = + ParquetTypeGenerator.generateColumnParquetTypeInfo(testCol, 0); + createParquetTypeInfoAssertionBuilder() + .typeInfo(typeInfo) + .expectedFieldName("TESTCOL") + .expectedTypeLength(0) + .expectedPrimitiveTypeName(PrimitiveType.PrimitiveTypeName.DOUBLE) + .expectedLogicalTypeAnnotation(null) + .expectedRepetition(Type.Repetition.OPTIONAL) + .expectedColMetaData( + AbstractRowBuffer.ColumnLogicalType.REAL.getOrdinal() + + "," + + AbstractRowBuffer.ColumnPhysicalType.SB16.getOrdinal()) + .assertMatches(); + } + + /** Builder that helps to assert parquet type info */ + private static class ParquetTypeInfoAssertionBuilder { + private String fieldName; + private int fieldId; + private PrimitiveType.PrimitiveTypeName primitiveTypeName; + private LogicalTypeAnnotation logicalTypeAnnotation; + private Type.Repetition repetition; + private int typeLength; + private String colMetadata; + private ParquetTypeGenerator.ParquetTypeInfo typeInfo; + + static ParquetTypeInfoAssertionBuilder newBuilder() { + ParquetTypeInfoAssertionBuilder builder = new ParquetTypeInfoAssertionBuilder(); + return builder; + } + + ParquetTypeInfoAssertionBuilder typeInfo(ParquetTypeGenerator.ParquetTypeInfo typeInfo) { + this.typeInfo = typeInfo; + return this; + } + + ParquetTypeInfoAssertionBuilder expectedFieldName(String fieldName) { + this.fieldName = fieldName; + return this; + } + + ParquetTypeInfoAssertionBuilder expectedFieldId(int fieldId) { + this.fieldId = fieldId; + return this; + } + + ParquetTypeInfoAssertionBuilder expectedPrimitiveTypeName( + PrimitiveType.PrimitiveTypeName primitiveTypeName) { + this.primitiveTypeName = primitiveTypeName; + return this; + } + + ParquetTypeInfoAssertionBuilder expectedLogicalTypeAnnotation( + LogicalTypeAnnotation logicalTypeAnnotation) { + this.logicalTypeAnnotation = logicalTypeAnnotation; + return this; + } + + ParquetTypeInfoAssertionBuilder expectedRepetition(Type.Repetition repetition) { + this.repetition = repetition; + return this; + } + + ParquetTypeInfoAssertionBuilder expectedTypeLength(int typeLength) { + this.typeLength = typeLength; + return this; + } + + ParquetTypeInfoAssertionBuilder expectedColMetaData(String colMetaData) { + this.colMetadata = colMetaData; + return this; + } + + void assertMatches() { + Type type = typeInfo.getParquetType(); + Map metadata = typeInfo.getMetadata(); + Assert.assertEquals(fieldName, type.getName()); + Assert.assertEquals(typeLength, type.asPrimitiveType().getTypeLength()); + Assert.assertEquals(fieldId, type.asPrimitiveType().getId().intValue()); + + Assert.assertEquals(primitiveTypeName, type.asPrimitiveType().getPrimitiveTypeName()); + Assert.assertEquals(logicalTypeAnnotation, type.getLogicalTypeAnnotation()); + Assert.assertEquals(repetition, type.getRepetition()); + Assert.assertEquals(colMetadata, metadata.get(type.getId().toString())); + } + } + + private static ColumnMetadataBuilder createColumnMetadataBuilder() { + return ColumnMetadataBuilder.newBuilder().ordinal(COL_ORDINAL); + } + + private static ParquetTypeInfoAssertionBuilder createParquetTypeInfoAssertionBuilder() { + return ParquetTypeInfoAssertionBuilder.newBuilder().expectedFieldId(COL_ORDINAL); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/ParquetValueParserTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/ParquetValueParserTest.java new file mode 100644 index 000000000..6878478e2 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/ParquetValueParserTest.java @@ -0,0 +1,699 @@ +package net.snowflake.ingest.streaming.internal; + +import static java.time.ZoneOffset.UTC; +import static net.snowflake.ingest.streaming.internal.ParquetValueParser.BIT_ENCODING_BYTE_LEN; +import static net.snowflake.ingest.streaming.internal.ParquetValueParser.BYTE_ARRAY_LENGTH_ENCODING_BYTE_LEN; +import static net.snowflake.ingest.streaming.internal.ParquetValueParser.DEFINITION_LEVEL_ENCODING_BYTE_LEN; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import net.snowflake.ingest.utils.SFException; +import org.apache.parquet.schema.PrimitiveType; +import org.junit.Assert; +import org.junit.Test; + +public class ParquetValueParserTest { + + @Test + public void parseValueFixedSB1ToInt32() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("FIXED") + .physicalType("SB1") + .scale(0) + .precision(2) + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + 12, testCol, PrimitiveType.PrimitiveTypeName.INT32, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Integer.class) + .expectedParsedValue(12) + .expectedSize(4.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(12)) + .assertMatches(); + } + + @Test + public void parseValueFixedSB2ToInt32() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("FIXED") + .physicalType("SB2") + .scale(0) + .precision(4) + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + 1234, testCol, PrimitiveType.PrimitiveTypeName.INT32, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Integer.class) + .expectedParsedValue(1234) + .expectedSize(4.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(1234)) + .assertMatches(); + } + + @Test + public void parseValueFixedSB4ToInt32() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("FIXED") + .physicalType("SB4") + .scale(0) + .precision(9) + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + 123456789, testCol, PrimitiveType.PrimitiveTypeName.INT32, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Integer.class) + .expectedParsedValue(123456789) + .expectedSize(4.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(123456789)) + .assertMatches(); + } + + @Test + public void parseValueFixedSB8ToInt64() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("FIXED") + .physicalType("SB8") + .scale(0) + .precision(18) + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + 123456789987654321L, + testCol, + PrimitiveType.PrimitiveTypeName.INT64, + rowBufferStats, + UTC, + 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Long.class) + .expectedParsedValue(123456789987654321L) + .expectedSize(8.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(123456789987654321L)) + .assertMatches(); + } + + @Test + public void parseValueFixedSB16ToByteArray() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("FIXED") + .physicalType("SB16") + .scale(0) + .precision(38) + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + new BigDecimal("91234567899876543219876543211234567891"), + testCol, + PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, + rowBufferStats, + UTC, + 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(byte[].class) + .expectedParsedValue( + ParquetValueParser.getSb16Bytes( + new BigInteger("91234567899876543219876543211234567891"))) + .expectedSize(16.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(new BigInteger("91234567899876543219876543211234567891")) + .assertMatches(); + } + + @Test + public void parseValueFixedDecimalToInt32() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("FIXED") + .physicalType("SB8") + .scale(5) + .precision(10) + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + new BigDecimal("12345.54321"), + testCol, + PrimitiveType.PrimitiveTypeName.DOUBLE, + rowBufferStats, + UTC, + 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Double.class) + .expectedParsedValue(Double.valueOf("12345.54321")) + .expectedSize(8.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(Double.valueOf("12345.54321")) + .assertMatches(); + } + + @Test + public void parseValueDouble() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("REAL") + .physicalType("DOUBLE") + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + 12345.54321d, testCol, PrimitiveType.PrimitiveTypeName.DOUBLE, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Double.class) + .expectedParsedValue(Double.valueOf(12345.54321)) + .expectedSize(8.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(Double.valueOf(12345.54321)) + .assertMatches(); + } + + @Test + public void parseValueBoolean() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("BOOLEAN") + .physicalType("SB1") + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + true, testCol, PrimitiveType.PrimitiveTypeName.BOOLEAN, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Boolean.class) + .expectedParsedValue(true) + .expectedSize(BIT_ENCODING_BYTE_LEN + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(1)) + .assertMatches(); + } + + @Test + public void parseValueBinary() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("BINARY") + .physicalType("LOB") + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + "1234abcd".getBytes(), + testCol, + PrimitiveType.PrimitiveTypeName.BINARY, + rowBufferStats, + UTC, + 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(byte[].class) + .expectedParsedValue("1234abcd".getBytes()) + .expectedSize( + BYTE_ARRAY_LENGTH_ENCODING_BYTE_LEN + 8.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax("1234abcd".getBytes(StandardCharsets.UTF_8)) + .assertMatches(); + } + + @Test + public void parseValueVariantToBinary() { + testJsonWithLogicalType("VARIANT"); + } + + @Test + public void parseValueObjectToBinary() { + testJsonWithLogicalType("OBJECT"); + } + + private void testJsonWithLogicalType(String logicalType) { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType(logicalType) + .physicalType("BINARY") + .nullable(true) + .build(); + + String var = + "{\"key1\":-879869596,\"key2\":\"value2\",\"key3\":null," + + "\"key4\":{\"key41\":0.032437,\"key42\":\"value42\",\"key43\":null}}"; + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + var, testCol, PrimitiveType.PrimitiveTypeName.BINARY, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(String.class) + .expectedParsedValue(var) + .expectedSize( + BYTE_ARRAY_LENGTH_ENCODING_BYTE_LEN + + var.getBytes().length + + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(null) + .assertMatches(); + } + + @Test + public void parseValueNullVariantToBinary() { + testNullJsonWithLogicalType(null); + } + + @Test + public void parseValueEmptyStringVariantToBinary() { + testNullJsonWithLogicalType(""); + } + + @Test + public void parseValueEmptySpaceStringVariantToBinary() { + testNullJsonWithLogicalType(" "); + } + + private void testNullJsonWithLogicalType(String var) { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("VARIANT") + .physicalType("BINARY") + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + var, testCol, PrimitiveType.PrimitiveTypeName.BINARY, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(String.class) + .expectedParsedValue(var) + .expectedSize(0) + .expectedMinMax(null) + .expectedNullCount(1) + .assertNull(); + } + + @Test + public void parseValueArrayToBinary() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("ARRAY") + .physicalType("BINARY") + .nullable(true) + .build(); + + Map input = new HashMap<>(); + input.put("a", "1"); + input.put("b", "2"); + input.put("c", "3"); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + input, testCol, PrimitiveType.PrimitiveTypeName.BINARY, rowBufferStats, UTC, 0); + + String resultArray = "[{\"a\":\"1\",\"b\":\"2\",\"c\":\"3\"}]"; + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(String.class) + .expectedParsedValue(resultArray) + .expectedSize( + BYTE_ARRAY_LENGTH_ENCODING_BYTE_LEN + + resultArray.length() + + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(null) + .assertMatches(); + } + + @Test + public void parseValueTextToBinary() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("TEXT") + .physicalType("LOB") + .nullable(true) + .length(56) + .build(); + + String text = "This is a sample text! Length is bigger than 32 bytes :)"; + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + text, testCol, PrimitiveType.PrimitiveTypeName.BINARY, rowBufferStats, UTC, 0); + + String result = text; + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(String.class) + .expectedParsedValue(result) + .expectedSize( + BYTE_ARRAY_LENGTH_ENCODING_BYTE_LEN + + result.length() + + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(text) // min/max are truncated later to 32 bytes, not in the parsing step. + .assertMatches(); + } + + @Test + public void parseValueTimestampNtzSB4Error() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("TIMESTAMP_NTZ") + .physicalType("SB4") + .scale(0) // seconds + .precision(9) + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + SFException exception = + Assert.assertThrows( + SFException.class, + () -> + ParquetValueParser.parseColumnValueToParquet( + "2013-04-28 20:57:00", + testCol, + PrimitiveType.PrimitiveTypeName.INT32, + rowBufferStats, + UTC, + 0)); + Assert.assertEquals( + "Unknown data type for logical: TIMESTAMP_NTZ, physical: SB4.", exception.getMessage()); + } + + @Test + public void parseValueTimestampNtzSB8ToINT64() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("TIMESTAMP_NTZ") + .physicalType("SB8") + .scale(3) // millis + .precision(18) + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + "2013-04-28T20:57:01.000", + testCol, + PrimitiveType.PrimitiveTypeName.INT64, + rowBufferStats, + UTC, + 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Long.class) + .expectedParsedValue(1367182621000L) + .expectedSize(8.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(1367182621000L)) + .assertMatches(); + } + + @Test + public void parseValueTimestampNtzSB16ToByteArray() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("TIMESTAMP_NTZ") + .physicalType("SB16") + .nullable(true) + .scale(9) // nanos + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + "2022-09-18T22:05:07.123456789", + testCol, + PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, + rowBufferStats, + UTC, + 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(byte[].class) + .expectedParsedValue( + ParquetValueParser.getSb16Bytes(BigInteger.valueOf(1663538707123456789L))) + .expectedSize(16.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(1663538707123456789L)) + .assertMatches(); + } + + @Test + public void parseValueDateToInt32() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("DATE") + .physicalType("SB4") + .scale(0) // seconds + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + "2021-01-01", testCol, PrimitiveType.PrimitiveTypeName.INT32, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Integer.class) + .expectedParsedValue(Integer.valueOf(18628)) + .expectedSize(4.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(18628)) + .assertMatches(); + } + + @Test + public void parseValueTimeSB4ToInt32() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("TIME") + .physicalType("SB4") + .scale(0) // seconds + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + "01:00:00", testCol, PrimitiveType.PrimitiveTypeName.INT32, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Integer.class) + .expectedParsedValue(3600) + .expectedSize(4.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(3600)) + .assertMatches(); + } + + @Test + public void parseValueTimeSB8ToInt64() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("TIME") + .physicalType("SB8") + .scale(3) // milliseconds + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + ParquetValueParser.ParquetBufferValue pv = + ParquetValueParser.parseColumnValueToParquet( + "01:00:00.123", testCol, PrimitiveType.PrimitiveTypeName.INT64, rowBufferStats, UTC, 0); + + ParquetValueParserAssertionBuilder.newBuilder() + .parquetBufferValue(pv) + .rowBufferStats(rowBufferStats) + .expectedValueClass(Long.class) + .expectedParsedValue(3600123L) + .expectedSize(8.0f + DEFINITION_LEVEL_ENCODING_BYTE_LEN) + .expectedMinMax(BigInteger.valueOf(3600123)) + .assertMatches(); + } + + @Test + public void parseValueTimeSB16Error() { + ColumnMetadata testCol = + ColumnMetadataBuilder.newBuilder() + .logicalType("TIME") + .physicalType("SB16") + .scale(9) // nanos + .nullable(true) + .build(); + + RowBufferStats rowBufferStats = new RowBufferStats("COL1"); + SFException exception = + Assert.assertThrows( + SFException.class, + () -> + ParquetValueParser.parseColumnValueToParquet( + "11:00:00.12345678", + testCol, + PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY, + rowBufferStats, + UTC, + 0)); + Assert.assertEquals( + "Unknown data type for logical: TIME, physical: SB16.", exception.getMessage()); + } + + /** Builder that helps to assert parsing of values to parquet types */ + private static class ParquetValueParserAssertionBuilder { + private ParquetValueParser.ParquetBufferValue parquetBufferValue; + private RowBufferStats rowBufferStats; + private Class valueClass; + private Object value; + private float size; + private Object minMaxStat; + private long currentNullCount; + + static ParquetValueParserAssertionBuilder newBuilder() { + ParquetValueParserAssertionBuilder builder = new ParquetValueParserAssertionBuilder(); + return builder; + } + + ParquetValueParserAssertionBuilder parquetBufferValue( + ParquetValueParser.ParquetBufferValue parquetBufferValue) { + this.parquetBufferValue = parquetBufferValue; + return this; + } + + ParquetValueParserAssertionBuilder rowBufferStats(RowBufferStats rowBufferStats) { + this.rowBufferStats = rowBufferStats; + return this; + } + + ParquetValueParserAssertionBuilder expectedValueClass(Class valueClass) { + this.valueClass = valueClass; + return this; + } + + ParquetValueParserAssertionBuilder expectedParsedValue(Object value) { + this.value = value; + return this; + } + + ParquetValueParserAssertionBuilder expectedSize(float size) { + this.size = size; + return this; + } + + public ParquetValueParserAssertionBuilder expectedMinMax(Object minMaxStat) { + this.minMaxStat = minMaxStat; + return this; + } + + public ParquetValueParserAssertionBuilder expectedNullCount(long currentNullCount) { + this.currentNullCount = currentNullCount; + return this; + } + + void assertMatches() { + Assert.assertEquals(valueClass, parquetBufferValue.getValue().getClass()); + if (valueClass.equals(byte[].class)) { + Assert.assertArrayEquals((byte[]) value, (byte[]) parquetBufferValue.getValue()); + } else { + Assert.assertEquals(value, parquetBufferValue.getValue()); + } + Assert.assertEquals(size, parquetBufferValue.getSize(), 0); + if (minMaxStat instanceof BigInteger) { + Assert.assertEquals(minMaxStat, rowBufferStats.getCurrentMinIntValue()); + Assert.assertEquals(minMaxStat, rowBufferStats.getCurrentMaxIntValue()); + return; + } else if (minMaxStat instanceof byte[]) { + Assert.assertArrayEquals((byte[]) minMaxStat, rowBufferStats.getCurrentMinStrValue()); + Assert.assertArrayEquals((byte[]) minMaxStat, rowBufferStats.getCurrentMaxStrValue()); + return; + } else if (valueClass.equals(String.class)) { + // String can have null min/max stats for variant data types + Object min = + rowBufferStats.getCurrentMinStrValue() != null + ? new String(rowBufferStats.getCurrentMinStrValue(), StandardCharsets.UTF_8) + : rowBufferStats.getCurrentMinStrValue(); + Object max = + rowBufferStats.getCurrentMaxStrValue() != null + ? new String(rowBufferStats.getCurrentMaxStrValue(), StandardCharsets.UTF_8) + : rowBufferStats.getCurrentMaxStrValue(); + Assert.assertEquals(minMaxStat, min); + Assert.assertEquals(minMaxStat, max); + return; + } else if (minMaxStat instanceof Double || minMaxStat instanceof BigDecimal) { + Assert.assertEquals(minMaxStat, rowBufferStats.getCurrentMinRealValue()); + Assert.assertEquals(minMaxStat, rowBufferStats.getCurrentMaxRealValue()); + return; + } + throw new IllegalArgumentException( + String.format("Unknown data type for min stat: %s", minMaxStat.getClass())); + } + + void assertNull() { + Assert.assertNull(parquetBufferValue.getValue()); + Assert.assertEquals(currentNullCount, rowBufferStats.getCurrentNullCount()); + } + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/RegisterServiceTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/RegisterServiceTest.java index 4de405a8f..37eb5f96e 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/RegisterServiceTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/RegisterServiceTest.java @@ -1,75 +1,126 @@ package net.snowflake.ingest.streaming.internal; +import static net.snowflake.ingest.utils.Constants.BLOB_UPLOAD_TIMEOUT_IN_SEC; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import net.snowflake.ingest.utils.Pair; import org.junit.Assert; +import org.junit.Ignore; import org.junit.Test; public class RegisterServiceTest { @Test - public void testRegisterService() throws Exception { - RegisterService rs = new RegisterService(null, true); + public void testRegisterService() throws ExecutionException, InterruptedException { + RegisterService rs = new RegisterService<>(null, true); - Pair> blobFuture = + Pair, CompletableFuture> blobFuture = new Pair<>( - new FlushService.BlobData("test", null), - CompletableFuture.completedFuture(new BlobMetadata("path", "md5", null))); + new FlushService.BlobData<>("test", null), + CompletableFuture.completedFuture(new BlobMetadata("path", "md5", null, null))); rs.addBlobs(Collections.singletonList(blobFuture)); Assert.assertEquals(1, rs.getBlobsList().size()); - List errorBlobs = rs.registerBlobs(null); + Assert.assertEquals(false, blobFuture.getValue().get().getSpansMixedTables()); + List> errorBlobs = rs.registerBlobs(null); Assert.assertEquals(0, rs.getBlobsList().size()); Assert.assertEquals(0, errorBlobs.size()); } + /** + * Note that this exception will not perform retries since the completeExceptionally throws the + * exception by wrapping inside ExecutionException. Check method {@link + * CompletableFuture#get(long, TimeUnit)} javadocs + * + *

    The check for retries checks the original exception instead of the exception.getCause() + * + * @throws Exception + */ @Test public void testRegisterServiceTimeoutException() throws Exception { - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal("client"); - RegisterService rs = new RegisterService(client, true); + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>("client"); + RegisterService rs = new RegisterService<>(client, true); - Pair> blobFuture1 = + Pair, CompletableFuture> blobFuture1 = new Pair<>( - new FlushService.BlobData("success", new ArrayList<>()), - CompletableFuture.completedFuture(new BlobMetadata("path", "md5", null))); + new FlushService.BlobData<>("success", new ArrayList<>()), + CompletableFuture.completedFuture(new BlobMetadata("path", "md5", null, null))); CompletableFuture future = new CompletableFuture(); future.completeExceptionally(new TimeoutException()); - Pair> blobFuture2 = - new Pair<>(new FlushService.BlobData("fail", new ArrayList<>()), future); + Pair, CompletableFuture> blobFuture2 = + new Pair<>(new FlushService.BlobData("fail", new ArrayList<>()), future); rs.addBlobs(Arrays.asList(blobFuture1, blobFuture2)); Assert.assertEquals(2, rs.getBlobsList().size()); try { - List errorBlobs = rs.registerBlobs(null); + List> errorBlobs = rs.registerBlobs(null); Assert.assertEquals(0, rs.getBlobsList().size()); Assert.assertEquals(1, errorBlobs.size()); - Assert.assertEquals("fail", errorBlobs.get(0).getFilePath()); + Assert.assertEquals("fail", errorBlobs.get(0).getPath()); } catch (Exception e) { Assert.fail("The timeout exception should be caught in registerBlobs"); } } + // Ignore since it runs for BLOB_UPLOAD_TIMEOUT_IN_SEC * BLOB_UPLOAD_MAX_RETRY_COUNT + @Ignore @Test - public void testRegisterServiceNonTimeoutException() { - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal("client"); - RegisterService rs = new RegisterService(client, true); + public void testRegisterServiceTimeoutException_testRetries() throws Exception { + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>("client"); + RegisterService rs = new RegisterService<>(client, true); + Pair, CompletableFuture> blobFuture1 = + new Pair<>( + new FlushService.BlobData<>("success", new ArrayList<>()), + CompletableFuture.completedFuture(new BlobMetadata("path", "md5", null, null))); CompletableFuture future = new CompletableFuture(); + future.thenRunAsync( + () -> { + try { + Thread.sleep(TimeUnit.SECONDS.toMillis(BLOB_UPLOAD_TIMEOUT_IN_SEC) + 5); + } catch (InterruptedException e) { + e.printStackTrace(); + } + return; + }); + Pair, CompletableFuture> blobFuture2 = + new Pair<>(new FlushService.BlobData("fail", new ArrayList<>()), future); + rs.addBlobs(Arrays.asList(blobFuture1, blobFuture2)); + Assert.assertEquals(2, rs.getBlobsList().size()); + try { + List> errorBlobs = rs.registerBlobs(null); + Assert.assertEquals(0, rs.getBlobsList().size()); + Assert.assertEquals(1, errorBlobs.size()); + Assert.assertEquals("fail", errorBlobs.get(0).getPath()); + } catch (Exception e) { + Assert.fail("The timeout exception should be caught in registerBlobs"); + } + } + + @Test + public void testRegisterServiceNonTimeoutException() { + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>("client"); + RegisterService rs = new RegisterService<>(client, true); + + CompletableFuture future = new CompletableFuture<>(); future.completeExceptionally(new IndexOutOfBoundsException()); - Pair> blobFuture = - new Pair<>(new FlushService.BlobData("fail", new ArrayList<>()), future); + Pair, CompletableFuture> blobFuture = + new Pair<>(new FlushService.BlobData<>("fail", new ArrayList<>()), future); rs.addBlobs(Collections.singletonList(blobFuture)); Assert.assertEquals(1, rs.getBlobsList().size()); try { - List errorBlobs = rs.registerBlobs(null); + List> errorBlobs = rs.registerBlobs(null); Assert.assertEquals(0, rs.getBlobsList().size()); Assert.assertEquals(1, errorBlobs.size()); - Assert.assertEquals("fail", errorBlobs.get(0).getFilePath()); + Assert.assertEquals("fail", errorBlobs.get(0).getPath()); } catch (Exception e) { Assert.fail("The exception should be caught in registerBlobs"); } diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/RowBufferStatsTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/RowBufferStatsTest.java index 50e5cac09..9f2e848f3 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/RowBufferStatsTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/RowBufferStatsTest.java @@ -1,85 +1,23 @@ package net.snowflake.ingest.streaming.internal; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import org.junit.Assert; import org.junit.Test; public class RowBufferStatsTest { - @Test - public void testCollationStates() throws Exception { - RowBufferStats ai = new RowBufferStats("en-ai"); - RowBufferStats as = new RowBufferStats("en-as"); - RowBufferStats pi = new RowBufferStats("en-pi"); - RowBufferStats ps = new RowBufferStats("en-ps"); - RowBufferStats fu = new RowBufferStats("en-fu"); - RowBufferStats fl = new RowBufferStats("en-fl"); - RowBufferStats lower = new RowBufferStats("lower"); - RowBufferStats upper = new RowBufferStats("upper"); - RowBufferStats ltrim = new RowBufferStats("ltrim"); - RowBufferStats rtrim = new RowBufferStats("rtrim"); - RowBufferStats trim = new RowBufferStats("trim"); - - // Accents - ai.addStrValue("a"); - ai.addStrValue("à"); - as.addStrValue("a"); - as.addStrValue("à"); - Assert.assertEquals("a", ai.getCurrentMinColStrValue()); - Assert.assertEquals("a", ai.getCurrentMaxColStrValue()); - Assert.assertEquals("a", as.getCurrentMinColStrValue()); - Assert.assertEquals("à", as.getCurrentMaxColStrValue()); - - // Punctuation - pi.addStrValue(".b"); - pi.addStrValue("a"); - ps.addStrValue(".b"); - ps.addStrValue("a"); - - Assert.assertEquals("a", pi.getCurrentMinColStrValue()); - Assert.assertEquals(".b", ps.getCurrentMinColStrValue()); - - // First Lower and Upper - fl.addStrValue("C"); - fl.addStrValue("b"); - fu.addStrValue("b"); - fu.addStrValue("C"); - Assert.assertEquals("b", fl.getCurrentMinColStrValue()); - Assert.assertEquals("b", fu.getCurrentMinColStrValue()); - - // Lower and Upper - lower.addStrValue("AA"); - lower.addStrValue("a"); - upper.addStrValue("AA"); - upper.addStrValue("aaa"); - Assert.assertEquals("a", lower.getCurrentMinColStrValue()); - Assert.assertEquals("AA", upper.getCurrentMinColStrValue()); - - // Trim settings - trim.addStrValue(" z "); - trim.addStrValue("b"); - ltrim.addStrValue(" z"); - ltrim.addStrValue("b"); - rtrim.addStrValue("z "); - rtrim.addStrValue("b"); - Assert.assertEquals("b", trim.getCurrentMinColStrValue()); - Assert.assertEquals("b", ltrim.getCurrentMinColStrValue()); - Assert.assertEquals("b", rtrim.getCurrentMinColStrValue()); - } - @Test public void testEmptyState() throws Exception { - RowBufferStats stats = new RowBufferStats(); + RowBufferStats stats = new RowBufferStats("COL1"); Assert.assertNull(stats.getCollationDefinitionString()); Assert.assertNull(stats.getCurrentMinRealValue()); Assert.assertNull(stats.getCurrentMaxRealValue()); - Assert.assertNull(stats.getCurrentMinColStrValue()); - Assert.assertNull(stats.getCurrentMaxColStrValue()); - Assert.assertNull(stats.getCurrentMinIntValue()); - Assert.assertNull(stats.getCurrentMaxIntValue()); Assert.assertNull(stats.getCurrentMinStrValue()); Assert.assertNull(stats.getCurrentMaxStrValue()); + Assert.assertNull(stats.getCurrentMinIntValue()); + Assert.assertNull(stats.getCurrentMaxIntValue()); Assert.assertEquals(0, stats.getCurrentNullCount()); Assert.assertEquals(-1, stats.getDistinctValues()); @@ -87,27 +25,24 @@ public void testEmptyState() throws Exception { @Test public void testMinMaxStrNonCol() throws Exception { - RowBufferStats stats = new RowBufferStats(); + RowBufferStats stats = new RowBufferStats("COL1"); stats.addStrValue("bob"); - Assert.assertEquals("bob", stats.getCurrentMinStrValue()); - Assert.assertEquals("bob", stats.getCurrentMaxStrValue()); - Assert.assertEquals("bob", stats.getCurrentMinColStrValue()); - Assert.assertEquals("bob", stats.getCurrentMaxColStrValue()); + Assert.assertArrayEquals("bob".getBytes(StandardCharsets.UTF_8), stats.getCurrentMinStrValue()); + Assert.assertArrayEquals("bob".getBytes(StandardCharsets.UTF_8), stats.getCurrentMaxStrValue()); Assert.assertEquals(-1, stats.getDistinctValues()); stats.addStrValue("charlie"); - Assert.assertEquals("bob", stats.getCurrentMinStrValue()); - Assert.assertEquals("charlie", stats.getCurrentMaxStrValue()); - Assert.assertEquals("bob", stats.getCurrentMinColStrValue()); - Assert.assertEquals("charlie", stats.getCurrentMaxColStrValue()); + Assert.assertArrayEquals("bob".getBytes(StandardCharsets.UTF_8), stats.getCurrentMinStrValue()); + Assert.assertArrayEquals( + "charlie".getBytes(StandardCharsets.UTF_8), stats.getCurrentMaxStrValue()); Assert.assertEquals(-1, stats.getDistinctValues()); stats.addStrValue("alice"); - Assert.assertEquals("alice", stats.getCurrentMinStrValue()); - Assert.assertEquals("charlie", stats.getCurrentMaxStrValue()); - Assert.assertEquals("alice", stats.getCurrentMinColStrValue()); - Assert.assertEquals("charlie", stats.getCurrentMaxColStrValue()); + Assert.assertArrayEquals( + "alice".getBytes(StandardCharsets.UTF_8), stats.getCurrentMinStrValue()); + Assert.assertArrayEquals( + "charlie".getBytes(StandardCharsets.UTF_8), stats.getCurrentMaxStrValue()); Assert.assertEquals(-1, stats.getDistinctValues()); Assert.assertNull(stats.getCurrentMinRealValue()); @@ -118,64 +53,9 @@ public void testMinMaxStrNonCol() throws Exception { Assert.assertEquals(0, stats.getCurrentNullCount()); } - @Test - public void testStrTruncation() throws Exception { - RowBufferStats stats = new RowBufferStats(); - stats.addStrValue("abcde|abcde|abcde|abcde|abcde|abcde|"); - Assert.assertEquals("abcde|abcde|abcde|abcde|abcde|ab", stats.getCurrentMinStrValue()); - Assert.assertEquals("abcde|abcde|abcde|abcde|abcde|ac", stats.getCurrentMaxStrValue()); - - stats.addStrValue("zabcde|abcde|abcde|abcde|abcde|abcde|"); - Assert.assertEquals("abcde|abcde|abcde|abcde|abcde|ab", stats.getCurrentMinStrValue()); - Assert.assertEquals("zabcde|abcde|abcde|abcde|abcde|b", stats.getCurrentMaxStrValue()); - - RowBufferStats ai = new RowBufferStats("en-ai"); - ai.addStrValue("abcde|abcde|abcde|abcde|abcde|abcde|"); - Assert.assertEquals("abcde|abcde|abcde|abcde|abcde|ab", ai.getCurrentMinColStrValue()); - Assert.assertEquals("abcde|abcde|abcde|abcde|abcde|ac", ai.getCurrentMaxColStrValue()); - - ai.addStrValue("zabcde|abcde|abcde|abcde|abcde|abcde|"); - Assert.assertEquals("abcde|abcde|abcde|abcde|abcde|ab", ai.getCurrentMinColStrValue()); - Assert.assertEquals("zabcde|abcde|abcde|abcde|abcde|b", ai.getCurrentMaxColStrValue()); - } - - @Test - public void testMinMaxStrCol() throws Exception { - RowBufferStats stats = new RowBufferStats("en-ci"); - - Assert.assertEquals("en-ci", stats.getCollationDefinitionString()); - - stats.addStrValue("bob"); - Assert.assertEquals("bob", stats.getCurrentMinStrValue()); - Assert.assertEquals("bob", stats.getCurrentMaxStrValue()); - Assert.assertEquals("bob", stats.getCurrentMinColStrValue()); - Assert.assertEquals("bob", stats.getCurrentMaxColStrValue()); - Assert.assertEquals(-1, stats.getDistinctValues()); - - stats.addStrValue("Bob"); - Assert.assertEquals("Bob", stats.getCurrentMinStrValue()); - Assert.assertEquals("bob", stats.getCurrentMaxStrValue()); - Assert.assertEquals("bob", stats.getCurrentMinColStrValue()); - Assert.assertEquals("bob", stats.getCurrentMaxColStrValue()); - Assert.assertEquals(-1, stats.getDistinctValues()); - - stats.addStrValue("Alice"); - Assert.assertEquals("Alice", stats.getCurrentMinStrValue()); - Assert.assertEquals("bob", stats.getCurrentMaxStrValue()); - Assert.assertEquals("Alice", stats.getCurrentMinColStrValue()); - Assert.assertEquals("bob", stats.getCurrentMaxColStrValue()); - Assert.assertEquals(-1, stats.getDistinctValues()); - - Assert.assertNull(stats.getCurrentMinRealValue()); - Assert.assertNull(stats.getCurrentMaxRealValue()); - Assert.assertNull(stats.getCurrentMinIntValue()); - Assert.assertNull(stats.getCurrentMaxIntValue()); - Assert.assertEquals(0, stats.getCurrentNullCount()); - } - @Test public void testMinMaxInt() throws Exception { - RowBufferStats stats = new RowBufferStats(); + RowBufferStats stats = new RowBufferStats("COL1"); stats.addIntValue(BigInteger.valueOf(5)); Assert.assertEquals(BigInteger.valueOf((5)), stats.getCurrentMinIntValue()); @@ -194,17 +74,15 @@ public void testMinMaxInt() throws Exception { Assert.assertNull(stats.getCurrentMinRealValue()); Assert.assertNull(stats.getCurrentMaxRealValue()); + Assert.assertNull(stats.getCollationDefinitionString()); Assert.assertNull(stats.getCurrentMinStrValue()); Assert.assertNull(stats.getCurrentMaxStrValue()); - Assert.assertNull(stats.getCollationDefinitionString()); - Assert.assertNull(stats.getCurrentMinColStrValue()); - Assert.assertNull(stats.getCurrentMaxColStrValue()); Assert.assertEquals(0, stats.getCurrentNullCount()); } @Test public void testMinMaxReal() throws Exception { - RowBufferStats stats = new RowBufferStats(); + RowBufferStats stats = new RowBufferStats("COL1"); stats.addRealValue(1.0); Assert.assertEquals(Double.valueOf(1), stats.getCurrentMinRealValue()); @@ -223,17 +101,15 @@ public void testMinMaxReal() throws Exception { Assert.assertNull(stats.getCurrentMinIntValue()); Assert.assertNull(stats.getCurrentMaxIntValue()); + Assert.assertNull(stats.getCollationDefinitionString()); Assert.assertNull(stats.getCurrentMinStrValue()); Assert.assertNull(stats.getCurrentMaxStrValue()); - Assert.assertNull(stats.getCollationDefinitionString()); - Assert.assertNull(stats.getCurrentMinColStrValue()); - Assert.assertNull(stats.getCurrentMaxColStrValue()); Assert.assertEquals(0, stats.getCurrentNullCount()); } @Test public void testIncCurrentNullCount() throws Exception { - RowBufferStats stats = new RowBufferStats(); + RowBufferStats stats = new RowBufferStats("COL1"); Assert.assertEquals(0, stats.getCurrentNullCount()); stats.incCurrentNullCount(); @@ -244,7 +120,7 @@ public void testIncCurrentNullCount() throws Exception { @Test public void testMaxLength() throws Exception { - RowBufferStats stats = new RowBufferStats(); + RowBufferStats stats = new RowBufferStats("COL1"); Assert.assertEquals(0, stats.getCurrentMaxLength()); stats.setCurrentMaxLength(100L); @@ -256,8 +132,8 @@ public void testMaxLength() throws Exception { @Test public void testGetCombinedStats() throws Exception { // Test for Integers - RowBufferStats one = new RowBufferStats(); - RowBufferStats two = new RowBufferStats(); + RowBufferStats one = new RowBufferStats("COL1"); + RowBufferStats two = new RowBufferStats("COL1"); one.addIntValue(BigInteger.valueOf(2)); one.addIntValue(BigInteger.valueOf(4)); @@ -283,8 +159,8 @@ public void testGetCombinedStats() throws Exception { Assert.assertNull(result.getCurrentMaxRealValue()); // Test for Reals - one = new RowBufferStats(); - two = new RowBufferStats(); + one = new RowBufferStats("COL1"); + two = new RowBufferStats("COL1"); one.addRealValue(2d); one.addRealValue(4d); @@ -302,17 +178,15 @@ public void testGetCombinedStats() throws Exception { Assert.assertEquals(-1, result.getDistinctValues()); Assert.assertEquals(0, result.getCurrentNullCount()); + Assert.assertNull(result.getCollationDefinitionString()); Assert.assertNull(result.getCurrentMinStrValue()); Assert.assertNull(result.getCurrentMaxStrValue()); - Assert.assertNull(result.getCollationDefinitionString()); - Assert.assertNull(result.getCurrentMinColStrValue()); - Assert.assertNull(result.getCurrentMaxColStrValue()); Assert.assertNull(result.getCurrentMinIntValue()); Assert.assertNull(result.getCurrentMaxIntValue()); // Test for Strings without collation - one = new RowBufferStats(); - two = new RowBufferStats(); + one = new RowBufferStats("COL1"); + two = new RowBufferStats("COL1"); one.addStrValue("alpha"); one.addStrValue("d"); @@ -329,10 +203,8 @@ public void testGetCombinedStats() throws Exception { two.setCurrentMaxLength(1); result = RowBufferStats.getCombinedStats(one, two); - Assert.assertEquals("a", result.getCurrentMinStrValue()); - Assert.assertEquals("g", result.getCurrentMaxStrValue()); - Assert.assertEquals("a", result.getCurrentMinColStrValue()); - Assert.assertEquals("g", result.getCurrentMaxColStrValue()); + Assert.assertArrayEquals("a".getBytes(StandardCharsets.UTF_8), result.getCurrentMinStrValue()); + Assert.assertArrayEquals("g".getBytes(StandardCharsets.UTF_8), result.getCurrentMaxStrValue()); Assert.assertEquals(-1, result.getDistinctValues()); Assert.assertEquals(2, result.getCurrentNullCount()); Assert.assertEquals(5, result.getCurrentMaxLength()); @@ -341,42 +213,13 @@ public void testGetCombinedStats() throws Exception { Assert.assertNull(result.getCurrentMaxRealValue()); Assert.assertNull(result.getCurrentMinIntValue()); Assert.assertNull(result.getCurrentMaxIntValue()); - - // Test for Strings with collation - one = new RowBufferStats("en-ci"); - two = new RowBufferStats("en-ci"); - - one.addStrValue("a"); - one.addStrValue("d"); - one.addStrValue("f"); - one.addStrValue("g"); - one.incCurrentNullCount(); - - two.addStrValue("Alpha"); - two.addStrValue("b"); - two.addStrValue("c"); - two.addStrValue("d"); - two.incCurrentNullCount(); - - result = RowBufferStats.getCombinedStats(one, two); - Assert.assertEquals("Alpha", result.getCurrentMinStrValue()); - Assert.assertEquals("g", result.getCurrentMaxStrValue()); - Assert.assertEquals("a", result.getCurrentMinColStrValue()); - Assert.assertEquals("g", result.getCurrentMaxColStrValue()); - Assert.assertEquals(-1, result.getDistinctValues()); - Assert.assertEquals(2, result.getCurrentNullCount()); - - Assert.assertNull(result.getCurrentMinRealValue()); - Assert.assertNull(result.getCurrentMaxRealValue()); - Assert.assertNull(result.getCurrentMinIntValue()); - Assert.assertNull(result.getCurrentMaxIntValue()); } @Test public void testGetCombinedStatsNull() throws Exception { // Test for Integers - RowBufferStats one = new RowBufferStats(); - RowBufferStats two = new RowBufferStats(); + RowBufferStats one = new RowBufferStats("COL1"); + RowBufferStats two = new RowBufferStats("COL1"); one.addIntValue(BigInteger.valueOf(2)); one.addIntValue(BigInteger.valueOf(4)); @@ -397,7 +240,7 @@ public void testGetCombinedStatsNull() throws Exception { Assert.assertNull(result.getCurrentMaxRealValue()); // Test for Reals - one = new RowBufferStats(); + one = new RowBufferStats("COL1"); one.addRealValue(2d); one.addRealValue(4d); @@ -416,8 +259,8 @@ public void testGetCombinedStatsNull() throws Exception { Assert.assertNull(result.getCurrentMaxIntValue()); // Test for Strings - one = new RowBufferStats(); - two = new RowBufferStats(); + one = new RowBufferStats("COL1"); + two = new RowBufferStats("COL1"); one.addStrValue("alpha"); one.addStrValue("d"); @@ -426,8 +269,9 @@ public void testGetCombinedStatsNull() throws Exception { one.incCurrentNullCount(); result = RowBufferStats.getCombinedStats(one, two); - Assert.assertEquals("alpha", result.getCurrentMinStrValue()); - Assert.assertEquals("g", result.getCurrentMaxStrValue()); + Assert.assertArrayEquals( + "alpha".getBytes(StandardCharsets.UTF_8), result.getCurrentMinStrValue()); + Assert.assertArrayEquals("g".getBytes(StandardCharsets.UTF_8), result.getCurrentMaxStrValue()); Assert.assertEquals(-1, result.getDistinctValues()); Assert.assertEquals(1, result.getCurrentNullCount()); diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/RowBufferTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/RowBufferTest.java index 23b36a1b5..5e34bc90b 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/RowBufferTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/RowBufferTest.java @@ -1,6 +1,8 @@ package net.snowflake.ingest.streaming.internal; -import static net.snowflake.ingest.streaming.internal.ArrowRowBuffer.DECIMAL_BIT_WIDTH; +import static java.time.ZoneOffset.UTC; +import static net.snowflake.ingest.utils.ParameterProvider.MAX_ALLOWED_ROW_SIZE_IN_BYTES_DEFAULT; +import static net.snowflake.ingest.utils.ParameterProvider.MAX_CHUNK_SIZE_IN_BYTES_DEFAULT; import java.math.BigDecimal; import java.math.BigInteger; @@ -9,72 +11,48 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import net.snowflake.ingest.streaming.InsertValidationResponse; import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.utils.Constants; import net.snowflake.ingest.utils.ErrorCode; -import net.snowflake.ingest.utils.Logging; import net.snowflake.ingest.utils.SFException; -import org.apache.arrow.vector.types.Types; -import org.apache.arrow.vector.types.pojo.ArrowType; -import org.apache.arrow.vector.types.pojo.Field; -import org.apache.arrow.vector.util.Text; +import org.apache.commons.codec.binary.Hex; import org.junit.Assert; import org.junit.Before; import org.junit.Test; public class RowBufferTest { - private static final Logging logger = new Logging(RegisterService.class); - private ArrowRowBuffer rowBufferOnErrorContinue; - private SnowflakeStreamingIngestChannelInternal channelOnErrorContinue; + private final boolean enableParquetMemoryOptimization; + private AbstractRowBuffer rowBufferOnErrorContinue; + private AbstractRowBuffer rowBufferOnErrorAbort; + private AbstractRowBuffer rowBufferOnErrorSkipBatch; - private ArrowRowBuffer rowBufferOnErrorAbort; - private SnowflakeStreamingIngestChannelInternal channelOnErrorAbort; + public RowBufferTest() { + this.enableParquetMemoryOptimization = false; + } @Before public void setupRowBuffer() { - // Create row buffer - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal("client"); - this.channelOnErrorContinue = - new SnowflakeStreamingIngestChannelInternal( - "channel", - "db", - "schema", - "table", - "0", - 0L, - 0L, - client, - "key", - 1234L, - OpenChannelRequest.OnErrorOption.CONTINUE, - true); - this.rowBufferOnErrorContinue = new ArrowRowBuffer(this.channelOnErrorContinue); - - this.channelOnErrorAbort = - new SnowflakeStreamingIngestChannelInternal( - "channel", - "db", - "schema", - "table", - "0", - 0L, - 0L, - client, - "key", - 1234L, - OpenChannelRequest.OnErrorOption.ABORT, - true); - this.rowBufferOnErrorAbort = new ArrowRowBuffer(this.channelOnErrorAbort); + this.rowBufferOnErrorContinue = createTestBuffer(OpenChannelRequest.OnErrorOption.CONTINUE); + this.rowBufferOnErrorAbort = createTestBuffer(OpenChannelRequest.OnErrorOption.ABORT); + this.rowBufferOnErrorSkipBatch = createTestBuffer(OpenChannelRequest.OnErrorOption.SKIP_BATCH); + List schema = createSchema(); + this.rowBufferOnErrorContinue.setupSchema(schema); + this.rowBufferOnErrorAbort.setupSchema(schema); + this.rowBufferOnErrorSkipBatch.setupSchema(schema); + } + static List createSchema() { ColumnMetadata colTinyIntCase = new ColumnMetadata(); - colTinyIntCase.setName("colTinyInt"); + colTinyIntCase.setName("\"colTinyInt\""); colTinyIntCase.setPhysicalType("SB1"); colTinyIntCase.setNullable(true); colTinyIntCase.setLogicalType("FIXED"); + colTinyIntCase.setPrecision(2); colTinyIntCase.setScale(0); ColumnMetadata colTinyInt = new ColumnMetadata(); @@ -82,6 +60,7 @@ public void setupRowBuffer() { colTinyInt.setPhysicalType("SB1"); colTinyInt.setNullable(true); colTinyInt.setLogicalType("FIXED"); + colTinyInt.setPrecision(1); colTinyInt.setScale(0); ColumnMetadata colSmallInt = new ColumnMetadata(); @@ -89,6 +68,7 @@ public void setupRowBuffer() { colSmallInt.setPhysicalType("SB2"); colSmallInt.setNullable(true); colSmallInt.setLogicalType("FIXED"); + colSmallInt.setPrecision(2); colSmallInt.setScale(0); ColumnMetadata colInt = new ColumnMetadata(); @@ -96,6 +76,7 @@ public void setupRowBuffer() { colInt.setPhysicalType("SB4"); colInt.setNullable(true); colInt.setLogicalType("FIXED"); + colInt.setPrecision(2); colInt.setScale(0); ColumnMetadata colBigInt = new ColumnMetadata(); @@ -103,6 +84,7 @@ public void setupRowBuffer() { colBigInt.setPhysicalType("SB8"); colBigInt.setNullable(true); colBigInt.setLogicalType("FIXED"); + colBigInt.setPrecision(2); colBigInt.setScale(0); ColumnMetadata colDecimal = new ColumnMetadata(); @@ -121,15 +103,51 @@ public void setupRowBuffer() { colChar.setByteLength(14); colChar.setLength(11); colChar.setScale(0); - colChar.setCollation("en-ci"); - // Setup column fields and vectors - this.rowBufferOnErrorContinue.setupSchema( - Arrays.asList( - colTinyIntCase, colTinyInt, colSmallInt, colInt, colBigInt, colDecimal, colChar)); - this.rowBufferOnErrorAbort.setupSchema( + List columns = Arrays.asList( - colTinyIntCase, colTinyInt, colSmallInt, colInt, colBigInt, colDecimal, colChar)); + colTinyIntCase, colTinyInt, colSmallInt, colInt, colBigInt, colDecimal, colChar); + for (int i = 0; i < columns.size(); i++) { + columns.get(i).setOrdinal(i + 1); + } + return columns; + } + + private AbstractRowBuffer createTestBuffer(OpenChannelRequest.OnErrorOption onErrorOption) { + ChannelRuntimeState initialState = new ChannelRuntimeState("0", 0L, true); + return AbstractRowBuffer.createRowBuffer( + onErrorOption, + UTC, + Constants.BdecVersion.THREE, + "test.buffer", + rs -> {}, + initialState, + ClientBufferParameters.test_createClientBufferParameters( + enableParquetMemoryOptimization, + MAX_CHUNK_SIZE_IN_BYTES_DEFAULT, + MAX_ALLOWED_ROW_SIZE_IN_BYTES_DEFAULT, + Constants.BdecParquetCompression.GZIP), + null, + null); + } + + @Test + public void testCollatedColumnsAreRejected() { + ColumnMetadata collatedColumn = new ColumnMetadata(); + collatedColumn.setName("COLCHAR"); + collatedColumn.setPhysicalType("LOB"); + collatedColumn.setNullable(true); + collatedColumn.setLogicalType("TEXT"); + collatedColumn.setByteLength(14); + collatedColumn.setLength(11); + collatedColumn.setScale(0); + collatedColumn.setCollation("en-ci"); + try { + this.rowBufferOnErrorAbort.setupSchema(Collections.singletonList(collatedColumn)); + Assert.fail("Collated columns are not supported"); + } catch (SFException e) { + Assert.assertEquals(ErrorCode.UNSUPPORTED_DATA_TYPE.getMessageCode(), e.getVendorCode()); + } } @Test @@ -145,7 +163,7 @@ public void buildFieldErrorStates() { testCol.setScale(0); testCol.setPrecision(4); try { - Field result = this.rowBufferOnErrorContinue.buildField(testCol); + this.rowBufferOnErrorContinue.setupSchema(Collections.singletonList(testCol)); Assert.fail("Expected error"); } catch (SFException e) { Assert.assertEquals(ErrorCode.UNKNOWN_DATA_TYPE.getMessageCode(), e.getVendorCode()); @@ -153,10 +171,11 @@ public void buildFieldErrorStates() { // Fixed LOB testCol = new ColumnMetadata(); + testCol.setName("COL1"); testCol.setPhysicalType("LOB"); testCol.setLogicalType("FIXED"); try { - Field result = this.rowBufferOnErrorContinue.buildField(testCol); + this.rowBufferOnErrorContinue.setupSchema(Collections.singletonList(testCol)); Assert.fail("Expected error"); } catch (SFException e) { Assert.assertEquals(ErrorCode.UNKNOWN_DATA_TYPE.getMessageCode(), e.getVendorCode()); @@ -164,10 +183,11 @@ public void buildFieldErrorStates() { // TIMESTAMP_NTZ SB2 testCol = new ColumnMetadata(); + testCol.setName("COL1"); testCol.setPhysicalType("SB2"); testCol.setLogicalType("TIMESTAMP_NTZ"); try { - Field result = this.rowBufferOnErrorContinue.buildField(testCol); + this.rowBufferOnErrorContinue.setupSchema(Collections.singletonList(testCol)); Assert.fail("Expected error"); } catch (SFException e) { Assert.assertEquals(ErrorCode.UNKNOWN_DATA_TYPE.getMessageCode(), e.getVendorCode()); @@ -175,10 +195,11 @@ public void buildFieldErrorStates() { // TIMESTAMP_TZ SB1 testCol = new ColumnMetadata(); + testCol.setName("COL1"); testCol.setPhysicalType("SB1"); testCol.setLogicalType("TIMESTAMP_TZ"); try { - Field result = this.rowBufferOnErrorContinue.buildField(testCol); + this.rowBufferOnErrorContinue.setupSchema(Collections.singletonList(testCol)); Assert.fail("Expected error"); } catch (SFException e) { Assert.assertEquals(ErrorCode.UNKNOWN_DATA_TYPE.getMessageCode(), e.getVendorCode()); @@ -186,377 +207,28 @@ public void buildFieldErrorStates() { // TIME SB16 testCol = new ColumnMetadata(); + testCol.setName("COL1"); testCol.setPhysicalType("SB16"); testCol.setLogicalType("TIME"); try { - Field result = this.rowBufferOnErrorContinue.buildField(testCol); + this.rowBufferOnErrorContinue.setupSchema(Collections.singletonList(testCol)); Assert.fail("Expected error"); } catch (SFException e) { Assert.assertEquals(ErrorCode.UNKNOWN_DATA_TYPE.getMessageCode(), e.getVendorCode()); } } - @Test - public void buildFieldFixedSB1() { - // FIXED, SB1 - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB1"); - testCol.setNullable(true); - testCol.setLogicalType("FIXED"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.TINYINT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB1"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "FIXED"); - Assert.assertEquals(result.getFieldType().getMetadata().get("nullable"), "true"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldFixedSB2() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB2"); - testCol.setNullable(false); - testCol.setLogicalType("FIXED"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.SMALLINT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB2"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "FIXED"); - Assert.assertEquals(result.getFieldType().getMetadata().get("nullable"), "false"); - Assert.assertFalse(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldFixedSB4() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB4"); - testCol.setNullable(true); - testCol.setLogicalType("FIXED"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.INT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB4"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "FIXED"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldFixedSB8() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB8"); - testCol.setNullable(true); - testCol.setLogicalType("FIXED"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.BIGINT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB8"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "FIXED"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldFixedSB16() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB16"); - testCol.setNullable(true); - testCol.setLogicalType("FIXED"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - ArrowType expectedType = - new ArrowType.Decimal(testCol.getPrecision(), testCol.getScale(), DECIMAL_BIT_WIDTH); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), expectedType); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB16"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "FIXED"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldLobVariant() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("LOB"); - testCol.setNullable(true); - testCol.setLogicalType("VARIANT"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.VARCHAR.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "LOB"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "VARIANT"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldTimestampNtzSB8() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB8"); - testCol.setNullable(true); - testCol.setLogicalType("TIMESTAMP_NTZ"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.BIGINT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB8"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "TIMESTAMP_NTZ"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldTimestampNtzSB16() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB16"); - testCol.setNullable(true); - testCol.setLogicalType("TIMESTAMP_NTZ"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.STRUCT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB16"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "TIMESTAMP_NTZ"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 2); - Assert.assertEquals( - result.getChildren().get(0).getFieldType().getType(), Types.MinorType.BIGINT.getType()); - Assert.assertEquals( - result.getChildren().get(1).getFieldType().getType(), Types.MinorType.INT.getType()); - } - - @Test - public void buildFieldTimestampTzSB8() throws Exception { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB8"); - testCol.setNullable(true); - testCol.setLogicalType("TIMESTAMP_TZ"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.STRUCT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB8"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "TIMESTAMP_TZ"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 2); - Assert.assertEquals( - result.getChildren().get(0).getFieldType().getType(), Types.MinorType.BIGINT.getType()); - Assert.assertEquals( - result.getChildren().get(1).getFieldType().getType(), Types.MinorType.INT.getType()); - } - - @Test - public void buildFieldTimestampTzSB16() throws Exception { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB16"); - testCol.setNullable(true); - testCol.setLogicalType("TIMESTAMP_TZ"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.STRUCT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB16"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "TIMESTAMP_TZ"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 3); - Assert.assertEquals( - result.getChildren().get(0).getFieldType().getType(), Types.MinorType.BIGINT.getType()); - Assert.assertEquals( - result.getChildren().get(1).getFieldType().getType(), Types.MinorType.INT.getType()); - Assert.assertEquals( - result.getChildren().get(2).getFieldType().getType(), Types.MinorType.INT.getType()); - } - - @Test - public void buildFieldTimestampDate() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB8"); - testCol.setNullable(true); - testCol.setLogicalType("DATE"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.DATEDAY.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB8"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "DATE"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldTimeSB4() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB4"); - testCol.setNullable(true); - testCol.setLogicalType("TIME"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.INT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB4"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "TIME"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldTimeSB8() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB8"); - testCol.setNullable(true); - testCol.setLogicalType("TIME"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.BIGINT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB8"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "TIME"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldBoolean() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("BINARY"); - testCol.setNullable(true); - testCol.setLogicalType("BOOLEAN"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.BIT.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "BINARY"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "BOOLEAN"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - - @Test - public void buildFieldRealSB16() { - ColumnMetadata testCol = new ColumnMetadata(); - testCol.setName("testCol"); - testCol.setPhysicalType("SB16"); - testCol.setNullable(true); - testCol.setLogicalType("REAL"); - testCol.setByteLength(14); - testCol.setLength(11); - testCol.setScale(0); - testCol.setPrecision(4); - Field result = this.rowBufferOnErrorContinue.buildField(testCol); - - Assert.assertEquals("testCol", result.getName()); - Assert.assertEquals(result.getFieldType().getType(), Types.MinorType.FLOAT8.getType()); - Assert.assertEquals(result.getFieldType().getMetadata().get("physicalType"), "SB16"); - Assert.assertEquals(result.getFieldType().getMetadata().get("scale"), "0"); - Assert.assertEquals(result.getFieldType().getMetadata().get("logicalType"), "REAL"); - Assert.assertTrue(result.getFieldType().isNullable()); - Assert.assertEquals(result.getChildren().size(), 0); - } - @Test public void testReset() { RowBufferStats stats = this.rowBufferOnErrorContinue.statsMap.get("COLCHAR"); stats.addIntValue(BigInteger.valueOf(1)); Assert.assertEquals(BigInteger.valueOf(1), stats.getCurrentMaxIntValue()); - Assert.assertEquals("en-ci", stats.getCollationDefinitionString()); + Assert.assertNull(stats.getCollationDefinitionString()); this.rowBufferOnErrorContinue.reset(); RowBufferStats resetStats = this.rowBufferOnErrorContinue.statsMap.get("COLCHAR"); Assert.assertNotNull(resetStats); Assert.assertNull(resetStats.getCurrentMaxIntValue()); - Assert.assertEquals("en-ci", resetStats.getCollationDefinitionString()); + Assert.assertNull(resetStats.getCollationDefinitionString()); } @Test @@ -602,34 +274,80 @@ public void testInvalidPhysicalType() { public void testStringLength() { testStringLengthHelper(this.rowBufferOnErrorContinue); testStringLengthHelper(this.rowBufferOnErrorAbort); + testStringLengthHelper(this.rowBufferOnErrorSkipBatch); } @Test - public void testRowIndexWithMultipleRowsWithErrorr() { + public void testRowIndexWithMultipleRowsWithError() { + testRowIndexWithMultipleRowsWithErrorHelper(this.rowBufferOnErrorContinue); + testRowIndexWithMultipleRowsWithErrorHelper(this.rowBufferOnErrorSkipBatch); + } + + public void testRowIndexWithMultipleRowsWithErrorHelper(AbstractRowBuffer rowBuffer) { List> rows = new ArrayList<>(); Map row = new HashMap<>(); - // row with good data + // row with interleaved good and bad data row.put("colInt", 3); rows.add(row); row = new HashMap<>(); row.put("colChar", "1111111111111111111111"); // too big + rows.add(row); - // lets add a row with bad data + row = new HashMap<>(); + row.put("colInt", 3); rows.add(row); - InsertValidationResponse response = this.rowBufferOnErrorContinue.insertRows(rows, null); + row = new HashMap<>(); + row.put("colChar", "1111111111111111111111"); // too big + rows.add(row); + + InsertValidationResponse response = rowBuffer.insertRows(rows, null, null); Assert.assertTrue(response.hasErrors()); - Assert.assertEquals(1, response.getErrorRowCount()); + Assert.assertEquals(2, response.getErrorRowCount()); // second row out of the rows we sent was having bad data. // so InsertError corresponds to second row. Assert.assertEquals(1, response.getInsertErrors().get(0).getRowIndex()); + Assert.assertEquals(3, response.getInsertErrors().get(1).getRowIndex()); + + Assert.assertNotNull(response.getInsertErrors().get(0).getException()); + Assert.assertNotNull(response.getInsertErrors().get(1).getException()); + + Assert.assertEquals( + response.getInsertErrors().get(0).getException().getVendorCode(), + ErrorCode.INVALID_VALUE_ROW.getMessageCode()); + Assert.assertEquals( + response.getInsertErrors().get(1).getException().getVendorCode(), + ErrorCode.INVALID_VALUE_ROW.getMessageCode()); + + Assert.assertTrue( + response + .getInsertErrors() + .get(0) + .getException() + .getMessage() + .equalsIgnoreCase( + "The given row cannot be converted to the internal format due to invalid value:" + + " Value cannot be ingested into Snowflake column COLCHAR of type STRING," + + " rowIndex:1, reason: String too long: length=22 characters maxLength=11" + + " characters")); + Assert.assertTrue( + response + .getInsertErrors() + .get(1) + .getException() + .getMessage() + .equalsIgnoreCase( + "The given row cannot be converted to the internal format due to invalid value:" + + " Value cannot be ingested into Snowflake column COLCHAR of type STRING," + + " rowIndex:3, reason: String too long: length=22 characters maxLength=11" + + " characters")); } - private void testStringLengthHelper(ArrowRowBuffer rowBuffer) { + private void testStringLengthHelper(AbstractRowBuffer rowBuffer) { Map row = new HashMap<>(); row.put("colTinyInt", (byte) 1); row.put("\"colTinyInt\"", (byte) 1); @@ -639,7 +357,8 @@ private void testStringLengthHelper(ArrowRowBuffer rowBuffer) { row.put("colDecimal", 1.23); row.put("colChar", "1234567890"); // still fits - InsertValidationResponse response = rowBuffer.insertRows(Collections.singletonList(row), null); + InsertValidationResponse response = + rowBuffer.insertRows(Collections.singletonList(row), null, null); Assert.assertFalse(response.hasErrors()); row.put("colTinyInt", (byte) 1); @@ -650,19 +369,19 @@ private void testStringLengthHelper(ArrowRowBuffer rowBuffer) { row.put("colDecimal", 1.23); row.put("colChar", "1111111111111111111111"); // too big - if (rowBuffer.owningChannel.getOnErrorOption() == OpenChannelRequest.OnErrorOption.CONTINUE) { - response = rowBuffer.insertRows(Collections.singletonList(row), null); + if (rowBuffer.onErrorOption == OpenChannelRequest.OnErrorOption.CONTINUE) { + response = rowBuffer.insertRows(Collections.singletonList(row), null, null); Assert.assertTrue(response.hasErrors()); Assert.assertEquals(1, response.getErrorRowCount()); Assert.assertEquals( - ErrorCode.INVALID_ROW.getMessageCode(), + ErrorCode.INVALID_VALUE_ROW.getMessageCode(), response.getInsertErrors().get(0).getException().getVendorCode()); Assert.assertTrue(response.getInsertErrors().get(0).getMessage().contains("String too long")); } else { try { - rowBuffer.insertRows(Collections.singletonList(row), null); + rowBuffer.insertRows(Collections.singletonList(row), null, null); } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); + Assert.assertEquals(ErrorCode.INVALID_VALUE_ROW.getMessageCode(), e.getVendorCode()); } } } @@ -671,9 +390,10 @@ private void testStringLengthHelper(ArrowRowBuffer rowBuffer) { public void testInsertRow() { testInsertRowHelper(this.rowBufferOnErrorContinue); testInsertRowHelper(this.rowBufferOnErrorAbort); + testInsertRowHelper(this.rowBufferOnErrorSkipBatch); } - private void testInsertRowHelper(ArrowRowBuffer rowBuffer) { + private void testInsertRowHelper(AbstractRowBuffer rowBuffer) { Map row = new HashMap<>(); row.put("colTinyInt", (byte) 1); row.put("\"colTinyInt\"", (byte) 1); @@ -683,7 +403,30 @@ private void testInsertRowHelper(ArrowRowBuffer rowBuffer) { row.put("colDecimal", 1.23); row.put("colChar", "2"); - InsertValidationResponse response = rowBuffer.insertRows(Collections.singletonList(row), null); + InsertValidationResponse response = + rowBuffer.insertRows(Collections.singletonList(row), null, null); + Assert.assertFalse(response.hasErrors()); + } + + @Test + public void testNullInsertRow() { + testInsertNullRowHelper(this.rowBufferOnErrorContinue); + testInsertNullRowHelper(this.rowBufferOnErrorAbort); + testInsertNullRowHelper(this.rowBufferOnErrorSkipBatch); + } + + private void testInsertNullRowHelper(AbstractRowBuffer rowBuffer) { + Map row = new HashMap<>(); + row.put("colTinyInt", null); + row.put("\"colTinyInt\"", null); + row.put("colSmallInt", null); + row.put("colInt", null); + row.put("colBigInt", null); + row.put("colDecimal", null); + row.put("colChar", null); + + InsertValidationResponse response = + rowBuffer.insertRows(Collections.singletonList(row), null, null); Assert.assertFalse(response.hasErrors()); } @@ -691,9 +434,10 @@ private void testInsertRowHelper(ArrowRowBuffer rowBuffer) { public void testInsertRows() { testInsertRowsHelper(this.rowBufferOnErrorContinue); testInsertRowsHelper(this.rowBufferOnErrorAbort); + testInsertRowsHelper(this.rowBufferOnErrorSkipBatch); } - private void testInsertRowsHelper(ArrowRowBuffer rowBuffer) { + private void testInsertRowsHelper(AbstractRowBuffer rowBuffer) { Map row1 = new HashMap<>(); row1.put("colTinyInt", (byte) 1); row1.put("\"colTinyInt\"", (byte) 1); @@ -712,37 +456,20 @@ private void testInsertRowsHelper(ArrowRowBuffer rowBuffer) { row2.put("colDecimal", 2.34); row2.put("colChar", "3"); - InsertValidationResponse response = rowBuffer.insertRows(Arrays.asList(row1, row2), null); + InsertValidationResponse response = rowBuffer.insertRows(Arrays.asList(row1, row2), null, null); Assert.assertFalse(response.hasErrors()); } - @Test - public void testClose() { - this.rowBufferOnErrorContinue.close(); - Map row = new HashMap<>(); - row.put("colTinyInt", (byte) 1); - row.put("colSmallInt", (short) 2); - row.put("colInt", 3); - row.put("colBigInt", 4L); - row.put("colDecimal", 1.23); - row.put("colChar", "2"); - - try { - this.rowBufferOnErrorContinue.insertRows(Collections.singletonList(row), null); - Assert.fail("Insert should fail after buffer is closed"); - } catch (SFException e) { - Assert.assertEquals(ErrorCode.INTERNAL_ERROR.getMessageCode(), e.getVendorCode()); - } - } - @Test public void testFlush() { testFlushHelper(this.rowBufferOnErrorAbort); testFlushHelper(this.rowBufferOnErrorContinue); + testFlushHelper(this.rowBufferOnErrorSkipBatch); } - private void testFlushHelper(ArrowRowBuffer rowBuffer) { - String offsetToken = "1"; + private void testFlushHelper(AbstractRowBuffer rowBuffer) { + String startOffsetToken = "1"; + String endOffsetToken = "2"; Map row1 = new HashMap<>(); row1.put("colTinyInt", (byte) 1); row1.put("\"colTinyInt\"", (byte) 1); @@ -762,29 +489,37 @@ private void testFlushHelper(ArrowRowBuffer rowBuffer) { row2.put("colChar", "3"); InsertValidationResponse response = - rowBuffer.insertRows(Arrays.asList(row1, row2), offsetToken); + rowBuffer.insertRows(Arrays.asList(row1, row2), startOffsetToken, endOffsetToken); Assert.assertFalse(response.hasErrors()); float bufferSize = rowBuffer.getSize(); - ChannelData data = rowBuffer.flush(); + final String filename = "2022/7/13/16/56/testFlushHelper_streaming.bdec"; + ChannelData data = rowBuffer.flush(filename); Assert.assertEquals(2, data.getRowCount()); Assert.assertEquals((Long) 1L, data.getRowSequencer()); - Assert.assertEquals(7, data.getVectors().getFieldVectors().size()); - Assert.assertEquals(offsetToken, data.getOffsetToken()); + Assert.assertEquals(startOffsetToken, data.getStartOffsetToken()); + Assert.assertEquals(endOffsetToken, data.getEndOffsetToken()); Assert.assertEquals(bufferSize, data.getBufferSize(), 0); + + final ParquetChunkData chunkData = (ParquetChunkData) data.getVectors(); + Assert.assertEquals( + StreamingIngestUtils.getShortname(filename), + chunkData.metadata.get(Constants.PRIMARY_FILE_ID_KEY)); } @Test public void testDoubleQuotesColumnName() { - testDoubleQuotesColumnNameHelper(this.channelOnErrorAbort); - testDoubleQuotesColumnNameHelper(this.channelOnErrorContinue); + testDoubleQuotesColumnNameHelper(OpenChannelRequest.OnErrorOption.ABORT); + testDoubleQuotesColumnNameHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testDoubleQuotesColumnNameHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); } - private void testDoubleQuotesColumnNameHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testDoubleQuotesColumnNameHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); ColumnMetadata colDoubleQuotes = new ColumnMetadata(); - colDoubleQuotes.setName("colDoubleQuotes"); + colDoubleQuotes.setOrdinal(1); + colDoubleQuotes.setName("\"colDoubleQuotes\""); colDoubleQuotes.setPhysicalType("SB16"); colDoubleQuotes.setNullable(true); colDoubleQuotes.setLogicalType("FIXED"); @@ -797,7 +532,7 @@ private void testDoubleQuotesColumnNameHelper(SnowflakeStreamingIngestChannelInt row.put("\"colDoubleQuotes\"", 1); InsertValidationResponse response = - innerBuffer.insertRows(Collections.singletonList(row), null); + innerBuffer.insertRows(Collections.singletonList(row), null, null); Assert.assertFalse(response.hasErrors()); } @@ -805,12 +540,12 @@ private void testDoubleQuotesColumnNameHelper(SnowflakeStreamingIngestChannelInt public void testBuildEpInfoFromStats() { Map colStats = new HashMap<>(); - RowBufferStats stats1 = new RowBufferStats(); + RowBufferStats stats1 = new RowBufferStats("intColumn"); stats1.addIntValue(BigInteger.valueOf(2)); stats1.addIntValue(BigInteger.valueOf(10)); stats1.addIntValue(BigInteger.valueOf(1)); - RowBufferStats stats2 = new RowBufferStats(); + RowBufferStats stats2 = new RowBufferStats("strColumn"); stats2.addStrValue("alice"); stats2.addStrValue("bob"); stats2.incCurrentNullCount(); @@ -818,14 +553,18 @@ public void testBuildEpInfoFromStats() { colStats.put("intColumn", stats1); colStats.put("strColumn", stats2); - EpInfo result = ArrowRowBuffer.buildEpInfoFromStats(2, colStats); + EpInfo result = AbstractRowBuffer.buildEpInfoFromStats(2, colStats); Map columnResults = result.getColumnEps(); Assert.assertEquals(2, columnResults.keySet().size()); FileColumnProperties strColumnResult = columnResults.get("strColumn"); Assert.assertEquals(-1, strColumnResult.getDistinctValues()); - Assert.assertEquals("alice", strColumnResult.getMinStrValue()); - Assert.assertEquals("bob", strColumnResult.getMaxStrValue()); + Assert.assertEquals( + Hex.encodeHexString("alice".getBytes(StandardCharsets.UTF_8)), + strColumnResult.getMinStrValue()); + Assert.assertEquals( + Hex.encodeHexString("bob".getBytes(StandardCharsets.UTF_8)), + strColumnResult.getMaxStrValue()); Assert.assertEquals(1, strColumnResult.getNullCount()); FileColumnProperties intColumnResult = columnResults.get("intColumn"); @@ -837,256 +576,107 @@ public void testBuildEpInfoFromStats() { @Test public void testBuildEpInfoFromNullColumnStats() { - final String colName = "intCol"; + final String intColName = "intCol"; + final String realColName = "realCol"; Map colStats = new HashMap<>(); - RowBufferStats stats = new RowBufferStats(); - stats.incCurrentNullCount(); + RowBufferStats stats1 = new RowBufferStats(intColName); + RowBufferStats stats2 = new RowBufferStats(realColName); + stats1.incCurrentNullCount(); + stats2.incCurrentNullCount(); - colStats.put(colName, stats); + colStats.put(intColName, stats1); + colStats.put(realColName, stats2); - EpInfo result = ArrowRowBuffer.buildEpInfoFromStats(2, colStats); + EpInfo result = AbstractRowBuffer.buildEpInfoFromStats(2, colStats); Map columnResults = result.getColumnEps(); - Assert.assertEquals(1, columnResults.keySet().size()); + Assert.assertEquals(2, columnResults.keySet().size()); - FileColumnProperties intColumnResult = columnResults.get(colName); + FileColumnProperties intColumnResult = columnResults.get(intColName); Assert.assertEquals(-1, intColumnResult.getDistinctValues()); Assert.assertEquals( - FileColumnProperties.DEFAULT_MIN_MAX_INT_VAL_FOR_EP, intColumnResult.getMinIntValue()); - Assert.assertEquals( - FileColumnProperties.DEFAULT_MIN_MAX_INT_VAL_FOR_EP, intColumnResult.getMaxIntValue()); - Assert.assertEquals(1, intColumnResult.getNullCount()); - Assert.assertEquals(0, intColumnResult.getMaxLength()); - } - - @Test - public void testArrowE2E() { - testArrowE2EHelper(this.rowBufferOnErrorAbort); - testArrowE2EHelper(this.rowBufferOnErrorContinue); - } - - private void testArrowE2EHelper(ArrowRowBuffer rowBuffer) { - Map row1 = new HashMap<>(); - row1.put("\"colTinyInt\"", (byte) 10); - row1.put("colTinyInt", (byte) 1); - row1.put("colSmallInt", (short) 2); - row1.put("colInt", 3); - row1.put("colBigInt", 4L); - row1.put("colDecimal", 4); - row1.put("colChar", "2"); - - InsertValidationResponse response = rowBuffer.insertRows(Collections.singletonList(row1), null); - Assert.assertFalse(response.hasErrors()); - - Assert.assertEquals((byte) 10, rowBuffer.vectorsRoot.getVector("colTinyInt").getObject(0)); - Assert.assertEquals((byte) 1, rowBuffer.vectorsRoot.getVector("COLTINYINT").getObject(0)); - Assert.assertEquals((short) 2, rowBuffer.vectorsRoot.getVector("COLSMALLINT").getObject(0)); - Assert.assertEquals(3, rowBuffer.vectorsRoot.getVector("COLINT").getObject(0)); - Assert.assertEquals(4L, rowBuffer.vectorsRoot.getVector("COLBIGINT").getObject(0)); - Assert.assertEquals( - new BigDecimal("4.00"), rowBuffer.vectorsRoot.getVector("COLDECIMAL").getObject(0)); - Assert.assertEquals(new Text("2"), rowBuffer.vectorsRoot.getVector("COLCHAR").getObject(0)); - } - - @Test - public void testArrowE2ETimestampLTZ() { - testArrowE2ETimestampLTZHelper(this.channelOnErrorContinue); - testArrowE2ETimestampLTZHelper(this.channelOnErrorAbort); - } - - private void testArrowE2ETimestampLTZHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); - - ColumnMetadata colTimestampLtzSB8 = new ColumnMetadata(); - colTimestampLtzSB8.setName("COLTIMESTAMPLTZ_SB8"); - colTimestampLtzSB8.setPhysicalType("SB8"); - colTimestampLtzSB8.setNullable(false); - colTimestampLtzSB8.setLogicalType("TIMESTAMP_LTZ"); - colTimestampLtzSB8.setScale(0); - - ColumnMetadata colTimestampLtzSB16 = new ColumnMetadata(); - colTimestampLtzSB16.setName("COLTIMESTAMPLTZ_SB16"); - colTimestampLtzSB16.setPhysicalType("SB16"); - colTimestampLtzSB16.setNullable(false); - colTimestampLtzSB16.setLogicalType("TIMESTAMP_LTZ"); - colTimestampLtzSB16.setScale(9); - - innerBuffer.setupSchema(Arrays.asList(colTimestampLtzSB8, colTimestampLtzSB16)); - - Map row = new HashMap<>(); - row.put("COLTIMESTAMPLTZ_SB8", "1621899220"); - row.put("COLTIMESTAMPLTZ_SB16", new BigDecimal("1621899220.123456789")); - - InsertValidationResponse response = - innerBuffer.insertRows(Collections.singletonList(row), null); - Assert.assertFalse(response.hasErrors()); - Assert.assertEquals( - 1621899220l, innerBuffer.vectorsRoot.getVector("COLTIMESTAMPLTZ_SB8").getObject(0)); - Assert.assertEquals( - "epoch", - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPLTZ_SB16") - .getChildrenFromFields() - .get(0) - .getName()); + FileColumnProperties.DEFAULT_MIN_MAX_INT_VAL_FOR_EP, intColumnResult.getMinIntValue()); Assert.assertEquals( - 1621899220l, - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPLTZ_SB16") - .getChildrenFromFields() - .get(0) - .getObject(0)); + FileColumnProperties.DEFAULT_MIN_MAX_INT_VAL_FOR_EP, intColumnResult.getMaxIntValue()); + Assert.assertEquals(1, intColumnResult.getNullCount()); + Assert.assertEquals(0, intColumnResult.getMaxLength()); + + FileColumnProperties realColumnResult = columnResults.get(realColName); + Assert.assertEquals(-1, intColumnResult.getDistinctValues()); Assert.assertEquals( - "fraction", - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPLTZ_SB16") - .getChildrenFromFields() - .get(1) - .getName()); + FileColumnProperties.DEFAULT_MIN_MAX_REAL_VAL_FOR_EP, realColumnResult.getMinRealValue()); Assert.assertEquals( - 123456789, - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPLTZ_SB16") - .getChildrenFromFields() - .get(1) - .getObject(0)); + FileColumnProperties.DEFAULT_MIN_MAX_REAL_VAL_FOR_EP, realColumnResult.getMaxRealValue()); + Assert.assertEquals(1, realColumnResult.getNullCount()); + Assert.assertEquals(0, realColumnResult.getMaxLength()); } @Test - public void testArrowE2ETimestampTZ() { - testArrowE2ETimestampTZHelper(this.channelOnErrorContinue); - testArrowE2ETimestampTZHelper(this.channelOnErrorAbort); - } + public void testInvalidEPInfo() { + Map colStats = new HashMap<>(); + + RowBufferStats stats1 = new RowBufferStats("intColumn"); + stats1.addIntValue(BigInteger.valueOf(2)); + stats1.addIntValue(BigInteger.valueOf(10)); + stats1.addIntValue(BigInteger.valueOf(1)); - private void testArrowE2ETimestampTZHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + RowBufferStats stats2 = new RowBufferStats("strColumn"); + stats2.addStrValue("alice"); + stats2.incCurrentNullCount(); + stats2.incCurrentNullCount(); - ColumnMetadata colTimestampTzSB8 = new ColumnMetadata(); - colTimestampTzSB8.setName("COLTIMESTAMPTZ_SB8"); - colTimestampTzSB8.setPhysicalType("SB8"); - colTimestampTzSB8.setNullable(false); - colTimestampTzSB8.setLogicalType("TIMESTAMP_TZ"); - colTimestampTzSB8.setScale(0); + colStats.put("intColumn", stats1); + colStats.put("strColumn", stats2); - ColumnMetadata colTimestampTzSB16 = new ColumnMetadata(); - colTimestampTzSB16.setName("COLTIMESTAMPTZ_SB16"); - colTimestampTzSB16.setPhysicalType("SB16"); - colTimestampTzSB16.setNullable(false); - colTimestampTzSB16.setLogicalType("TIMESTAMP_TZ"); - colTimestampTzSB16.setScale(9); + try { + AbstractRowBuffer.buildEpInfoFromStats(1, colStats); + Assert.fail("should fail when row count is smaller than null count."); + } catch (SFException e) { + Assert.assertEquals(ErrorCode.INTERNAL_ERROR.getMessageCode(), e.getVendorCode()); + } + } - innerBuffer.setupSchema(Arrays.asList(colTimestampTzSB8, colTimestampTzSB16)); + @Test + public void testE2E() { + testE2EHelper(this.rowBufferOnErrorAbort); + testE2EHelper(this.rowBufferOnErrorContinue); + testE2EHelper(this.rowBufferOnErrorSkipBatch); + } - Map row = new HashMap<>(); - row.put("COLTIMESTAMPTZ_SB8", "2021-01-01 01:00:00 +0100"); - row.put("COLTIMESTAMPTZ_SB16", "2021-01-01 10:00:00.123456789 +1000"); + private void testE2EHelper(AbstractRowBuffer rowBuffer) { + Map row1 = new HashMap<>(); + row1.put("\"colTinyInt\"", (byte) 10); + row1.put("colTinyInt", (byte) 1); + row1.put("colSmallInt", (short) 2); + row1.put("colInt", 3); + row1.put("colBigInt", 4L); + row1.put("colDecimal", 4); + row1.put("colChar", "2"); InsertValidationResponse response = - innerBuffer.insertRows(Collections.singletonList(row), null); + rowBuffer.insertRows(Collections.singletonList(row1), null, null); Assert.assertFalse(response.hasErrors()); - // SB8 - Assert.assertEquals( - "epoch", - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB8") - .getChildrenFromFields() - .get(0) - .getName()); - Assert.assertEquals( - 1609459200l, - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB8") - .getChildrenFromFields() - .get(0) - .getObject(0)); - - Assert.assertEquals( - "timezone", - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB8") - .getChildrenFromFields() - .get(1) - .getName()); - Assert.assertEquals( - 1500, - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB8") - .getChildrenFromFields() - .get(1) - .getObject(0)); - - // SB16 - Assert.assertEquals( - "epoch", - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB16") - .getChildrenFromFields() - .get(0) - .getName()); - Assert.assertEquals( - 1609459200l, - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB16") - .getChildrenFromFields() - .get(0) - .getObject(0)); - - Assert.assertEquals( - "fraction", - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB16") - .getChildrenFromFields() - .get(1) - .getName()); - Assert.assertEquals( - 123456789, - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB16") - .getChildrenFromFields() - .get(1) - .getObject(0)); - - Assert.assertEquals( - "timezone", - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB16") - .getChildrenFromFields() - .get(2) - .getName()); - Assert.assertEquals( - 2040, - innerBuffer - .vectorsRoot - .getVector("COLTIMESTAMPTZ_SB16") - .getChildrenFromFields() - .get(2) - .getObject(0)); + Assert.assertEquals((byte) 10, rowBuffer.getVectorValueAt("colTinyInt", 0)); + Assert.assertEquals((byte) 1, rowBuffer.getVectorValueAt("COLTINYINT", 0)); + Assert.assertEquals((short) 2, rowBuffer.getVectorValueAt("COLSMALLINT", 0)); + Assert.assertEquals(3, rowBuffer.getVectorValueAt("COLINT", 0)); + Assert.assertEquals(4L, rowBuffer.getVectorValueAt("COLBIGINT", 0)); + Assert.assertEquals(new BigDecimal("4.00"), rowBuffer.getVectorValueAt("COLDECIMAL", 0)); + Assert.assertEquals("2", rowBuffer.getVectorValueAt("COLCHAR", 0)); } @Test - public void testArrowE2ETimestampErrors() { - testArrowE2ETimestampErrorsHelper(this.channelOnErrorAbort); - testArrowE2ETimestampErrorsHelper(this.channelOnErrorContinue); + public void testE2ETimestampErrors() { + testE2ETimestampErrorsHelper(this.rowBufferOnErrorAbort); + testE2ETimestampErrorsHelper(this.rowBufferOnErrorContinue); + testE2ETimestampErrorsHelper(this.rowBufferOnErrorSkipBatch); } - private void testArrowE2ETimestampErrorsHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testE2ETimestampErrorsHelper(AbstractRowBuffer innerBuffer) { ColumnMetadata colTimestampLtzSB16 = new ColumnMetadata(); + colTimestampLtzSB16.setOrdinal(1); colTimestampLtzSB16.setName("COLTIMESTAMPLTZ_SB16"); colTimestampLtzSB16.setPhysicalType("SB16"); colTimestampLtzSB16.setNullable(false); @@ -1099,19 +689,18 @@ private void testArrowE2ETimestampErrorsHelper(SnowflakeStreamingIngestChannelIn row.put("COLTIMESTAMPLTZ_SB8", "1621899220"); row.put("COLTIMESTAMPLTZ_SB16", "1621899220.1234567"); - if (channel.getOnErrorOption() == OpenChannelRequest.OnErrorOption.CONTINUE) { + if (innerBuffer.onErrorOption == OpenChannelRequest.OnErrorOption.CONTINUE) { InsertValidationResponse response = - innerBuffer.insertRows(Collections.singletonList(row), null); + innerBuffer.insertRows(Collections.singletonList(row), null, null); Assert.assertTrue(response.hasErrors()); Assert.assertEquals( - ErrorCode.INVALID_ROW.getMessageCode(), + ErrorCode.INVALID_FORMAT_ROW.getMessageCode(), response.getInsertErrors().get(0).getException().getVendorCode()); } else { try { - InsertValidationResponse response = - innerBuffer.insertRows(Collections.singletonList(row), null); + innerBuffer.insertRows(Collections.singletonList(row), null, null); } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); + Assert.assertEquals(ErrorCode.INVALID_FORMAT_ROW.getMessageCode(), e.getVendorCode()); } } } @@ -1120,9 +709,10 @@ private void testArrowE2ETimestampErrorsHelper(SnowflakeStreamingIngestChannelIn public void testStatsE2E() { testStatsE2EHelper(this.rowBufferOnErrorAbort); testStatsE2EHelper(this.rowBufferOnErrorContinue); + testStatsE2EHelper(this.rowBufferOnErrorSkipBatch); } - private void testStatsE2EHelper(ArrowRowBuffer rowBuffer) { + private void testStatsE2EHelper(AbstractRowBuffer rowBuffer) { Map row1 = new HashMap<>(); row1.put("\"colTinyInt\"", (byte) 10); row1.put("colTinyInt", (byte) 1); @@ -1141,9 +731,10 @@ private void testStatsE2EHelper(ArrowRowBuffer rowBuffer) { row2.put("colDecimal", 4); row2.put("colChar", "alice"); - InsertValidationResponse response = rowBuffer.insertRows(Arrays.asList(row1, row2), null); + final String filename = "testStatsE2EHelper_streaming.bdec"; + InsertValidationResponse response = rowBuffer.insertRows(Arrays.asList(row1, row2), null, null); Assert.assertFalse(response.hasErrors()); - ChannelData result = rowBuffer.flush(); + ChannelData result = rowBuffer.flush(filename); Map columnEpStats = result.getColumnEps(); Assert.assertEquals( @@ -1179,26 +770,34 @@ private void testStatsE2EHelper(ArrowRowBuffer rowBuffer) { Assert.assertEquals(0, columnEpStats.get("COLBIGINT").getCurrentNullCount()); Assert.assertEquals(-1, columnEpStats.get("COLBIGINT").getDistinctValues()); - Assert.assertEquals("alice", columnEpStats.get("COLCHAR").getCurrentMaxStrValue()); - Assert.assertEquals("2", columnEpStats.get("COLCHAR").getCurrentMinStrValue()); + Assert.assertArrayEquals( + "2".getBytes(StandardCharsets.UTF_8), columnEpStats.get("COLCHAR").getCurrentMinStrValue()); + Assert.assertArrayEquals( + "alice".getBytes(StandardCharsets.UTF_8), + columnEpStats.get("COLCHAR").getCurrentMaxStrValue()); Assert.assertEquals(0, columnEpStats.get("COLCHAR").getCurrentNullCount()); Assert.assertEquals(-1, columnEpStats.get("COLCHAR").getDistinctValues()); + final ParquetChunkData chunkData = (ParquetChunkData) result.getVectors(); + Assert.assertEquals(filename, chunkData.metadata.get(Constants.PRIMARY_FILE_ID_KEY)); + // Confirm we reset - ChannelData resetResults = rowBuffer.flush(); + ChannelData resetResults = rowBuffer.flush("my_snowpipe_streaming.bdec"); Assert.assertNull(resetResults); } @Test public void testStatsE2ETimestamp() { - testStatsE2ETimestampHelper(this.channelOnErrorAbort); - testStatsE2ETimestampHelper(this.channelOnErrorContinue); + testStatsE2ETimestampHelper(OpenChannelRequest.OnErrorOption.ABORT); + testStatsE2ETimestampHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testStatsE2ETimestampHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); } - private void testStatsE2ETimestampHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testStatsE2ETimestampHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); ColumnMetadata colTimestampLtzSB8 = new ColumnMetadata(); + colTimestampLtzSB8.setOrdinal(1); colTimestampLtzSB8.setName("COLTIMESTAMPLTZ_SB8"); colTimestampLtzSB8.setPhysicalType("SB8"); colTimestampLtzSB8.setNullable(true); @@ -1206,6 +805,7 @@ private void testStatsE2ETimestampHelper(SnowflakeStreamingIngestChannelInternal colTimestampLtzSB8.setScale(0); ColumnMetadata colTimestampLtzSB16 = new ColumnMetadata(); + colTimestampLtzSB16.setOrdinal(2); colTimestampLtzSB16.setName("COLTIMESTAMPLTZ_SB16"); colTimestampLtzSB16.setPhysicalType("SB16"); colTimestampLtzSB16.setNullable(true); @@ -1213,6 +813,7 @@ private void testStatsE2ETimestampHelper(SnowflakeStreamingIngestChannelInternal colTimestampLtzSB16.setScale(9); ColumnMetadata colTimestampLtzSB16Scale6 = new ColumnMetadata(); + colTimestampLtzSB16Scale6.setOrdinal(2); colTimestampLtzSB16Scale6.setName("COLTIMESTAMPLTZ_SB16_SCALE6"); colTimestampLtzSB16Scale6.setPhysicalType("SB16"); colTimestampLtzSB16Scale6.setNullable(true); @@ -1224,13 +825,13 @@ private void testStatsE2ETimestampHelper(SnowflakeStreamingIngestChannelInternal Map row1 = new HashMap<>(); row1.put("COLTIMESTAMPLTZ_SB8", "1621899220"); - row1.put("COLTIMESTAMPLTZ_SB16", "1621899220.123456789"); - row1.put("COLTIMESTAMPLTZ_SB16_SCALE6", "1621899220.123456"); + row1.put("COLTIMESTAMPLTZ_SB16", "1621899220123456789"); + row1.put("COLTIMESTAMPLTZ_SB16_SCALE6", "1621899220123456"); Map row2 = new HashMap<>(); row2.put("COLTIMESTAMPLTZ_SB8", "1621899221"); - row2.put("COLTIMESTAMPLTZ_SB16", "1621899220.12345679"); - row2.put("COLTIMESTAMPLTZ_SB16_SCALE6", "1621899220.123457"); + row2.put("COLTIMESTAMPLTZ_SB16", "1621899220223456789"); + row2.put("COLTIMESTAMPLTZ_SB16_SCALE6", "1621899220123457"); Map row3 = new HashMap<>(); row3.put("COLTIMESTAMPLTZ_SB8", null); @@ -1238,9 +839,9 @@ private void testStatsE2ETimestampHelper(SnowflakeStreamingIngestChannelInternal row3.put("COLTIMESTAMPLTZ_SB16_SCALE6", null); InsertValidationResponse response = - innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null); + innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null, null); Assert.assertFalse(response.hasErrors()); - ChannelData result = innerBuffer.flush(); + ChannelData result = innerBuffer.flush("my_snowpipe_streaming.bdec"); Assert.assertEquals(3, result.getRowCount()); Assert.assertEquals( @@ -1254,7 +855,7 @@ private void testStatsE2ETimestampHelper(SnowflakeStreamingIngestChannelInternal new BigInteger("1621899220123456789"), result.getColumnEps().get("COLTIMESTAMPLTZ_SB16").getCurrentMinIntValue()); Assert.assertEquals( - new BigInteger("1621899220123456790"), + new BigInteger("1621899220223456789"), result.getColumnEps().get("COLTIMESTAMPLTZ_SB16").getCurrentMaxIntValue()); Assert.assertEquals( @@ -1272,14 +873,16 @@ private void testStatsE2ETimestampHelper(SnowflakeStreamingIngestChannelInternal @Test public void testE2EDate() { - testE2EDateHelper(this.channelOnErrorContinue); - testE2EDateHelper(this.channelOnErrorAbort); + testE2EDateHelper(OpenChannelRequest.OnErrorOption.ABORT); + testE2EDateHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testE2EDateHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); } - private void testE2EDateHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testE2EDateHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); ColumnMetadata colDate = new ColumnMetadata(); + colDate.setOrdinal(1); colDate.setName("COLDATE"); colDate.setPhysicalType("SB8"); colDate.setNullable(true); @@ -1289,25 +892,25 @@ private void testE2EDateHelper(SnowflakeStreamingIngestChannelInternal channel) innerBuffer.setupSchema(Collections.singletonList(colDate)); Map row1 = new HashMap<>(); - row1.put("COLDATE", "18772"); + row1.put("COLDATE", String.valueOf(18772 * 24 * 60 * 60 * 1000L + 1)); Map row2 = new HashMap<>(); - row2.put("COLDATE", "18773"); + row2.put("COLDATE", String.valueOf(18773 * 24 * 60 * 60 * 1000L + 1)); Map row3 = new HashMap<>(); row3.put("COLDATE", null); InsertValidationResponse response = - innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null); + innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null, null); Assert.assertFalse(response.hasErrors()); - // Check data was inserted into Arrow correctly - Assert.assertEquals(18772, innerBuffer.vectorsRoot.getVector("COLDATE").getObject(0)); - Assert.assertEquals(18773, innerBuffer.vectorsRoot.getVector("COLDATE").getObject(1)); - Assert.assertNull(innerBuffer.vectorsRoot.getVector("COLDATE").getObject(2)); + // Check data was inserted into the buffer correctly + Assert.assertEquals(18772, innerBuffer.getVectorValueAt("COLDATE", 0)); + Assert.assertEquals(18773, innerBuffer.getVectorValueAt("COLDATE", 1)); + Assert.assertNull(innerBuffer.getVectorValueAt("COLDATE", 2)); // Check stats generation - ChannelData result = innerBuffer.flush(); + ChannelData result = innerBuffer.flush("my_snowpipe_streaming.bdec"); Assert.assertEquals(3, result.getRowCount()); Assert.assertEquals( @@ -1320,14 +923,16 @@ private void testE2EDateHelper(SnowflakeStreamingIngestChannelInternal channel) @Test public void testE2ETime() { - testE2ETimeHelper(this.channelOnErrorAbort); - testE2ETimeHelper(this.channelOnErrorContinue); + testE2ETimeHelper(OpenChannelRequest.OnErrorOption.ABORT); + testE2ETimeHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testE2ETimeHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); } - private void testE2ETimeHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testE2ETimeHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); ColumnMetadata colTimeSB4 = new ColumnMetadata(); + colTimeSB4.setOrdinal(1); colTimeSB4.setName("COLTIMESB4"); colTimeSB4.setPhysicalType("SB4"); colTimeSB4.setNullable(true); @@ -1335,6 +940,7 @@ private void testE2ETimeHelper(SnowflakeStreamingIngestChannelInternal channel) colTimeSB4.setScale(0); ColumnMetadata colTimeSB8 = new ColumnMetadata(); + colTimeSB8.setOrdinal(2); colTimeSB8.setName("COLTIMESB8"); colTimeSB8.setPhysicalType("SB8"); colTimeSB8.setNullable(true); @@ -1344,59 +950,102 @@ private void testE2ETimeHelper(SnowflakeStreamingIngestChannelInternal channel) innerBuffer.setupSchema(Arrays.asList(colTimeSB4, colTimeSB8)); Map row1 = new HashMap<>(); - row1.put("COLTIMESB4", "43200"); - row1.put("COLTIMESB8", "44200.123"); + row1.put("COLTIMESB4", "10:00:00"); + row1.put("COLTIMESB8", "10:00:00.123"); Map row2 = new HashMap<>(); - row2.put("COLTIMESB4", "43260"); - row2.put("COLTIMESB8", "44201"); + row2.put("COLTIMESB4", "11:15:00.000"); + row2.put("COLTIMESB8", "11:15:00.456"); Map row3 = new HashMap<>(); row3.put("COLTIMESB4", null); row3.put("COLTIMESB8", null); InsertValidationResponse response = - innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null); + innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null, null); Assert.assertFalse(response.hasErrors()); - // Check data was inserted into Arrow correctly - Assert.assertEquals(43200, innerBuffer.vectorsRoot.getVector("COLTIMESB4").getObject(0)); - Assert.assertEquals(43260, innerBuffer.vectorsRoot.getVector("COLTIMESB4").getObject(1)); - Assert.assertNull(innerBuffer.vectorsRoot.getVector("COLTIMESB4").getObject(2)); + // Check data was inserted into the buffer correctly + Assert.assertEquals(10 * 60 * 60, innerBuffer.getVectorValueAt("COLTIMESB4", 0)); + Assert.assertEquals(11 * 60 * 60 + 15 * 60, innerBuffer.getVectorValueAt("COLTIMESB4", 1)); + Assert.assertNull(innerBuffer.getVectorValueAt("COLTIMESB4", 2)); - Assert.assertEquals(44200123l, innerBuffer.vectorsRoot.getVector("COLTIMESB8").getObject(0)); - Assert.assertEquals(44201000l, innerBuffer.vectorsRoot.getVector("COLTIMESB8").getObject(1)); - Assert.assertNull(innerBuffer.vectorsRoot.getVector("COLTIMESB8").getObject(2)); + Assert.assertEquals(10 * 60 * 60 * 1000L + 123, innerBuffer.getVectorValueAt("COLTIMESB8", 0)); + Assert.assertEquals( + 11 * 60 * 60 * 1000L + 15 * 60 * 1000 + 456, innerBuffer.getVectorValueAt("COLTIMESB8", 1)); + Assert.assertNull(innerBuffer.getVectorValueAt("COLTIMESB8", 2)); // Check stats generation - ChannelData result = innerBuffer.flush(); + ChannelData result = innerBuffer.flush("my_snowpipe_streaming.bdec"); Assert.assertEquals(3, result.getRowCount()); Assert.assertEquals( - BigInteger.valueOf(43200), result.getColumnEps().get("COLTIMESB4").getCurrentMinIntValue()); + BigInteger.valueOf(10 * 60 * 60), + result.getColumnEps().get("COLTIMESB4").getCurrentMinIntValue()); Assert.assertEquals( - BigInteger.valueOf(43260), result.getColumnEps().get("COLTIMESB4").getCurrentMaxIntValue()); + BigInteger.valueOf(11 * 60 * 60 + 15 * 60), + result.getColumnEps().get("COLTIMESB4").getCurrentMaxIntValue()); Assert.assertEquals(1, result.getColumnEps().get("COLTIMESB4").getCurrentNullCount()); Assert.assertEquals( - BigInteger.valueOf(44200123), + BigInteger.valueOf(10 * 60 * 60 * 1000L + 123), result.getColumnEps().get("COLTIMESB8").getCurrentMinIntValue()); Assert.assertEquals( - BigInteger.valueOf(44201000), + BigInteger.valueOf(11 * 60 * 60 * 1000L + 15 * 60 * 1000 + 456), result.getColumnEps().get("COLTIMESB8").getCurrentMaxIntValue()); Assert.assertEquals(1, result.getColumnEps().get("COLTIMESB8").getCurrentNullCount()); } + @Test + public void testMaxInsertRowsBatchSize() { + testMaxInsertRowsBatchSizeHelper(OpenChannelRequest.OnErrorOption.ABORT); + testMaxInsertRowsBatchSizeHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); + } + + private void testMaxInsertRowsBatchSizeHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); + ColumnMetadata colBinary = new ColumnMetadata(); + colBinary.setOrdinal(1); + colBinary.setName("COLBINARY"); + colBinary.setPhysicalType("LOB"); + colBinary.setNullable(true); + colBinary.setLogicalType("BINARY"); + colBinary.setLength(8 * 1024 * 1024); + colBinary.setByteLength(8 * 1024 * 1024); + + byte[] arr = new byte[8 * 1024 * 1024]; + innerBuffer.setupSchema(Collections.singletonList(colBinary)); + List> rows = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + rows.add(Collections.singletonMap("COLBINARY", arr)); + } + + // Insert rows should succeed + innerBuffer.insertRows(rows, "", ""); + + // After adding another row, it should fail due to too large batch of rows passed to + // insertRows() in one go + rows.add(Collections.singletonMap("COLBINARY", arr)); + try { + innerBuffer.insertRows(rows, "", ""); + Assert.fail("Inserting rows should have failed"); + } catch (SFException e) { + Assert.assertEquals(ErrorCode.MAX_BATCH_SIZE_EXCEEDED.getMessageCode(), e.getVendorCode()); + } + } + @Test public void testNullableCheck() { - testNullableCheckHelper(this.channelOnErrorContinue); - testNullableCheckHelper(this.channelOnErrorAbort); + testNullableCheckHelper(OpenChannelRequest.OnErrorOption.ABORT); + testNullableCheckHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testNullableCheckHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); } - private void testNullableCheckHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testNullableCheckHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); ColumnMetadata colBoolean = new ColumnMetadata(); + colBoolean.setOrdinal(1); colBoolean.setName("COLBOOLEAN"); colBoolean.setPhysicalType("SB1"); colBoolean.setNullable(false); @@ -1407,36 +1056,38 @@ private void testNullableCheckHelper(SnowflakeStreamingIngestChannelInternal cha Map row = new HashMap<>(); row.put("COLBOOLEAN", true); - InsertValidationResponse response = innerBuffer.insertRows(Collections.singletonList(row), "1"); + InsertValidationResponse response = + innerBuffer.insertRows(Collections.singletonList(row), "1", "1"); Assert.assertFalse(response.hasErrors()); - ; row.put("COLBOOLEAN", null); - if (channel.getOnErrorOption() == OpenChannelRequest.OnErrorOption.CONTINUE) { - response = innerBuffer.insertRows(Collections.singletonList(row), "1"); + if (innerBuffer.onErrorOption == OpenChannelRequest.OnErrorOption.CONTINUE) { + response = innerBuffer.insertRows(Collections.singletonList(row), "1", "1"); Assert.assertTrue(response.hasErrors()); Assert.assertEquals( - ErrorCode.INVALID_ROW.getMessageCode(), + ErrorCode.INVALID_FORMAT_ROW.getMessageCode(), response.getInsertErrors().get(0).getException().getVendorCode()); } else { try { - innerBuffer.insertRows(Collections.singletonList(row), "1"); + innerBuffer.insertRows(Collections.singletonList(row), "1", "1"); } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); + Assert.assertEquals(ErrorCode.INVALID_FORMAT_ROW.getMessageCode(), e.getVendorCode()); } } } @Test public void testMissingColumnCheck() { - testMissingColumnCheckHelper(this.channelOnErrorContinue); - testMissingColumnCheckHelper(this.channelOnErrorAbort); + testMissingColumnCheckHelper(OpenChannelRequest.OnErrorOption.ABORT); + testMissingColumnCheckHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testMissingColumnCheckHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); } - private void testMissingColumnCheckHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testMissingColumnCheckHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); ColumnMetadata colBoolean = new ColumnMetadata(); + colBoolean.setOrdinal(1); colBoolean.setName("COLBOOLEAN"); colBoolean.setPhysicalType("SB1"); colBoolean.setNullable(false); @@ -1444,6 +1095,7 @@ private void testMissingColumnCheckHelper(SnowflakeStreamingIngestChannelInterna colBoolean.setScale(0); ColumnMetadata colBoolean2 = new ColumnMetadata(); + colBoolean2.setOrdinal(2); colBoolean2.setName("COLBOOLEAN2"); colBoolean2.setPhysicalType("SB1"); colBoolean2.setNullable(true); @@ -1454,36 +1106,148 @@ private void testMissingColumnCheckHelper(SnowflakeStreamingIngestChannelInterna Map row = new HashMap<>(); row.put("COLBOOLEAN", true); - InsertValidationResponse response = innerBuffer.insertRows(Collections.singletonList(row), "1"); + InsertValidationResponse response = + innerBuffer.insertRows(Collections.singletonList(row), "1", "1"); Assert.assertFalse(response.hasErrors()); Map row2 = new HashMap<>(); row2.put("COLBOOLEAN2", true); - if (channel.getOnErrorOption() == OpenChannelRequest.OnErrorOption.CONTINUE) { - response = innerBuffer.insertRows(Collections.singletonList(row2), "2"); + if (innerBuffer.onErrorOption == OpenChannelRequest.OnErrorOption.CONTINUE) { + response = innerBuffer.insertRows(Collections.singletonList(row2), "1", "2"); Assert.assertTrue(response.hasErrors()); + InsertValidationResponse.InsertError error = response.getInsertErrors().get(0); Assert.assertEquals( - ErrorCode.INVALID_ROW.getMessageCode(), - response.getInsertErrors().get(0).getException().getVendorCode()); + ErrorCode.INVALID_FORMAT_ROW.getMessageCode(), error.getException().getVendorCode()); + Assert.assertEquals( + Collections.singletonList("COLBOOLEAN"), error.getMissingNotNullColNames()); } else { try { - innerBuffer.insertRows(Collections.singletonList(row2), "2"); + innerBuffer.insertRows(Collections.singletonList(row2), "1", "2"); } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); + Assert.assertEquals(ErrorCode.INVALID_FORMAT_ROW.getMessageCode(), e.getVendorCode()); + } + } + } + + @Test + public void testExtraColumnsCheck() { + AbstractRowBuffer innerBuffer = createTestBuffer(OpenChannelRequest.OnErrorOption.CONTINUE); + + ColumnMetadata colBoolean = new ColumnMetadata(); + colBoolean.setOrdinal(1); + colBoolean.setName("COLBOOLEAN1"); + colBoolean.setPhysicalType("SB1"); + colBoolean.setNullable(false); + colBoolean.setLogicalType("BOOLEAN"); + colBoolean.setScale(0); + + innerBuffer.setupSchema(Collections.singletonList(colBoolean)); + Map row = new HashMap<>(); + row.put("COLBOOLEAN1", true); + row.put("COLBOOLEAN2", true); + row.put("COLBOOLEAN3", true); + + InsertValidationResponse response = + innerBuffer.insertRows(Collections.singletonList(row), "1", "1"); + Assert.assertTrue(response.hasErrors()); + InsertValidationResponse.InsertError error = response.getInsertErrors().get(0); + Assert.assertEquals( + ErrorCode.INVALID_FORMAT_ROW.getMessageCode(), error.getException().getVendorCode()); + Assert.assertEquals(Arrays.asList("COLBOOLEAN3", "COLBOOLEAN2"), error.getExtraColNames()); + } + + @Test + public void testFailureHalfwayThroughColumnProcessing() { + doTestFailureHalfwayThroughColumnProcessing(OpenChannelRequest.OnErrorOption.CONTINUE); + doTestFailureHalfwayThroughColumnProcessing(OpenChannelRequest.OnErrorOption.ABORT); + doTestFailureHalfwayThroughColumnProcessing(OpenChannelRequest.OnErrorOption.SKIP_BATCH); + } + + private void doTestFailureHalfwayThroughColumnProcessing( + OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); + + ColumnMetadata colVarchar1 = new ColumnMetadata(); + colVarchar1.setOrdinal(1); + colVarchar1.setName("COLVARCHAR1"); + colVarchar1.setPhysicalType("LOB"); + colVarchar1.setNullable(true); + colVarchar1.setLogicalType("TEXT"); + colVarchar1.setLength(1000); + + ColumnMetadata colVarchar2 = new ColumnMetadata(); + colVarchar2.setOrdinal(2); + colVarchar2.setName("COLVARCHAR2"); + colVarchar2.setPhysicalType("LOB"); + colVarchar2.setNullable(true); + colVarchar2.setLogicalType("TEXT"); + colVarchar2.setLength(1000); + + ColumnMetadata colBoolean = new ColumnMetadata(); + colBoolean.setOrdinal(3); + colBoolean.setName("COLBOOLEAN1"); + colBoolean.setPhysicalType("SB1"); + colBoolean.setNullable(true); + colBoolean.setLogicalType("BOOLEAN"); + colBoolean.setScale(0); + + innerBuffer.setupSchema(Arrays.asList(colVarchar1, colVarchar2, colBoolean)); + + LinkedHashMap row1 = new LinkedHashMap<>(); + row1.put("COLVARCHAR1", null); + row1.put("COLVARCHAR2", "X"); + row1.put("COLBOOLEAN1", "falze"); // will fail validation + + LinkedHashMap row2 = new LinkedHashMap<>(); + row2.put("COLVARCHAR1", "A"); + row2.put("COLVARCHAR2", null); + row2.put("COLBOOLEAN1", "falze"); // will fail validation + + LinkedHashMap row3 = new LinkedHashMap<>(); + row3.put("COLVARCHAR1", "c"); + row3.put("COLVARCHAR2", "d"); + row3.put("COLBOOLEAN1", "true"); + + for (Map row : Arrays.asList(row1, row2, row3)) { + try { + innerBuffer.insertRows(Collections.singletonList(row), "", ""); + } catch (Exception ignored) { + // we ignore exceptions, for ABORT option there will be some, but we don't care in this test } } + + ChannelData channelData = innerBuffer.flush("my_snowpipe_streaming.bdec"); + RowBufferStats statsCol1 = channelData.getColumnEps().get("COLVARCHAR1"); + RowBufferStats statsCol2 = channelData.getColumnEps().get("COLVARCHAR2"); + RowBufferStats statsCol3 = channelData.getColumnEps().get("COLBOOLEAN1"); + Assert.assertEquals(1, channelData.getRowCount()); + Assert.assertEquals(0, statsCol1.getCurrentNullCount()); + Assert.assertEquals(0, statsCol2.getCurrentNullCount()); + Assert.assertEquals(0, statsCol3.getCurrentNullCount()); + Assert.assertArrayEquals( + "c".getBytes(StandardCharsets.UTF_8), statsCol1.getCurrentMinStrValue()); + Assert.assertArrayEquals( + "c".getBytes(StandardCharsets.UTF_8), statsCol1.getCurrentMaxStrValue()); + Assert.assertArrayEquals( + "d".getBytes(StandardCharsets.UTF_8), statsCol2.getCurrentMinStrValue()); + Assert.assertArrayEquals( + "d".getBytes(StandardCharsets.UTF_8), statsCol2.getCurrentMaxStrValue()); + Assert.assertEquals(BigInteger.ONE, statsCol3.getCurrentMinIntValue()); + Assert.assertEquals(BigInteger.ONE, statsCol3.getCurrentMaxIntValue()); } @Test public void testE2EBoolean() { - testE2EBooleanHelper(this.channelOnErrorContinue); - testE2EBooleanHelper(this.channelOnErrorAbort); + testE2EBooleanHelper(OpenChannelRequest.OnErrorOption.ABORT); + testE2EBooleanHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testE2EBooleanHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); } - private void testE2EBooleanHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testE2EBooleanHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); ColumnMetadata colBoolean = new ColumnMetadata(); + colBoolean.setOrdinal(1); colBoolean.setName("COLBOOLEAN"); colBoolean.setPhysicalType("SB1"); colBoolean.setNullable(true); @@ -1503,16 +1267,16 @@ private void testE2EBooleanHelper(SnowflakeStreamingIngestChannelInternal channe // innerBuffer.insertRows(Collections.singletonList(row1)); InsertValidationResponse response = - innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null); + innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null, null); Assert.assertFalse(response.hasErrors()); - // Check data was inserted into Arrow correctly - Assert.assertEquals(true, innerBuffer.vectorsRoot.getVector("COLBOOLEAN").getObject(0)); - Assert.assertEquals(false, innerBuffer.vectorsRoot.getVector("COLBOOLEAN").getObject(1)); - Assert.assertNull(innerBuffer.vectorsRoot.getVector("COLBOOLEAN").getObject(2)); + // Check data was inserted into the buffer correctly + Assert.assertEquals(true, innerBuffer.getVectorValueAt("COLBOOLEAN", 0)); + Assert.assertEquals(false, innerBuffer.getVectorValueAt("COLBOOLEAN", 1)); + Assert.assertNull(innerBuffer.getVectorValueAt("COLBOOLEAN", 2)); // Check stats generation - ChannelData result = innerBuffer.flush(); + ChannelData result = innerBuffer.flush("my_snowpipe_streaming.bdec"); Assert.assertEquals(3, result.getRowCount()); Assert.assertEquals( @@ -1524,18 +1288,22 @@ private void testE2EBooleanHelper(SnowflakeStreamingIngestChannelInternal channe @Test public void testE2EBinary() { - testE2EBinaryHelper(this.channelOnErrorAbort); - testE2EBinaryHelper(this.channelOnErrorContinue); + testE2EBinaryHelper(OpenChannelRequest.OnErrorOption.ABORT); + testE2EBinaryHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testE2EBinaryHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); } - private void testE2EBinaryHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testE2EBinaryHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); ColumnMetadata colBinary = new ColumnMetadata(); + colBinary.setOrdinal(1); colBinary.setName("COLBINARY"); colBinary.setPhysicalType("LOB"); colBinary.setNullable(true); colBinary.setLogicalType("BINARY"); + colBinary.setLength(32); + colBinary.setByteLength(256); colBinary.setScale(0); innerBuffer.setupSchema(Collections.singletonList(colBinary)); @@ -1550,44 +1318,44 @@ private void testE2EBinaryHelper(SnowflakeStreamingIngestChannelInternal channel row3.put("COLBINARY", null); InsertValidationResponse response = - innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null); + innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null, null); Assert.assertFalse(response.hasErrors()); - // Check data was inserted into Arrow correctly + // Check data was inserted into the buffer correctly Assert.assertEquals( "Hello World", - new String( - (byte[]) innerBuffer.vectorsRoot.getVector("COLBINARY").getObject(0), - StandardCharsets.UTF_8)); + new String((byte[]) innerBuffer.getVectorValueAt("COLBINARY", 0), StandardCharsets.UTF_8)); Assert.assertEquals( "Honk Honk", - new String( - (byte[]) innerBuffer.vectorsRoot.getVector("COLBINARY").getObject(1), - StandardCharsets.UTF_8)); - Assert.assertNull(innerBuffer.vectorsRoot.getVector("COLBINARY").getObject(2)); + new String((byte[]) innerBuffer.getVectorValueAt("COLBINARY", 1), StandardCharsets.UTF_8)); + Assert.assertNull(innerBuffer.getVectorValueAt("COLBINARY", 2)); // Check stats generation - ChannelData result = innerBuffer.flush(); + ChannelData result = innerBuffer.flush("my_snowpipe_streaming.bdec"); Assert.assertEquals(3, result.getRowCount()); Assert.assertEquals(11L, result.getColumnEps().get("COLBINARY").getCurrentMaxLength()); - Assert.assertEquals( - "Hello World", result.getColumnEps().get("COLBINARY").getCurrentMinStrValue()); - Assert.assertEquals( - "Honk Honk", result.getColumnEps().get("COLBINARY").getCurrentMaxStrValue()); + Assert.assertArrayEquals( + "Hello World".getBytes(StandardCharsets.UTF_8), + result.getColumnEps().get("COLBINARY").getCurrentMinStrValue()); + Assert.assertArrayEquals( + "Honk Honk".getBytes(StandardCharsets.UTF_8), + result.getColumnEps().get("COLBINARY").getCurrentMaxStrValue()); Assert.assertEquals(1, result.getColumnEps().get("COLBINARY").getCurrentNullCount()); } @Test public void testE2EReal() { - testE2ERealHelper(this.channelOnErrorContinue); - testE2ERealHelper(this.channelOnErrorAbort); + testE2ERealHelper(OpenChannelRequest.OnErrorOption.ABORT); + testE2ERealHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testE2ERealHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); } - private void testE2ERealHelper(SnowflakeStreamingIngestChannelInternal channel) { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(channel); + private void testE2ERealHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); ColumnMetadata colReal = new ColumnMetadata(); + colReal.setOrdinal(1); colReal.setName("COLREAL"); colReal.setPhysicalType("SB16"); colReal.setNullable(true); @@ -1606,16 +1374,16 @@ private void testE2ERealHelper(SnowflakeStreamingIngestChannelInternal channel) row3.put("COLREAL", null); InsertValidationResponse response = - innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null); + innerBuffer.insertRows(Arrays.asList(row1, row2, row3), null, null); Assert.assertFalse(response.hasErrors()); - // Check data was inserted into Arrow correctly - Assert.assertEquals(123.456, innerBuffer.vectorsRoot.getVector("COLREAL").getObject(0)); - Assert.assertEquals(123.4567, innerBuffer.vectorsRoot.getVector("COLREAL").getObject(1)); - Assert.assertNull(innerBuffer.vectorsRoot.getVector("COLREAL").getObject(2)); + // Check data was inserted into the buffer correctly + Assert.assertEquals(123.456, innerBuffer.getVectorValueAt("COLREAL", 0)); + Assert.assertEquals(123.4567, innerBuffer.getVectorValueAt("COLREAL", 1)); + Assert.assertNull(innerBuffer.getVectorValueAt("COLREAL", 2)); // Check stats generation - ChannelData result = innerBuffer.flush(); + ChannelData result = innerBuffer.flush("my_snowpipe_streaming.bdec"); Assert.assertEquals(3, result.getRowCount()); Assert.assertEquals( @@ -1627,9 +1395,10 @@ private void testE2ERealHelper(SnowflakeStreamingIngestChannelInternal channel) @Test public void testOnErrorAbortFailures() { - ArrowRowBuffer innerBuffer = new ArrowRowBuffer(this.channelOnErrorAbort); + AbstractRowBuffer innerBuffer = createTestBuffer(OpenChannelRequest.OnErrorOption.ABORT); ColumnMetadata colDecimal = new ColumnMetadata(); + colDecimal.setOrdinal(1); colDecimal.setName("COLDECIMAL"); colDecimal.setPhysicalType("SB16"); colDecimal.setNullable(true); @@ -1641,11 +1410,12 @@ public void testOnErrorAbortFailures() { Map row = new HashMap<>(); row.put("COLDECIMAL", 1); - InsertValidationResponse response = innerBuffer.insertRows(Collections.singletonList(row), "1"); + InsertValidationResponse response = + innerBuffer.insertRows(Collections.singletonList(row), "1", "1"); Assert.assertFalse(response.hasErrors()); - Assert.assertEquals(1, innerBuffer.rowCount); - Assert.assertEquals(0, innerBuffer.tempVectorsRoot.getRowCount()); + Assert.assertEquals(1, innerBuffer.bufferedRowCount); + Assert.assertEquals(0, innerBuffer.getTempRowCount()); Assert.assertEquals( 1, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMaxIntValue().intValue()); Assert.assertEquals( @@ -1655,11 +1425,11 @@ public void testOnErrorAbortFailures() { Map row2 = new HashMap<>(); row2.put("COLDECIMAL", 2); - response = innerBuffer.insertRows(Collections.singletonList(row2), "2"); + response = innerBuffer.insertRows(Collections.singletonList(row2), "1", "2"); Assert.assertFalse(response.hasErrors()); - Assert.assertEquals(2, innerBuffer.rowCount); - Assert.assertEquals(0, innerBuffer.tempVectorsRoot.getRowCount()); + Assert.assertEquals(2, innerBuffer.bufferedRowCount); + Assert.assertEquals(0, innerBuffer.getTempRowCount()); Assert.assertEquals( 2, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMaxIntValue().intValue()); Assert.assertEquals( @@ -1670,13 +1440,13 @@ public void testOnErrorAbortFailures() { Map row3 = new HashMap<>(); row3.put("COLDECIMAL", true); try { - innerBuffer.insertRows(Collections.singletonList(row3), "3"); + innerBuffer.insertRows(Collections.singletonList(row3), "1", "3"); } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); + Assert.assertEquals(ErrorCode.INVALID_FORMAT_ROW.getMessageCode(), e.getVendorCode()); } - Assert.assertEquals(2, innerBuffer.rowCount); - Assert.assertEquals(0, innerBuffer.tempVectorsRoot.getRowCount()); + Assert.assertEquals(2, innerBuffer.bufferedRowCount); + Assert.assertEquals(0, innerBuffer.getTempRowCount()); Assert.assertEquals( 2, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMaxIntValue().intValue()); Assert.assertEquals( @@ -1685,10 +1455,84 @@ public void testOnErrorAbortFailures() { Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMinIntValue()); row3.put("COLDECIMAL", 3); - response = innerBuffer.insertRows(Collections.singletonList(row3), "3"); + response = innerBuffer.insertRows(Collections.singletonList(row3), "1", "3"); + Assert.assertFalse(response.hasErrors()); + Assert.assertEquals(3, innerBuffer.bufferedRowCount); + Assert.assertEquals(0, innerBuffer.getTempRowCount()); + Assert.assertEquals( + 3, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMaxIntValue().intValue()); + Assert.assertEquals( + 1, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMinIntValue().intValue()); + Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMaxIntValue()); + Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMinIntValue()); + + ChannelData data = innerBuffer.flush("my_snowpipe_streaming.bdec"); + Assert.assertEquals(3, data.getRowCount()); + Assert.assertEquals(0, innerBuffer.bufferedRowCount); + } + + @Test + public void testOnErrorAbortSkipBatch() { + AbstractRowBuffer innerBuffer = + createTestBuffer(OpenChannelRequest.OnErrorOption.SKIP_BATCH); + + ColumnMetadata colDecimal = new ColumnMetadata(); + colDecimal.setOrdinal(1); + colDecimal.setName("COLDECIMAL"); + colDecimal.setPhysicalType("SB16"); + colDecimal.setNullable(true); + colDecimal.setLogicalType("FIXED"); + colDecimal.setPrecision(38); + colDecimal.setScale(0); + + innerBuffer.setupSchema(Collections.singletonList(colDecimal)); + Map row = new HashMap<>(); + row.put("COLDECIMAL", 1); + + InsertValidationResponse response = + innerBuffer.insertRows(Collections.singletonList(row), "1", "1"); + Assert.assertFalse(response.hasErrors()); + + Assert.assertEquals(1, innerBuffer.bufferedRowCount); + Assert.assertEquals(0, innerBuffer.getTempRowCount()); + Assert.assertEquals( + 1, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMaxIntValue().intValue()); + Assert.assertEquals( + 1, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMinIntValue().intValue()); + Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMaxIntValue()); + Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMinIntValue()); + + Map row2 = new HashMap<>(); + row2.put("COLDECIMAL", 2); + Map row3 = new HashMap<>(); + row3.put("COLDECIMAL", true); + + response = innerBuffer.insertRows(Arrays.asList(row2, row3), "1", "3"); + Assert.assertTrue(response.hasErrors()); + + Assert.assertEquals(1, innerBuffer.bufferedRowCount); + Assert.assertEquals(0, innerBuffer.getTempRowCount()); + Assert.assertEquals( + 1, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMaxIntValue().intValue()); + Assert.assertEquals( + 1, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMinIntValue().intValue()); + Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMaxIntValue()); + Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMinIntValue()); + + Assert.assertEquals(1, innerBuffer.bufferedRowCount); + Assert.assertEquals(0, innerBuffer.getTempRowCount()); + Assert.assertEquals( + 1, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMaxIntValue().intValue()); + Assert.assertEquals( + 1, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMinIntValue().intValue()); + Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMaxIntValue()); + Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMinIntValue()); + + row3.put("COLDECIMAL", 3); + response = innerBuffer.insertRows(Arrays.asList(row2, row3), "1", "3"); Assert.assertFalse(response.hasErrors()); - Assert.assertEquals(3, innerBuffer.rowCount); - Assert.assertEquals(0, innerBuffer.tempVectorsRoot.getRowCount()); + Assert.assertEquals(3, innerBuffer.bufferedRowCount); + Assert.assertEquals(0, innerBuffer.getTempRowCount()); Assert.assertEquals( 3, innerBuffer.statsMap.get("COLDECIMAL").getCurrentMaxIntValue().intValue()); Assert.assertEquals( @@ -1696,8 +1540,198 @@ public void testOnErrorAbortFailures() { Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMaxIntValue()); Assert.assertNull(innerBuffer.tempStatsMap.get("COLDECIMAL").getCurrentMinIntValue()); - ChannelData data = innerBuffer.flush(); - Assert.assertEquals(3, data.getVectors().getRowCount()); - Assert.assertEquals(0, innerBuffer.rowCount); + ChannelData data = innerBuffer.flush("my_snowpipe_streaming.bdec"); + Assert.assertEquals(3, data.getRowCount()); + Assert.assertEquals(0, innerBuffer.bufferedRowCount); + } + + @Test + public void testE2EVariant() { + testE2EVariantHelper(OpenChannelRequest.OnErrorOption.ABORT); + testE2EVariantHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testE2EVariantHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); + } + + private void testE2EVariantHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); + + ColumnMetadata colVariant = new ColumnMetadata(); + colVariant.setOrdinal(1); + colVariant.setName("COLVARIANT"); + colVariant.setPhysicalType("LOB"); + colVariant.setNullable(true); + colVariant.setLogicalType("VARIANT"); + + innerBuffer.setupSchema(Collections.singletonList(colVariant)); + + Map row1 = new HashMap<>(); + row1.put("COLVARIANT", null); + + Map row2 = new HashMap<>(); + row2.put("COLVARIANT", ""); + + Map row3 = new HashMap<>(); + row3.put("COLVARIANT", "null"); + + Map row4 = new HashMap<>(); + row4.put("COLVARIANT", "{\"key\":1}"); + + Map row5 = new HashMap<>(); + row5.put("COLVARIANT", 3); + + InsertValidationResponse response = + innerBuffer.insertRows(Arrays.asList(row1, row2, row3, row4, row5), null, null); + Assert.assertFalse(response.hasErrors()); + + // Check data was inserted into the buffer correctly + Assert.assertNull(null, innerBuffer.getVectorValueAt("COLVARIANT", 0)); + Assert.assertNull(null, innerBuffer.getVectorValueAt("COLVARIANT", 1)); + Assert.assertEquals("null", innerBuffer.getVectorValueAt("COLVARIANT", 2)); + Assert.assertEquals("{\"key\":1}", innerBuffer.getVectorValueAt("COLVARIANT", 3)); + Assert.assertEquals("3", innerBuffer.getVectorValueAt("COLVARIANT", 4)); + + // Check stats generation + ChannelData result = innerBuffer.flush("my_snowpipe_streaming.bdec"); + Assert.assertEquals(5, result.getRowCount()); + Assert.assertEquals(2, result.getColumnEps().get("COLVARIANT").getCurrentNullCount()); + } + + @Test + public void testE2EObject() { + testE2EObjectHelper(OpenChannelRequest.OnErrorOption.ABORT); + testE2EObjectHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testE2EObjectHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); + } + + private void testE2EObjectHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); + + ColumnMetadata colObject = new ColumnMetadata(); + colObject.setOrdinal(1); + colObject.setName("COLOBJECT"); + colObject.setPhysicalType("LOB"); + colObject.setNullable(true); + colObject.setLogicalType("OBJECT"); + + innerBuffer.setupSchema(Collections.singletonList(colObject)); + + Map row1 = new HashMap<>(); + row1.put("COLOBJECT", "{\"key\":1}"); + + InsertValidationResponse response = innerBuffer.insertRows(Arrays.asList(row1), null, null); + Assert.assertFalse(response.hasErrors()); + + // Check data was inserted into the buffer correctly + Assert.assertEquals("{\"key\":1}", innerBuffer.getVectorValueAt("COLOBJECT", 0)); + + // Check stats generation + ChannelData result = innerBuffer.flush("my_snowpipe_streaming.bdec"); + Assert.assertEquals(1, result.getRowCount()); + } + + @Test + public void testE2EArray() { + testE2EArrayHelper(OpenChannelRequest.OnErrorOption.ABORT); + testE2EArrayHelper(OpenChannelRequest.OnErrorOption.CONTINUE); + testE2EArrayHelper(OpenChannelRequest.OnErrorOption.SKIP_BATCH); + } + + private void testE2EArrayHelper(OpenChannelRequest.OnErrorOption onErrorOption) { + AbstractRowBuffer innerBuffer = createTestBuffer(onErrorOption); + + ColumnMetadata colObject = new ColumnMetadata(); + colObject.setOrdinal(1); + colObject.setName("COLARRAY"); + colObject.setPhysicalType("LOB"); + colObject.setNullable(true); + colObject.setLogicalType("ARRAY"); + + innerBuffer.setupSchema(Collections.singletonList(colObject)); + + Map row1 = new HashMap<>(); + row1.put("COLARRAY", null); + + Map row2 = new HashMap<>(); + row2.put("COLARRAY", ""); + + Map row3 = new HashMap<>(); + row3.put("COLARRAY", "null"); + + Map row4 = new HashMap<>(); + row4.put("COLARRAY", "{\"key\":1}"); + + Map row5 = new HashMap<>(); + row5.put("COLARRAY", Arrays.asList(1, 2, 3)); + + InsertValidationResponse response = + innerBuffer.insertRows(Arrays.asList(row1, row2, row3, row4, row5), null, null); + Assert.assertFalse(response.hasErrors()); + + // Check data was inserted into the buffer correctly + Assert.assertNull(innerBuffer.getVectorValueAt("COLARRAY", 0)); + Assert.assertEquals("[null]", innerBuffer.getVectorValueAt("COLARRAY", 1)); + Assert.assertEquals("[null]", innerBuffer.getVectorValueAt("COLARRAY", 2)); + Assert.assertEquals("[{\"key\":1}]", innerBuffer.getVectorValueAt("COLARRAY", 3)); + Assert.assertEquals("[1,2,3]", innerBuffer.getVectorValueAt("COLARRAY", 4)); + + // Check stats generation + ChannelData result = innerBuffer.flush("my_snowpipe_streaming.bdec"); + Assert.assertEquals(5, result.getRowCount()); + } + + @Test + public void testOnErrorAbortRowsWithError() { + AbstractRowBuffer innerBufferOnErrorContinue = + createTestBuffer(OpenChannelRequest.OnErrorOption.CONTINUE); + AbstractRowBuffer innerBufferOnErrorAbort = + createTestBuffer(OpenChannelRequest.OnErrorOption.ABORT); + AbstractRowBuffer innerBufferOnErrorSkipBatch = + createTestBuffer(OpenChannelRequest.OnErrorOption.SKIP_BATCH); + + ColumnMetadata colChar = new ColumnMetadata(); + colChar.setOrdinal(1); + colChar.setName("COLCHAR"); + colChar.setPhysicalType("LOB"); + colChar.setNullable(true); + colChar.setLogicalType("TEXT"); + colChar.setByteLength(14); + colChar.setLength(11); + colChar.setScale(0); + + innerBufferOnErrorContinue.setupSchema(Collections.singletonList(colChar)); + innerBufferOnErrorAbort.setupSchema(Collections.singletonList(colChar)); + + // insert one valid row + List> validRows = new ArrayList<>(); + validRows.add(Collections.singletonMap("colChar", "a")); + + InsertValidationResponse response = innerBufferOnErrorContinue.insertRows(validRows, "1", "1"); + Assert.assertFalse(response.hasErrors()); + response = innerBufferOnErrorAbort.insertRows(validRows, "1", "1"); + Assert.assertFalse(response.hasErrors()); + + // insert one valid and one invalid row + List> mixedRows = new ArrayList<>(); + mixedRows.add(Collections.singletonMap("colChar", "b")); + mixedRows.add(Collections.singletonMap("colChar", "1111111111111111111111")); // too big + + response = innerBufferOnErrorContinue.insertRows(mixedRows, "1", "3"); + Assert.assertTrue(response.hasErrors()); + + Assert.assertThrows( + SFException.class, () -> innerBufferOnErrorAbort.insertRows(mixedRows, "1", "3")); + + List> snapshotContinueParquet = + ((ParquetChunkData) innerBufferOnErrorContinue.getSnapshot("fake/filePath").get()).rows; + // validRows and only the good row from mixedRows are in the buffer + Assert.assertEquals(2, snapshotContinueParquet.size()); + Assert.assertEquals(Arrays.asList("a"), snapshotContinueParquet.get(0)); + Assert.assertEquals(Arrays.asList("b"), snapshotContinueParquet.get(1)); + + List> snapshotAbortParquet = + ((ParquetChunkData) innerBufferOnErrorAbort.getSnapshot("fake/filePath").get()).rows; + // only validRows and none of the mixedRows are in the buffer + Assert.assertEquals(1, snapshotAbortParquet.size()); + Assert.assertEquals(Arrays.asList("a"), snapshotAbortParquet.get(0)); } } diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelTest.java index 563858782..780563d28 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestChannelTest.java @@ -1,22 +1,36 @@ package net.snowflake.ingest.streaming.internal; -import static net.snowflake.ingest.utils.Constants.JDBC_PRIVATE_KEY; +import static java.time.ZoneOffset.UTC; +import static net.snowflake.ingest.utils.Constants.ACCOUNT_URL; import static net.snowflake.ingest.utils.Constants.OPEN_CHANNEL_ENDPOINT; import static net.snowflake.ingest.utils.Constants.PRIVATE_KEY; -import static net.snowflake.ingest.utils.Constants.RESPONSE_ROW_SEQUENCER_IS_COMMITTED; +import static net.snowflake.ingest.utils.Constants.RESPONSE_SUCCESS; +import static net.snowflake.ingest.utils.Constants.ROLE; import static net.snowflake.ingest.utils.Constants.USER; +import static org.mockito.ArgumentMatchers.argThat; import java.security.KeyPair; import java.security.PrivateKey; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import net.snowflake.client.core.SFSessionProperty; import net.snowflake.client.jdbc.internal.apache.commons.io.IOUtils; +import net.snowflake.client.jdbc.internal.apache.http.HttpEntity; +import net.snowflake.client.jdbc.internal.apache.http.HttpHeaders; +import net.snowflake.client.jdbc.internal.apache.http.StatusLine; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.CloseableHttpResponse; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpPost; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; import net.snowflake.ingest.TestUtils; import net.snowflake.ingest.connection.RequestBuilder; import net.snowflake.ingest.streaming.InsertValidationResponse; @@ -28,45 +42,65 @@ import net.snowflake.ingest.utils.SFException; import net.snowflake.ingest.utils.SnowflakeURL; import net.snowflake.ingest.utils.Utils; -import org.apache.http.HttpEntity; -import org.apache.http.HttpHeaders; -import org.apache.http.HttpResponse; -import org.apache.http.StatusLine; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.HttpPost; import org.junit.Assert; import org.junit.Test; import org.mockito.Mockito; public class SnowflakeStreamingIngestChannelTest { + /** + * Mock memory provider, allows the user to set what memory values should be returned. Fields are + * volatile, so can be modified by another threads than the one reading it. + */ + private static class MockedMemoryInfoProvider implements MemoryInfoProvider { + public volatile long freeMemory; + public volatile long maxMemory; + + @Override + public long getMaxMemory() { + return maxMemory; + } + + @Override + public long getTotalMemory() { + return maxMemory; + } + + @Override + public long getFreeMemory() { + return freeMemory; + } + } + @Test public void testChannelFactoryNullFields() { String name = "CHANNEL"; String dbName = "DATABASE"; String schemaName = "SCHEMA"; String tableName = "TABLE"; - Long channelSequencer = 0L; - Long rowSequencer = 0L; - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal("client"); + long channelSequencer = 0L; + long rowSequencer = 0L; + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>("client"); Object[] fields = - new Object[] {name, dbName, schemaName, tableName, channelSequencer, rowSequencer, client}; + new Object[] { + name, dbName, schemaName, tableName, channelSequencer, rowSequencer, client, UTC + }; for (int i = 0; i < fields.length; i++) { Object tmp = fields[i]; fields[i] = null; try { - SnowflakeStreamingIngestChannelInternal channel = - SnowflakeStreamingIngestChannelFactory.builder((String) fields[0]) - .setDBName((String) fields[1]) - .setSchemaName((String) fields[2]) - .setTableName((String) fields[3]) - .setRowSequencer((Long) fields[4]) - .setChannelSequencer((Long) fields[5]) - .setOwningClient((SnowflakeStreamingIngestClientInternal) fields[6]) - .build(); + SnowflakeStreamingIngestChannelFactory.builder((String) fields[0]) + .setDBName((String) fields[1]) + .setSchemaName((String) fields[2]) + .setTableName((String) fields[3]) + .setRowSequencer((Long) fields[4]) + .setChannelSequencer((Long) fields[5]) + .setOwningClient((SnowflakeStreamingIngestClientInternal) fields[6]) + .setDefaultTimezone((ZoneId) fields[7]) + .build(); Assert.fail("Channel factory should fail with null fields"); } catch (SFException e) { Assert.assertTrue( @@ -86,13 +120,13 @@ public void testChannelFactorySuccess() { String offsetToken = "0"; String encryptionKey = "key"; Long channelSequencer = 0L; - Long rowSequencer = 0L; + long rowSequencer = 0L; - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal("client"); + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>("client"); - SnowflakeStreamingIngestChannelInternal channel = - SnowflakeStreamingIngestChannelFactory.builder(name) + SnowflakeStreamingIngestChannelInternal channel = + SnowflakeStreamingIngestChannelFactory.builder(name) .setDBName(dbName) .setSchemaName(schemaName) .setTableName(tableName) @@ -103,15 +137,16 @@ public void testChannelFactorySuccess() { .setEncryptionKey(encryptionKey) .setEncryptionKeyId(1234L) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .setDefaultTimezone(UTC) .build(); Assert.assertEquals(name, channel.getName()); Assert.assertEquals(dbName, channel.getDBName()); Assert.assertEquals(schemaName, channel.getSchemaName()); Assert.assertEquals(tableName, channel.getTableName()); - Assert.assertEquals(offsetToken, channel.getOffsetToken()); + Assert.assertEquals(offsetToken, channel.getChannelState().getEndOffsetToken()); Assert.assertEquals(channelSequencer, channel.getChannelSequencer()); - Assert.assertEquals(rowSequencer + 1L, channel.incrementAndGetRowSequencer()); + Assert.assertEquals(rowSequencer + 1L, channel.getChannelState().incrementAndGetRowSequencer()); Assert.assertEquals( String.format("%s.%s.%s.%s", dbName, schemaName, tableName, name), channel.getFullyQualifiedName()); @@ -122,10 +157,10 @@ public void testChannelFactorySuccess() { @Test public void testChannelValid() { - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal("client"); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>("client"); + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schema", @@ -137,11 +172,11 @@ public void testChannelValid() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); Assert.assertTrue(channel.isValid()); - channel.invalidate(); - Assert.assertTrue(!channel.isValid()); + channel.invalidate("from testChannelValid"); + Assert.assertFalse(channel.isValid()); // Can't insert rows to invalid channel try { @@ -172,10 +207,10 @@ public void testChannelValid() { @Test public void testChannelClose() { - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal("client"); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>("client"); + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schema", @@ -187,9 +222,9 @@ public void testChannelClose() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); - Assert.assertTrue(!channel.isClosed()); + Assert.assertFalse(channel.isClosed()); channel.markClosed(); Assert.assertTrue(channel.isClosed()); @@ -207,11 +242,10 @@ public void testChannelClose() { @Test public void testOpenChannelRequestCreationMissingField() { try { - OpenChannelRequest request = - OpenChannelRequest.builder("CHANNEL") - .setDBName("STREAMINGINGEST_TEST") - .setSchemaName("PUBLIC") - .build(); + OpenChannelRequest.builder("CHANNEL") + .setDBName("STREAMINGINGEST_TEST") + .setSchemaName("PUBLIC") + .build(); Assert.fail("Request should fail with missing table name"); } catch (SFException e) { Assert.assertEquals(ErrorCode.NULL_OR_EMPTY_STRING.getMessageCode(), e.getVendorCode()); @@ -230,6 +264,24 @@ public void testOpenChannelRequestCreationSuccess() { Assert.assertEquals( "STREAMINGINGEST_TEST.PUBLIC.T_STREAMINGINGEST", request.getFullyQualifiedTableName()); + Assert.assertFalse(request.isOffsetTokenProvided()); + } + + @Test + public void testOpenChannelRequesCreationtWithOffsetToken() { + OpenChannelRequest request = + OpenChannelRequest.builder("CHANNEL") + .setDBName("STREAMINGINGEST_TEST") + .setSchemaName("PUBLIC") + .setTableName("T_STREAMINGINGEST") + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .setOffsetToken("TEST_TOKEN") + .build(); + + Assert.assertEquals( + "STREAMINGINGEST_TEST.PUBLIC.T_STREAMINGINGEST", request.getFullyQualifiedTableName()); + Assert.assertEquals("TEST_TOKEN", request.getOffsetToken()); + Assert.assertTrue(request.isOffsetTokenProvided()); } @Test @@ -237,13 +289,18 @@ public void testOpenChannelPostRequest() throws Exception { Properties prop = new Properties(); prop.put(USER, TestUtils.getUser()); prop.put(PRIVATE_KEY, TestUtils.getPrivateKey()); - prop = Utils.createProperties(prop, false); + prop.put(ACCOUNT_URL, TestUtils.getAccountURL()); + prop.put(ROLE, TestUtils.getRole()); + prop = Utils.createProperties(prop); String urlStr = "https://sfctest0.snowflakecomputing.com:80"; SnowflakeURL url = new SnowflakeURL(urlStr); - KeyPair keyPair = Utils.createKeyPairFromPrivateKey((PrivateKey) prop.get(JDBC_PRIVATE_KEY)); - RequestBuilder requestBuilder = new RequestBuilder(url, prop.get(USER).toString(), keyPair); + KeyPair keyPair = + Utils.createKeyPairFromPrivateKey( + (PrivateKey) prop.get(SFSessionProperty.PRIVATE_KEY.getPropertyKey())); + RequestBuilder requestBuilder = + new RequestBuilder(url, prop.get(USER).toString(), keyPair, null, null); Map payload = new HashMap<>(); payload.put("channel", "CHANNEL"); @@ -257,8 +314,7 @@ public void testOpenChannelPostRequest() throws Exception { payload, OPEN_CHANNEL_ENDPOINT, "open channel"); Assert.assertEquals( - String.format("%s%s", urlStr, OPEN_CHANNEL_ENDPOINT), - request.getRequestLine().getUri().toString()); + String.format("%s%s", urlStr, OPEN_CHANNEL_ENDPOINT), request.getRequestLine().getUri()); Assert.assertNotNull(request.getFirstHeader(HttpHeaders.USER_AGENT)); Assert.assertNotNull(request.getFirstHeader(HttpHeaders.AUTHORIZATION)); Assert.assertEquals("POST", request.getMethod()); @@ -272,8 +328,8 @@ public void testOpenChannelPostRequest() throws Exception { @Test public void testOpenChannelErrorResponse() throws Exception { - HttpClient httpClient = Mockito.mock(HttpClient.class); - HttpResponse httpResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine statusLine = Mockito.mock(StatusLine.class); HttpEntity httpEntity = Mockito.mock(HttpEntity.class); Mockito.when(statusLine.getStatusCode()).thenReturn(500); @@ -285,8 +341,8 @@ public void testOpenChannelErrorResponse() throws Exception { RequestBuilder requestBuilder = new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair()); - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( "client", new SnowflakeURL("snowflake.dev.local:8082"), null, @@ -304,7 +360,7 @@ public void testOpenChannelErrorResponse() throws Exception { .build(); try { - SnowflakeStreamingIngestChannel channel = client.openChannel(request); + client.openChannel(request); Assert.fail("Open channel should fail on 500 error"); } catch (SFException e) { Assert.assertEquals(ErrorCode.OPEN_CHANNEL_FAILURE.getMessageCode(), e.getVendorCode()); @@ -343,8 +399,8 @@ public void testOpenChannelSnowflakeInternalErrorResponse() throws Exception { + " } ]\n" + "}"; - HttpClient httpClient = Mockito.mock(HttpClient.class); - HttpResponse httpResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine statusLine = Mockito.mock(StatusLine.class); HttpEntity httpEntity = Mockito.mock(HttpEntity.class); Mockito.when(statusLine.getStatusCode()).thenReturn(200); @@ -355,8 +411,8 @@ public void testOpenChannelSnowflakeInternalErrorResponse() throws Exception { RequestBuilder requestBuilder = new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair()); - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( "client", new SnowflakeURL("snowflake.dev.local:8082"), null, @@ -374,7 +430,7 @@ public void testOpenChannelSnowflakeInternalErrorResponse() throws Exception { .build(); try { - SnowflakeStreamingIngestChannel channel = client.openChannel(request); + client.openChannel(request); Assert.fail("Open channel should fail on Snowflake internal error"); } catch (SFException e) { Assert.assertEquals(ErrorCode.OPEN_CHANNEL_FAILURE.getMessageCode(), e.getVendorCode()); @@ -400,6 +456,7 @@ public void testOpenChannelSuccessResponse() throws Exception { + " \"offset_token\" : \"\",\n" + " \"encryption_key_id\" : 17229585102,\n" + " \"table_columns\" : [ {\n" + + " \"ordinal\" : 1,\n" + " \"name\" : \"C1\",\n" + " \"type\" : \"NUMBER(38,0)\",\n" + " \"logical_type\" : \"fixed\",\n" @@ -410,6 +467,7 @@ public void testOpenChannelSuccessResponse() throws Exception { + " \"length\" : null,\n" + " \"nullable\" : true\n" + " }, {\n" + + " \"ordinal\" : 2,\n" + " \"name\" : \"C2\",\n" + " \"type\" : \"NUMBER(38,0)\",\n" + " \"logical_type\" : \"fixed\",\n" @@ -423,8 +481,8 @@ public void testOpenChannelSuccessResponse() throws Exception { + " \"encryption_key\" : \"3/l6q2xeDurO4ljfde4DXA==\"\n" + "}"; - HttpClient httpClient = Mockito.mock(HttpClient.class); - HttpResponse httpResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine statusLine = Mockito.mock(StatusLine.class); HttpEntity httpEntity = Mockito.mock(HttpEntity.class); Mockito.when(statusLine.getStatusCode()).thenReturn(200); @@ -435,8 +493,8 @@ public void testOpenChannelSuccessResponse() throws Exception { RequestBuilder requestBuilder = new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair()); - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( "client", new SnowflakeURL("snowflake.dev.local:8082"), null, @@ -458,14 +516,30 @@ public void testOpenChannelSuccessResponse() throws Exception { Assert.assertEquals(dbName, channel.getDBName()); Assert.assertEquals(schemaName, channel.getSchemaName()); Assert.assertEquals(tableName, channel.getTableName()); + + Assert.assertTrue(channel.getTableSchema().containsKey("C1")); + Assert.assertEquals("NUMBER(38,0)", channel.getTableSchema().get("C1").getType()); + Assert.assertEquals("FIXED", channel.getTableSchema().get("C1").getLogicalType()); + Assert.assertEquals(38, (int) channel.getTableSchema().get("C1").getPrecision()); + Assert.assertEquals(0, (int) channel.getTableSchema().get("C1").getScale()); + Assert.assertNull(channel.getTableSchema().get("C1").getByteLength()); + Assert.assertTrue(channel.getTableSchema().get("C1").isNullable()); + + Assert.assertTrue(channel.getTableSchema().containsKey("C2")); + Assert.assertEquals("NUMBER(38,0)", channel.getTableSchema().get("C2").getType()); + Assert.assertEquals("FIXED", channel.getTableSchema().get("C2").getLogicalType()); + Assert.assertEquals(38, (int) channel.getTableSchema().get("C2").getPrecision()); + Assert.assertEquals(0, (int) channel.getTableSchema().get("C2").getScale()); + Assert.assertNull(channel.getTableSchema().get("C2").getByteLength()); + Assert.assertTrue(channel.getTableSchema().get("C2").isNullable()); } @Test public void testInsertRow() { - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal("client"); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestClientInternal client; + client = new SnowflakeStreamingIngestClientInternal("client_PARQUET"); + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schema", @@ -477,9 +551,10 @@ public void testInsertRow() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); ColumnMetadata col = new ColumnMetadata(); + col.setOrdinal(1); col.setName("COL"); col.setPhysicalType("SB16"); col.setNullable(false); @@ -494,29 +569,145 @@ public void testInsertRow() { row.put("col", 1); // Get data before insert to verify that there is no row (data should be null) - ChannelData data = channel.getData(); + ChannelData data = channel.getData("my_snowpipe_streaming.bdec"); Assert.assertNull(data); + long insertStartTimeInMs = System.currentTimeMillis(); InsertValidationResponse response = channel.insertRow(row, "1"); Assert.assertFalse(response.hasErrors()); response = channel.insertRows(Collections.singletonList(row), "2"); Assert.assertFalse(response.hasErrors()); + response = channel.insertRows(Collections.singletonList(row), "3", "3"); + Assert.assertFalse(response.hasErrors()); + long insertEndTimeInMs = System.currentTimeMillis(); // Get data again to verify the row is inserted - data = channel.getData(); - Assert.assertEquals(2, data.getRowCount()); + data = channel.getData("my_snowpipe_streaming.bdec"); + Assert.assertEquals(3, data.getRowCount()); Assert.assertEquals((Long) 1L, data.getRowSequencer()); - Assert.assertEquals(1, data.getVectors().getFieldVectors().size()); - Assert.assertEquals("2", data.getOffsetToken()); + Assert.assertEquals(1, ((ChannelData) data).getVectors().rows.get(0).size()); + Assert.assertEquals("3", data.getEndOffsetToken()); + Assert.assertEquals("1", data.getStartOffsetToken()); Assert.assertTrue(data.getBufferSize() > 0); + Assert.assertTrue(insertStartTimeInMs <= data.getMinMaxInsertTimeInMs().getFirst()); + Assert.assertTrue(insertEndTimeInMs >= data.getMinMaxInsertTimeInMs().getSecond()); + } + + @Test + public void testInsertTooLargeRow() { + byte[] byteArrayOneMb = new byte[1024 * 1024]; + + List schema = + IntStream.range(0, 64) + .mapToObj( + colId -> { + ColumnMetadata col = new ColumnMetadata(); + col.setOrdinal(colId + 1); + col.setName("COL" + colId); + col.setPhysicalType("LOB"); + col.setNullable(false); + col.setLogicalType("BINARY"); + col.setLength(8388608); + col.setByteLength(8388608); + return col; + }) + .collect(Collectors.toList()); + + String expectedMessage = + "The given row exceeds the maximum allowed row size rowSizeInBytes:67109128.000," + + " maxAllowedRowSizeInBytes:67108864, rowIndex:0"; + + Map row = new HashMap<>(); + schema.forEach(x -> row.put(x.getName(), byteArrayOneMb)); + + SnowflakeStreamingIngestClientInternal client; + client = new SnowflakeStreamingIngestClientInternal("test_client"); + + // Test channel with on error CONTINUE + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( + "channel", + "db", + "schema", + "table", + "0", + 0L, + 0L, + client, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.CONTINUE, + UTC); + channel.setupSchema(schema); + + InsertValidationResponse insertValidationResponse = channel.insertRow(row, "token-1"); + Assert.assertEquals(1, insertValidationResponse.getErrorRowCount()); + SFException thrownException = insertValidationResponse.getInsertErrors().get(0).getException(); + Assert.assertEquals( + ErrorCode.MAX_ROW_SIZE_EXCEEDED.getMessageCode(), thrownException.getVendorCode()); + Assert.assertEquals(expectedMessage, thrownException.getMessage()); + + // Test channel with on error ABORT + channel = + new SnowflakeStreamingIngestChannelInternal<>( + "channel", + "db", + "schema", + "table", + "0", + 0L, + 0L, + client, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.ABORT, + UTC); + channel.setupSchema(schema); + + try { + channel.insertRow(row, "token-1"); + Assert.fail("Insert row shouldn't have succeeded"); + } catch (SFException e) { + Assert.assertEquals(ErrorCode.MAX_ROW_SIZE_EXCEEDED.getMessageCode(), e.getVendorCode()); + Assert.assertEquals(expectedMessage, e.getMessage()); + } + + // Test channel with on error SKIP_BATCH + channel = + new SnowflakeStreamingIngestChannelInternal<>( + "channel", + "db", + "schema", + "table", + "0", + 0L, + 0L, + client, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.SKIP_BATCH, + UTC); + channel.setupSchema(schema); + + insertValidationResponse = channel.insertRow(row, "token-1"); + Assert.assertEquals(1, insertValidationResponse.getErrorRowCount()); + thrownException = insertValidationResponse.getInsertErrors().get(0).getException(); + Assert.assertEquals( + ErrorCode.MAX_ROW_SIZE_EXCEEDED.getMessageCode(), thrownException.getVendorCode()); + Assert.assertEquals(expectedMessage, thrownException.getMessage()); } @Test public void testInsertRowThrottling() { - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal("client"); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + final long maxMemory = 1000000L; + + final MockedMemoryInfoProvider memoryInfoProvider = new MockedMemoryInfoProvider(); + memoryInfoProvider.maxMemory = maxMemory; + + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>("client"); + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schema", @@ -528,36 +719,41 @@ public void testInsertRowThrottling() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); - Runtime mockedRunTime = Mockito.mock(Runtime.class); - Mockito.when(mockedRunTime.totalMemory()).thenReturn(1000000L); ParameterProvider parameterProvider = new ParameterProvider(); - Mockito.when(mockedRunTime.freeMemory()) - .thenReturn( - 1000000L * (parameterProvider.getInsertThrottleThresholdInPercentage() - 1) / 100); + memoryInfoProvider.freeMemory = + maxMemory * (parameterProvider.getInsertThrottleThresholdInPercentage() - 1) / 100; CompletableFuture future = - CompletableFuture.runAsync( - () -> { - channel.throttleInsertIfNeeded(mockedRunTime); - }); + CompletableFuture.runAsync(() -> channel.throttleInsertIfNeeded(memoryInfoProvider)); try { - future.get(1L, TimeUnit.SECONDS); + future.get(5L, TimeUnit.SECONDS); Assert.fail("the insert should be throttled."); } catch (TimeoutException ignored) { } catch (Exception e) { + e.printStackTrace(); + Assert.fail("unexpected exception encountered."); + } + + memoryInfoProvider.freeMemory = maxMemory; + + // We should succeed now + try { + future.get(5L, TimeUnit.SECONDS); + } catch (Exception e) { + e.printStackTrace(); Assert.fail("unexpected exception encountered."); } } @Test public void testFlush() throws Exception { - SnowflakeStreamingIngestClientInternal client = - Mockito.spy(new SnowflakeStreamingIngestClientInternal("client")); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestClientInternal client = + Mockito.spy(new SnowflakeStreamingIngestClientInternal<>("client")); + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schema", @@ -569,7 +765,7 @@ public void testFlush() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); ChannelsStatusResponse response = new ChannelsStatusResponse(); response.setStatusCode(0L); response.setMessage("Success"); @@ -590,10 +786,10 @@ public void testFlush() throws Exception { @Test public void testClose() throws Exception { - SnowflakeStreamingIngestClientInternal client = - Mockito.spy(new SnowflakeStreamingIngestClientInternal("client")); + SnowflakeStreamingIngestClientInternal client = + Mockito.spy(new SnowflakeStreamingIngestClientInternal<>("client")); SnowflakeStreamingIngestChannel channel = - new SnowflakeStreamingIngestChannelInternal( + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schema", @@ -605,7 +801,7 @@ public void testClose() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); ChannelsStatusResponse response = new ChannelsStatusResponse(); response.setStatusCode(0L); response.setMessage("Success"); @@ -619,15 +815,85 @@ public void testClose() throws Exception { // Calling close again on closed channel shouldn't fail channel.close().get(); + Mockito.verify(client, Mockito.times(0)).dropChannel(Mockito.any()); + } + + @Test + public void testDropOnClose() throws Exception { + SnowflakeStreamingIngestClientInternal client = + Mockito.spy(new SnowflakeStreamingIngestClientInternal<>("client")); + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( + "channel", + "db", + "schema", + "table", + "0", + 1L, + 0L, + client, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.CONTINUE, + UTC); + ChannelsStatusResponse response = new ChannelsStatusResponse(); + response.setStatusCode(0L); + response.setMessage("Success"); + response.setChannels(new ArrayList<>()); + + Mockito.doReturn(response).when(client).getChannelsStatus(Mockito.any()); + + Assert.assertFalse(channel.isClosed()); + Mockito.doNothing().when(client).dropChannel(Mockito.any()); + channel.close(true).get(); + Assert.assertTrue(channel.isClosed()); + Mockito.verify(client, Mockito.times(1)) + .dropChannel( + argThat( + (DropChannelVersionRequest req) -> + req.getChannelName().equals(channel.getName()) + && req.getClientSequencer().equals(channel.getChannelSequencer()))); + } + + @Test + public void testDropOnCloseInvalidChannel() throws Exception { + SnowflakeStreamingIngestClientInternal client = + Mockito.spy(new SnowflakeStreamingIngestClientInternal<>("client")); + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( + "channel", + "db", + "schema", + "table", + "0", + 1L, + 0L, + client, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.CONTINUE, + UTC); + ChannelsStatusResponse response = new ChannelsStatusResponse(); + response.setStatusCode(0L); + response.setMessage("Success"); + response.setChannels(new ArrayList<>()); + + Mockito.doReturn(response).when(client).getChannelsStatus(Mockito.any()); + + Assert.assertFalse(channel.isClosed()); + channel.invalidate("test"); + Mockito.doNothing().when(client).dropChannel(Mockito.any()); + Assert.assertThrows(SFException.class, () -> channel.close(true).get()); + Mockito.verify(client, Mockito.never()).dropChannel(Mockito.any()); } @Test public void testGetLatestCommittedOffsetToken() { String offsetToken = "10"; - SnowflakeStreamingIngestClientInternal client = - Mockito.spy(new SnowflakeStreamingIngestClientInternal("client")); + SnowflakeStreamingIngestClientInternal client = + Mockito.spy(new SnowflakeStreamingIngestClientInternal<>("client")); SnowflakeStreamingIngestChannel channel = - new SnowflakeStreamingIngestChannelInternal( + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schema", @@ -639,19 +905,30 @@ public void testGetLatestCommittedOffsetToken() { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); ChannelsStatusResponse response = new ChannelsStatusResponse(); response.setStatusCode(0L); response.setMessage("Success"); + + // Test success case ChannelsStatusResponse.ChannelStatusResponseDTO channelStatus = new ChannelsStatusResponse.ChannelStatusResponseDTO(); - channelStatus.setStatusCode((long) RESPONSE_ROW_SEQUENCER_IS_COMMITTED); + channelStatus.setStatusCode(RESPONSE_SUCCESS); channelStatus.setPersistedOffsetToken(offsetToken); response.setChannels(Collections.singletonList(channelStatus)); Mockito.doReturn(response).when(client).getChannelsStatus(Mockito.any()); Assert.assertEquals(offsetToken, channel.getLatestCommittedOffsetToken()); + + // Test error case + channelStatus.setStatusCode(-1L); + try { + Assert.assertEquals(offsetToken, channel.getLatestCommittedOffsetToken()); + Assert.fail("Get offset token should fail"); + } catch (SFException e) { + Assert.assertEquals(ErrorCode.CHANNEL_STATUS_INVALID.getMessageCode(), e.getVendorCode()); + } } } diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestClientTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestClientTest.java index eddaa9ce0..1693e1520 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestClientTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeStreamingIngestClientTest.java @@ -1,78 +1,176 @@ package net.snowflake.ingest.streaming.internal; +import static java.time.ZoneOffset.UTC; import static net.snowflake.ingest.utils.Constants.ACCOUNT_URL; import static net.snowflake.ingest.utils.Constants.CHANNEL_STATUS_ENDPOINT; -import static net.snowflake.ingest.utils.Constants.JDBC_PRIVATE_KEY; +import static net.snowflake.ingest.utils.Constants.DROP_CHANNEL_ENDPOINT; +import static net.snowflake.ingest.utils.Constants.MAX_STREAMING_INGEST_API_CHANNEL_RETRY; import static net.snowflake.ingest.utils.Constants.PRIVATE_KEY; import static net.snowflake.ingest.utils.Constants.REGISTER_BLOB_ENDPOINT; +import static net.snowflake.ingest.utils.Constants.RESPONSE_ERR_ENQUEUE_TABLE_CHUNK_QUEUE_FULL; +import static net.snowflake.ingest.utils.Constants.RESPONSE_SUCCESS; import static net.snowflake.ingest.utils.Constants.ROLE; import static net.snowflake.ingest.utils.Constants.USER; -import static net.snowflake.ingest.utils.ParameterProvider.ENABLE_SNOWPIPE_STREAMING_METRICS_MAP_KEY; +import static net.snowflake.ingest.utils.ParameterProvider.ENABLE_SNOWPIPE_STREAMING_METRICS; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.when; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.StringWriter; +import java.nio.charset.Charset; import java.security.KeyPair; import java.security.PrivateKey; -import java.security.Security; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import net.snowflake.client.core.SFSessionProperty; import net.snowflake.client.jdbc.internal.apache.commons.io.IOUtils; -import net.snowflake.client.jdbc.internal.org.bouncycastle.asn1.nist.NISTObjectIdentifiers; -import net.snowflake.client.jdbc.internal.org.bouncycastle.jce.provider.BouncyCastleProvider; -import net.snowflake.client.jdbc.internal.org.bouncycastle.openssl.jcajce.JcaPEMWriter; -import net.snowflake.client.jdbc.internal.org.bouncycastle.operator.OperatorCreationException; -import net.snowflake.client.jdbc.internal.org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfoBuilder; -import net.snowflake.client.jdbc.internal.org.bouncycastle.pkcs.jcajce.JcaPKCS8EncryptedPrivateKeyInfoBuilder; -import net.snowflake.client.jdbc.internal.org.bouncycastle.pkcs.jcajce.JcePKCSPBEOutputEncryptorBuilder; +import net.snowflake.client.jdbc.internal.apache.http.HttpEntity; +import net.snowflake.client.jdbc.internal.apache.http.HttpHeaders; +import net.snowflake.client.jdbc.internal.apache.http.StatusLine; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.CloseableHttpResponse; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.HttpPost; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import net.snowflake.client.jdbc.internal.google.common.collect.Sets; import net.snowflake.ingest.TestUtils; import net.snowflake.ingest.connection.RequestBuilder; +import net.snowflake.ingest.streaming.DropChannelRequest; import net.snowflake.ingest.streaming.OpenChannelRequest; import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClient; import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClientFactory; +import net.snowflake.ingest.utils.Constants; import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.Pair; import net.snowflake.ingest.utils.ParameterProvider; import net.snowflake.ingest.utils.SFException; import net.snowflake.ingest.utils.SnowflakeURL; import net.snowflake.ingest.utils.Utils; -import org.apache.http.HttpEntity; -import org.apache.http.HttpHeaders; -import org.apache.http.HttpResponse; -import org.apache.http.StatusLine; -import org.apache.http.client.HttpClient; -import org.apache.http.client.methods.HttpPost; +import org.bouncycastle.asn1.nist.NISTObjectIdentifiers; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfoBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS8EncryptedPrivateKeyInfoBuilder; +import org.bouncycastle.pkcs.jcajce.JcePKCSPBEOutputEncryptorBuilder; import org.junit.Assert; +import org.junit.Before; import org.junit.Ignore; import org.junit.Test; +import org.mockito.ArgumentMatchers; import org.mockito.Mockito; public class SnowflakeStreamingIngestClientTest { private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final Constants.BdecVersion BDEC_VERSION = Constants.BdecVersion.THREE; + + SnowflakeStreamingIngestChannelInternal channel1; + SnowflakeStreamingIngestChannelInternal channel2; + SnowflakeStreamingIngestChannelInternal channel3; + SnowflakeStreamingIngestChannelInternal channel4; + + @Before + public void setup() { + objectMapper.setVisibility(PropertyAccessor.GETTER, JsonAutoDetect.Visibility.ANY); + objectMapper.setVisibility(PropertyAccessor.SETTER, JsonAutoDetect.Visibility.ANY); + channel1 = + new SnowflakeStreamingIngestChannelInternal<>( + "channel1", + "db", + "schemaName", + "tableName", + "0", + 0L, + 0L, + null, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.CONTINUE, + ZoneOffset.UTC, + BDEC_VERSION, + null); + channel2 = + new SnowflakeStreamingIngestChannelInternal<>( + "channel2", + "db", + "schemaName", + "tableName", + "0", + 2L, + 0L, + null, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.CONTINUE, + ZoneOffset.UTC, + BDEC_VERSION, + null); + channel3 = + new SnowflakeStreamingIngestChannelInternal<>( + "channel3", + "db", + "schemaName", + "tableName3", + "0", + 3L, + 0L, + null, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.CONTINUE, + ZoneOffset.UTC, + BDEC_VERSION, + null); + channel4 = + new SnowflakeStreamingIngestChannelInternal<>( + "channel4", + "db", + "schemaName", + "tableName4", + "0", + 3L, + 0L, + null, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.CONTINUE, + ZoneOffset.UTC, + BDEC_VERSION, + null); + } + @Test - @Ignore // Until able to test in PROD public void testConstructorParameters() throws Exception { Properties prop = new Properties(); prop.put(USER, TestUtils.getUser()); prop.put(ACCOUNT_URL, TestUtils.getHost()); prop.put(PRIVATE_KEY, TestUtils.getPrivateKey()); - prop.put(ROLE, "role"); - prop.put(ParameterProvider.BUFFER_FLUSH_INTERVAL_IN_MILLIS_MAP_KEY, 123); + prop.put(ROLE, TestUtils.getRole()); + prop.put(ParameterProvider.MAX_CLIENT_LAG, 1234); Map parameterMap = new HashMap<>(); - parameterMap.put(ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS_MAP_KEY, 321); + parameterMap.put(ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS, 321L); - SnowflakeStreamingIngestClientInternal client = - (SnowflakeStreamingIngestClientInternal) - SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); + SnowflakeStreamingIngestClientInternal client = + (SnowflakeStreamingIngestClientInternal) + SnowflakeStreamingIngestClientFactory.builder("client") + .setProperties(prop) + .setParameterOverrides(parameterMap) + .setIsTestMode(true) + .build(); Assert.assertEquals("client", client.getName()); - Assert.assertEquals(123, client.getParameterProvider().getBufferFlushIntervalInMs()); + Assert.assertEquals(1234, client.getParameterProvider().getCachedMaxClientLagInMs()); Assert.assertEquals(321, client.getParameterProvider().getBufferFlushCheckIntervalInMs()); Assert.assertEquals( ParameterProvider.INSERT_THROTTLE_INTERVAL_IN_MILLIS_DEFAULT, @@ -89,12 +187,12 @@ public void testClientFactoryWithJmxMetrics() throws Exception { prop.put(PRIVATE_KEY, TestUtils.getPrivateKey()); prop.put(ROLE, "role"); - SnowflakeStreamingIngestClientInternal client = - (SnowflakeStreamingIngestClientInternal) + SnowflakeStreamingIngestClientInternal client = + (SnowflakeStreamingIngestClientInternal) SnowflakeStreamingIngestClientFactory.builder("client") .setProperties(prop) .setParameterOverrides( - Collections.singletonMap(ENABLE_SNOWPIPE_STREAMING_METRICS_MAP_KEY, true)) + Collections.singletonMap(ENABLE_SNOWPIPE_STREAMING_METRICS, true)) .build(); Assert.assertEquals("client", client.getName()); @@ -111,8 +209,7 @@ public void testClientFactoryMissingName() throws Exception { prop.put(ROLE, "role"); try { - SnowflakeStreamingIngestClient client = - SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); + SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); Assert.fail("Client creation should fail."); } catch (SFException e) { Assert.assertEquals(ErrorCode.MISSING_CONFIG.getMessageCode(), e.getVendorCode()); @@ -127,8 +224,7 @@ public void testClientFactoryMissingURL() throws Exception { prop.put(ROLE, "role"); try { - SnowflakeStreamingIngestClient client = - SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); + SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); Assert.fail("Client creation should fail."); } catch (SFException e) { Assert.assertEquals(ErrorCode.MISSING_CONFIG.getMessageCode(), e.getVendorCode()); @@ -144,8 +240,7 @@ public void testClientFactoryInvalidURL() throws Exception { prop.put(ROLE, "role"); try { - SnowflakeStreamingIngestClient client = - SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); + SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); Assert.fail("Client creation should fail."); } catch (SFException e) { Assert.assertEquals(ErrorCode.INVALID_URL.getMessageCode(), e.getVendorCode()); @@ -161,8 +256,7 @@ public void testClientFactoryInvalidPrivateKey() throws Exception { prop.put(ROLE, "role"); try { - SnowflakeStreamingIngestClient client = - SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); + SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); Assert.fail("Client creation should fail."); } catch (SFException e) { Assert.assertEquals(ErrorCode.INVALID_PRIVATE_KEY.getMessageCode(), e.getVendorCode()); @@ -170,18 +264,18 @@ public void testClientFactoryInvalidPrivateKey() throws Exception { } @Test - @Ignore // Wait for the client/configure endpoint to be available in PROD, can't mock the - // HttpUtil.executeGeneralRequest call because it's also used when setting up the - // connection public void testClientFactorySuccess() throws Exception { Properties prop = new Properties(); prop.put(USER, TestUtils.getUser()); prop.put(ACCOUNT_URL, TestUtils.getHost()); prop.put(PRIVATE_KEY, TestUtils.getPrivateKey()); - prop.put(ROLE, "role"); + prop.put(ROLE, TestUtils.getRole()); SnowflakeStreamingIngestClient client = - SnowflakeStreamingIngestClientFactory.builder("client").setProperties(prop).build(); + SnowflakeStreamingIngestClientFactory.builder("client") + .setProperties(prop) + .setIsTestMode(true) + .build(); Assert.assertEquals("client", client.getName()); Assert.assertFalse(client.isClosed()); @@ -207,7 +301,6 @@ public void testEncryptedPrivateKey() throws Exception { private String generateAESKey(PrivateKey key, char[] passwd) throws IOException, OperatorCreationException { - Security.addProvider(new BouncyCastleProvider()); StringWriter writer = new StringWriter(); JcaPEMWriter pemWriter = new JcaPEMWriter(writer); PKCS8EncryptedPrivateKeyInfoBuilder pkcs8EncryptedPrivateKeyInfoBuilder = @@ -215,7 +308,7 @@ private String generateAESKey(PrivateKey key, char[] passwd) pemWriter.writeObject( pkcs8EncryptedPrivateKeyInfoBuilder.build( new JcePKCSPBEOutputEncryptorBuilder(NISTObjectIdentifiers.id_aes256_CBC) - .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .setProvider(Utils.getProvider().getName()) .build(passwd))); pemWriter.close(); return writer.toString(); @@ -223,27 +316,30 @@ private String generateAESKey(PrivateKey key, char[] passwd) @Test public void testGetChannelsStatusWithRequest() throws Exception { + ChannelsStatusResponse.ChannelStatusResponseDTO channelStatus = + new ChannelsStatusResponse.ChannelStatusResponseDTO(); + channelStatus.setStatusCode(RESPONSE_SUCCESS); ChannelsStatusResponse response = new ChannelsStatusResponse(); response.setStatusCode(0L); response.setMessage("honk"); - response.setChannels(new ArrayList<>()); + response.setChannels(Collections.singletonList(channelStatus)); String responseString = objectMapper.writeValueAsString(response); - HttpClient httpClient = Mockito.mock(HttpClient.class); - HttpResponse httpResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine statusLine = Mockito.mock(StatusLine.class); HttpEntity httpEntity = Mockito.mock(HttpEntity.class); - Mockito.when(statusLine.getStatusCode()).thenReturn(200); - Mockito.when(httpResponse.getStatusLine()).thenReturn(statusLine); - Mockito.when(httpResponse.getEntity()).thenReturn(httpEntity); - Mockito.when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(responseString)); - Mockito.when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + when(statusLine.getStatusCode()).thenReturn(200); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); + when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(responseString)); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); RequestBuilder requestBuilder = Mockito.spy( new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( "client", new SnowflakeURL("snowflake.dev.local:8082"), null, @@ -252,8 +348,8 @@ public void testGetChannelsStatusWithRequest() throws Exception { requestBuilder, null); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schemaName", @@ -265,7 +361,9 @@ public void testGetChannelsStatusWithRequest() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + ZoneOffset.UTC, + BDEC_VERSION, + null); ChannelsStatusRequest.ChannelStatusRequestDTO dto = new ChannelsStatusRequest.ChannelStatusRequestDTO(channel); @@ -279,6 +377,51 @@ public void testGetChannelsStatusWithRequest() throws Exception { objectMapper.writeValueAsString(request), CHANNEL_STATUS_ENDPOINT, "channel status"); } + @Test + public void testDropChannel() throws Exception { + DropChannelResponse response = new DropChannelResponse(); + response.setStatusCode(RESPONSE_SUCCESS); + response.setMessage("dropped"); + String responseString = objectMapper.writeValueAsString(response); + + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); + StatusLine statusLine = Mockito.mock(StatusLine.class); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + when(statusLine.getStatusCode()).thenReturn(200); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); + when(httpEntity.getContent()) + .thenReturn(IOUtils.toInputStream(responseString, Charset.defaultCharset())); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( + "client", + new SnowflakeURL("snowflake.dev.local:8082"), + null, + httpClient, + true, + requestBuilder, + null); + + DropChannelRequest request = + DropChannelRequest.builder("channel") + .setDBName("db") + .setTableName("table") + .setSchemaName("schema") + .build(); + client.dropChannel(request); + Mockito.verify(requestBuilder) + .generateStreamingIngestPostRequest( + ArgumentMatchers.contains("channel"), + ArgumentMatchers.refEq(DROP_CHANNEL_ENDPOINT), + ArgumentMatchers.refEq("drop channel")); + } + @Test public void testGetChannelsStatusWithRequestError() throws Exception { ChannelsStatusResponse response = new ChannelsStatusResponse(); @@ -287,21 +430,21 @@ public void testGetChannelsStatusWithRequestError() throws Exception { response.setChannels(new ArrayList<>()); String responseString = objectMapper.writeValueAsString(response); - HttpClient httpClient = Mockito.mock(HttpClient.class); - HttpResponse httpResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine statusLine = Mockito.mock(StatusLine.class); HttpEntity httpEntity = Mockito.mock(HttpEntity.class); - Mockito.when(statusLine.getStatusCode()).thenReturn(500); - Mockito.when(httpResponse.getStatusLine()).thenReturn(statusLine); - Mockito.when(httpResponse.getEntity()).thenReturn(httpEntity); - Mockito.when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(responseString)); - Mockito.when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + when(statusLine.getStatusCode()).thenReturn(500); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); + when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(responseString)); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); RequestBuilder requestBuilder = Mockito.spy( new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( "client", new SnowflakeURL("snowflake.dev.local:8082"), null, @@ -310,8 +453,8 @@ public void testGetChannelsStatusWithRequestError() throws Exception { requestBuilder, null); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schemaName", @@ -323,7 +466,9 @@ public void testGetChannelsStatusWithRequestError() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + ZoneOffset.UTC, + BDEC_VERSION, + null); try { client.getChannelsStatus(Collections.singletonList(channel)); @@ -337,17 +482,21 @@ public void testRegisterBlobRequestCreationSuccess() throws Exception { Properties prop = new Properties(); prop.put(USER, TestUtils.getUser()); prop.put(PRIVATE_KEY, TestUtils.getPrivateKey()); - prop.put(ROLE, "role"); - prop = Utils.createProperties(prop, false); + prop.put(ACCOUNT_URL, TestUtils.getAccountURL()); + prop.put(ROLE, TestUtils.getRole()); + prop = Utils.createProperties(prop); String urlStr = "https://sfctest0.snowflakecomputing.com:80"; SnowflakeURL url = new SnowflakeURL(urlStr); - KeyPair keyPair = Utils.createKeyPairFromPrivateKey((PrivateKey) prop.get(JDBC_PRIVATE_KEY)); - RequestBuilder requestBuilder = new RequestBuilder(url, prop.get(USER).toString(), keyPair); + KeyPair keyPair = + Utils.createKeyPairFromPrivateKey( + (PrivateKey) prop.get(SFSessionProperty.PRIVATE_KEY.getPropertyKey())); + RequestBuilder requestBuilder = + new RequestBuilder(url, prop.get(USER).toString(), keyPair, null, null); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( "channel", "db", "schemaName", @@ -359,33 +508,37 @@ public void testRegisterBlobRequestCreationSuccess() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + ZoneOffset.UTC, + BDEC_VERSION, + null); ChannelMetadata channelMetadata = ChannelMetadata.builder() - .setOwningChannel(channel) - .setRowSequencer(channel.incrementAndGetRowSequencer()) - .setOffsetToken(channel.getOffsetToken()) + .setOwningChannelFromContext(channel.getChannelContext()) + .setRowSequencer(channel.getChannelState().incrementAndGetRowSequencer()) + .setOffsetToken(channel.getChannelState().getEndOffsetToken()) .build(); Map columnEps = new HashMap<>(); - columnEps.put("column", new RowBufferStats()); - EpInfo epInfo = ArrowRowBuffer.buildEpInfoFromStats(1, columnEps); + columnEps.put("column", new RowBufferStats("COL1")); + EpInfo epInfo = AbstractRowBuffer.buildEpInfoFromStats(1, columnEps); ChunkMetadata chunkMetadata = ChunkMetadata.builder() - .setOwningTable(channel) + .setOwningTableFromChannelContext(channel.getChannelContext()) .setChunkStartOffset(0L) .setChunkLength(100) .setChannelList(Collections.singletonList(channelMetadata)) .setChunkMD5("md5") .setEncryptionKeyId(1234L) .setEpInfo(epInfo) + .setFirstInsertTimeInMs(1L) + .setLastInsertTimeInMs(2L) .build(); List blobs = Collections.singletonList( - new BlobMetadata("path", "md5", Collections.singletonList(chunkMetadata))); + new BlobMetadata("path", "md5", Collections.singletonList(chunkMetadata), null)); Map payload = new HashMap<>(); payload.put("request_id", null); @@ -396,8 +549,7 @@ public void testRegisterBlobRequestCreationSuccess() throws Exception { payload, REGISTER_BLOB_ENDPOINT, "register blob"); Assert.assertEquals( - String.format("%s%s", urlStr, REGISTER_BLOB_ENDPOINT), - request.getRequestLine().getUri().toString()); + String.format("%s%s", urlStr, REGISTER_BLOB_ENDPOINT), request.getRequestLine().getUri()); Assert.assertNotNull(request.getFirstHeader(HttpHeaders.USER_AGENT)); Assert.assertNotNull(request.getFirstHeader(HttpHeaders.AUTHORIZATION)); Assert.assertEquals("POST", request.getMethod()); @@ -409,23 +561,159 @@ public void testRegisterBlobRequestCreationSuccess() throws Exception { request.getFirstHeader(RequestBuilder.SF_HEADER_AUTHORIZATION_TOKEN_TYPE).getValue()); } + private Pair, Set> getRetryBlobMetadata() { + Map columnEps = new HashMap<>(); + columnEps.put("column", new RowBufferStats("COL1")); + EpInfo epInfo = AbstractRowBuffer.buildEpInfoFromStats(1, columnEps); + + ChannelMetadata channelMetadata1 = + ChannelMetadata.builder() + .setOwningChannelFromContext(channel1.getChannelContext()) + .setRowSequencer(channel1.getChannelState().incrementAndGetRowSequencer()) + .setOffsetToken(channel1.getChannelState().getEndOffsetToken()) + .build(); + ChannelMetadata channelMetadata2 = + ChannelMetadata.builder() + .setOwningChannelFromContext(channel2.getChannelContext()) + .setRowSequencer(channel2.getChannelState().incrementAndGetRowSequencer()) + .setOffsetToken(channel2.getChannelState().getEndOffsetToken()) + .build(); + ChannelMetadata channelMetadata3 = + ChannelMetadata.builder() + .setOwningChannelFromContext(channel3.getChannelContext()) + .setRowSequencer(channel3.getChannelState().incrementAndGetRowSequencer()) + .setOffsetToken(channel3.getChannelState().getEndOffsetToken()) + .build(); + ChannelMetadata channelMetadata4 = + ChannelMetadata.builder() + .setOwningChannelFromContext(channel4.getChannelContext()) + .setRowSequencer(channel4.getChannelState().incrementAndGetRowSequencer()) + .setOffsetToken(channel4.getChannelState().getEndOffsetToken()) + .build(); + + List blobs = new ArrayList<>(); + List chunks1 = new ArrayList<>(); + List chunks2 = new ArrayList<>(); + + List channels1 = new ArrayList<>(); + channels1.add(channelMetadata1); + channels1.add(channelMetadata2); + ChunkMetadata chunkMetadata1 = + ChunkMetadata.builder() + .setOwningTableFromChannelContext(channel1.getChannelContext()) + .setChunkStartOffset(0L) + .setChunkLength(100) + .setChannelList(channels1) + .setChunkMD5("md51") + .setEncryptionKeyId(1234L) + .setEpInfo(epInfo) + .setFirstInsertTimeInMs(1L) + .setLastInsertTimeInMs(2L) + .build(); + ChunkMetadata chunkMetadata2 = + ChunkMetadata.builder() + .setOwningTableFromChannelContext(channel2.getChannelContext()) + .setChunkStartOffset(0L) + .setChunkLength(100) + .setChannelList(Collections.singletonList(channelMetadata3)) + .setChunkMD5("md52") + .setEncryptionKeyId(1234L) + .setEpInfo(epInfo) + .setFirstInsertTimeInMs(1L) + .setLastInsertTimeInMs(2L) + .build(); + ChunkMetadata chunkMetadata3 = + ChunkMetadata.builder() + .setOwningTableFromChannelContext(channel3.getChannelContext()) + .setChunkStartOffset(0L) + .setChunkLength(100) + .setChannelList(Collections.singletonList(channelMetadata4)) + .setChunkMD5("md53") + .setEncryptionKeyId(1234L) + .setEpInfo(epInfo) + .setFirstInsertTimeInMs(1L) + .setLastInsertTimeInMs(2L) + .build(); + + chunks1.add(chunkMetadata1); + chunks1.add(chunkMetadata2); + chunks2.add(chunkMetadata3); + blobs.add(new BlobMetadata("path1", "md51", chunks1, null)); + blobs.add(new BlobMetadata("path2", "md52", chunks2, null)); + + List channelRegisterStatuses = new ArrayList<>(); + ChannelRegisterStatus status1 = new ChannelRegisterStatus(); + status1.setStatusCode(RESPONSE_ERR_ENQUEUE_TABLE_CHUNK_QUEUE_FULL); + status1.setChannelName(channelMetadata1.getChannelName()); + status1.setChannelSequencer(channelMetadata1.getClientSequencer()); + + ChannelRegisterStatus status2 = new ChannelRegisterStatus(); + status2.setStatusCode(RESPONSE_ERR_ENQUEUE_TABLE_CHUNK_QUEUE_FULL); + status2.setChannelName(channelMetadata2.getChannelName()); + status2.setChannelSequencer(channelMetadata2.getClientSequencer()); + + channelRegisterStatuses.add(status1); + channelRegisterStatuses.add(status2); + + Set badChunks = new HashSet<>(); + ChunkRegisterStatus badChunkRegisterStatus = new ChunkRegisterStatus(); + badChunkRegisterStatus.setDBName(chunkMetadata1.getDBName()); + badChunkRegisterStatus.setSchemaName(chunkMetadata1.getSchemaName()); + badChunkRegisterStatus.setTableName(chunkMetadata1.getTableName()); + badChunkRegisterStatus.setChannelsStatus(channelRegisterStatuses); + badChunks.add(badChunkRegisterStatus); + return new Pair<>(blobs, badChunks); + } + + @Test + public void testGetRetryBlobs() throws Exception { + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + RequestBuilder requestBuilder = + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair()); + + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( + "client", + new SnowflakeURL("snowflake.dev.local:8082"), + null, + httpClient, + true, + requestBuilder, + null); + Pair, Set> testData = getRetryBlobMetadata(); + List blobs = testData.getFirst(); + Set badChunks = testData.getSecond(); + List result = client.getRetryBlobs(badChunks, blobs); + Assert.assertEquals(1, result.size()); + Assert.assertEquals("path1", result.get(0).getPath()); + Assert.assertEquals("md51", result.get(0).getMD5()); + Assert.assertEquals(1, result.get(0).getChunks().size()); + Assert.assertEquals(2, result.get(0).getChunks().get(0).getChannels().size()); + Assert.assertEquals( + Sets.newHashSet("channel1", "channel2"), + result.get(0).getChunks().get(0).getChannels().stream() + .map(ChannelMetadata::getChannelName) + .collect(Collectors.toSet())); + Assert.assertEquals(ParameterProvider.BLOB_FORMAT_VERSION_DEFAULT, result.get(0).getVersion()); + } + @Test public void testRegisterBlobErrorResponse() throws Exception { - HttpClient httpClient = Mockito.mock(HttpClient.class); - HttpResponse httpResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine statusLine = Mockito.mock(StatusLine.class); HttpEntity httpEntity = Mockito.mock(HttpEntity.class); - Mockito.when(statusLine.getStatusCode()).thenReturn(500); - Mockito.when(httpResponse.getStatusLine()).thenReturn(statusLine); - Mockito.when(httpResponse.getEntity()).thenReturn(httpEntity); + when(statusLine.getStatusCode()).thenReturn(500); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); String response = "testRegisterBlobErrorResponse"; - Mockito.when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(response)); - Mockito.when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(response)); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); RequestBuilder requestBuilder = new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair()); - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( "client", new SnowflakeURL("snowflake.dev.local:8082"), null, @@ -436,8 +724,7 @@ public void testRegisterBlobErrorResponse() throws Exception { try { List blobs = - Collections.singletonList( - new BlobMetadata("path", "md5", new ArrayList())); + Collections.singletonList(new BlobMetadata("path", "md5", new ArrayList<>(), null)); client.registerBlobs(blobs); Assert.fail("Register blob should fail on 404 error"); } catch (SFException e) { @@ -460,20 +747,20 @@ public void testRegisterBlobSnowflakeInternalErrorResponse() throws Exception { + " } ]\n" + "}"; - HttpClient httpClient = Mockito.mock(HttpClient.class); - HttpResponse httpResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine statusLine = Mockito.mock(StatusLine.class); HttpEntity httpEntity = Mockito.mock(HttpEntity.class); - Mockito.when(statusLine.getStatusCode()).thenReturn(200); - Mockito.when(httpResponse.getStatusLine()).thenReturn(statusLine); - Mockito.when(httpResponse.getEntity()).thenReturn(httpEntity); - Mockito.when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(response)); - Mockito.when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + when(statusLine.getStatusCode()).thenReturn(200); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); + when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(response)); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); RequestBuilder requestBuilder = new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair()); - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( "client", new SnowflakeURL("snowflake.dev.local:8082"), null, @@ -484,8 +771,7 @@ public void testRegisterBlobSnowflakeInternalErrorResponse() throws Exception { try { List blobs = - Collections.singletonList( - new BlobMetadata("path", "md5", new ArrayList())); + Collections.singletonList(new BlobMetadata("path", "md5", new ArrayList<>(), null)); client.registerBlobs(blobs); Assert.fail("Register blob should fail on SF internal error"); } catch (SFException e) { @@ -517,20 +803,20 @@ public void testRegisterBlobSuccessResponse() throws Exception { + " } ]\n" + "}"; - HttpClient httpClient = Mockito.mock(HttpClient.class); - HttpResponse httpResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine statusLine = Mockito.mock(StatusLine.class); HttpEntity httpEntity = Mockito.mock(HttpEntity.class); - Mockito.when(statusLine.getStatusCode()).thenReturn(200); - Mockito.when(httpResponse.getStatusLine()).thenReturn(statusLine); - Mockito.when(httpResponse.getEntity()).thenReturn(httpEntity); - Mockito.when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(response)); - Mockito.when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + when(statusLine.getStatusCode()).thenReturn(200); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); + when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(response)); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); RequestBuilder requestBuilder = new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair()); - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( "client", new SnowflakeURL("snowflake.dev.local:8082"), null, @@ -540,8 +826,300 @@ public void testRegisterBlobSuccessResponse() throws Exception { null); List blobs = - Collections.singletonList(new BlobMetadata("path", "md5", new ArrayList())); + Collections.singletonList(new BlobMetadata("path", "md5", new ArrayList<>(), null)); + client.registerBlobs(blobs); + } + + @Test + public void testRegisterBlobsRetries() throws Exception { + Pair, Set> testData = getRetryBlobMetadata(); + List blobs = testData.getFirst(); + Set badChunks = testData.getSecond(); + + ChunkRegisterStatus goodChunkRegisterStatus = new ChunkRegisterStatus(); + goodChunkRegisterStatus.setDBName(blobs.get(0).getChunks().get(0).getDBName()); + goodChunkRegisterStatus.setSchemaName(blobs.get(0).getChunks().get(0).getSchemaName()); + goodChunkRegisterStatus.setTableName(blobs.get(0).getChunks().get(0).getTableName()); + ChannelRegisterStatus goodStatus1 = new ChannelRegisterStatus(); + goodStatus1.setStatusCode(RESPONSE_SUCCESS); + goodStatus1.setChannelName("channel3"); + goodStatus1.setChannelSequencer(3L); + goodChunkRegisterStatus.setChannelsStatus(Collections.singletonList(goodStatus1)); + + ChunkRegisterStatus goodChunkRegisterStatus2 = new ChunkRegisterStatus(); + goodChunkRegisterStatus2.setDBName(blobs.get(0).getChunks().get(0).getDBName()); + goodChunkRegisterStatus2.setSchemaName(blobs.get(0).getChunks().get(0).getSchemaName()); + goodChunkRegisterStatus2.setTableName(blobs.get(0).getChunks().get(0).getTableName()); + ChannelRegisterStatus goodStatus2 = new ChannelRegisterStatus(); + goodStatus2.setStatusCode(RESPONSE_SUCCESS); + goodStatus2.setChannelName("channel4"); + goodStatus2.setChannelSequencer(3L); + goodChunkRegisterStatus2.setChannelsStatus(Collections.singletonList(goodStatus2)); + + RegisterBlobResponse initialResponse = new RegisterBlobResponse(); + initialResponse.setMessage("successish"); + initialResponse.setStatusCode(RESPONSE_SUCCESS); + + RegisterBlobResponse retryResponse = new RegisterBlobResponse(); + retryResponse.setMessage("successish"); + retryResponse.setStatusCode(RESPONSE_SUCCESS); + + List blobRegisterStatuses = new ArrayList<>(); + BlobRegisterStatus blobRegisterStatus1 = new BlobRegisterStatus(); + blobRegisterStatus1.setChunksStatus(new ArrayList<>(badChunks)); + blobRegisterStatuses.add(blobRegisterStatus1); + BlobRegisterStatus blobRegisterStatus2 = new BlobRegisterStatus(); + blobRegisterStatus2.setChunksStatus(Collections.singletonList(goodChunkRegisterStatus)); + blobRegisterStatuses.add(blobRegisterStatus2); + BlobRegisterStatus blobRegisterStatus3 = new BlobRegisterStatus(); + blobRegisterStatus3.setChunksStatus(Collections.singletonList(goodChunkRegisterStatus2)); + blobRegisterStatuses.add(blobRegisterStatus3); + initialResponse.setBlobsStatus(blobRegisterStatuses); + + retryResponse.setBlobsStatus(Collections.singletonList(blobRegisterStatus1)); + + String responseString = objectMapper.writeValueAsString(initialResponse); + String retryResponseString = objectMapper.writeValueAsString(retryResponse); + + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); + StatusLine statusLine = Mockito.mock(StatusLine.class); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + when(statusLine.getStatusCode()).thenReturn(200); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); + when(httpEntity.getContent()) + .thenReturn( + IOUtils.toInputStream(responseString), + IOUtils.toInputStream(retryResponseString), + IOUtils.toInputStream(retryResponseString), + IOUtils.toInputStream(retryResponseString)); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( + "client", + new SnowflakeURL("snowflake.dev.local:8082"), + null, + httpClient, + true, + requestBuilder, + null); + + client.getChannelCache().addChannel(channel1); + client.getChannelCache().addChannel(channel2); + client.getChannelCache().addChannel(channel3); + client.getChannelCache().addChannel(channel4); + client.registerBlobs(blobs); + Mockito.verify(requestBuilder, Mockito.times(MAX_STREAMING_INGEST_API_CHANNEL_RETRY + 1)) + .generateStreamingIngestPostRequest(Mockito.anyString(), Mockito.any(), Mockito.any()); + Assert.assertFalse(channel1.isValid()); + Assert.assertFalse(channel2.isValid()); + } + + @Test + public void testRegisterBlobChunkLimit() throws Exception { + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); + + SnowflakeStreamingIngestClientInternal client = + Mockito.spy( + new SnowflakeStreamingIngestClientInternal<>( + "client", + new SnowflakeURL("snowflake.dev.local:8082"), + null, + httpClient, + true, + requestBuilder, + null)); + + assertEquals(0, client.partitionBlobListForRegistrationRequest(new ArrayList<>()).size()); + assertEquals( + 1, client.partitionBlobListForRegistrationRequest(createTestBlobMetadata(1)).size()); + assertEquals( + 1, client.partitionBlobListForRegistrationRequest(createTestBlobMetadata(99)).size()); + assertEquals( + 1, client.partitionBlobListForRegistrationRequest(createTestBlobMetadata(100)).size()); + + assertEquals( + 1, client.partitionBlobListForRegistrationRequest(createTestBlobMetadata(3, 95, 2)).size()); + assertEquals( + 2, + client.partitionBlobListForRegistrationRequest(createTestBlobMetadata(3, 95, 2, 1)).size()); + assertEquals( + 3, + client + .partitionBlobListForRegistrationRequest(createTestBlobMetadata(3, 95, 2, 1, 100)) + .size()); + assertEquals( + 2, client.partitionBlobListForRegistrationRequest(createTestBlobMetadata(99, 2)).size()); + assertEquals( + 2, + client + .partitionBlobListForRegistrationRequest(createTestBlobMetadata(55, 44, 2, 98)) + .size()); + assertEquals( + 3, + client + .partitionBlobListForRegistrationRequest(createTestBlobMetadata(55, 44, 2, 99)) + .size()); + assertEquals( + 3, + client + .partitionBlobListForRegistrationRequest(createTestBlobMetadata(55, 44, 2, 99, 1)) + .size()); + } + + /** + * Generate blob metadata with specified number of chunks per blob + * + * @param numbersOfChunks Array of chunk numbers per blob + * @return List of blob metadata + */ + private List createTestBlobMetadata(int... numbersOfChunks) { + List result = new ArrayList<>(); + for (int n : numbersOfChunks) { + List chunkMetadata = new ArrayList<>(); + for (int i = 0; i < n; i++) { + ChunkMetadata chunk = + ChunkMetadata.builder() + .setOwningTableFromChannelContext(channel1.getChannelContext()) + .setChunkStartOffset(0L) + .setChunkLength(1) + .setEncryptionKeyId(0L) + .setChunkMD5("") + .setEpInfo(new EpInfo()) + .setChannelList(new ArrayList<>()) + .setFirstInsertTimeInMs(0L) + .setLastInsertTimeInMs(0L) + .build(); + chunkMetadata.add(chunk); + } + + result.add(new BlobMetadata("", "", chunkMetadata, new BlobStats())); + } + return result; + } + + @Test + public void testRegisterBlobsRetriesSucceeds() throws Exception { + Pair, Set> testData = getRetryBlobMetadata(); + List blobs = testData.getFirst(); + Set badChunks = testData.getSecond(); + + ChunkRegisterStatus goodChunkRegisterStatus = new ChunkRegisterStatus(); + goodChunkRegisterStatus.setDBName(blobs.get(0).getChunks().get(0).getDBName()); + goodChunkRegisterStatus.setSchemaName(blobs.get(0).getChunks().get(0).getSchemaName()); + goodChunkRegisterStatus.setTableName(blobs.get(0).getChunks().get(0).getTableName()); + ChannelRegisterStatus goodStatus1 = new ChannelRegisterStatus(); + goodStatus1.setStatusCode(RESPONSE_SUCCESS); + goodStatus1.setChannelName("channel3"); + goodStatus1.setChannelSequencer(3L); + goodChunkRegisterStatus.setChannelsStatus(Collections.singletonList(goodStatus1)); + + ChunkRegisterStatus goodChunkRegisterStatus2 = new ChunkRegisterStatus(); + goodChunkRegisterStatus2.setDBName(blobs.get(0).getChunks().get(0).getDBName()); + goodChunkRegisterStatus2.setSchemaName(blobs.get(0).getChunks().get(0).getSchemaName()); + goodChunkRegisterStatus2.setTableName(blobs.get(0).getChunks().get(0).getTableName()); + ChannelRegisterStatus goodStatus2 = new ChannelRegisterStatus(); + goodStatus2.setStatusCode(RESPONSE_SUCCESS); + goodStatus2.setChannelName("channel4"); + goodStatus2.setChannelSequencer(3L); + goodChunkRegisterStatus2.setChannelsStatus(Collections.singletonList(goodStatus2)); + + List goodChunkRegisterRetryStatus = + badChunks.stream() + .map( + chunkRegisterStatus -> { + ChunkRegisterStatus newStatus = new ChunkRegisterStatus(); + newStatus.setTableName(chunkRegisterStatus.getTableName()); + newStatus.setDBName(chunkRegisterStatus.getDBName()); + newStatus.setSchemaName(chunkRegisterStatus.getSchemaName()); + newStatus.setChannelsStatus( + chunkRegisterStatus.getChannelsStatus().stream() + .map( + channelRegisterStatus -> { + ChannelRegisterStatus newChannelStatus = + new ChannelRegisterStatus(); + newChannelStatus.setStatusCode(RESPONSE_SUCCESS); + newChannelStatus.setChannelSequencer( + channelRegisterStatus.getChannelSequencer()); + newChannelStatus.setChannelName( + channelRegisterStatus.getChannelName()); + return newChannelStatus; + }) + .collect(Collectors.toList())); + return newStatus; + }) + .collect(Collectors.toList()); + + RegisterBlobResponse initialResponse = new RegisterBlobResponse(); + initialResponse.setMessage("successish"); + initialResponse.setStatusCode(RESPONSE_SUCCESS); + + RegisterBlobResponse retryResponse = new RegisterBlobResponse(); + retryResponse.setMessage("successish"); + retryResponse.setStatusCode(RESPONSE_SUCCESS); + + List blobRegisterStatuses = new ArrayList<>(); + BlobRegisterStatus blobRegisterStatus1 = new BlobRegisterStatus(); + blobRegisterStatus1.setChunksStatus(new ArrayList<>(badChunks)); + blobRegisterStatuses.add(blobRegisterStatus1); + BlobRegisterStatus blobRegisterStatus2 = new BlobRegisterStatus(); + blobRegisterStatus2.setChunksStatus(Collections.singletonList(goodChunkRegisterStatus)); + blobRegisterStatuses.add(blobRegisterStatus2); + BlobRegisterStatus blobRegisterStatus3 = new BlobRegisterStatus(); + blobRegisterStatus3.setChunksStatus(Collections.singletonList(goodChunkRegisterStatus2)); + blobRegisterStatuses.add(blobRegisterStatus3); + initialResponse.setBlobsStatus(blobRegisterStatuses); + + BlobRegisterStatus retryBlobRegisterStatus = new BlobRegisterStatus(); + retryBlobRegisterStatus.setChunksStatus(goodChunkRegisterRetryStatus); + retryResponse.setBlobsStatus(Collections.singletonList(retryBlobRegisterStatus)); + + String responseString = objectMapper.writeValueAsString(initialResponse); + String retryResponseString = objectMapper.writeValueAsString(retryResponse); + + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); + StatusLine statusLine = Mockito.mock(StatusLine.class); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + when(statusLine.getStatusCode()).thenReturn(200); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); + when(httpEntity.getContent()) + .thenReturn( + IOUtils.toInputStream(responseString), IOUtils.toInputStream(retryResponseString)); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( + "client", + new SnowflakeURL("snowflake.dev.local:8082"), + null, + httpClient, + true, + requestBuilder, + null); + + client.getChannelCache().addChannel(channel1); + client.getChannelCache().addChannel(channel2); + client.getChannelCache().addChannel(channel3); + client.getChannelCache().addChannel(channel4); + client.registerBlobs(blobs); + Mockito.verify(requestBuilder, Mockito.times(2)) + .generateStreamingIngestPostRequest(Mockito.anyString(), Mockito.any(), Mockito.any()); + Assert.assertTrue(channel1.isValid()); + Assert.assertTrue(channel2.isValid()); } @Test @@ -584,20 +1162,20 @@ public void testRegisterBlobResponseWithInvalidChannel() throws Exception { channel2Name, channel2Sequencer); - HttpClient httpClient = Mockito.mock(HttpClient.class); - HttpResponse httpResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine statusLine = Mockito.mock(StatusLine.class); HttpEntity httpEntity = Mockito.mock(HttpEntity.class); - Mockito.when(statusLine.getStatusCode()).thenReturn(200); - Mockito.when(httpResponse.getStatusLine()).thenReturn(statusLine); - Mockito.when(httpResponse.getEntity()).thenReturn(httpEntity); - Mockito.when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(response)); - Mockito.when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + when(statusLine.getStatusCode()).thenReturn(200); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); + when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(response)); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); RequestBuilder requestBuilder = new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair()); - SnowflakeStreamingIngestClientInternal client = - new SnowflakeStreamingIngestClientInternal( + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( "client", new SnowflakeURL("snowflake.dev.local:8082"), null, @@ -606,8 +1184,8 @@ public void testRegisterBlobResponseWithInvalidChannel() throws Exception { requestBuilder, null); - SnowflakeStreamingIngestChannelInternal channel1 = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestChannelInternal channel1 = + new SnowflakeStreamingIngestChannelInternal<>( channel1Name, dbName, schemaName, @@ -619,9 +1197,9 @@ public void testRegisterBlobResponseWithInvalidChannel() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); - SnowflakeStreamingIngestChannelInternal channel2 = - new SnowflakeStreamingIngestChannelInternal( + UTC); + SnowflakeStreamingIngestChannelInternal channel2 = + new SnowflakeStreamingIngestChannelInternal<>( channel2Name, dbName, schemaName, @@ -633,7 +1211,7 @@ public void testRegisterBlobResponseWithInvalidChannel() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); client.getChannelCache().addChannel(channel1); client.getChannelCache().addChannel(channel2); @@ -641,7 +1219,7 @@ public void testRegisterBlobResponseWithInvalidChannel() throws Exception { Assert.assertTrue(channel2.isValid()); List blobs = - Collections.singletonList(new BlobMetadata("path", "md5", new ArrayList())); + Collections.singletonList(new BlobMetadata("path", "md5", new ArrayList<>(), null)); client.registerBlobs(blobs); // Channel2 should be invalidated now @@ -651,8 +1229,8 @@ public void testRegisterBlobResponseWithInvalidChannel() throws Exception { @Test public void testFlush() throws Exception { - SnowflakeStreamingIngestClientInternal client = - Mockito.spy(new SnowflakeStreamingIngestClientInternal("client")); + SnowflakeStreamingIngestClientInternal client = + Mockito.spy(new SnowflakeStreamingIngestClientInternal<>("client")); ChannelsStatusResponse response = new ChannelsStatusResponse(); response.setStatusCode(0L); response.setMessage("Success"); @@ -673,8 +1251,8 @@ public void testFlush() throws Exception { @Test public void testClose() throws Exception { - SnowflakeStreamingIngestClientInternal client = - Mockito.spy(new SnowflakeStreamingIngestClientInternal("client")); + SnowflakeStreamingIngestClientInternal client = + Mockito.spy(new SnowflakeStreamingIngestClientInternal<>("client")); ChannelsStatusResponse response = new ChannelsStatusResponse(); response.setStatusCode(0L); response.setMessage("Success"); @@ -707,16 +1285,12 @@ public void testClose() throws Exception { @Test public void testCloseWithError() throws Exception { - SnowflakeStreamingIngestClientInternal client = - Mockito.spy(new SnowflakeStreamingIngestClientInternal("client")); - ChannelsStatusResponse response = new ChannelsStatusResponse(); - response.setStatusCode(20L); - response.setMessage("Failure"); - response.setChannels(new ArrayList<>()); + SnowflakeStreamingIngestClientInternal client = + Mockito.spy(new SnowflakeStreamingIngestClientInternal<>("client")); CompletableFuture future = new CompletableFuture<>(); future.completeExceptionally(new Exception("Simulating Error")); - Mockito.when(client.flush(true)).thenReturn(future); + when(client.flush(true)).thenReturn(future); Assert.assertFalse(client.isClosed()); try { @@ -749,10 +1323,10 @@ public void testCloseWithError() throws Exception { @Test public void testVerifyChannelsAreFullyCommittedSuccess() throws Exception { - SnowflakeStreamingIngestClientInternal client = - Mockito.spy(new SnowflakeStreamingIngestClientInternal("client")); - SnowflakeStreamingIngestChannelInternal channel = - new SnowflakeStreamingIngestChannelInternal( + SnowflakeStreamingIngestClientInternal client = + Mockito.spy(new SnowflakeStreamingIngestClientInternal<>("client")); + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( "channel1", "db", "schema", @@ -764,7 +1338,7 @@ public void testVerifyChannelsAreFullyCommittedSuccess() throws Exception { "key", 1234L, OpenChannelRequest.OnErrorOption.CONTINUE, - true); + UTC); client.getChannelCache().addChannel(channel); ChannelsStatusResponse response = new ChannelsStatusResponse(); @@ -780,4 +1354,92 @@ public void testVerifyChannelsAreFullyCommittedSuccess() throws Exception { client.close(); } + + @Test(expected = IllegalArgumentException.class) + public void testFlushServiceException() throws Exception { + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); + + // Set IO_TIME_CPU_RATIO to MAX_VALUE in order to generate an exception + Map parameterMap = new HashMap<>(); + parameterMap.put(ParameterProvider.IO_TIME_CPU_RATIO, Integer.MAX_VALUE); + + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( + "client", + new SnowflakeURL("snowflake.dev.local:8082"), + null, + httpClient, + true, + requestBuilder, + parameterMap); + } + + @Test + public void testGetLatestCommittedOffsetTokens() throws Exception { + ChannelsStatusResponse.ChannelStatusResponseDTO channelStatus = + new ChannelsStatusResponse.ChannelStatusResponseDTO(); + channelStatus.setStatusCode(RESPONSE_SUCCESS); + channelStatus.setPersistedOffsetToken("foo"); + ChannelsStatusResponse response = new ChannelsStatusResponse(); + response.setStatusCode(0L); + response.setMessage("honk"); + response.setChannels(Collections.singletonList(channelStatus)); + String responseString = objectMapper.writeValueAsString(response); + + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); + StatusLine statusLine = Mockito.mock(StatusLine.class); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + when(statusLine.getStatusCode()).thenReturn(200); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(httpEntity); + when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(responseString)); + when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); + SnowflakeStreamingIngestClientInternal client = + new SnowflakeStreamingIngestClientInternal<>( + "client", + new SnowflakeURL("snowflake.dev.local:8082"), + null, + httpClient, + true, + requestBuilder, + null); + + SnowflakeStreamingIngestChannelInternal channel = + new SnowflakeStreamingIngestChannelInternal<>( + "channel", + "db", + "schemaName", + "tableName", + "0", + 0L, + 0L, + null, + "key", + 1234L, + OpenChannelRequest.OnErrorOption.CONTINUE, + ZoneOffset.UTC, + BDEC_VERSION, + null); + + ChannelsStatusRequest.ChannelStatusRequestDTO dto = + new ChannelsStatusRequest.ChannelStatusRequestDTO(channel); + ChannelsStatusRequest request = new ChannelsStatusRequest(); + request.setRequestId("null_0"); + request.setChannels(Collections.singletonList(dto)); + Map result = + client.getLatestCommittedOffsetTokens(Collections.singletonList(channel)); + Assert.assertEquals( + channelStatus.getPersistedOffsetToken(), result.get(channel.getFullyQualifiedName())); + Mockito.verify(requestBuilder) + .generateStreamingIngestPostRequest( + objectMapper.writeValueAsString(request), CHANNEL_STATUS_ENDPOINT, "channel status"); + } } diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeURLTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeURLTest.java index 837501083..de1942669 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeURLTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/SnowflakeURLTest.java @@ -37,6 +37,18 @@ public void testParseSane() throws Exception { Assert.assertEquals(8082, url.getPort()); Assert.assertEquals("http", url.getScheme()); Assert.assertEquals("snowflake.dev.local:8082", url.getFullUrl()); + + url = new SnowflakeURL("https://pm-connectors.snowflakecomputing.com:443"); + Assert.assertEquals(443, url.getPort()); + Assert.assertEquals("https", url.getScheme()); + Assert.assertEquals("pm-connectors.snowflakecomputing.com:443", url.getFullUrl()); + Assert.assertEquals("pm-connectors", url.getAccount()); + + url = new SnowflakeURL("https://pm_connectors.snowflakecomputing.com:443"); + Assert.assertEquals(443, url.getPort()); + Assert.assertEquals("https", url.getScheme()); + Assert.assertEquals("pm_connectors.snowflakecomputing.com:443", url.getFullUrl()); + Assert.assertEquals("pm_connectors", url.getAccount()); } @Test diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestBigFilesIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestBigFilesIT.java new file mode 100644 index 000000000..ead26acd6 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestBigFilesIT.java @@ -0,0 +1,198 @@ +package net.snowflake.ingest.streaming.internal; + +import static net.snowflake.ingest.TestUtils.verifyTableRowCount; +import static net.snowflake.ingest.utils.Constants.ROLE; +import static net.snowflake.ingest.utils.ParameterProvider.BDEC_PARQUET_COMPRESSION_ALGORITHM; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.*; +import java.util.concurrent.*; +import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClientFactory; +import net.snowflake.ingest.utils.Constants; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +/** Ingest large amount of rows. */ +@RunWith(Parameterized.class) +public class StreamingIngestBigFilesIT { + private static final String TEST_DB_PREFIX = "STREAMING_INGEST_TEST_DB"; + private static final String TEST_SCHEMA = "STREAMING_INGEST_TEST_SCHEMA"; + + private Properties prop; + + private SnowflakeStreamingIngestClientInternal client; + private Connection jdbcConnection; + private String testDb; + + @Parameters(name = "{index}: {0}") + public static Object[] compressionAlgorithms() { + return new Object[] {"GZIP", "ZSTD"}; + } + + @Parameter public String compressionAlgorithm; + + @Before + public void beforeAll() throws Exception { + testDb = TEST_DB_PREFIX + "_" + UUID.randomUUID().toString().substring(0, 4); + // Create a streaming ingest client + jdbcConnection = TestUtils.getConnection(true); + + jdbcConnection + .createStatement() + .execute(String.format("create or replace database %s;", testDb)); + jdbcConnection + .createStatement() + .execute(String.format("create or replace schema %s.%s;", testDb, TEST_SCHEMA)); + // Set timezone to UTC + jdbcConnection.createStatement().execute("alter session set timezone = 'UTC';"); + jdbcConnection + .createStatement() + .execute(String.format("use warehouse %s", TestUtils.getWarehouse())); + + prop = TestUtils.getProperties(Constants.BdecVersion.THREE, false); + if (prop.getProperty(ROLE).equals("DEFAULT_ROLE")) { + prop.setProperty(ROLE, "ACCOUNTADMIN"); + } + prop.setProperty(BDEC_PARQUET_COMPRESSION_ALGORITHM, compressionAlgorithm); + client = + (SnowflakeStreamingIngestClientInternal) + SnowflakeStreamingIngestClientFactory.builder("client1").setProperties(prop).build(); + } + + @After + public void afterAll() throws Exception { + client.close(); + jdbcConnection.createStatement().execute(String.format("drop database %s", testDb)); + } + + @Test + public void testManyRowsMultipleChannelsToMultipleTable() + throws SQLException, ExecutionException, InterruptedException { + String tableNamePrefix = "t_big_table_"; + + int numTables = 2; + int numChannels = 4; // channels are assigned round-robin to tables. + int batchSize = 10000; + int numBatches = 10; // number of rows PER CHANNEL is batchSize * numBatches + boolean isNullable = false; + + Map tableIdToNumChannels = new HashMap<>(); + for (int i = 0; i < numChannels; i++) { + tableIdToNumChannels.put( + i % numTables, tableIdToNumChannels.getOrDefault(i % numTables, 0) + 1); + } + for (int i = 0; i < numTables; i++) { + String tableName = tableNamePrefix + i; + createTableForTest(tableName); + } + + ingestRandomRowsToTable( + tableNamePrefix, numTables, numChannels, batchSize, numBatches, isNullable); + + for (int i = 0; i < numTables; i++) { + int numChannelsToTable = tableIdToNumChannels.get(i); + verifyTableRowCount( + batchSize * numBatches * numChannelsToTable, + jdbcConnection, + testDb, + TEST_SCHEMA, + tableNamePrefix + i); + + // select * to ensure scanning works + ResultSet result = + jdbcConnection + .createStatement() + .executeQuery( + String.format( + "select * from %s.%s.%s", testDb, TEST_SCHEMA, tableNamePrefix + i)); + result.next(); + Assert.assertNotNull(result.getString("STR")); + } + } + + private void ingestRandomRowsToTable( + String tablePrefix, + int numTables, + int numChannels, + int batchSize, + int iterations, + boolean isNullable) + throws ExecutionException, InterruptedException { + + List> rows = new ArrayList<>(); + for (int i = 0; i < batchSize; i++) { + Random r = new Random(); + rows.add(TestUtils.getRandomRow(r, isNullable)); + } + + ExecutorService testThreadPool = Executors.newFixedThreadPool(numChannels); + CompletableFuture[] futures = new CompletableFuture[numChannels]; + List channelList = new ArrayList<>(); + for (int i = 0; i < numChannels; i++) { + final String channelName = "CHANNEL" + i; + int finalI = i; + futures[i] = + CompletableFuture.runAsync( + () -> { + int targetTable = finalI % numTables; + SnowflakeStreamingIngestChannel channel = + openChannel(tablePrefix + targetTable, channelName); + channelList.add(channel); + for (int val = 0; val < iterations; val++) { + TestUtils.verifyInsertValidationResponse( + channel.insertRows(rows, Integer.toString(val))); + } + }, + testThreadPool); + } + CompletableFuture joined = CompletableFuture.allOf(futures); + joined.get(); + channelList.forEach(channel -> TestUtils.waitChannelFlushed(channel, iterations)); + testThreadPool.shutdown(); + } + + private void createTableForTest(String tableName) { + try { + jdbcConnection + .createStatement() + .execute( + String.format( + "create or replace table %s (\n" + + " num_2_1 NUMBER(2, 1),\n" + + " num_4_2 NUMBER(4, 2),\n" + + " num_9_4 NUMBER(9, 4),\n" + + " num_18_7 NUMBER(18, 7),\n" + + " num_38_15 NUMBER(38, 15),\n" + + " num_float FLOAT,\n" + + " str VARCHAR(256),\n" + + " bin BINARY(256));", + tableName)); + } catch (SQLException e) { + throw new RuntimeException("Cannot create table " + tableName, e); + } + } + + private SnowflakeStreamingIngestChannel openChannel(String tableName, String channelName) { + OpenChannelRequest request = + OpenChannelRequest.builder(channelName) + .setDBName(testDb) + .setSchemaName(TEST_SCHEMA) + .setTableName(tableName) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .build(); + + // Open a streaming ingest channel from the given client + return client.openChannel(request); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestIT.java index 547dfc4b2..17f4d31fe 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestIT.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestIT.java @@ -2,17 +2,28 @@ import static net.snowflake.ingest.utils.Constants.BLOB_NO_HEADER; import static net.snowflake.ingest.utils.Constants.COMPRESS_BLOB_TWICE; +import static net.snowflake.ingest.utils.Constants.DROP_CHANNEL_ENDPOINT; +import static net.snowflake.ingest.utils.Constants.REGISTER_BLOB_ENDPOINT; +import static net.snowflake.ingest.utils.ParameterProvider.BDEC_PARQUET_COMPRESSION_ALGORITHM; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import java.math.BigDecimal; import java.math.BigInteger; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; +import java.text.ParseException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Calendar; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.TimeZone; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -21,79 +32,137 @@ import java.util.function.IntConsumer; import java.util.stream.IntStream; import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.connection.RequestBuilder; import net.snowflake.ingest.streaming.InsertValidationResponse; +import net.snowflake.ingest.streaming.OffsetTokenVerificationFunction; import net.snowflake.ingest.streaming.OpenChannelRequest; import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClientFactory; +import net.snowflake.ingest.utils.Constants; import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.HttpUtil; +import net.snowflake.ingest.utils.ParameterProvider; import net.snowflake.ingest.utils.SFException; +import net.snowflake.ingest.utils.SnowflakeURL; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; /** Example streaming ingest sdk integration test */ +@RunWith(Parameterized.class) public class StreamingIngestIT { private static final String TEST_TABLE = "STREAMING_INGEST_TEST_TABLE"; - private static final String TEST_DB = "STREAMING_INGEST_TEST_DB"; + private static final String TEST_DB_PREFIX = "STREAMING_INGEST_TEST_DB"; private static final String TEST_SCHEMA = "STREAMING_INGEST_TEST_SCHEMA"; private static final String INTERLEAVED_TABLE_PREFIX = "t_interleaved_test_"; + private static final String INTERLEAVED_CHANNEL_TABLE = "t_interleaved_channel_test"; private static final int INTERLEAVED_CHANNEL_NUMBER = 3; private Properties prop; - private SnowflakeStreamingIngestClientInternal client; + private SnowflakeStreamingIngestClientInternal client; private Connection jdbcConnection; + private String testDb; + + @Parameters(name = "{index}: {0}") + public static Object[] compressionAlgorithms() { + return new Object[] {"GZIP", "ZSTD"}; + } + + @Parameter public String compressionAlgorithm; + + private static final OffsetTokenVerificationFunction offsetTokenVerificationFunction = + (prevBatchEndOffset, curBatchStartOffset, curBatchEndOffset, rowCount) -> { + boolean isMatch = true; + + if (curBatchStartOffset != null) { + try { + long curStart = Long.parseLong(curBatchStartOffset); + long curEnd = Long.parseLong(curBatchEndOffset); + + // We verify that the end_offset - start_offset + 1 = row_count + if (curEnd - curStart + 1 != rowCount) { + isMatch = false; + } + + // We verify that start_offset_of_current_batch = end_offset_of_previous_batch+1 + if (prevBatchEndOffset != null) { + long prevEnd = Long.parseLong(prevBatchEndOffset); + if (curStart != prevEnd + 1) { + isMatch = false; + } + } + } catch (NumberFormatException ignored) { + // Do nothing since we can't compare the offset + } + } + + return isMatch; + }; @Before public void beforeAll() throws Exception { + testDb = TEST_DB_PREFIX + "_" + UUID.randomUUID().toString().substring(0, 4); // Create a streaming ingest client - jdbcConnection = TestUtils.getConnection(); - jdbcConnection - .createStatement() - .execute(String.format("create or replace database %s;", TEST_DB)); - jdbcConnection - .createStatement() - .execute(String.format("create or replace schema %s;", TEST_SCHEMA)); - jdbcConnection - .createStatement() - .execute(String.format("create or replace table %s (c1 char(10));", TEST_TABLE)); + jdbcConnection = TestUtils.getConnection(true); + jdbcConnection .createStatement() - .execute("alter session set ENABLE_PR_37692_MULTI_FORMAT_SCANSET=true;"); + .execute(String.format("create or replace database %s;", testDb)); jdbcConnection .createStatement() - .execute( - String.format( - "alter database %s set ENABLE_PR_37692_MULTI_FORMAT_SCANSET=true;", TEST_DB)); + .execute(String.format("create or replace schema %s.%s;", testDb, TEST_SCHEMA)); jdbcConnection .createStatement() .execute( String.format( - "alter table %s set ENABLE_PR_37692_MULTI_FORMAT_SCANSET=true;", TEST_TABLE)); - jdbcConnection.createStatement().execute("alter session set ENABLE_UNIFIED_TABLE_SCAN=true;"); + "create or replace table %s.%s.%s (c1 char(10));", + testDb, TEST_SCHEMA, TEST_TABLE)); + // Set timezone to UTC + jdbcConnection.createStatement().execute("alter session set timezone = 'UTC';"); jdbcConnection .createStatement() .execute(String.format("use warehouse %s", TestUtils.getWarehouse())); - prop = TestUtils.getProperties(); + // Test without role param + prop = TestUtils.getProperties(Constants.BdecVersion.THREE, true); + prop.setProperty(BDEC_PARQUET_COMPRESSION_ALGORITHM, compressionAlgorithm); client = - (SnowflakeStreamingIngestClientInternal) + (SnowflakeStreamingIngestClientInternal) SnowflakeStreamingIngestClientFactory.builder("client1").setProperties(prop).build(); } @After public void afterAll() throws Exception { client.close(); - jdbcConnection.createStatement().execute(String.format("drop database %s", TEST_DB)); + jdbcConnection.createStatement().execute(String.format("drop database %s", testDb)); } @Test public void testSimpleIngest() throws Exception { + // TODO @rcheng - dont want to change factory, so inject + SnowflakeURL url = new SnowflakeURL(TestUtils.getAccountURL()); + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder( + url, + TestUtils.getUser(), + TestUtils.getKeyPair(), + HttpUtil.getHttpClient(url.getAccount()), + "testrequestbuilder")); + client.injectRequestBuilder(requestBuilder); + OpenChannelRequest request1 = OpenChannelRequest.builder("CHANNEL") - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(TEST_TABLE) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) @@ -107,6 +176,19 @@ public void testSimpleIngest() throws Exception { verifyInsertValidationResponse(channel1.insertRow(row, Integer.toString(val))); } + // Close the channel after insertion + channel1.close().get(); + + // verify expected request sent to server + String[] expectedPayloadParams = {"request_id", "blobs", "role", "blob_stats"}; + for (String expectedParam : expectedPayloadParams) { + Mockito.verify(requestBuilder) + .generateStreamingIngestPostRequest( + ArgumentMatchers.contains(expectedParam), + ArgumentMatchers.refEq(REGISTER_BLOB_ENDPOINT), + ArgumentMatchers.refEq("register blob")); + } + for (int i = 1; i < 15; i++) { if (channel1.getLatestCommittedOffsetToken() != null && channel1.getLatestCommittedOffsetToken().equals("999")) { @@ -115,7 +197,7 @@ public void testSimpleIngest() throws Exception { .createStatement() .executeQuery( String.format( - "select count(*) from %s.%s.%s", TEST_DB, TEST_SCHEMA, TEST_TABLE)); + "select count(*) from %s.%s.%s", testDb, TEST_SCHEMA, TEST_TABLE)); result.next(); Assert.assertEquals(1000, result.getLong(1)); @@ -125,7 +207,7 @@ public void testSimpleIngest() throws Exception { .executeQuery( String.format( "select * from %s.%s.%s order by c1 limit 2", - TEST_DB, TEST_SCHEMA, TEST_TABLE)); + testDb, TEST_SCHEMA, TEST_TABLE)); result2.next(); Assert.assertEquals("0", result2.getString(1)); result2.next(); @@ -137,7 +219,7 @@ public void testSimpleIngest() throws Exception { if (BLOB_NO_HEADER && COMPRESS_BLOB_TWICE) { Assert.assertEquals(3445, client.blobSizeHistogram.getSnapshot().getMax()); } else if (BLOB_NO_HEADER) { - Assert.assertEquals(3579, client.blobSizeHistogram.getSnapshot().getMax()); + Assert.assertEquals(3600, client.blobSizeHistogram.getSnapshot().getMax()); } else if (COMPRESS_BLOB_TWICE) { Assert.assertEquals(3981, client.blobSizeHistogram.getSnapshot().getMax()); } else { @@ -152,73 +234,159 @@ public void testSimpleIngest() throws Exception { } @Test - public void testInterleavedIngest() { - Consumer iter = - f -> IntStream.rangeClosed(1, INTERLEAVED_CHANNEL_NUMBER).forEach(f); - - iter.accept(i -> createTableForInterleavedTest(INTERLEAVED_TABLE_PREFIX + i)); - - SnowflakeStreamingIngestChannel[] channels = - new SnowflakeStreamingIngestChannel[INTERLEAVED_CHANNEL_NUMBER]; - iter.accept(i -> channels[i - 1] = openChannel(INTERLEAVED_TABLE_PREFIX + i)); + public void testDropChannel() throws Exception { + SnowflakeURL url = new SnowflakeURL(TestUtils.getAccountURL()); + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder( + url, + TestUtils.getUser(), + TestUtils.getKeyPair(), + HttpUtil.getHttpClient(url.getAccount()), + "testrequestbuilder")); + client.injectRequestBuilder(requestBuilder); - iter.accept( - i -> - produceRowsForInterleavedTest( - channels[i - 1], INTERLEAVED_TABLE_PREFIX + i, 1 << (i + 1))); - iter.accept(i -> waitChannelFlushed(channels[i - 1], 1 << (i + 1))); + OpenChannelRequest request1 = + OpenChannelRequest.builder("CHANNEL") + .setDBName(testDb) + .setSchemaName(TEST_SCHEMA) + .setTableName(TEST_TABLE) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .build(); - iter.accept(i -> verifyInterleavedResult(1 << (i + 1), INTERLEAVED_TABLE_PREFIX + i)); + // Open a streaming ingest channel from the given client + SnowflakeStreamingIngestChannel channel1 = client.openChannel(request1); + // Close the channel after insertion + channel1.close(true).get(); + + // verify expected request sent to server + Mockito.verify(requestBuilder) + .generateStreamingIngestPostRequest( + ArgumentMatchers.contains("client_sequencer"), + ArgumentMatchers.refEq(DROP_CHANNEL_ENDPOINT), + ArgumentMatchers.refEq("drop channel")); } @Test - public void testCollation() throws Exception { - String collationTable = "collation_table"; - jdbcConnection - .createStatement() - .execute( - String.format( - "create or replace table %s (noncol char(10), col char(10) collate 'en-ci');", - collationTable)); + public void testParameterOverrides() throws Exception { + Map parameterMap = new HashMap<>(); + parameterMap.put(ParameterProvider.MAX_CLIENT_LAG, "3 sec"); + parameterMap.put(ParameterProvider.BUFFER_FLUSH_CHECK_INTERVAL_IN_MILLIS, 50L); + parameterMap.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_PERCENTAGE, 1); + parameterMap.put(ParameterProvider.INSERT_THROTTLE_THRESHOLD_IN_BYTES, 1024); + parameterMap.put(ParameterProvider.INSERT_THROTTLE_INTERVAL_IN_MILLIS, 1L); + parameterMap.put(ParameterProvider.ENABLE_SNOWPIPE_STREAMING_METRICS, true); + parameterMap.put(ParameterProvider.IO_TIME_CPU_RATIO, 1); + parameterMap.put(ParameterProvider.BLOB_UPLOAD_MAX_RETRY_COUNT, 1); + client = + (SnowflakeStreamingIngestClientInternal) + SnowflakeStreamingIngestClientFactory.builder("testParameterOverridesClient") + .setProperties(prop) + .setParameterOverrides(parameterMap) + .build(); OpenChannelRequest request1 = OpenChannelRequest.builder("CHANNEL") - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) - .setTableName(collationTable) + .setTableName(TEST_TABLE) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) .build(); // Open a streaming ingest channel from the given client SnowflakeStreamingIngestChannel channel1 = client.openChannel(request1); - Map row = new HashMap<>(); - row.put("col", "AA"); - row.put("noncol", "AA"); - verifyInsertValidationResponse(channel1.insertRow(row, "1")); - row.put("col", "a"); - row.put("noncol", "a"); - verifyInsertValidationResponse(channel1.insertRow(row, "2")); + for (int val = 0; val < 10; val++) { + Map row = new HashMap<>(); + row.put("c1", Integer.toString(val)); + verifyInsertValidationResponse(channel1.insertRow(row, Integer.toString(val))); + } + + // Close the channel after insertion + channel1.close().get(); for (int i = 1; i < 15; i++) { if (channel1.getLatestCommittedOffsetToken() != null - && channel1.getLatestCommittedOffsetToken().equals("2")) { + && channel1.getLatestCommittedOffsetToken().equals("9")) { ResultSet result = jdbcConnection .createStatement() .executeQuery( String.format( - "select min(col), min(noncol) from %s.%s.%s", - TEST_DB, TEST_SCHEMA, collationTable)); + "select count(*) from %s.%s.%s", testDb, TEST_SCHEMA, TEST_TABLE)); result.next(); - Assert.assertEquals("a", result.getString(1)); - Assert.assertEquals("AA", result.getString(2)); + Assert.assertEquals(10, result.getLong(1)); return; } - Thread.sleep(1000); + Thread.sleep(500); } Assert.fail("Row sequencer not updated before timeout"); } + @Test + public void testInterleavedIngest() { + Consumer iter = + f -> IntStream.rangeClosed(1, INTERLEAVED_CHANNEL_NUMBER).forEach(f); + + iter.accept(i -> createTableForInterleavedTest(INTERLEAVED_TABLE_PREFIX + i)); + + SnowflakeStreamingIngestChannel[] channels = + new SnowflakeStreamingIngestChannel[INTERLEAVED_CHANNEL_NUMBER]; + iter.accept( + i -> + channels[i - 1] = + openChannel( + INTERLEAVED_TABLE_PREFIX + i, + "CHANNEL", + OpenChannelRequest.OnErrorOption.ABORT)); + + iter.accept( + i -> + produceRowsForInterleavedTest( + channels[i - 1], INTERLEAVED_TABLE_PREFIX + i, 1 << (i + 1))); + iter.accept(i -> waitChannelFlushed(channels[i - 1], 1 << (i + 1))); + + iter.accept(i -> verifyTableRowCount(1 << (i + 1), INTERLEAVED_TABLE_PREFIX + i)); + iter.accept( + i -> + verifyInterleavedResult( + 1 << (i + 1), INTERLEAVED_TABLE_PREFIX + i, INTERLEAVED_TABLE_PREFIX + i)); + } + + @Test + public void testMultiChannelChunk() { + Consumer iter = + f -> IntStream.rangeClosed(1, INTERLEAVED_CHANNEL_NUMBER).forEach(f); + + createTableForInterleavedTest(INTERLEAVED_CHANNEL_TABLE); + + SnowflakeStreamingIngestChannel[] channels = + new SnowflakeStreamingIngestChannel[INTERLEAVED_CHANNEL_NUMBER]; + iter.accept( + i -> + channels[i - 1] = + openChannel( + INTERLEAVED_CHANNEL_TABLE, + "CHANNEL_" + i, + OpenChannelRequest.OnErrorOption.ABORT)); + + iter.accept( + i -> + produceRowsForInterleavedTest( + channels[i - 1], INTERLEAVED_CHANNEL_TABLE + "_channel_" + i, 1 << (i + 1))); + iter.accept(i -> waitChannelFlushed(channels[i - 1], 1 << (i + 1))); + + int rowNumber = + IntStream.rangeClosed(1, INTERLEAVED_CHANNEL_NUMBER).map(i -> 1 << (i + 1)).sum(); + verifyTableRowCount(rowNumber, INTERLEAVED_CHANNEL_TABLE); + + iter.accept( + i -> + verifyInterleavedResult( + 1 << (i + 1), + INTERLEAVED_CHANNEL_TABLE, + INTERLEAVED_CHANNEL_TABLE + "_channel_" + i)); + } + @Test public void testDecimalColumnIngest() throws Exception { String decimalTableName = "decimal_table"; @@ -229,7 +397,7 @@ public void testDecimalColumnIngest() throws Exception { "create or replace table %s (tinyfloat NUMBER(38,2));", decimalTableName)); OpenChannelRequest request1 = OpenChannelRequest.builder("CHANNEL_DECI") - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(decimalTableName) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) @@ -246,6 +414,9 @@ public void testDecimalColumnIngest() throws Exception { row.put("tinyfloat", BigInteger.valueOf(10).pow(35)); verifyInsertValidationResponse(channel1.insertRow(row, "1")); + // Close the channel after insertion + channel1.close().get(); + for (int i = 1; i < 15; i++) { if (channel1.getLatestCommittedOffsetToken() != null && channel1.getLatestCommittedOffsetToken().equals("1")) { @@ -254,8 +425,7 @@ public void testDecimalColumnIngest() throws Exception { jdbcConnection .createStatement() .executeQuery( - String.format( - "select * from %s.%s.%s", TEST_DB, TEST_SCHEMA, decimalTableName)); + String.format("select * from %s.%s.%s", testDb, TEST_SCHEMA, decimalTableName)); result.next(); Assert.assertEquals(-1.1, result.getFloat("TINYFLOAT"), 0.001); @@ -283,14 +453,9 @@ public void testTimeColumnIngest() throws Exception { + " TIMESTAMP_TZ(3), tbig TIME(9), tntzbig TIMESTAMP_NTZ(9), ttzbig" + " TIMESTAMP_TZ(9) );", timeTableName)); - jdbcConnection - .createStatement() - .execute( - String.format( - "alter table %s set ENABLE_PR_37692_MULTI_FORMAT_SCANSET=true;", timeTableName)); OpenChannelRequest request1 = OpenChannelRequest.builder("CHANNEL_TIME") - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(timeTableName) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) @@ -300,28 +465,33 @@ public void testTimeColumnIngest() throws Exception { SnowflakeStreamingIngestChannel channel1 = client.openChannel(request1); Map row = new HashMap<>(); - row.put("ttzsmall", "2021-01-01 01:00:00.123 -0300"); - row.put("ttzbig", "2021-01-01 09:00:00.12345678 -0300"); + row.put("ttzsmall", "2021-01-01T01:00:00.123-03:00"); + row.put("ttzbig", "2021-01-01T09:00:00.12345678-03:00"); row.put("tsmall", "01:00:00.123"); row.put("tbig", "09:00:00.12345678"); - row.put("tntzsmall", "1609462800.123"); - row.put("tntzbig", "1609462800.12345"); + row.put("tntzsmall", "1609462800123"); + row.put("tntzbig", "1609462800123450000"); verifyInsertValidationResponse(channel1.insertRow(row, null)); - row.put("ttzsmall", "2021-01-01 10:00:00.123 +0700"); - row.put("ttzbig", "2021-01-01 19:00:00.12345678 -0300"); + row.put("ttzsmall", "2021-01-01T10:00:00.123+07:00"); + row.put("ttzbig", "2021-01-01T19:00:00.12345678-03:00"); row.put("tsmall", "02:00:00.123"); row.put("tbig", "10:00:00.12345678"); - row.put("tntzsmall", "1709462800.123"); - row.put("tntzbig", "1709462800.12345"); + row.put("tntzsmall", "1709462800123"); + row.put("tntzbig", "170946280212345000"); verifyInsertValidationResponse(channel1.insertRow(row, null)); - row.put("ttzsmall", "2021-01-01 05:00:00 +0100"); - row.put("ttzbig", "2021-01-01 23:00:00.12345678 -0300"); + row.put("ttzsmall", "2021-01-01T05:00:00+01:00"); + row.put("ttzbig", "2021-01-01T23:00:00.12345678-03:00"); row.put("tsmall", "03:00:00.123"); row.put("tbig", "11:00:00.12345678"); - row.put("tntzsmall", "1809462800.123"); - row.put("tntzbig", "2031-01-01 09:00:00.12345678"); + row.put("tntzsmall", "1809462800123"); + row.put("tntzbig", "2031-01-01T09:00:00.123456780"); verifyInsertValidationResponse(channel1.insertRow(row, "1")); + // Close the channel after insertion + channel1.close().get(); + + Calendar cal = Calendar.getInstance(); + cal.setTimeZone(TimeZone.getTimeZone("UTC")); for (int i = 1; i < 15; i++) { if (channel1.getLatestCommittedOffsetToken() != null && channel1.getLatestCommittedOffsetToken().equals("1")) { @@ -330,7 +500,7 @@ public void testTimeColumnIngest() throws Exception { jdbcConnection .createStatement() .executeQuery( - String.format("select * from %s.%s.%s", TEST_DB, TEST_SCHEMA, timeTableName)); + String.format("select * from %s.%s.%s", testDb, TEST_SCHEMA, timeTableName)); result.next(); Assert.assertEquals(1609473600123l, result.getTimestamp("TTZSMALL").getTime()); @@ -339,9 +509,9 @@ public void testTimeColumnIngest() throws Exception { Assert.assertEquals(3600123, result.getTimestamp("TSMALL").getTime()); Assert.assertEquals(32400123, result.getTimestamp("TBIG").getTime()); Assert.assertEquals(123456780, result.getTimestamp("TBIG").getNanos()); - Assert.assertEquals(1609462800123L, result.getTimestamp("TNTZSMALL").getTime()); - Assert.assertEquals(1609462800123L, result.getTimestamp("TNTZBIG").getTime()); - Assert.assertEquals(123450000, result.getTimestamp("TNTZBIG").getNanos()); + Assert.assertEquals(1609462800123L, result.getTimestamp("TNTZSMALL", cal).getTime()); + Assert.assertEquals(1609462800123L, result.getTimestamp("TNTZBIG", cal).getTime()); + Assert.assertEquals(123450000, result.getTimestamp("TNTZBIG", cal).getNanos()); result = jdbcConnection @@ -356,7 +526,7 @@ public void testTimeColumnIngest() throws Exception { + "max(tntzsmall) as mtntzsmall," + "max(tntzbig) as mtntzbig" + " from %s.%s.%s", - TEST_DB, TEST_SCHEMA, timeTableName)); + testDb, TEST_SCHEMA, timeTableName)); result.next(); Assert.assertEquals(1609473600123L, result.getTimestamp("MTTZSMALL").getTime()); @@ -365,9 +535,9 @@ public void testTimeColumnIngest() throws Exception { Assert.assertEquals(10800123, result.getTimestamp("MTSMALL").getTime()); Assert.assertEquals(39600123, result.getTimestamp("MTBIG").getTime()); Assert.assertEquals(123456780, result.getTimestamp("MTBIG").getNanos()); - Assert.assertEquals(1809462800123L, result.getTimestamp("MTNTZSMALL").getTime()); - Assert.assertEquals(1925024400123L, result.getTimestamp("MTNTZBIG").getTime()); - Assert.assertEquals(123456780, result.getTimestamp("MTNTZBIG").getNanos()); + Assert.assertEquals(1809462800123L, result.getTimestamp("MTNTZSMALL", cal).getTime()); + Assert.assertEquals(1925024400123L, result.getTimestamp("MTNTZBIG", cal).getTime()); + Assert.assertEquals(123456780, result.getTimestamp("MTNTZBIG", cal).getNanos()); return; } else { @@ -389,7 +559,7 @@ public void testMultiColumnIngest() throws Exception { multiTableName)); OpenChannelRequest request1 = OpenChannelRequest.builder("CHANNEL_MULTI") - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(multiTableName) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) @@ -405,10 +575,15 @@ public void testMultiColumnIngest() throws Exception { row.put("f", 3.14); row.put("tinyfloat", 1.1); row.put("var", "{\"e\":2.7}"); - row.put("t", timestamp); - row.put("d", "1967-06-23 01:01:01"); + row.put("t", String.valueOf(timestamp)); + row.put("d", "1969-12-31T00:00:00"); verifyInsertValidationResponse(channel1.insertRow(row, "1")); + // Close the channel after insertion + channel1.close().get(); + + Calendar cal = Calendar.getInstance(); + cal.setTimeZone(TimeZone.getTimeZone("UTC")); for (int i = 1; i < 15; i++) { if (channel1.getLatestCommittedOffsetToken() != null && channel1.getLatestCommittedOffsetToken().equals("1")) { @@ -417,7 +592,7 @@ public void testMultiColumnIngest() throws Exception { jdbcConnection .createStatement() .executeQuery( - String.format("select * from %s.%s.%s", TEST_DB, TEST_SCHEMA, multiTableName)); + String.format("select * from %s.%s.%s", testDb, TEST_SCHEMA, multiTableName)); result.next(); Assert.assertEquals("honk", result.getString("S")); @@ -425,8 +600,40 @@ public void testMultiColumnIngest() throws Exception { Assert.assertEquals(3.14, result.getFloat("F"), 0.0001); Assert.assertEquals(1.1, result.getFloat("TINYFLOAT"), 0.001); Assert.assertEquals("{\n" + " \"e\": 2.7\n" + "}", result.getString("VAR")); - Assert.assertEquals(timestamp * 1000, result.getTimestamp("T").getTime()); - Assert.assertEquals(-923, TimeUnit.MILLISECONDS.toDays(result.getDate("D").getTime())); + Assert.assertEquals(timestamp * 1000, result.getTimestamp("T", cal).getTime()); + Assert.assertEquals(-1, TimeUnit.MILLISECONDS.toDays(result.getDate("D", cal).getTime())); + return; + } else { + Thread.sleep(2000); + } + } + Assert.fail("Row sequencer not updated before timeout"); + } + + @Test + public void testOpenChannelOffsetToken() throws Exception { + String tableName = "offsetTokenTest"; + jdbcConnection + .createStatement() + .execute(String.format("create or replace table %s (s text);", tableName)); + OpenChannelRequest request1 = + OpenChannelRequest.builder("TEST_CHANNEL") + .setDBName(testDb) + .setSchemaName(TEST_SCHEMA) + .setTableName(tableName) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .setOffsetToken("TEST_OFFSET") + .build(); + + // Open a streaming ingest channel from the given client + SnowflakeStreamingIngestChannel channel1 = client.openChannel(request1); + + // Close the channel after insertion + channel1.close().get(); + + for (int i = 1; i < 15; i++) { + if (channel1.getLatestCommittedOffsetToken() != null + && channel1.getLatestCommittedOffsetToken().equals("TEST_OFFSET")) { return; } else { Thread.sleep(2000); @@ -445,7 +652,7 @@ public void testNullableColumns() throws Exception { "create or replace table %s (s text, notnull text NOT NULL);", multiTableName)); OpenChannelRequest request1 = OpenChannelRequest.builder("CHANNEL_MULTI1") - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(multiTableName) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) @@ -456,7 +663,7 @@ public void testNullableColumns() throws Exception { OpenChannelRequest request2 = OpenChannelRequest.builder("CHANNEL_MULTI2") - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(multiTableName) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) @@ -481,6 +688,10 @@ public void testNullableColumns() throws Exception { verifyInsertValidationResponse(channel2.insertRow(row3, "1")); + // Close the channel after insertion + channel1.close().get(); + channel2.close().get(); + for (int i = 1; i < 15; i++) { if (channel1.getLatestCommittedOffsetToken() != null && channel1.getLatestCommittedOffsetToken().equals("3") @@ -493,7 +704,7 @@ public void testNullableColumns() throws Exception { .executeQuery( String.format( "select * from %s.%s.%s order by notnull", - TEST_DB, TEST_SCHEMA, multiTableName)); + testDb, TEST_SCHEMA, multiTableName)); result.next(); Assert.assertEquals("honk", result.getString("S")); @@ -526,7 +737,7 @@ public void testMultiThread() throws Exception { .execute( String.format("create or replace table %s (numcol NUMBER(10,2));", multiThreadTable)); int numThreads = 20; - int numRows = 1000000; + int numRows = 10000; ExecutorService testThreadPool = Executors.newFixedThreadPool(numThreads); CompletableFuture[] futures = new CompletableFuture[numThreads]; List channelList = new ArrayList<>(); @@ -537,7 +748,7 @@ public void testMultiThread() throws Exception { () -> { OpenChannelRequest request = OpenChannelRequest.builder(channelName) - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(multiThreadTable) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) @@ -555,6 +766,11 @@ public void testMultiThread() throws Exception { CompletableFuture joined = CompletableFuture.allOf(futures); joined.get(); + // Close the channel after insertion + for (SnowflakeStreamingIngestChannel channel : channelList) { + channel.close().get(); + } + for (int i = 1; i < 15; i++) { if (channelList.stream() .allMatch( @@ -567,7 +783,7 @@ public void testMultiThread() throws Exception { .executeQuery( String.format( "select count(*), max(numcol), min(numcol) from %s.%s.%s", - TEST_DB, TEST_SCHEMA, multiThreadTable)); + testDb, TEST_SCHEMA, multiThreadTable)); result.next(); Assert.assertEquals(numRows * numThreads, result.getInt(1)); @@ -588,16 +804,16 @@ public void testMultiThread() throws Exception { */ @Test public void testTwoClientsOneChannel() throws Exception { - SnowflakeStreamingIngestClientInternal clientA = - (SnowflakeStreamingIngestClientInternal) + SnowflakeStreamingIngestClientInternal clientA = + (SnowflakeStreamingIngestClientInternal) SnowflakeStreamingIngestClientFactory.builder("clientA").setProperties(prop).build(); - SnowflakeStreamingIngestClientInternal clientB = - (SnowflakeStreamingIngestClientInternal) + SnowflakeStreamingIngestClientInternal clientB = + (SnowflakeStreamingIngestClientInternal) SnowflakeStreamingIngestClientFactory.builder("clientB").setProperties(prop).build(); OpenChannelRequest requestA = OpenChannelRequest.builder("CHANNEL") - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(TEST_TABLE) .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) @@ -654,6 +870,9 @@ public void testTwoClientsOneChannel() throws Exception { row.put("c1", "8"); verifyInsertValidationResponse(channelA2.insertRow(row, "8")); + // Close the channel after insertion + channelA2.close().get(); + for (int i = 1; i < 15; i++) { if (channelA2.getLatestCommittedOffsetToken() != null && channelA2.getLatestCommittedOffsetToken().equals("8")) { @@ -662,7 +881,7 @@ public void testTwoClientsOneChannel() throws Exception { .createStatement() .executeQuery( String.format( - "select count(*) from %s.%s.%s", TEST_DB, TEST_SCHEMA, TEST_TABLE)); + "select count(*) from %s.%s.%s", testDb, TEST_SCHEMA, TEST_TABLE)); result.next(); Assert.assertEquals(4, result.getLong(1)); @@ -671,7 +890,7 @@ public void testTwoClientsOneChannel() throws Exception { .createStatement() .executeQuery( String.format( - "select * from %s.%s.%s order by c1", TEST_DB, TEST_SCHEMA, TEST_TABLE)); + "select * from %s.%s.%s order by c1", testDb, TEST_SCHEMA, TEST_TABLE)); result2.next(); Assert.assertEquals("1", result2.getString(1)); result2.next(); @@ -695,7 +914,7 @@ public void testAbortOnErrorOption() throws Exception { OpenChannelRequest request = OpenChannelRequest.builder("CHANNEL") - .setDBName(TEST_DB) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(onErrorOptionTable) .setOnErrorOption(OpenChannelRequest.OnErrorOption.ABORT) @@ -715,18 +934,21 @@ public void testAbortOnErrorOption() throws Exception { channel.insertRow(row3, "3"); Assert.fail("insert should fail"); } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); + Assert.assertEquals(ErrorCode.INVALID_VALUE_ROW.getMessageCode(), e.getVendorCode()); } try { channel.insertRows(Arrays.asList(row1, row2, row3), "6"); Assert.fail("insert should fail"); } catch (SFException e) { - Assert.assertEquals(ErrorCode.INVALID_ROW.getMessageCode(), e.getVendorCode()); + Assert.assertEquals(ErrorCode.INVALID_VALUE_ROW.getMessageCode(), e.getVendorCode()); } Map row7 = new HashMap<>(); row7.put("c1", 7); channel.insertRow(row7, "7"); + // Close the channel after insertion + channel.close().get(); + for (int i = 1; i < 15; i++) { if (channel.getLatestCommittedOffsetToken() != null && channel.getLatestCommittedOffsetToken().equals("7")) { @@ -736,7 +958,7 @@ public void testAbortOnErrorOption() throws Exception { .executeQuery( String.format( "select count(c1), min(c1), max(c1) from %s.%s.%s", - TEST_DB, TEST_SCHEMA, onErrorOptionTable)); + testDb, TEST_SCHEMA, onErrorOptionTable)); result.next(); Assert.assertEquals("3", result.getString(1)); Assert.assertEquals("1", result.getString(2)); @@ -748,6 +970,515 @@ public void testAbortOnErrorOption() throws Exception { Assert.fail("Row sequencer not updated before timeout"); } + @Test + public void testAbortOnErrorSkipBatch() throws Exception { + String onErrorOptionTable = "skip_batch_on_error_option"; + jdbcConnection + .createStatement() + .execute(String.format("create or replace table %s (c1 int);", onErrorOptionTable)); + + OpenChannelRequest request = + OpenChannelRequest.builder("CHANNEL") + .setDBName(testDb) + .setSchemaName(TEST_SCHEMA) + .setTableName(onErrorOptionTable) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.SKIP_BATCH) + .build(); + + // Open a streaming ingest channel from the given client + SnowflakeStreamingIngestChannel channel = client.openChannel(request); + Map row1 = new HashMap<>(); + row1.put("c1", 1); + Map row2 = new HashMap<>(); + row2.put("c1", 2); + Map row3 = new HashMap<>(); + row3.put("c1", "a"); + + verifyInsertValidationResponse(channel.insertRow(row1, "1")); + + InsertValidationResponse response = channel.insertRows(Arrays.asList(row1, row2, row3), "3"); + Assert.assertTrue(response.hasErrors()); + + Map row4 = new HashMap<>(); + row4.put("c1", 4); + verifyInsertValidationResponse(channel.insertRow(row4, "4")); + + // Close the channel after insertion + channel.close().get(); + + for (int i = 1; i < 15; i++) { + if (channel.getLatestCommittedOffsetToken() != null + && channel.getLatestCommittedOffsetToken().equals("4")) { + ResultSet result = + jdbcConnection + .createStatement() + .executeQuery( + String.format( + "select count(c1), min(c1), max(c1) from %s.%s.%s", + testDb, TEST_SCHEMA, onErrorOptionTable)); + result.next(); + Assert.assertEquals("2", result.getString(1)); + Assert.assertEquals("1", result.getString(2)); + Assert.assertEquals("4", result.getString(3)); + return; + } + Thread.sleep(1000); + } + Assert.fail("Row sequencer not updated before timeout"); + } + + @Test + public void testInsertRowsWithValidStartOffsetToken() throws Exception { + String insertRowsWithValidStartOffsetToken = "insert_rows_with_valid_start_offset_token"; + jdbcConnection + .createStatement() + .execute( + String.format( + "create or replace table %s (c1 int);", insertRowsWithValidStartOffsetToken)); + + OpenChannelRequest request = + OpenChannelRequest.builder("CHANNEL") + .setDBName(testDb) + .setSchemaName(TEST_SCHEMA) + .setTableName(insertRowsWithValidStartOffsetToken) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .setOffsetTokenVerificationFunction(offsetTokenVerificationFunction) + .build(); + + // Open a streaming ingest channel from the given client + SnowflakeStreamingIngestChannel channel = client.openChannel(request); + Map row1 = new HashMap<>(); + row1.put("c1", 1); + Map row2 = new HashMap<>(); + row2.put("c1", 2); + Map row3 = new HashMap<>(); + row3.put("c1", "a"); + + verifyInsertValidationResponse(channel.insertRow(row1, "1")); + + InsertValidationResponse response = + channel.insertRows(Arrays.asList(row1, row2, row3), "2", "4"); + Assert.assertTrue(response.hasErrors()); + + // Close the channel after insertion + channel.close().get(); + + for (int i = 1; i < 15; i++) { + if (channel.getLatestCommittedOffsetToken() != null + && channel.getLatestCommittedOffsetToken().equals("4")) { + ResultSet result = + jdbcConnection + .createStatement() + .executeQuery( + String.format( + "select count(c1), min(c1), max(c1) from %s.%s.%s", + testDb, TEST_SCHEMA, insertRowsWithValidStartOffsetToken)); + result.next(); + Assert.assertEquals("3", result.getString(1)); + Assert.assertEquals("1", result.getString(2)); + Assert.assertEquals("2", result.getString(3)); + return; + } + Thread.sleep(1000); + } + Assert.fail("Row sequencer not updated before timeout"); + } + + @Test + public void testInsertRowsWithInvalidStartOffsetToken() throws Exception { + String insertRowsWithInvalidStartOffsetToken = "insert_rows_with_invalid_start_offset_token"; + jdbcConnection + .createStatement() + .execute( + String.format( + "create or replace table %s (c1 int);", insertRowsWithInvalidStartOffsetToken)); + + OpenChannelRequest request = + OpenChannelRequest.builder("CHANNEL") + .setDBName(testDb) + .setSchemaName(TEST_SCHEMA) + .setTableName(insertRowsWithInvalidStartOffsetToken) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .setOffsetTokenVerificationFunction(offsetTokenVerificationFunction) + .build(); + + // Open a streaming ingest channel from the given client + SnowflakeStreamingIngestChannel channel = client.openChannel(request); + Map row1 = new HashMap<>(); + row1.put("c1", 1); + Map row2 = new HashMap<>(); + row2.put("c1", 2); + + // end-start != rowcount + channel.insertRows(Arrays.asList(row1, row2), "1", "4"); + + // offset != prevEnd+1 + channel.insertRow(row1, "7"); + + // Close the channel after insertion + channel.close().get(); + + for (int i = 1; i < 15; i++) { + if (channel.getLatestCommittedOffsetToken() != null + && channel.getLatestCommittedOffsetToken().equals("7")) { + ResultSet result = + jdbcConnection + .createStatement() + .executeQuery( + String.format( + "select count(c1), min(c1), max(c1) from %s.%s.%s", + testDb, TEST_SCHEMA, insertRowsWithInvalidStartOffsetToken)); + result.next(); + Assert.assertEquals("3", result.getString(1)); + Assert.assertEquals("1", result.getString(2)); + Assert.assertEquals("2", result.getString(3)); + return; + } + Thread.sleep(1000); + } + Assert.fail("Row sequencer not updated before timeout"); + } + + @Test + public void testOffsetTokenVerificationFunction() { + Assert.assertTrue(offsetTokenVerificationFunction.verify("1", "2", "4", 3)); + Assert.assertTrue(offsetTokenVerificationFunction.verify(null, "2", "4", 3)); + Assert.assertTrue(offsetTokenVerificationFunction.verify("1", "2", null, 3)); + Assert.assertTrue(offsetTokenVerificationFunction.verify("a", "2", "4", 3)); + Assert.assertFalse(offsetTokenVerificationFunction.verify("1", "3", "4", 3)); + Assert.assertFalse(offsetTokenVerificationFunction.verify("2", "1", "4", 3)); + Assert.assertFalse(offsetTokenVerificationFunction.verify("1", "2", "4", 2)); + } + + @Test + public void testChannelClose() throws Exception { + OpenChannelRequest request1 = + OpenChannelRequest.builder("CHANNEL") + .setDBName(testDb) + .setSchemaName(TEST_SCHEMA) + .setTableName(TEST_TABLE) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .build(); + + // Open a streaming ingest channel from the given client + SnowflakeStreamingIngestChannel channel1 = client.openChannel(request1); + for (int val = 0; val < 1000; val++) { + Map row = new HashMap<>(); + row.put("c1", Integer.toString(val)); + verifyInsertValidationResponse(channel1.insertRow(row, Integer.toString(val))); + } + + // Close the channel to make sure everything is committed + channel1.close().get(); + } + + @Test + public void testNullValuesOnMultiDataTypes() throws Exception { + String nullValuesOnMultiDataTypesTable = "test_null_values_on_multi_data_types"; + jdbcConnection + .createStatement() + .execute( + String.format( + "create or replace table %s (c0 int, c1 number, c2 decimal, c3 bigint, c4 float," + + " c5 real, c6 varchar, c7 char, c8 string, c9 text, c10 binary, c11 boolean," + + " c12 date, c13 time, c14 timestamp, c15 variant, c16 object, c17 array);", + nullValuesOnMultiDataTypesTable)); + + OpenChannelRequest request = + OpenChannelRequest.builder("CHANNEL") + .setDBName(testDb) + .setSchemaName(TEST_SCHEMA) + .setTableName(nullValuesOnMultiDataTypesTable) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .build(); + + // Open a streaming ingest channel from the given client + SnowflakeStreamingIngestChannel channel1 = client.openChannel(request); + for (int val = 0; val < 1000; val++) { + Map row = new HashMap<>(); + row.put("c0", val); + verifyInsertValidationResponse(channel1.insertRow(row, Integer.toString(val))); + } + + // Sleep for a few seconds to make sure a flush has finished + Thread.sleep(5000); + + // Insert one row with all NULLs to make sure it works + Map row = new HashMap<>(); + for (int idx = 0; idx < 18; idx++) { + row.put("c" + idx, null); + } + verifyInsertValidationResponse(channel1.insertRow(row, "0")); + + // Close the channel to make sure everything is committed + channel1.close().get(); + + // Query the table in the end to make sure everything is readable + ResultSet result = + jdbcConnection + .createStatement() + .executeQuery( + String.format( + "select top 1 c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11, c12, c13, c14," + + " c15, c16, c17 from %s.%s.%s", + testDb, TEST_SCHEMA, nullValuesOnMultiDataTypesTable)); + result.next(); + for (int idx = 1; idx < 18; idx++) { + Assert.assertNull(result.getObject(idx)); + } + } + + @Test + public void testFailureHalfwayThroughRowProcessing() throws Exception { + String tableName = "failure_halfway_through_table"; + jdbcConnection + .createStatement() + .execute( + String.format("create or replace table %s(c1 varchar(1), c2 varchar(1));", tableName)); + SnowflakeStreamingIngestChannel channel = + openChannel(tableName, "channel1", OpenChannelRequest.OnErrorOption.CONTINUE); + + // Row will be ingested + Map row = new LinkedHashMap<>(); + row.put("c1", null); + row.put("c2", "b"); + channel.insertRow(row, "offset0"); + + // Row will not be ingested, its contribution to nullCount and other statistics should be + // discarded + row = new LinkedHashMap<>(); + row.put("c1", null); + row.put("c2", "qa"); + channel.insertRow(row, "offset1"); + + // Row will be ingested + row = new LinkedHashMap<>(); + row.put("c1", null); + row.put("c2", null); + channel.insertRow(row, "offset2"); + + TestUtils.waitForOffset(channel, "offset2"); + + jdbcConnection.createStatement().execute(String.format("alter table %s migrate;", tableName)); + + ResultSet rs = + jdbcConnection.createStatement().executeQuery(String.format("select * from %s", tableName)); + rs.next(); + Assert.assertEquals(null, rs.getString(1)); + Assert.assertEquals("b", rs.getString(2)); + + rs.next(); + Assert.assertEquals(null, rs.getString(1)); + Assert.assertEquals(null, rs.getString(2)); + } + + @Test + public void testColumnNameQuotes() throws Exception { + final String tableName = "T_COLUMN_WITH_QUOTES"; + final String unquotedColumn = "c1"; // user name + final String quotedColumn = "\"c 2\""; // user name + final String doubleQuotedColumn = "\"c\"\"a\\ \"\"\\ 3\""; // user name + final String escapedSpacesColumn1 = + "c\\ 4"; // space is escaped with '\' in Snowflake SQL, user name + final String escapedSpacesColumn2 = + "c\\ 5"; // space is escaped with '\' in Snowflake SQL, user name + jdbcConnection + .createStatement() + .execute( + String.format( + "create or replace table %s.%s.%s (%s NUMBER(38,0), %s NUMBER(38,0), %s" + + " NUMBER(38,0), %s NUMBER(38,0), %s NUMBER(38,0));", + testDb, + TEST_SCHEMA, + tableName, + unquotedColumn, + quotedColumn, + doubleQuotedColumn, + escapedSpacesColumn1, + escapedSpacesColumn2)); + + OpenChannelRequest request1 = + OpenChannelRequest.builder("CHANNEL") + .setDBName(testDb) + .setSchemaName(TEST_SCHEMA) + .setTableName(tableName) + .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .build(); + + // Open a streaming ingest channel from the given client + SnowflakeStreamingIngestChannel channel1 = client.openChannel(request1); + for (int val = 0; val < 10; val++) { + Map row = new HashMap<>(); + row.put(unquotedColumn, val); + row.put(quotedColumn, val); + row.put(doubleQuotedColumn, val); + row.put(escapedSpacesColumn1, val); + // space escape '\' is not really required, because Java always has its own outer double + // quotes. + // hence, it happens to also work w/o the space escape after the internal literal + // normalisation. + String escapedSpacesColumn2NoSpaceEscape = escapedSpacesColumn2.replace("\\", ""); + row.put(escapedSpacesColumn2NoSpaceEscape, val); + verifyInsertValidationResponse(channel1.insertRow(row, Integer.toString(val))); + } + + // Close the channel after insertion + channel1.close().get(); + + for (int i = 1; i < 15; i++) { + if (channel1.getLatestCommittedOffsetToken() != null + && channel1.getLatestCommittedOffsetToken().equals("9")) { + ResultSet result = + jdbcConnection + .createStatement() + .executeQuery( + String.format( + "select %s, %s, %s, %s, %s from %s.%s.%s order by %s", + unquotedColumn, + quotedColumn, + doubleQuotedColumn, + escapedSpacesColumn1, + escapedSpacesColumn2, + testDb, + TEST_SCHEMA, + tableName, + unquotedColumn)); + for (int val = 0; val < 10; val++) { + result.next(); + for (int index = 1; index <= 5; index++) Assert.assertEquals(val, result.getInt(index)); + } + return; + } + Thread.sleep(500); + } + Assert.fail("Row sequencer not updated before timeout"); + } + + @Test + public void testTableColumnEvolution() throws Exception { + final int rowNum = 100; + final String tableName = TEST_TABLE + "_TEST_COL_EVOL"; + final String tableFullName = String.format("%s.%s.%s", testDb, TEST_SCHEMA, tableName); + final String streamName = + String.format("%s.%s.%s", testDb, TEST_SCHEMA, "STREAM_TEST_COL_EVOL"); + + executeQuery("create or replace table %s (c1 varchar, c2 varchar, c3 varchar);", tableName); + int channelNum = 1; + + // insert first 3 columns + ingestByColIndexAndVerifyTable(tableName, channelNum, Arrays.asList(1, 2, 3), rowNum); + channelNum++; + + // add one column + executeQuery(String.format("alter table %s add column c4 varchar;", tableFullName)); + ingestByColIndexAndVerifyTable(tableName, channelNum, Arrays.asList(1, 2, 3, 4), rowNum); + channelNum++; + + // remove first column + executeQuery(String.format("alter table %s drop column c1;", tableFullName)); + ingestByColIndexAndVerifyTable(tableName, channelNum, Arrays.asList(2, 3, 4), rowNum); + channelNum++; + + // remove last column + executeQuery(String.format("alter table %s drop column c4;", tableFullName)); + ingestByColIndexAndVerifyTable(tableName, channelNum, Arrays.asList(2, 3), rowNum); + channelNum++; + + // add last column + executeQuery(String.format("alter table %s add column c5 varchar;", tableFullName)); + ingestByColIndexAndVerifyTable(tableName, channelNum, Arrays.asList(2, 3, 5), rowNum); + channelNum++; + + // create stream on table to append change tracking columns + executeQuery(String.format("create or replace stream %s on table %s;", streamName, tableName)); + ingestByColIndexAndVerifyTable(tableName, channelNum, Arrays.asList(2, 3, 5), rowNum); + channelNum++; + + // add one more column after change tracking columns + executeQuery(String.format("alter table %s add column c6 varchar;", tableFullName)); + ingestByColIndexAndVerifyTable(tableName, channelNum, Arrays.asList(2, 3, 5, 6), rowNum); + } + + /** + * Ingests rows into a table and verifies them by querying the table. + * + *

    The test opens channel 'channel' for ingestion. + * + *

    Expected table schema is ('C' varchar, 'C' varchar) where indexes are from + * {@code columnIndexes}. + * + *

    The ingested text values have the format: 'v_'. The row index is from + * range [(channelNum - 1) * numberOfRows, numberOfRows]. + * + * @param tableName table full name + * @param channelNum channel number to open + * @param columnIndexes indexes of columns to ingest + * @param numberOfRows to ingest + */ + private void ingestByColIndexAndVerifyTable( + String tableName, int channelNum, List columnIndexes, int numberOfRows) + throws InterruptedException, SQLException { + final String valueFormat = "v%01d_%03d"; + final String channelName = "channel" + channelNum; + final int startIndex = (channelNum - 1) * numberOfRows; + + // ingest rows + SnowflakeStreamingIngestChannel channel = + openChannel(tableName, channelName, OpenChannelRequest.OnErrorOption.ABORT); + for (int i = 0; i < numberOfRows; i++) { + Map row = new HashMap<>(); + for (int col : columnIndexes) { + String value = String.format(valueFormat, col, startIndex + i); + row.put("c" + col, value); + } + verifyInsertValidationResponse(channel.insertRow(row, Integer.toString(i))); + } + TestUtils.waitForOffset(channel, Integer.toString(numberOfRows - 1)); + channel.close(); + + // query them and verify + int firstColNum = columnIndexes.get(0); + try (ResultSet result = + jdbcConnection + .createStatement() + .executeQuery( + String.format( + "select * from %s.%s.%s where c%d >= 'v%01d_%03d' and c%d < 'v%01d_%03d' order" + + " by c%d", + testDb, + TEST_SCHEMA, + tableName, + firstColNum, + firstColNum, + startIndex, + firstColNum, + firstColNum, + startIndex + numberOfRows, + firstColNum))) { + for (int i = 0; i < numberOfRows; i++) { + result.next(); + for (int col : columnIndexes) { + String value = result.getString("C" + col); + String expectedValue = String.format(valueFormat, col, startIndex + i); + String errorMessage = + String.format( + "Ingested value mismatch: table %s, startIndex %d, column %s, index %d", + tableName, startIndex, "c" + col, i); + assertThat(errorMessage, value, is(expectedValue)); + } + } + } + } + + private void executeQuery(String sqlFmt, Object... args) { + String sql = String.format(sqlFmt, args); + try { + jdbcConnection.createStatement().executeQuery(sql); + } catch (SQLException e) { + throw new RuntimeException("SQL query failed : " + sql, e); + } + } + /** Verify the insert validation response and throw the exception if needed */ private void verifyInsertValidationResponse(InsertValidationResponse response) { if (response.hasErrors()) { @@ -761,19 +1492,20 @@ private void createTableForInterleavedTest(String tableName) { .createStatement() .execute( String.format( - "create or replace table %s (num NUMBER(38,0 ), str VARCHAR);", tableName)); + "create or replace table %s (num NUMBER(38,0), str VARCHAR);", tableName)); } catch (SQLException e) { throw new RuntimeException("Cannot create table " + tableName, e); } } - private SnowflakeStreamingIngestChannel openChannel(String tableName) { + private SnowflakeStreamingIngestChannel openChannel( + String tableName, String channelName, OpenChannelRequest.OnErrorOption onErrorOption) { OpenChannelRequest request = - OpenChannelRequest.builder("CHANNEL") - .setDBName(TEST_DB) + OpenChannelRequest.builder(channelName) + .setDBName(testDb) .setSchemaName(TEST_SCHEMA) .setTableName(tableName) - .setOnErrorOption(OpenChannelRequest.OnErrorOption.CONTINUE) + .setOnErrorOption(onErrorOption) .build(); // Open a streaming ingest channel from the given client @@ -781,20 +1513,22 @@ private SnowflakeStreamingIngestChannel openChannel(String tableName) { } private void produceRowsForInterleavedTest( - SnowflakeStreamingIngestChannel channel, String tableName, int rowNumber) { + SnowflakeStreamingIngestChannel channel, String strPrefix, int rowNumber) { // Insert a few rows into the channel for (int val = 0; val < rowNumber; val++) { Map row = new HashMap<>(); row.put("num", val); - row.put("str", tableName + "_value_" + val); + row.put("str", strPrefix + "_value_" + val); verifyInsertValidationResponse(channel.insertRow(row, Integer.toString(val))); } } private void waitChannelFlushed(SnowflakeStreamingIngestChannel channel, int numberOfRows) { + String latestCommittedOffsetToken = null; for (int i = 1; i < 15; i++) { - if (channel.getLatestCommittedOffsetToken() != null - && channel.getLatestCommittedOffsetToken().equals(Integer.toString(numberOfRows - 1))) { + latestCommittedOffsetToken = channel.getLatestCommittedOffsetToken(); + if (latestCommittedOffsetToken != null + && latestCommittedOffsetToken.equals(Integer.toString(numberOfRows - 1))) { return; } try { @@ -804,25 +1538,273 @@ private void waitChannelFlushed(SnowflakeStreamingIngestChannel channel, int num "Interrupted waitChannelFlushed for " + numberOfRows + " rows", e); } } - Assert.fail("Row sequencer not updated before timeout"); + Assert.fail( + "Row sequencer not updated before timeout, latestCommittedOffsetToken: " + + latestCommittedOffsetToken); } - private void verifyInterleavedResult(int rowNumber, String tableName) { - ResultSet result; + private void verifyInterleavedResult(int rowNumber, String tableName, String strPrefix) { try { - result = + ResultSet result = jdbcConnection .createStatement() .executeQuery( String.format( - "select * from %s.%s.%s order by num", TEST_DB, TEST_SCHEMA, tableName)); + "select * from %s.%s.%s where str ilike '%s%%' order by num", + testDb, TEST_SCHEMA, tableName, strPrefix)); for (int val = 0; val < rowNumber; val++) { result.next(); Assert.assertEquals(val, result.getLong("NUM")); - Assert.assertEquals(tableName + "_value_" + val, result.getString("STR")); + Assert.assertEquals(strPrefix + "_value_" + val, result.getString("STR")); } } catch (SQLException e) { throw new RuntimeException("Cannot verifyInterleavedResult for " + tableName, e); } } + + private void verifyTableRowCount(int rowNumber, String tableName) { + try { + ResultSet resultCount = + jdbcConnection + .createStatement() + .executeQuery( + String.format("select count(*) from %s.%s.%s", testDb, TEST_SCHEMA, tableName)); + resultCount.next(); + Assert.assertEquals(rowNumber, resultCount.getLong(1)); + } catch (SQLException e) { + throw new RuntimeException("Cannot verifyTableRowCount for " + tableName, e); + } + } + + @Test + public void testDataTypes() throws SQLException, ParseException { + String tableName = "t_data_types"; + try { + jdbcConnection + .createStatement() + .execute( + String.format( + "create or replace table %s (num NUMBER, -- default FIXED\n" + + " num_10_5 NUMBER(10, 5), -- FIXED with" + + " non zero scale: big dec\n" + + " num_38_0 NUMBER(38, 0), -- FIXED sb16:" + + " big dec\n" + + " num_2_0 NUMBER(2, 0), -- FIXED sb1\n" + + " num_4_0 NUMBER(4, 0), -- FIXED sb2\n" + + " num_9_0 NUMBER(9, 0), -- FIXED sb4\n" + + " num_18_0 NUMBER(18, 0), -- FIXED sb8\n" + + " num_float FLOAT, -- REAL\n" + + " str_varchar VARCHAR(256), -- varchar," + + " char, any, string, text\n" + + " bin BINARY(256), -- BINARY, VARBINARY:" + + " hex\n" + + " bl BOOLEAN, -- BOOLEAN: true/false\n" + + " var VARIANT, -- VARIANT: json\n" + + " obj OBJECT, -- OBJECT: json map\n" + + " arr ARRAY, -- ARRAY: array of" + + " variants\n" + + " epochdays DATE,\n" + + " timesec TIME(0),\n" + + " timenano TIME(9),\n" + + " epochsec TIMESTAMP_NTZ(0),\n" + + " epochnano TIMESTAMP_NTZ(9));", + tableName)); + } catch (SQLException e) { + throw new RuntimeException("Cannot create table " + tableName, e); + } + + SnowflakeStreamingIngestChannel channel = + openChannel(tableName, "CHANNEL", OpenChannelRequest.OnErrorOption.ABORT); + + Map negRow = getNegRow(); + verifyInsertValidationResponse(channel.insertRow(negRow, Integer.toString(0))); + + Map nullRow = getNullRow(); + verifyInsertValidationResponse(channel.insertRow(nullRow, Integer.toString(1))); + + Map posRow = getPosRow(); + verifyInsertValidationResponse(channel.insertRow(posRow, Integer.toString(2))); + + waitChannelFlushed(channel, 3); + verifyTableRowCount(3, tableName); + + ResultSet result = + jdbcConnection + .createStatement() + .executeQuery( + String.format( + "select * from %s.%s.%s order by num", testDb, TEST_SCHEMA, tableName)); + + result.next(); // negRow + assertNegRow(negRow, result); + + result.next(); // nullRow + assertNullRow(nullRow, result); + + result.next(); // posRow + assertPosRow(posRow, result); + } + + private Map getNegRow() { + Map negRow = new HashMap<>(); + negRow.put("num", -123); + negRow.put("num_10_5", new BigDecimal("-12345.54321")); + negRow.put("num_38_0", new BigDecimal("-91234567899876543219876543211234567891")); + negRow.put("num_2_0", -12); + negRow.put("num_4_0", -1234); + negRow.put("num_9_0", -123456789); + negRow.put("num_18_0", -123456789987654321L); + negRow.put("num_float", -12345.54321f); + negRow.put("str_varchar", "zxccvbnm,."); + negRow.put("bin", new byte[] {1, 2, 3}); + negRow.put("bl", true); + negRow.put("var", "{}"); + negRow.put("obj", "{}"); + negRow.put("arr", Arrays.asList()); + negRow.put("epochdays", "0"); + negRow.put("epochsec", "0"); + negRow.put("epochnano", "0"); + negRow.put("timesec", "0"); + negRow.put("timenano", "0"); + + return negRow; + } + + private Map getNullRow() { + Map nullRow = new HashMap<>(); + nullRow.put("num", 0); + nullRow.put("num_10_5", new BigDecimal("0.00000")); + nullRow.put("num_38_0", new BigDecimal("0")); + nullRow.put("num_2_0", 0); + nullRow.put("num_4_0", 0); + nullRow.put("num_9_0", 0); + nullRow.put("num_18_0", 0L); + nullRow.put("num_float", 0.0f); + nullRow.put("str_varchar", null); + nullRow.put("bin", null); + nullRow.put("bl", false); + nullRow.put("var", null); + nullRow.put("obj", null); + nullRow.put("arr", null); + nullRow.put("epochdays", null); + nullRow.put("epochsec", null); + nullRow.put("epochnano", null); + nullRow.put("timesec", null); + nullRow.put("timenano", null); + + return nullRow; + } + + private Map getPosRow() { + Map posRow = new HashMap<>(); + posRow.put("num", 123); + posRow.put("num_10_5", new BigDecimal("12345.54321")); + posRow.put("num_38_0", new BigDecimal("91234567899876543219876543211234567891")); + posRow.put("num_2_0", 12); + posRow.put("num_4_0", 1234); + posRow.put("num_9_0", 123456789); + posRow.put("num_18_0", 123456789987654321L); + posRow.put("num_float", 12345.54321f); + posRow.put("str_varchar", "abcd123456."); + posRow.put("bin", new byte[] {4, 5, 6}); + posRow.put("bl", true); + posRow.put( + "var", + "{ \"a\": 1, \"b\": \"qwerty\", \"c\": null, \"d\": { \"e\": 2, \"f\": \"asdf\", \"g\":" + + " null } }"); + posRow.put( + "obj", + "{ \"a\": 1, \"b\": \"qwerty\", \"c\": null, \"d\": { \"e\": 2, \"f\": \"asdf\", \"g\":" + + " null } }"); + posRow.put("arr", Arrays.asList("{ \"a\": 1}", "{ \"b\": 2 }", "{ \"c\": 3 }")); + posRow.put("epochdays", "2022-09-18T20:05:07"); // DATE, 18.09.2022 + posRow.put("epochsec", "2022-09-18T20:05:07"); // TIMESTAMP_NTZ(0) + posRow.put("epochnano", "2022-09-18T20:05:07.999999999"); // TIMESTAMP_NTZ(9) + posRow.put("timesec", "01:00:01.999999999"); // TIME(0) + posRow.put("timenano", "01:00:01.999999999"); // TIME(9) + + return posRow; + } + + private void assertNegRow(Map expectedNegRow, ResultSet actualResult) + throws SQLException { + assertNonTimeAndVarFields(expectedNegRow, actualResult); + Assert.assertEquals("{}", actualResult.getString("VAR")); + Assert.assertEquals("{}", actualResult.getString("OBJ")); + Assert.assertEquals("[]", actualResult.getString("ARR")); + Assert.assertEquals(0, actualResult.getDate("EPOCHDAYS").getTime()); + Assert.assertEquals(0, actualResult.getTimestamp("EPOCHSEC").getTime()); + Assert.assertEquals(0, actualResult.getTimestamp("EPOCHNANO").getNanos()); + Assert.assertEquals(0, actualResult.getTimestamp("EPOCHNANO").getTime()); + Assert.assertEquals(0, actualResult.getTimestamp("TIMESEC").getTime()); + Assert.assertEquals(0, actualResult.getTimestamp("TIMENANO").getNanos()); + Assert.assertEquals(0, actualResult.getTimestamp("TIMENANO").getTime()); + } + + private void assertNullRow(Map expectedNullRow, ResultSet actualResult) + throws SQLException { + Assert.assertNotNull(actualResult); + assertNonTimeAndVarFields(expectedNullRow, actualResult); + Assert.assertNull(actualResult.getString("VAR")); + Assert.assertNull(actualResult.getString("OBJ")); + Assert.assertNull(actualResult.getString("ARR")); + Assert.assertNull(actualResult.getDate("EPOCHDAYS")); + Assert.assertNull(actualResult.getTimestamp("EPOCHSEC")); + Assert.assertNull(actualResult.getTimestamp("EPOCHNANO")); + Assert.assertNull(actualResult.getTimestamp("TIMESEC")); + Assert.assertNull(actualResult.getTimestamp("TIMENANO")); + } + + private void assertPosRow(Map expectedPosRow, ResultSet actualResult) + throws SQLException { + String formattedJSON = + "{\n" + + " \"a\": 1,\n" + + " \"b\": \"qwerty\",\n" + + " \"c\": null,\n" + + " \"d\": {\n" + + " \"e\": 2,\n" + + " \"f\": \"asdf\",\n" + + " \"g\": null\n" + + " }\n" + + "}"; + + String formattedArray = + "[\n" + + " \"{ \\\"a\\\": 1}\",\n" + + " \"{ \\\"b\\\": 2 }\",\n" + + " \"{ \\\"c\\\": 3 }\"\n" + + "]"; + Assert.assertNotNull(actualResult); + assertNonTimeAndVarFields(expectedPosRow, actualResult); + Assert.assertEquals(formattedJSON, actualResult.getString("VAR")); + Assert.assertEquals(formattedJSON, actualResult.getString("OBJ")); + Assert.assertEquals(formattedArray, actualResult.getString("ARR")); + Assert.assertEquals( + 1663459200000l, actualResult.getDate("EPOCHDAYS").getTime()); // in ms, 18.09.2022 00:00:00 + Assert.assertEquals(1663531507000L, actualResult.getTimestamp("EPOCHSEC").getTime()); + Assert.assertEquals(999999999L, actualResult.getTimestamp("EPOCHNANO").getNanos()); + // 1663531507000 ms + 999 ms (from ns) + Assert.assertEquals(1663531507999L, actualResult.getTimestamp("EPOCHNANO").getTime()); + Assert.assertEquals(3601000, actualResult.getTimestamp("TIMESEC").getTime()); // 1h + 1s in ms + Assert.assertEquals(999999999, actualResult.getTimestamp("TIMENANO").getNanos()); + // 1h + 1s + 999 ms (from ns) + Assert.assertEquals(3601999, actualResult.getTimestamp("TIMENANO").getTime()); + } + + private void assertNonTimeAndVarFields(Map row, ResultSet result) + throws SQLException { + Assert.assertNotNull(result); + Assert.assertEquals(row.get("num"), result.getInt("NUM")); + Assert.assertEquals(row.get("num_10_5"), result.getBigDecimal("NUM_10_5")); + Assert.assertEquals(row.get("num_38_0"), result.getBigDecimal("NUM_38_0")); + Assert.assertEquals(row.get("num_2_0"), result.getInt("NUM_2_0")); + Assert.assertEquals(row.get("num_4_0"), result.getInt("NUM_4_0")); + Assert.assertEquals(row.get("num_9_0"), result.getInt("NUM_9_0")); + Assert.assertEquals((long) row.get("num_18_0"), result.getLong("NUM_18_0")); + Assert.assertEquals((float) row.get("num_float"), result.getFloat("NUM_FLOAT"), 0); + Assert.assertEquals(row.get("str_varchar"), result.getString("STR_VARCHAR")); + Assert.assertArrayEquals((byte[]) row.get("bin"), result.getBytes("BIN")); + Assert.assertEquals(row.get("bl"), result.getBoolean("BL")); + } } diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestResponseCodeTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestResponseCodeTest.java new file mode 100644 index 000000000..f209ef9e9 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestResponseCodeTest.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Snowflake Computing Inc. All rights reserved. + */ + +package net.snowflake.ingest.streaming.internal; + +import org.junit.Assert; +import org.junit.Test; + +public class StreamingIngestResponseCodeTest { + @Test + public void testGetMessageNullCode() { + Assert.assertEquals( + StreamingIngestResponseCode.UNKNOWN_STATUS_MESSAGE, + StreamingIngestResponseCode.getMessageByCode(null)); + } + + @Test + public void testGetMessageUnknownCode() { + Assert.assertEquals( + StreamingIngestResponseCode.UNKNOWN_STATUS_MESSAGE, + StreamingIngestResponseCode.getMessageByCode(-1L)); + } + + @Test + public void testGetMessageKnownCodes() { + for (StreamingIngestResponseCode code : StreamingIngestResponseCode.values()) { + Assert.assertEquals( + code.getMessage(), StreamingIngestResponseCode.getMessageByCode(code.getStatusCode())); + } + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestStageTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestStageTest.java index 0aecce7e0..1ba9f98df 100644 --- a/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestStageTest.java +++ b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestStageTest.java @@ -3,10 +3,12 @@ import static net.snowflake.client.core.Constants.CLOUD_STORAGE_CREDENTIALS_EXPIRED; import static net.snowflake.ingest.utils.HttpUtil.HTTP_PROXY_PASSWORD; import static net.snowflake.ingest.utils.HttpUtil.HTTP_PROXY_USER; +import static net.snowflake.ingest.utils.HttpUtil.NON_PROXY_HOSTS; import static net.snowflake.ingest.utils.HttpUtil.PROXY_HOST; import static net.snowflake.ingest.utils.HttpUtil.PROXY_PORT; import static net.snowflake.ingest.utils.HttpUtil.USE_PROXY; import static net.snowflake.ingest.utils.HttpUtil.generateProxyPropertiesForJDBC; +import static net.snowflake.ingest.utils.HttpUtil.shouldBypassProxy; import static org.mockito.Mockito.times; import java.io.ByteArrayInputStream; @@ -16,7 +18,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Properties; import java.util.concurrent.ExecutorService; @@ -32,6 +33,10 @@ import net.snowflake.client.jdbc.SnowflakeSQLException; import net.snowflake.client.jdbc.cloud.storage.StageInfo; import net.snowflake.client.jdbc.internal.amazonaws.util.IOUtils; +import net.snowflake.client.jdbc.internal.apache.http.StatusLine; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.CloseableHttpResponse; +import net.snowflake.client.jdbc.internal.apache.http.entity.BasicHttpEntity; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; import net.snowflake.client.jdbc.internal.fasterxml.jackson.databind.JsonNode; import net.snowflake.client.jdbc.internal.fasterxml.jackson.databind.ObjectMapper; import net.snowflake.client.jdbc.internal.google.common.util.concurrent.ThreadFactoryBuilder; @@ -39,10 +44,7 @@ import net.snowflake.ingest.connection.RequestBuilder; import net.snowflake.ingest.utils.Constants; import net.snowflake.ingest.utils.ParameterProvider; -import org.apache.http.HttpResponse; -import org.apache.http.StatusLine; -import org.apache.http.client.HttpClient; -import org.apache.http.entity.BasicHttpEntity; +import net.snowflake.ingest.utils.SFException; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -55,6 +57,11 @@ @RunWith(PowerMockRunner.class) @PrepareForTest({TestUtils.class, HttpUtil.class, SnowflakeFileTransferAgent.class}) public class StreamingIngestStageTest { + + private final String prefix = "EXAMPLE_PREFIX"; + + private final long deploymentId = 123; + private ObjectMapper mapper = new ObjectMapper(); final String exampleRemoteMeta = @@ -74,13 +81,18 @@ public class StreamingIngestStageTest { + " \"region\": \"us-east-1\", \"storageAccount\": null, \"isClientSideEncrypted\":" + " true, \"creds\": {\"AWS_KEY_ID\": \"EXAMPLE_AWS_KEY_ID\", \"AWS_SECRET_KEY\":" + " \"EXAMPLE_AWS_SECRET_KEY\", \"AWS_TOKEN\": \"EXAMPLE_AWS_TOKEN\", \"AWS_ID\":" - + " \"EXAMPLE_AWS_ID\", \"AWS_KEY\": \"EXAMPLE_AWS_KEY\"}, \"presignedUrl\": null," - + " \"endPoint\": null}}}"; + + " \"EXAMPLE_AWS_ID\", \"AWS_KEY\": \"EXAMPLE_AWS_KEY\"}, \"presignedUrl\":" + + " \"presignedUrl\", \"endPoint\": null}}}"; String exampleRemoteMetaResponse = "{\"src_locations\": [\"foo/\"]," + + " \"deployment_id\": " + + deploymentId + + "," + " \"status_code\": 0, \"message\": \"Success\", \"prefix\":" - + " \"EXAMPLE_PREFIX\", \"stage_location\": {\"locationType\": \"S3\", \"location\":" + + " \"" + + prefix + + "\", \"stage_location\": {\"locationType\": \"S3\", \"location\":" + " \"foo/streaming_ingest/\", \"path\": \"streaming_ingest/\", \"region\":" + " \"us-east-1\", \"storageAccount\": null, \"isClientSideEncrypted\": true," + " \"creds\": {\"AWS_KEY_ID\": \"EXAMPLE_AWS_KEY_ID\", \"AWS_SECRET_KEY\":" @@ -110,7 +122,8 @@ public void testPutRemote() throws Exception { null, "clientName", new StreamingIngestStage.SnowflakeFileTransferMetadataWithAge( - originalMetadata, Optional.of(System.currentTimeMillis()))); + originalMetadata, Optional.of(System.currentTimeMillis())), + 1); PowerMockito.mockStatic(SnowflakeFileTransferAgent.class); final ArgumentCaptor captor = @@ -152,7 +165,8 @@ public void testPutLocal() throws Exception { null, "clientName", new StreamingIngestStage.SnowflakeFileTransferMetadataWithAge( - fullFilePath, Optional.of(System.currentTimeMillis())))); + fullFilePath, Optional.of(System.currentTimeMillis())), + 1)); Mockito.doReturn(true).when(stage).isLocalFS(); stage.put(fileName, dataBytes); @@ -163,7 +177,8 @@ public void testPutLocal() throws Exception { } @Test - public void testPutRemoteRefreshes() throws Exception { + public void doTestPutRemoteRefreshes() throws Exception { + int maxUploadRetryCount = 2; JsonNode exampleJson = mapper.readTree(exampleRemoteMeta); SnowflakeFileTransferMetadataV1 originalMetadata = (SnowflakeFileTransferMetadataV1) @@ -179,7 +194,8 @@ public void testPutRemoteRefreshes() throws Exception { null, "clientName", new StreamingIngestStage.SnowflakeFileTransferMetadataWithAge( - originalMetadata, Optional.of(System.currentTimeMillis()))); + originalMetadata, Optional.of(System.currentTimeMillis())), + maxUploadRetryCount); PowerMockito.mockStatic(SnowflakeFileTransferAgent.class); SnowflakeSQLException e = new SnowflakeSQLException( @@ -191,16 +207,15 @@ public void testPutRemoteRefreshes() throws Exception { try { stage.putRemote("test/path", dataBytes); - Assert.assertTrue(false); - } catch (SnowflakeSQLException ex) { + Assert.fail("Should not succeed"); + } catch (SFException ex) { // Expected behavior given mocked response } - PowerMockito.verifyStatic( - SnowflakeFileTransferAgent.class, times(StreamingIngestStage.MAX_RETRY_COUNT + 1)); + PowerMockito.verifyStatic(SnowflakeFileTransferAgent.class, times(maxUploadRetryCount + 1)); SnowflakeFileTransferAgent.uploadWithoutConnection(captor.capture()); SnowflakeFileTransferConfig capturedConfig = captor.getValue(); - Assert.assertEquals(false, capturedConfig.getRequireCompress()); + Assert.assertFalse(capturedConfig.getRequireCompress()); Assert.assertEquals(OCSPMode.FAIL_OPEN, capturedConfig.getOcspMode()); SnowflakeFileTransferMetadataV1 capturedMetadata = @@ -234,7 +249,8 @@ public void testPutRemoteGCS() throws Exception { null, "clientName", new StreamingIngestStage.SnowflakeFileTransferMetadataWithAge( - originalMetadata, Optional.of(System.currentTimeMillis())))); + originalMetadata, Optional.of(System.currentTimeMillis())), + 1)); PowerMockito.mockStatic(SnowflakeFileTransferAgent.class); SnowflakeFileTransferMetadataV1 metaMock = Mockito.mock(SnowflakeFileTransferMetadataV1.class); @@ -247,8 +263,8 @@ public void testPutRemoteGCS() throws Exception { @Test public void testRefreshSnowflakeMetadataRemote() throws Exception { RequestBuilder mockBuilder = Mockito.mock(RequestBuilder.class); - HttpClient mockClient = Mockito.mock(HttpClient.class); - HttpResponse mockResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient mockClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse mockResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine mockStatusLine = Mockito.mock(StatusLine.class); Mockito.when(mockStatusLine.getStatusCode()).thenReturn(200); @@ -262,31 +278,35 @@ public void testRefreshSnowflakeMetadataRemote() throws Exception { ParameterProvider parameterProvider = new ParameterProvider(); StreamingIngestStage stage = - new StreamingIngestStage(true, "role", mockClient, mockBuilder, "clientName"); + new StreamingIngestStage(true, "role", mockClient, mockBuilder, "clientName", 1); StreamingIngestStage.SnowflakeFileTransferMetadataWithAge metadataWithAge = stage.refreshSnowflakeMetadata(true); final ArgumentCaptor endpointCaptor = ArgumentCaptor.forClass(String.class); - final ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); + final ArgumentCaptor stringCaptor = ArgumentCaptor.forClass(String.class); Mockito.verify(mockBuilder) .generateStreamingIngestPostRequest( - mapCaptor.capture(), endpointCaptor.capture(), Mockito.any()); + stringCaptor.capture(), endpointCaptor.capture(), Mockito.any()); Assert.assertEquals(Constants.CLIENT_CONFIGURE_ENDPOINT, endpointCaptor.getValue()); Assert.assertTrue(metadataWithAge.timestamp.isPresent()); Assert.assertEquals( StageInfo.StageType.S3, metadataWithAge.fileTransferMetadata.getStageInfo().getStageType()); Assert.assertEquals( "foo/streaming_ingest/", metadataWithAge.fileTransferMetadata.getStageInfo().getLocation()); + // Here we need to compare paths and not just strings because on windows, due to how JDBC driver + // works with path separators, presignedUrlFileName is absolute, but on Linux it is relative Assert.assertEquals( - "placeholder", metadataWithAge.fileTransferMetadata.getPresignedUrlFileName()); + Paths.get("placeholder").toAbsolutePath(), + Paths.get(metadataWithAge.fileTransferMetadata.getPresignedUrlFileName()).toAbsolutePath()); + Assert.assertEquals(prefix + "_" + deploymentId, stage.getClientPrefix()); } @Test public void testFetchSignedURL() throws Exception { RequestBuilder mockBuilder = Mockito.mock(RequestBuilder.class); - HttpClient mockClient = Mockito.mock(HttpClient.class); - HttpResponse mockResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient mockClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse mockResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine mockStatusLine = Mockito.mock(StatusLine.class); Mockito.when(mockStatusLine.getStatusCode()).thenReturn(200); @@ -299,15 +319,15 @@ public void testFetchSignedURL() throws Exception { Mockito.when(mockClient.execute(Mockito.any())).thenReturn(mockResponse); StreamingIngestStage stage = - new StreamingIngestStage(true, "role", mockClient, mockBuilder, "clientName"); + new StreamingIngestStage(true, "role", mockClient, mockBuilder, "clientName", 1); SnowflakeFileTransferMetadataV1 metadata = stage.fetchSignedURL("path/fileName"); final ArgumentCaptor endpointCaptor = ArgumentCaptor.forClass(String.class); - final ArgumentCaptor> mapCaptor = ArgumentCaptor.forClass(Map.class); + final ArgumentCaptor stringCaptor = ArgumentCaptor.forClass(String.class); Mockito.verify(mockBuilder) .generateStreamingIngestPostRequest( - mapCaptor.capture(), endpointCaptor.capture(), Mockito.any()); + stringCaptor.capture(), endpointCaptor.capture(), Mockito.any()); Assert.assertEquals(Constants.CLIENT_CONFIGURE_ENDPOINT, endpointCaptor.getValue()); Assert.assertEquals(StageInfo.StageType.S3, metadata.getStageInfo().getStageType()); Assert.assertEquals("foo/streaming_ingest/", metadata.getStageInfo().getLocation()); @@ -323,8 +343,8 @@ public void testRefreshSnowflakeMetadataSynchronized() throws Exception { SnowflakeFileTransferAgent.getFileTransferMetadatas(exampleJson).get(0); RequestBuilder mockBuilder = Mockito.mock(RequestBuilder.class); - HttpClient mockClient = Mockito.mock(HttpClient.class); - HttpResponse mockResponse = Mockito.mock(HttpResponse.class); + CloseableHttpClient mockClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse mockResponse = Mockito.mock(CloseableHttpResponse.class); StatusLine mockStatusLine = Mockito.mock(StatusLine.class); Mockito.when(mockStatusLine.getStatusCode()).thenReturn(200); @@ -344,7 +364,8 @@ public void testRefreshSnowflakeMetadataSynchronized() throws Exception { mockBuilder, "clientName", new StreamingIngestStage.SnowflakeFileTransferMetadataWithAge( - originalMetadata, Optional.of(0L))); + originalMetadata, Optional.of(0L)), + 1); ThreadFactory buildUploadThreadFactory = new ThreadFactoryBuilder().setNameFormat("ingest-build-upload-thread-%d").build(); @@ -381,11 +402,13 @@ public void testGenerateProxyPropertiesForJDBC() { String oldProxyPort = System.getProperty(PROXY_PORT); String oldUser = System.getProperty(HTTP_PROXY_USER); String oldPassword = System.getProperty(HTTP_PROXY_PASSWORD); + String oldNonProxyHosts = System.getProperty(NON_PROXY_HOSTS); String proxyHost = "localhost"; String proxyPort = "8080"; String user = "admin"; String password = "test"; + String nonProxyHosts = "*.snowflakecomputing.com"; try { // Test empty properties when USE_PROXY is NOT set; @@ -397,6 +420,7 @@ public void testGenerateProxyPropertiesForJDBC() { System.setProperty(PROXY_PORT, proxyPort); System.setProperty(HTTP_PROXY_USER, user); System.setProperty(HTTP_PROXY_PASSWORD, password); + System.setProperty(NON_PROXY_HOSTS, nonProxyHosts); // Verify that properties are set props = generateProxyPropertiesForJDBC(); @@ -405,6 +429,8 @@ public void testGenerateProxyPropertiesForJDBC() { Assert.assertEquals(proxyPort, props.get(SFSessionProperty.PROXY_PORT.getPropertyKey())); Assert.assertEquals(user, props.get(SFSessionProperty.PROXY_USER.getPropertyKey())); Assert.assertEquals(password, props.get(SFSessionProperty.PROXY_PASSWORD.getPropertyKey())); + Assert.assertEquals( + nonProxyHosts, props.get(SFSessionProperty.NON_PROXY_HOSTS.getPropertyKey())); } finally { // Cleanup if (oldUseProxy != null) { @@ -416,6 +442,108 @@ public void testGenerateProxyPropertiesForJDBC() { System.setProperty(HTTP_PROXY_USER, oldUser); System.setProperty(HTTP_PROXY_PASSWORD, oldPassword); } + if (oldNonProxyHosts != null) { + System.setProperty(NON_PROXY_HOSTS, oldNonProxyHosts); + } } } + + @Test + public void testShouldBypassProxy() { + String oldNonProxyHosts = System.getProperty(NON_PROXY_HOSTS); + String accountName = "accountName12345"; + String accountDashName = "account-name12345"; + String accountUnderscoreName = "account_name12345"; + String nonProxyHosts = "*.snowflakecomputing.com|localhost"; + + System.setProperty(NON_PROXY_HOSTS, nonProxyHosts); + Assert.assertTrue(shouldBypassProxy(accountName)); + Assert.assertTrue(shouldBypassProxy(accountDashName)); + Assert.assertTrue(shouldBypassProxy(accountUnderscoreName)); + + String accountNamePrivateLink = "accountName12345.privatelink"; + nonProxyHosts = "*.privatelink.snowflakecomputing.com|localhost"; + System.setProperty(NON_PROXY_HOSTS, nonProxyHosts); + Assert.assertTrue(shouldBypassProxy(accountNamePrivateLink)); + + // Previous tests should return false with the new nonProxyHosts value + Assert.assertFalse(shouldBypassProxy(accountName)); + Assert.assertFalse(shouldBypassProxy(accountDashName)); + Assert.assertFalse(shouldBypassProxy(accountUnderscoreName)); + + // All tests should return false after clearing the nonProxyHosts property + System.clearProperty(NON_PROXY_HOSTS); + Assert.assertFalse(shouldBypassProxy(accountName)); + Assert.assertFalse(shouldBypassProxy(accountDashName)); + Assert.assertFalse(shouldBypassProxy(accountUnderscoreName)); + Assert.assertFalse(shouldBypassProxy(accountNamePrivateLink)); + + if (oldNonProxyHosts != null) { + System.setProperty(NON_PROXY_HOSTS, oldNonProxyHosts); + } + } + + @Test + public void testRefreshMetadataOnFirstPutException() throws Exception { + int maxUploadRetryCount = 2; + JsonNode exampleJson = mapper.readTree(exampleRemoteMeta); + SnowflakeFileTransferMetadataV1 originalMetadata = + (SnowflakeFileTransferMetadataV1) + SnowflakeFileTransferAgent.getFileTransferMetadatas(exampleJson).get(0); + + byte[] dataBytes = "Hello Upload".getBytes(StandardCharsets.UTF_8); + + StreamingIngestStage stage = + new StreamingIngestStage( + true, + "role", + null, + null, + "clientName", + new StreamingIngestStage.SnowflakeFileTransferMetadataWithAge( + originalMetadata, Optional.of(System.currentTimeMillis())), + maxUploadRetryCount); + PowerMockito.mockStatic(SnowflakeFileTransferAgent.class); + SnowflakeSQLException e = + new SnowflakeSQLException( + "Fake bad creds", CLOUD_STORAGE_CREDENTIALS_EXPIRED, "S3 credentials have expired"); + PowerMockito.doAnswer( + new org.mockito.stubbing.Answer() { + private boolean firstInvocation = true; + + public Object answer(org.mockito.invocation.InvocationOnMock invocation) + throws Throwable { + if (firstInvocation) { + firstInvocation = false; + throw e; // Throw the exception only for the first invocation + } + return null; // Do nothing on subsequent invocations + } + }) + .when(SnowflakeFileTransferAgent.class); + SnowflakeFileTransferAgent.uploadWithoutConnection(Mockito.any()); + final ArgumentCaptor captor = + ArgumentCaptor.forClass(SnowflakeFileTransferConfig.class); + + stage.putRemote("test/path", dataBytes); + + PowerMockito.verifyStatic(SnowflakeFileTransferAgent.class, times(maxUploadRetryCount)); + SnowflakeFileTransferAgent.uploadWithoutConnection(captor.capture()); + SnowflakeFileTransferConfig capturedConfig = captor.getValue(); + + Assert.assertFalse(capturedConfig.getRequireCompress()); + Assert.assertEquals(OCSPMode.FAIL_OPEN, capturedConfig.getOcspMode()); + + SnowflakeFileTransferMetadataV1 capturedMetadata = + (SnowflakeFileTransferMetadataV1) capturedConfig.getSnowflakeFileTransferMetadata(); + Assert.assertEquals("test/path", capturedMetadata.getPresignedUrlFileName()); + Assert.assertEquals(originalMetadata.getCommandType(), capturedMetadata.getCommandType()); + Assert.assertEquals(originalMetadata.getPresignedUrl(), capturedMetadata.getPresignedUrl()); + Assert.assertEquals( + originalMetadata.getStageInfo().getStageType(), + capturedMetadata.getStageInfo().getStageType()); + + InputStream capturedInput = capturedConfig.getUploadStream(); + Assert.assertEquals("Hello Upload", IOUtils.toString(capturedInput)); + } } diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestUtilsIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestUtilsIT.java new file mode 100644 index 000000000..4e054c209 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestUtilsIT.java @@ -0,0 +1,113 @@ +package net.snowflake.ingest.streaming.internal; + +import static net.snowflake.ingest.connection.ServiceResponseHandler.ApiName.STREAMING_CLIENT_CONFIGURE; +import static net.snowflake.ingest.streaming.internal.StreamingIngestUtils.executeWithRetries; +import static net.snowflake.ingest.utils.Constants.CLIENT_CONFIGURE_ENDPOINT; +import static net.snowflake.ingest.utils.Constants.RESPONSE_SUCCESS; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.Map; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.connection.IngestResponseException; +import net.snowflake.ingest.connection.JWTManager; +import net.snowflake.ingest.connection.RequestBuilder; +import net.snowflake.ingest.utils.HttpUtil; +import net.snowflake.ingest.utils.SnowflakeURL; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({JWTManager.class}) +@PowerMockIgnore({"javax.net.ssl.*"}) /* Avoid ssl related exception from power mockito*/ +public class StreamingIngestUtilsIT { + @Test + public void testJWTRetries() throws Exception { + SnowflakeURL url = new SnowflakeURL(TestUtils.getAccountURL()); + CloseableHttpClient httpClient = HttpUtil.getHttpClient(TestUtils.getAccount()); + + JWTManager manager = + new JWTManager(url.getAccount(), TestUtils.getUser(), TestUtils.getKeyPair(), null); + JWTManager spyManager = PowerMockito.spy(manager); + + // inject spy manager + RequestBuilder requestBuilder = + PowerMockito.spy( + new RequestBuilder( + url.getAccount(), + TestUtils.getUser(), + TestUtils.getKeyPair(), + url.getScheme(), + TestUtils.getHost(), + url.getPort(), + "testJWTRetries", + spyManager, + httpClient, + "testJWTRetries")); + + // build payload + Map payload = new HashMap<>(); + if (!TestUtils.getRole().isEmpty() && !TestUtils.getRole().equals("DEFAULT_ROLE")) { + payload.put("role", TestUtils.getRole()); + } + ObjectMapper mapper = new ObjectMapper(); + + // request wih invalid token, should get 401 3 times + PowerMockito.doReturn("invalid_token").when(spyManager).getToken(); + try { + ChannelsStatusResponse response = + executeWithRetries( + ChannelsStatusResponse.class, + CLIENT_CONFIGURE_ENDPOINT, + mapper.writeValueAsString(payload), + "client configure", + STREAMING_CLIENT_CONFIGURE, + httpClient, + requestBuilder); + Assert.fail("Expected error for invalid token"); + } catch (SecurityException ignored) { + } + Mockito.verify(requestBuilder, Mockito.times(1)).refreshToken(); + Mockito.clearInvocations(requestBuilder); + + // request wih invalid header, should get 400 once and never refresh + PowerMockito.doReturn("invalid header").when(spyManager).getToken(); + try { + ChannelsStatusResponse response = + executeWithRetries( + ChannelsStatusResponse.class, + CLIENT_CONFIGURE_ENDPOINT, + mapper.writeValueAsString(payload), + "client configure", + STREAMING_CLIENT_CONFIGURE, + httpClient, + requestBuilder); + Assert.fail("Expected error for invalid token"); + } catch (IngestResponseException ignored) { + } + Mockito.verify(requestBuilder, Mockito.times(0)).refreshToken(); + Mockito.clearInvocations(requestBuilder); + + // request with invalid token first time but with valid token second time after refresh + PowerMockito.doReturn("invalid_token").doReturn(manager.getToken()).when(spyManager).getToken(); + ChannelsStatusResponse response = + executeWithRetries( + ChannelsStatusResponse.class, + CLIENT_CONFIGURE_ENDPOINT, + mapper.writeValueAsString(payload), + "client configure", + STREAMING_CLIENT_CONFIGURE, + httpClient, + requestBuilder); + + assert (response.getStatusCode() == RESPONSE_SUCCESS); + Mockito.verify(requestBuilder, Mockito.times(1)).refreshToken(); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestUtilsTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestUtilsTest.java new file mode 100644 index 000000000..6aadfbcae --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/StreamingIngestUtilsTest.java @@ -0,0 +1,182 @@ +package net.snowflake.ingest.streaming.internal; + +import static net.snowflake.ingest.connection.ServiceResponseHandler.ApiName.STREAMING_CHANNEL_STATUS; +import static net.snowflake.ingest.streaming.internal.StreamingIngestUtils.executeWithRetries; +import static net.snowflake.ingest.streaming.internal.StreamingIngestUtils.getSleepForRetryMs; +import static net.snowflake.ingest.utils.Constants.CHANNEL_STATUS_ENDPOINT; +import static net.snowflake.ingest.utils.Constants.RESPONSE_ERR_GENERAL_EXCEPTION_RETRY_REQUEST; +import static net.snowflake.ingest.utils.Constants.RESPONSE_SUCCESS; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.util.ArrayList; +import net.snowflake.client.jdbc.internal.apache.commons.io.IOUtils; +import net.snowflake.client.jdbc.internal.apache.http.HttpEntity; +import net.snowflake.client.jdbc.internal.apache.http.StatusLine; +import net.snowflake.client.jdbc.internal.apache.http.client.methods.CloseableHttpResponse; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.connection.RequestBuilder; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +public class StreamingIngestUtilsTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void testSuccessNoRetries() throws Exception { + ChannelsStatusResponse response = new ChannelsStatusResponse(); + response.setStatusCode(0L); + response.setMessage("honk"); + response.setChannels(new ArrayList<>()); + String responseString = objectMapper.writeValueAsString(response); + + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); + StatusLine statusLine = Mockito.mock(StatusLine.class); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + Mockito.when(statusLine.getStatusCode()).thenReturn(200); + Mockito.when(httpResponse.getStatusLine()).thenReturn(statusLine); + Mockito.when(httpResponse.getEntity()).thenReturn(httpEntity); + Mockito.when(httpEntity.getContent()).thenReturn(IOUtils.toInputStream(responseString)); + Mockito.when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); + + executeWithRetries( + ChannelsStatusResponse.class, + CHANNEL_STATUS_ENDPOINT, + "{}", + "channel status", + STREAMING_CHANNEL_STATUS, + httpClient, + requestBuilder); + + Mockito.verify(requestBuilder, Mockito.times(1)) + .generateStreamingIngestPostRequest(Mockito.anyString(), Mockito.any(), Mockito.any()); + } + + InputStream getInputStream(String value) { + return IOUtils.toInputStream(value); + } + + @Test + public void testRetries() throws Exception { + ChannelsStatusResponse response = new ChannelsStatusResponse(); + response.setStatusCode(RESPONSE_ERR_GENERAL_EXCEPTION_RETRY_REQUEST); + // response.setStatusCode(7L); + + response.setMessage("honk"); + response.setChannels(new ArrayList<>()); + String responseString = objectMapper.writeValueAsString(response); + + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); + StatusLine statusLine = Mockito.mock(StatusLine.class); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + Mockito.when(statusLine.getStatusCode()).thenReturn(200); + Mockito.when(httpResponse.getStatusLine()).thenReturn(statusLine); + Mockito.when(httpResponse.getEntity()).thenReturn(httpEntity); + + Mockito.when(httpEntity.getContent()) + .thenAnswer( + new Answer() { + @Override + public InputStream answer(InvocationOnMock invocation) throws Throwable { + return IOUtils.toInputStream(responseString); + } + }); + + Mockito.when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); + + ChannelsStatusResponse result = + executeWithRetries( + ChannelsStatusResponse.class, + CHANNEL_STATUS_ENDPOINT, + "{}", + "channel status", + STREAMING_CHANNEL_STATUS, + httpClient, + requestBuilder); + + Mockito.verify(requestBuilder, Mockito.times(1)) + .generateStreamingIngestPostRequest(Mockito.anyString(), Mockito.any(), Mockito.any()); + Assert.assertEquals("honk", result.getMessage()); + } + + @Test + public void testRetriesRecovery() throws Exception { + ChannelsStatusResponse errorResponse = new ChannelsStatusResponse(); + errorResponse.setStatusCode(RESPONSE_ERR_GENERAL_EXCEPTION_RETRY_REQUEST); + + errorResponse.setMessage("honkFailure"); + errorResponse.setChannels(new ArrayList<>()); + String errorResponseString = objectMapper.writeValueAsString(errorResponse); + + ChannelsStatusResponse successfulResponse = new ChannelsStatusResponse(); + successfulResponse.setStatusCode(RESPONSE_SUCCESS); + + successfulResponse.setMessage("honkSuccess"); + successfulResponse.setChannels(new ArrayList<>()); + String successfulResponseString = objectMapper.writeValueAsString(successfulResponse); + + CloseableHttpClient httpClient = Mockito.mock(CloseableHttpClient.class); + CloseableHttpResponse httpResponse = Mockito.mock(CloseableHttpResponse.class); + StatusLine statusLine = Mockito.mock(StatusLine.class); + HttpEntity httpEntity = Mockito.mock(HttpEntity.class); + Mockito.when(statusLine.getStatusCode()).thenReturn(200); + Mockito.when(httpResponse.getStatusLine()).thenReturn(statusLine); + Mockito.when(httpResponse.getEntity()).thenReturn(httpEntity); + + Mockito.when(httpEntity.getContent()) + .thenReturn( + IOUtils.toInputStream(errorResponseString), + IOUtils.toInputStream(errorResponseString), + IOUtils.toInputStream(successfulResponseString)); + Mockito.when(httpClient.execute(Mockito.any())).thenReturn(httpResponse); + + RequestBuilder requestBuilder = + Mockito.spy( + new RequestBuilder(TestUtils.getHost(), TestUtils.getUser(), TestUtils.getKeyPair())); + + ChannelsStatusResponse result = + executeWithRetries( + ChannelsStatusResponse.class, + CHANNEL_STATUS_ENDPOINT, + "{}", + "channel status", + STREAMING_CHANNEL_STATUS, + httpClient, + requestBuilder); + + Mockito.verify(requestBuilder, Mockito.times(1)) + .generateStreamingIngestPostRequest(Mockito.anyString(), Mockito.any(), Mockito.any()); + + Assert.assertEquals("honkSuccess", result.getMessage()); + } + + @Test + public void testGetSleepForRetry() { + Assert.assertEquals(0, getSleepForRetryMs(0)); + Assert.assertEquals(0, getSleepForRetryMs(1)); + Assert.assertEquals(1000, getSleepForRetryMs(2)); + Assert.assertEquals(2000, getSleepForRetryMs(3)); + Assert.assertEquals(4000, getSleepForRetryMs(4)); + Assert.assertEquals(4000, getSleepForRetryMs(5)); + Assert.assertEquals(4000, getSleepForRetryMs(100000)); + } + + @Test(expected = IllegalArgumentException.class) + public void testGetSleepForRetryNegative() { + getSleepForRetryMs(-1); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/StubChunkData.java b/src/test/java/net/snowflake/ingest/streaming/internal/StubChunkData.java new file mode 100644 index 000000000..21edab33a --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/StubChunkData.java @@ -0,0 +1,4 @@ +package net.snowflake.ingest.streaming.internal; + +/** Stub class for class parameters where it does not matter. */ +public final class StubChunkData {} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/AbstractDataTypeTest.java b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/AbstractDataTypeTest.java new file mode 100644 index 000000000..ec3017ad5 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/AbstractDataTypeTest.java @@ -0,0 +1,403 @@ +package net.snowflake.ingest.streaming.internal.datatypes; + +import static net.snowflake.ingest.utils.Constants.ROLE; +import static net.snowflake.ingest.utils.ParameterProvider.BDEC_PARQUET_COMPRESSION_ALGORITHM; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.UUID; +import java.util.function.Predicate; +import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClient; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestClientFactory; +import net.snowflake.ingest.utils.Constants; +import net.snowflake.ingest.utils.SFException; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +@RunWith(Parameterized.class) +public abstract class AbstractDataTypeTest { + private static final String SOURCE_COLUMN_NAME = "source"; + private static final String VALUE_COLUMN_NAME = "value"; + + private static final String SOURCE_STREAMING_INGEST = "STREAMING_INGEST"; + private static final String SOURCE_JDBC = "JDBC"; + private static final String SCHEMA_NAME = "PUBLIC"; + + protected static BigInteger MAX_ALLOWED_BIG_INTEGER = + new BigInteger("99999999999999999999999999999999999999"); + protected static BigInteger MIN_ALLOWED_BIG_INTEGER = + new BigInteger("-99999999999999999999999999999999999999"); + + protected static BigDecimal MAX_ALLOWED_BIG_DECIMAL = new BigDecimal(MAX_ALLOWED_BIG_INTEGER); + protected static BigDecimal MIN_ALLOWED_BIG_DECIMAL = new BigDecimal(MIN_ALLOWED_BIG_INTEGER); + + protected Connection conn; + private String databaseName; + + /** + * Default timezone for newly created channels. If empty, the test will not explicitly set default + * timezone in the channel builder. + */ + private Optional defaultTimezone = Optional.empty(); + + private String schemaName = "PUBLIC"; + private SnowflakeStreamingIngestClient client; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Parameters(name = "{index}: {0}") + public static Object[] compressionAlgorithms() { + return new Object[] {"GZIP", "ZSTD"}; + } + + @Parameter public String compressionAlgorithm; + + @Before + public void before() throws Exception { + databaseName = String.format("SDK_DATATYPE_COMPATIBILITY_IT_%s", getRandomIdentifier()); + conn = TestUtils.getConnection(true); + conn.createStatement().execute(String.format("create or replace database %s;", databaseName)); + conn.createStatement().execute(String.format("use database %s;", databaseName)); + conn.createStatement().execute(String.format("use schema %s;", schemaName)); + + conn.createStatement().execute(String.format("use warehouse %s;", TestUtils.getWarehouse())); + + Properties props = TestUtils.getProperties(Constants.BdecVersion.THREE, false); + if (props.getProperty(ROLE).equals("DEFAULT_ROLE")) { + props.setProperty(ROLE, "ACCOUNTADMIN"); + } + props.setProperty(BDEC_PARQUET_COMPRESSION_ALGORITHM, compressionAlgorithm); + client = SnowflakeStreamingIngestClientFactory.builder("client1").setProperties(props).build(); + } + + @After + public void after() throws Exception { + this.defaultTimezone = Optional.empty(); + if (conn != null) { + conn.createStatement().executeQuery(String.format("drop database %s", databaseName)); + } + if (client != null) { + client.close(); + } + if (conn != null) { + conn.close(); + } + } + + void setChannelDefaultTimezone(ZoneId defaultTimezone) { + this.defaultTimezone = Optional.of(defaultTimezone); + } + + protected String createTable(String dataType) throws SQLException { + String tableName = getRandomIdentifier(); + conn.createStatement() + .execute( + String.format( + "create or replace table %s (%s string, %s %s)", + tableName, SOURCE_COLUMN_NAME, VALUE_COLUMN_NAME, dataType)); + return tableName; + } + + protected String getRandomIdentifier() { + return String.format("test_%s", UUID.randomUUID()).replace('-', '_'); + } + + protected SnowflakeStreamingIngestChannel openChannel(String tableName) { + return openChannel(tableName, OpenChannelRequest.OnErrorOption.ABORT); + } + + protected SnowflakeStreamingIngestChannel openChannel( + String tableName, OpenChannelRequest.OnErrorOption onErrorOption) { + OpenChannelRequest.OpenChannelRequestBuilder requestBuilder = + OpenChannelRequest.builder("CHANNEL") + .setDBName(databaseName) + .setSchemaName(SCHEMA_NAME) + .setTableName(tableName) + .setOnErrorOption(onErrorOption); + defaultTimezone.ifPresent(requestBuilder::setDefaultTimezone); + OpenChannelRequest openChannelRequest = requestBuilder.build(); + return client.openChannel(openChannelRequest); + } + + protected Map createStreamingIngestRow(Object value) { + Map row = new HashMap<>(); + row.put(SOURCE_COLUMN_NAME, SOURCE_STREAMING_INGEST); + row.put(VALUE_COLUMN_NAME, value); + return row; + } + + private void expectError(String dataType, T value, Predicate errorMatcher) + throws Exception { + String tableName = createTable(dataType); + SnowflakeStreamingIngestChannel channel = null; + try { + channel = openChannel(tableName); + channel.insertRow(createStreamingIngestRow(value), "0"); + Assert.fail( + String.format("Inserting value %s for data type %s should fail", value, dataType)); + } catch (Exception e) { + if (errorMatcher.test(e)) { + // all good, expected exception has been thrown + } else { + e.printStackTrace(); + Assert.fail(String.format("Unexpected exception thrown: %s", e.getMessage())); + } + } finally { + if (channel != null) { + channel.close().get(); + } + } + } + + protected void expectNumberOutOfRangeError( + String dataType, T value, int maxPowerOf10Exclusive) throws Exception { + expectError( + dataType, + value, + x -> + (x instanceof SFException + && x.getMessage() + .contains( + String.format( + "Number out of representable exclusive range of (-1e%d..1e%d)", + maxPowerOf10Exclusive, maxPowerOf10Exclusive)))); + } + + protected void expectNotSupported(String dataType, T value) throws Exception { + expectError( + dataType, + value, + x -> + (x instanceof SFException + && x.getMessage() + .contains("The given row cannot be converted to the internal format"))); + } + + /** + * Simplified version, which does not insert using JDBC. Useful for testing non-JDBC types like + * BigInteger, java.time.* types, etc. + */ + void testIngestion(String dataType, VALUE expectedValue, Provider selectProvider) + throws Exception { + ingestAndAssert(dataType, expectedValue, null, expectedValue, null, selectProvider); + } + + /** + * Simplified version, which does not insert using JDBC. Useful for testing non-JDBC types like + * BigInteger, java.time.* types, etc. + */ + void testIngestion( + String dataType, + STREAMING_INGEST_WRITE streamingIngestWriteValue, + JDBC_READ expectedValue, + Provider selectProvider) + throws Exception { + ingestAndAssert(dataType, streamingIngestWriteValue, null, expectedValue, null, selectProvider); + } + + /** + * Simplified version where streaming ingest write type, JDBC write type and JDBC read type are + * the same type + */ + void testJdbcTypeCompatibility(String typeName, T value, Provider provider) + throws Exception { + ingestAndAssert(typeName, value, value, value, provider, provider); + } + + /** Simplified version where write value for streaming ingest and JDBC are the same */ + void testJdbcTypeCompatibility( + String typeName, + WRITE writeValue, + READ expectedValue, + Provider insertProvider, + Provider selectProvider) + throws Exception { + ingestAndAssert( + typeName, writeValue, writeValue, expectedValue, insertProvider, selectProvider); + } + + /** + * Ingests values with streaming ingest and JDBC driver, SELECTs them back with WHERE condition + * and asserts they exist. + * + * @param dataType Snowflake data type + * @param streamingIngestWriteValue Value ingested by streaming ingest + * @param jdbcWriteValue Value written by JDBC driver + * @param expectedValue Expected value received from JDBC driver SELECT + * @param insertProvider JDBC parameter provider for INSERT + * @param selectProvider JDBC parameter provider for SELECT ... WHERE + * @param Type ingested by streaming ingest + * @param Type written by JDBC driver + * @param Type read by JDBC driver + */ + void ingestAndAssert( + String dataType, + STREAMING_INGEST_WRITE streamingIngestWriteValue, + JDBC_WRITE jdbcWriteValue, + JDBC_READ expectedValue, + Provider insertProvider, + Provider selectProvider) + throws Exception { + if (jdbcWriteValue == null ^ insertProvider == null) + throw new IllegalArgumentException( + "jdbcWriteValue and provider must be both null or not null"); + boolean insertAlsoWithJdbc = jdbcWriteValue != null; + String tableName = createTable(dataType); + String offsetToken = UUID.randomUUID().toString(); + + // Insert using JDBC + if (insertAlsoWithJdbc) { + String query = "insert into %s (select ?, ?)"; + PreparedStatement insertStatement = conn.prepareStatement(String.format(query, tableName)); + insertStatement.setString(1, SOURCE_JDBC); + insertProvider.provide(insertStatement, 2, jdbcWriteValue); + insertStatement.execute(); + } + + // Ingest using streaming ingest + SnowflakeStreamingIngestChannel channel = openChannel(tableName); + channel.insertRow(createStreamingIngestRow(streamingIngestWriteValue), offsetToken); + TestUtils.waitForOffset(channel, offsetToken); + + // Select expected value and assert that expected number of results is returned (selecting with + // WHERE also tests pruning). + // The following is evaluated as true in Snowflake: + // select '2020-01-01 14:00:00 +0000'::timestamp_tz = '2020-01-01 12:00:00 + // -0200'::timestamp_tz; + // Therefore, to correctly test TIMESTAMP_TZ values, we need to compare them as strings. + // We can do that for all date/time types, not just TIMESTAMP_TZ. + String selectQuery; + if (expectedValue == null) { + selectQuery = + String.format("select count(*) from %s where %s is NULL", tableName, VALUE_COLUMN_NAME); + } else if (dataType.startsWith("TIMESTAMP_")) { + selectQuery = + String.format( + "select count(*) from %s where to_varchar(%s, 'YYYY-MM-DD HH24:MI:SS.FF TZHTZM') =" + + " ?;", + tableName, VALUE_COLUMN_NAME); + } else if (dataType.startsWith("TIME")) { + selectQuery = + String.format( + "select count(*) from %s where to_varchar(%s, 'HH24:MI:SS.FF TZHTZM') = ?;", + tableName, VALUE_COLUMN_NAME); + } else { + selectQuery = "select count(*) from %s where %s = ?"; + } + PreparedStatement selectStatement = + conn.prepareStatement(String.format(selectQuery, tableName, VALUE_COLUMN_NAME)); + if (expectedValue != null) { + selectProvider.provide(selectStatement, 1, expectedValue); + } + ResultSet resultSet = selectStatement.executeQuery(); + Assert.assertTrue(resultSet.next()); + int count = resultSet.getInt(1); + Assert.assertEquals(insertAlsoWithJdbc ? 2 : 1, count); + migrateTable(tableName); // migration should always succeed + } + + void assertVariant( + String dataType, + STREAMING_INGEST_WRITE streamingIngestWriteValue, + String expectedValue, + String expectedType) + throws Exception { + assertVariant( + dataType, + streamingIngestWriteValue, + expectedValue, + expectedType, + false /*Not max variant or max array*/); + } + + void assertVariant( + String dataType, + STREAMING_INGEST_WRITE streamingIngestWriteValue, + String expectedValue, + String expectedType, + boolean isAtMaxValueForDataType) + throws Exception { + + String tableName = createTable(dataType); + String offsetToken = UUID.randomUUID().toString(); + + // Ingest using streaming ingest + SnowflakeStreamingIngestChannel channel = openChannel(tableName); + channel.insertRow(createStreamingIngestRow(streamingIngestWriteValue), offsetToken); + TestUtils.waitForOffset(channel, offsetToken); + + String query; + // if added row value is using max possible value, we will not verify its returned type since + // JDBC doesnt parse it properly because of Jackson Databind version 2.15 + // we will only verify type of it. + // Check this: https://github.com/snowflakedb/snowflake-sdks-drivers-issues-teamwork/issues/819 + // TODO:: SNOW-1051731 + if (isAtMaxValueForDataType) { + query = String.format("select typeof(%s) from %s", VALUE_COLUMN_NAME, tableName); + } else { + query = + String.format( + "select typeof(%s), %s from %s", VALUE_COLUMN_NAME, VALUE_COLUMN_NAME, tableName); + } + ResultSet resultSet = conn.createStatement().executeQuery(query); + int counter = 0; + String value = null; + String typeof = null; + while (resultSet.next()) { + counter++; + typeof = resultSet.getString(1); + if (!isAtMaxValueForDataType) value = resultSet.getString(2); + } + + Assert.assertEquals(1, counter); + if (!isAtMaxValueForDataType) { + if (expectedValue == null) { + Assert.assertNull(value); + } else { + Assert.assertEquals(objectMapper.readTree(expectedValue), objectMapper.readTree(value)); + } + } + Assert.assertEquals(expectedType, typeof); + migrateTable(tableName); // migration should always succeed + } + + protected void migrateTable(String tableName) throws SQLException { + conn.createStatement().execute(String.format("alter table %s migrate;", tableName)); + } + + /** + * Ingest multiple values, wait for the latest offset to be committed, migrate the table and + * assert no errors have been thrown. + */ + @SafeVarargs + protected final void ingestManyAndMigrate( + String datatype, final STREAMING_INGEST_WRITE... values) throws Exception { + String tableName = createTable(datatype); + SnowflakeStreamingIngestChannel channel = openChannel(tableName); + String offsetToken = null; + for (int i = 0; i < values.length; i++) { + offsetToken = String.format("offsetToken%d", i); + channel.insertRow(createStreamingIngestRow(values[i]), offsetToken); + } + + TestUtils.waitForOffset(channel, offsetToken); + migrateTable(tableName); // migration should always succeed + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/BinaryIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/BinaryIT.java new file mode 100644 index 000000000..2a5023a3a --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/BinaryIT.java @@ -0,0 +1,72 @@ +package net.snowflake.ingest.streaming.internal.datatypes; + +import org.bouncycastle.util.encoders.Hex; +import org.junit.Test; + +public class BinaryIT extends AbstractDataTypeTest { + + @Test + public void testBinary() throws Exception { + testJdbcTypeCompatibility("BINARY", new byte[0], new ByteArrayProvider()); + testJdbcTypeCompatibility("BINARY", new byte[3], new ByteArrayProvider()); + testJdbcTypeCompatibility( + "BINARY", new byte[] {Byte.MIN_VALUE, 0, Byte.MAX_VALUE}, new ByteArrayProvider()); + testJdbcTypeCompatibility("BINARY", Hex.decode("a0b0c0d0e0f0"), new ByteArrayProvider()); + testJdbcTypeCompatibility("BINARY", Hex.decode("aaff"), new ByteArrayProvider()); + testJdbcTypeCompatibility( + "BINARY", + Hex.decode("0000000000000000000000000000000000000000000000000000000000000000"), + new ByteArrayProvider()); + testJdbcTypeCompatibility( + "BINARY", + Hex.decode("000000000000000000000000000000000000000000000000000000000000000000"), + new ByteArrayProvider()); + testJdbcTypeCompatibility( + "BINARY", + Hex.decode("aaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + new ByteArrayProvider()); + testJdbcTypeCompatibility( + "BINARY", + Hex.decode("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + new ByteArrayProvider()); + testJdbcTypeCompatibility( + "BINARY", + Hex.decode("aaffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + new ByteArrayProvider()); + testJdbcTypeCompatibility( + "BINARY", + Hex.decode("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + new ByteArrayProvider()); + + testJdbcTypeCompatibility( + "BINARY", "212D", new byte[] {33, 45}, new StringProvider(), new ByteArrayProvider()); + testJdbcTypeCompatibility( + "BINARY", + "0F00030A057F", + new byte[] {15, 0, 3, 10, 5, 127}, + new StringProvider(), + new ByteArrayProvider()); + } + + @Test + public void testBinaryComparison() throws Exception { + ingestManyAndMigrate( + "BINARY", new byte[] {Byte.MIN_VALUE}, new byte[] {Byte.MAX_VALUE}, new byte[] {0}); + ingestManyAndMigrate( + "BINARY", new byte[] {Byte.MAX_VALUE}, new byte[] {Byte.MIN_VALUE}, new byte[] {0}); + ingestManyAndMigrate( + "BINARY", + Hex.decode("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + Hex.decode("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00")); + ingestManyAndMigrate( + "BINARY", + Hex.decode("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), + Hex.decode("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00")); + } + + @Test + public void testMaxBinary() throws Exception { + byte[] arr = new byte[8 * 1024 * 1024]; + testJdbcTypeCompatibility("BINARY", arr, new ByteArrayProvider()); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/DateTimeIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/DateTimeIT.java new file mode 100644 index 000000000..62829656c --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/DateTimeIT.java @@ -0,0 +1,1111 @@ +package net.snowflake.ingest.streaming.internal.datatypes; + +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class DateTimeIT extends AbstractDataTypeTest { + + private static final ZoneId TZ_LOS_ANGELES = ZoneId.of("America/Los_Angeles"); + private static final ZoneId TZ_BERLIN = ZoneId.of("Europe/Berlin"); + private static final ZoneId TZ_TOKYO = ZoneId.of("Asia/Tokyo"); + + @Before + public void setup() throws Exception { + // Set to a random time zone not to interfere with any of the tests + conn.createStatement().execute("alter session set timezone = 'America/New_York';"); + } + + @After + public void tearDown() throws Exception { + if (conn != null) { + conn.createStatement().execute("alter session unset timezone;"); + } + } + + @Test + public void testDefaultTimezone() throws Exception { + testIngestion( + "TIMESTAMP_TZ", "2000-01-01", "2000-01-01 00:00:00.000000000 -0800", new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ", "2000-01-01", "2000-01-01 03:00:00.000000000 -0500", new StringProvider()); + } + + @Test + public void testTimestampWithTimeZone() throws Exception { + setJdbcSessionTimezone(TZ_LOS_ANGELES); + setChannelDefaultTimezone(TZ_LOS_ANGELES); + + // Test timestamp formats + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "2013-04-28T20:57", + "2013-04-28 20:57:00.000000000 -0700", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "2013-04-28T20:57:01", + "2013-04-28 20:57:01.000000000 -0700", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "2013-04-28T20:57:01-07:00", + "2013-04-28 20:57:01.000000000 -0700", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "2013-04-28T20:57:01.123456", + "2013-04-28 20:57:01.123456000 -0700", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "2013-04-28T20:57:01.123456789+07:00", + "2013-04-28 20:57:01.123456789 +0700", + new StringProvider(), + new StringProvider()); + // Here we test ingestion only because JDBC cannot ingest ISO_ZONED_DATE_TIME + testIngestion( + "TIMESTAMP_TZ", + "2013-04-28T20:57:01.123456789+09:00[Asia/Tokyo]", + "2013-04-28 20:57:01.123456789 +0900", + new StringProvider()); + + // Test date formats + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "2013-04-28", + "2013-04-28 00:00:00.000000000 -0700", + new StringProvider(), + new StringProvider()); + + // Test leap years + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "2024-02-29T23:59:59.999999999Z", + "2024-02-29 23:59:59.999999999 Z", + new StringProvider(), + new StringProvider()); + expectNotSupported("TIMESTAMP_TZ", "2023-02-29T23:59:59.999999999"); + + // Numeric strings + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "0", + "1970-01-01 00:00:00.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "1674478926", + "2023-01-23 13:02:06.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "1674478926123", + "2023-01-23 13:02:06.123000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "1674478926123456", + "2023-01-23 13:02:06.123456000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "1674478926123456789", + "2023-01-23 13:02:06.123456789 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "-1674478926", + "1916-12-09 10:57:54.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "-1674478926123", + "1916-12-09 10:57:53.877000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "-1674478926123456", + "1916-12-09 10:57:53.876544000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "-1674478926123456789", + "1916-12-09 10:57:53.876543211 Z", + new StringProvider(), + new StringProvider()); + + // java.time.LocalDate + testIngestion( + "TIMESTAMP_TZ", + LocalDate.parse("2007-12-03"), + "2007-12-03 00:00:00.000000000 -0800", + new StringProvider()); + // java.time.LocalDateTime + testIngestion( + "TIMESTAMP_TZ", + LocalDateTime.parse("2007-12-03T10:15:30"), + "2007-12-03 10:15:30.000000000 -0800", + new StringProvider()); + // java.time.OffsetDateTime + testIngestion( + "TIMESTAMP_TZ", + OffsetDateTime.parse("2007-12-03T10:15:30+01:00"), + "2007-12-03 10:15:30.000000000 +0100", + new StringProvider()); + // java.time.ZonedDateTime + testIngestion( + "TIMESTAMP_TZ", + ZonedDateTime.parse("2007-12-03T10:15:30.000456789+01:00[Europe/Paris]"), + "2007-12-03 10:15:30.000456789 +0100", + new StringProvider()); + // java.time.Instant + testIngestion( + "TIMESTAMP_TZ", + OffsetDateTime.parse("2007-12-03T10:15:30.123456789+00:00").toInstant(), + "2007-12-03 10:15:30.123456789 Z", + new StringProvider()); + + // Verify that default timezone has impact on input without timezone information + setChannelDefaultTimezone(TZ_BERLIN); + testIngestion( + "TIMESTAMP_TZ", + "2013-04-28T20:57:01.123456789", + "2013-04-28 20:57:01.123456789 +0200", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ", + LocalDate.parse("2007-12-03"), + "2007-12-03 00:00:00.000000000 +0100", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ", + LocalDateTime.parse("2007-12-03T10:15:30"), + "2007-12-03 10:15:30.000000000 +0100", + new StringProvider()); + + // No impact on integer-stored and instant as they are always UTC + testIngestion( + "TIMESTAMP_TZ", + "1674478926123456789", + "2023-01-23 13:02:06.123456789 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ", + OffsetDateTime.parse("2007-12-03T10:15:30.123456789+00:00").toInstant(), + "2007-12-03 10:15:30.123456789 Z", + new StringProvider()); + + // Limited scale + testIngestion( + "TIMESTAMP_TZ(0)", + "2022-01-31T13:00:00+09:00", + "2022-01-31 13:00:00. +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ(0)", + "2022-01-31T13:00:00.123456+09:00", + "2022-01-31 13:00:00. +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ(1)", + "2022-01-31T13:00:00+09:00", + "2022-01-31 13:00:00.0 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ(1)", + "2022-01-31T13:00:00.123456+09:00", + "2022-01-31 13:00:00.1 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ(2)", + "2022-01-31T13:00:00.1+09:00", + "2022-01-31 13:00:00.10 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ(2)", + "2022-01-31T13:00:00.123456+09:00", + "2022-01-31 13:00:00.12 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ(8)", + "2022-01-31T13:00:00.1+09:00", + "2022-01-31 13:00:00.10000000 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ(8)", + "2022-01-31T13:00:00.123456789+09:00", + "2022-01-31 13:00:00.12345678 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ(8)", + "2022-01-31T13:00:00.000000009+09:00", + "2022-01-31 13:00:00.00000000 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_TZ(8)", + "2022-01-31T13:00:00.000000019+09:00", + "2022-01-31 13:00:00.00000001 +0900", + new StringProvider()); + } + + @Test + public void testTimestampWithLocalTimeZone() throws Exception { + setJdbcSessionTimezone(TZ_LOS_ANGELES); + setChannelDefaultTimezone(TZ_LOS_ANGELES); + + // Test timestamp formats + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "2013-04-28T20:57", + "2013-04-28 20:57:00.000000000 -0700", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "2013-04-28T20:57:01", + "2013-04-28 20:57:01.000000000 -0700", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "2013-04-28T20:57:01-07:00", + "2013-04-28 20:57:01.000000000 -0700", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "2013-04-28T20:57:01.123456", + "2013-04-28 20:57:01.123456000 -0700", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "2013-04-28T20:57:01.123456789+07:00", + "2013-04-28 06:57:01.123456789 -0700", + new StringProvider(), + new StringProvider()); + // Here we test ingestion only because JDBC cannot ingest ISO_ZONED_DATE_TIME + testIngestion( + "TIMESTAMP_LTZ", + "2013-04-28T20:57:01.123456789+09:00[Asia/Tokyo]", + "2013-04-28 04:57:01.123456789 -0700", + new StringProvider()); + + // Test date formats + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "2013-04-28", + "2013-04-28 00:00:00.000000000 -0700", + new StringProvider(), + new StringProvider()); + + // Test boundary time zone + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "2013-04-28T00:00:00+07:00", + "2013-04-27 10:00:00.000000000 -0700", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "2013-04-28T00:00:00-07:00", + "2013-04-28 00:00:00.000000000 -0700", + new StringProvider(), + new StringProvider()); + + // Test leap years + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "2024-02-29T23:59:59.999999999Z", + "2024-02-29 15:59:59.999999999 -0800", + new StringProvider(), + new StringProvider()); + expectNotSupported("TIMESTAMP_LTZ", "2023-02-29T23:59:59.999999999"); + + // Numeric strings + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "0", + "1969-12-31 16:00:00.000000000 -0800", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "1674478926", + "2023-01-23 05:02:06.000000000 -0800", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "1674478926123", + "2023-01-23 05:02:06.123000000 -0800", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "1674478926123456", + "2023-01-23 05:02:06.123456000 -0800", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "1674478926123456789", + "2023-01-23 05:02:06.123456789 -0800", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "-1674478926", + "1916-12-09 02:57:54.000000000 -0800", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "-1674478926123", + "1916-12-09 02:57:53.877000000 -0800", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "-1674478926123456", + "1916-12-09 02:57:53.876544000 -0800", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "-1674478926123456789", + "1916-12-09 02:57:53.876543211 -0800", + new StringProvider(), + new StringProvider()); + + // java.time.LocalDate + testIngestion( + "TIMESTAMP_LTZ", + LocalDate.parse("2007-12-03"), + "2007-12-03 00:00:00.000000000 -0800", + new StringProvider()); + // java.time.LocalDateTime + testIngestion( + "TIMESTAMP_LTZ", + LocalDateTime.parse("2007-12-03T10:15:30"), + "2007-12-03 10:15:30.000000000 -0800", + new StringProvider()); + // java.time.OffsetDateTime + testIngestion( + "TIMESTAMP_LTZ", + OffsetDateTime.parse("2007-12-03T10:15:30+01:00"), + "2007-12-03 01:15:30.000000000 -0800", + new StringProvider()); + // java.time.ZonedDateTime + testIngestion( + "TIMESTAMP_LTZ", + ZonedDateTime.parse("2007-12-03T10:15:30.123456789+01:00[Europe/Paris]"), + "2007-12-03 01:15:30.123456789 -0800", + new StringProvider()); + // java.time.Instant + testIngestion( + "TIMESTAMP_LTZ", + OffsetDateTime.parse("2007-12-03T10:15:30.123456789+00:00").toInstant(), + "2007-12-03 02:15:30.123456789 -0800", + new StringProvider()); + + // Verify that default timezone has impact on input without timezone information + setChannelDefaultTimezone(TZ_BERLIN); + setJdbcSessionTimezone(TZ_BERLIN); + testIngestion( + "TIMESTAMP_LTZ", + "1674478926123456789", + "2023-01-23 14:02:06.123456789 +0100", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ", + "2013-04-28T20:57:01.123456789", + "2013-04-28 20:57:01.123456789 +0200", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ", + LocalDate.parse("2007-12-03"), + "2007-12-03 00:00:00.000000000 +0100", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ", + LocalDateTime.parse("2007-12-03T10:15:30"), + "2007-12-03 10:15:30.000000000 +0100", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ", + OffsetDateTime.parse("2007-12-03T10:15:30.123456789+00:00").toInstant(), + "2007-12-03 11:15:30.123456789 +0100", + new StringProvider()); + + // Limited scale + setChannelDefaultTimezone(TZ_TOKYO); + setJdbcSessionTimezone(TZ_TOKYO); + testIngestion( + "TIMESTAMP_LTZ(0)", + "2022-01-31T13:00:00+09:00", + "2022-01-31 13:00:00. +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ(0)", + "2022-01-31T13:00:00.123456+09:00", + "2022-01-31 13:00:00. +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ(1)", + "2022-01-31T13:00:00+09:00", + "2022-01-31 13:00:00.0 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ(1)", + "2022-01-31T13:00:00.123456+09:00", + "2022-01-31 13:00:00.1 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ(2)", + "2022-01-31T13:00:00.1+09:00", + "2022-01-31 13:00:00.10 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ(2)", + "2022-01-31T13:00:00.123456+09:00", + "2022-01-31 13:00:00.12 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ(8)", + "2022-01-31T13:00:00.1+09:00", + "2022-01-31 13:00:00.10000000 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ(8)", + "2022-01-31T13:00:00.123456789+09:00", + "2022-01-31 13:00:00.12345678 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ(8)", + "2022-01-31T13:00:00.000000009+09:00", + "2022-01-31 13:00:00.00000000 +0900", + new StringProvider()); + testIngestion( + "TIMESTAMP_LTZ(8)", + "2022-01-31T13:00:00.000000019+09:00", + "2022-01-31 13:00:00.00000001 +0900", + new StringProvider()); + } + + @Test + public void testTimestampWithoutTimeZone() throws Exception { + // Test timestamp formats + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "2013-04-28T20:57", + "2013-04-28 20:57:00.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "2013-04-28T20:57:01", + "2013-04-28 20:57:01.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "2013-04-28T20:57:01-07:00", + "2013-04-28 20:57:01.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "2013-04-28T20:57:01.123456", + "2013-04-28 20:57:01.123456000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "2013-04-28T20:57:01.123456789+07:00", + "2013-04-28 20:57:01.123456789 Z", + new StringProvider(), + new StringProvider()); + // Here we test ingestion only because JDBC cannot ingest ISO_ZONED_DATE_TIME + testIngestion( + "TIMESTAMP_NTZ", + "2013-04-28T20:57:01.123456789+09:00[Asia/Tokyo]", + "2013-04-28 20:57:01.123456789 Z", + new StringProvider()); + + // Test date formats + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "2013-04-28", + "2013-04-28 00:00:00.000000000 Z", + new StringProvider(), + new StringProvider()); + + // Test boundary time zone + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "2013-04-28T00:00:00+07:00", + "2013-04-28 00:00:00.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "2013-04-28T00:00:00-07:00", + "2013-04-28 00:00:00.000000000 Z", + new StringProvider(), + new StringProvider()); + + // Test leap years + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "2024-02-29T23:59:59.999999999Z", + "2024-02-29 23:59:59.999999999 Z", + new StringProvider(), + new StringProvider()); + expectNotSupported("TIMESTAMP_NTZ", "2023-02-29T23:59:59.999999999"); + + // Numeric strings + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "0", + "1970-01-01 00:00:00.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "1674478926", + "2023-01-23 13:02:06.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "1674478926123", + "2023-01-23 13:02:06.123000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "1674478926123456", + "2023-01-23 13:02:06.123456000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "1674478926123456789", + "2023-01-23 13:02:06.123456789 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "-1674478926", + "1916-12-09 10:57:54.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "-1674478926123", + "1916-12-09 10:57:53.877000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "-1674478926123456", + "1916-12-09 10:57:53.876544000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "-1674478926123456789", + "1916-12-09 10:57:53.876543211 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "31536000001", + "1971-01-01 00:00:00.001000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "31535999999999", + "2969-05-02 23:59:59.999000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "31536000000000", + "1971-01-01 00:00:00.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "31535999999999", + "2969-05-02 23:59:59.999000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "31536000000000000", + "1971-01-01 00:00:00.000000000 Z", + new StringProvider(), + new StringProvider()); + + // java.time.LocalDate + testIngestion( + "TIMESTAMP_NTZ", + LocalDate.parse("2007-12-03"), + "2007-12-03 00:00:00.000000000 Z", + new StringProvider()); + // java.time.LocalDateTime + testIngestion( + "TIMESTAMP_NTZ", + LocalDateTime.parse("2007-12-03T10:15:30"), + "2007-12-03 10:15:30.000000000 Z", + new StringProvider()); + // java.time.OffsetDateTime + testIngestion( + "TIMESTAMP_NTZ", + OffsetDateTime.parse("2007-12-03T10:15:30+01:00"), + "2007-12-03 10:15:30.000000000 Z", + new StringProvider()); + // java.time.ZonedDateTime + testIngestion( + "TIMESTAMP_NTZ", + ZonedDateTime.parse("2007-12-03T10:15:30.123456789+01:00[Europe/Paris]"), + "2007-12-03 10:15:30.123456789 Z", + new StringProvider()); + // java.time.Instant + testIngestion( + "TIMESTAMP_NTZ", + OffsetDateTime.parse("2007-12-03T10:15:30.123456789+00:00").toInstant(), + "2007-12-03 10:15:30.123456789 Z", + new StringProvider()); + + // Verify that default timezone has no impact + setChannelDefaultTimezone(TZ_LOS_ANGELES); + testIngestion( + "TIMESTAMP_NTZ", + "1674478926123456789", + "2023-01-23 13:02:06.123456789 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ", + "2013-04-28T20:57:01.123456789", + "2013-04-28 20:57:01.123456789 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ", + LocalDate.parse("2007-12-03"), + "2007-12-03 00:00:00.000000000 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ", + LocalDateTime.parse("2007-12-03T10:15:30"), + "2007-12-03 10:15:30.000000000 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ", + OffsetDateTime.parse("2007-12-03T10:15:30.123456789+00:00").toInstant(), + "2007-12-03 10:15:30.123456789 Z", + new StringProvider()); + + // Limited scale + testIngestion( + "TIMESTAMP_NTZ(0)", + "2022-01-31T13:00:00+09:00", + "2022-01-31 13:00:00. Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ(0)", + "2022-01-31T13:00:00.123456+09:00", + "2022-01-31 13:00:00. Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ(1)", + "2022-01-31T13:00:00+09:00", + "2022-01-31 13:00:00.0 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ(1)", + "2022-01-31T13:00:00.123456+09:00", + "2022-01-31 13:00:00.1 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ(2)", + "2022-01-31T13:00:00.1+09:00", + "2022-01-31 13:00:00.10 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ(2)", + "2022-01-31T13:00:00.123456+09:00", + "2022-01-31 13:00:00.12 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ(8)", + "2022-01-31T13:00:00.1+09:00", + "2022-01-31 13:00:00.10000000 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ(8)", + "2022-01-31T13:00:00.123456789+09:00", + "2022-01-31 13:00:00.12345678 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ(8)", + "2022-01-31T13:00:00.000000009+09:00", + "2022-01-31 13:00:00.00000000 Z", + new StringProvider()); + testIngestion( + "TIMESTAMP_NTZ(8)", + "2022-01-31T13:00:00.000000019+09:00", + "2022-01-31 13:00:00.00000001 Z", + new StringProvider()); + } + + @Test + public void testTime() throws Exception { + // Simple time parsing + testJdbcTypeCompatibility( + "TIME", "20:57", "20:57:00.000000000 Z", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "TIME", "20:57:01", "20:57:01.000000000 Z", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "TIME", + "20:57:01.123456789", + "20:57:01.123456789 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIME", + "20:57:01.123456789+07:00", + "20:57:01.123456789 Z", + new StringProvider(), + new StringProvider()); + + // Numeric strings + testJdbcTypeCompatibility( + "TIME", "0", "00:00:00.000000000 Z", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "TIME", "1674478926", "13:02:06.000000000 Z", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "TIME", + "1674478926123", + "13:02:06.123000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIME", + "1674478926123456", + "13:02:06.123456000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIME", + "1674478926123456789", + "13:02:06.123456789 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIME", "-1674478926", "10:57:54.000000000 Z", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "TIME", + "-1674478926123", + "10:57:53.877000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIME", + "-1674478926123456", + "10:57:53.876544000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIME", + "-1674478926123456789", + "10:57:53.876543211 Z", + new StringProvider(), + new StringProvider()); + + // java.time.LocalTime + testIngestion("TIME", LocalTime.of(23, 59, 59), "23:59:59.000000000 Z", new StringProvider()); + + // java.time.OffsetTime + testIngestion( + "TIME", + OffsetTime.of(23, 59, 59, 0, ZoneOffset.ofHoursMinutes(4, 0)), + "23:59:59.000000000 Z", + new StringProvider()); + testIngestion( + "TIME", + OffsetTime.of(23, 59, 59, 123, ZoneOffset.ofHoursMinutes(-4, 0)), + "23:59:59.000000123 Z", + new StringProvider()); + testIngestion( + "TIME", + OffsetTime.of(23, 59, 59, 123456789, ZoneOffset.ofHoursMinutes(-4, 0)), + "23:59:59.123456789 Z", + new StringProvider()); + + // Limited scale + testIngestion("TIME(0)", "13:00:00", "13:00:00. Z", new StringProvider()); + testIngestion("TIME(0)", "13:00:00.123456", "13:00:00. Z", new StringProvider()); + testIngestion("TIME(1)", "13:00:00", "13:00:00.0 Z", new StringProvider()); + testIngestion("TIME(1)", "13:00:00.123456", "13:00:00.1 Z", new StringProvider()); + testIngestion("TIME(2)", "13:00:00.1", "13:00:00.10 Z", new StringProvider()); + testIngestion("TIME(2)", "13:00:00.123456", "13:00:00.12 Z", new StringProvider()); + testIngestion("TIME(8)", "13:00:00.1", "13:00:00.10000000 Z", new StringProvider()); + testIngestion("TIME(8)", "13:00:00.123456789", "13:00:00.12345678 Z", new StringProvider()); + testIngestion("TIME(8)", "13:00:00.000000009", "13:00:00.00000000 Z", new StringProvider()); + testIngestion("TIME(8)", "13:00:00.000000019", "13:00:00.00000001 Z", new StringProvider()); + } + + @Test + public void testDate() throws Exception { + // Test timestamp formats + testJdbcTypeCompatibility( + "DATE", "2013-04-28T20:57", "2013-04-28", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "2013-04-28T20:57:01", "2013-04-28", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", + "2013-04-28T20:57:01-07:00", + "2013-04-28", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "DATE", + "2013-04-28T20:57:01.123456", + "2013-04-28", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "DATE", + "2013-04-28T20:57:01.123456789+07:00", + "2013-04-28", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "2013-04-28T20:57+07:00", "2013-04-28", new StringProvider(), new StringProvider()); + + // Test date formats + testJdbcTypeCompatibility( + "DATE", "2013-04-28", "2013-04-28", new StringProvider(), new StringProvider()); + + // Test LocalDate + testIngestion("DATE", LocalDate.parse("2007-12-03"), "2007-12-03", new StringProvider()); + // Test LocalDateTime + testIngestion( + "DATE", LocalDateTime.parse("2007-12-03T10:15:30.123"), "2007-12-03", new StringProvider()); + // Test OffsetDateTime + testIngestion( + "DATE", + OffsetDateTime.parse("2007-12-03T02:15:30.123+09:00"), + "2007-12-03", + new StringProvider()); + testIngestion( + "DATE", + OffsetDateTime.parse("2007-12-03T02:15:30.123-08:00"), + "2007-12-03", + new StringProvider()); + // Test ZonedDateTime + testIngestion( + "DATE", + ZonedDateTime.parse("2007-12-03T02:15:30.123+09:00[Asia/Tokyo]"), + "2007-12-03", + new StringProvider()); + testIngestion( + "DATE", + ZonedDateTime.parse("2007-12-03T02:15:30.123-08:00[America/Los_Angeles]"), + "2007-12-03", + new StringProvider()); + // Test Instant + testIngestion( + "DATE", + ZonedDateTime.parse("2007-12-03T02:15:30.123-08:00[America/Los_Angeles]").toInstant(), + "2007-12-03", + new StringProvider()); + + // Test leap years + testJdbcTypeCompatibility( + "DATE", + "2024-02-29T23:59:59.999999999Z", + "2024-02-29", + new StringProvider(), + new StringProvider()); + expectNotSupported("DATE", "2023-02-29T23:59:59.999999999"); + + // Test numeric strings + testJdbcTypeCompatibility( + "DATE", "0", "1970-01-01", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "1662731080", "2022-09-09", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "1662731080123", "2022-09-09", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "1662731080123456", "2022-09-09", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "1662731080123456789", "2022-09-09", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "-1674478926", "1916-12-09", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "-1674478926123", "1916-12-09", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "-1674478926123456", "1916-12-09", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "-1674478926123456789", "1916-12-09", new StringProvider(), new StringProvider()); + } + + @Test + public void testOldTimestamps() throws Exception { + // DATE + testJdbcTypeCompatibility("DATE", "0001-12-31", new StringProvider()); + testJdbcTypeCompatibility("DATE", "0000-01-01", new StringProvider()); + testJdbcTypeCompatibility("DATE", "-0001-01-01", new StringProvider()); + testJdbcTypeCompatibility("DATE", "-9999-01-01", new StringProvider()); + + // TIMESTAMP_NTZ + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "0001-12-31T11:11:11", + "0001-12-31 11:11:11.000000000 Z", + new StringProvider(), + new StringProvider()); + + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "0001-01-01T00:00:00", + "0001-01-01 00:00:00.000000000 Z", + new StringProvider(), + new StringProvider()); + + // TIMESTAMP_TZ + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "0001-12-31T11:11:11+03:00", + "0001-12-31 11:11:11.000000000 +0300", + new StringProvider(), + new StringProvider()); + + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "0001-01-01T00:00:00+03:00", + "0001-01-01 00:00:00.000000000 +0300", + new StringProvider(), + new StringProvider()); + + // TIMESTAMP_LTZ + conn.createStatement() + .execute("alter session set timezone = 'UTC';"); // workaround for SNOW-727474 + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "0001-12-31T11:11:11+00:00", + "0001-12-31 11:11:11.000000000 Z", + new StringProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "0001-01-01T00:00:00+00:00", + "0001-01-01 00:00:00.000000000 Z", + new StringProvider(), + new StringProvider()); + } + + @Test + public void testJulianGregorianGap() throws Exception { + // During switch from Julian to Gregorian calendars, there is a 10-day gap. The next day after + // 1582-10-04 was 1582-10-15. + // Snowflake doesn't have any special handling of this, these dates can be normally inserted. + testJdbcTypeCompatibility( + "DATE", "1582-10-04", "1582-10-04", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "1582-10-08", "1582-10-08", new StringProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "DATE", "1582-10-15", "1582-10-15", new StringProvider(), new StringProvider()); + } + + @Test + public void testFutureDates() throws Exception { + setJdbcSessionTimezone(TZ_LOS_ANGELES); + setChannelDefaultTimezone(TZ_LOS_ANGELES); + testJdbcTypeCompatibility( + "DATE", "9999-12-31", "9999-12-31", new StringProvider(), new StringProvider()); + + // SB16 + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ", + "9999-12-31T23:59:59.999999999", + "9999-12-31 23:59:59.999999999 Z", + new StringProvider(), + new StringProvider()); + + // SB8 + testJdbcTypeCompatibility( + "TIMESTAMP_NTZ(7)", + "9999-12-31T23:59:59.999999999", + "9999-12-31 23:59:59.9999999 Z", + new StringProvider(), + new StringProvider()); + + // SB16 + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ", + "9999-12-31T23:59:59.999999999", + "9999-12-31 23:59:59.999999999 -0800", + new StringProvider(), + new StringProvider()); + + // SB8 + testJdbcTypeCompatibility( + "TIMESTAMP_LTZ(7)", + "9999-12-31T23:59:59.999999999", + "9999-12-31 23:59:59.9999999 -0800", + new StringProvider(), + new StringProvider()); + + // SB16 + testJdbcTypeCompatibility( + "TIMESTAMP_TZ", + "9999-12-31T23:59:59.999999999", + "9999-12-31 23:59:59.999999999 -0800", + new StringProvider(), + new StringProvider()); + + // SB8 + testJdbcTypeCompatibility( + "TIMESTAMP_TZ(3)", + "9999-12-31T23:59:59.999999999", + "9999-12-31 23:59:59.999 -0800", + new StringProvider(), + new StringProvider()); + } + + /** + * To make assertions of timestamps without timezone to work, make sure JDBC connection session + * time zone is the same as the streaming ingest default timezone + */ + private void setJdbcSessionTimezone(ZoneId timezone) throws SQLException { + conn.createStatement().execute(String.format("alter session set timezone = '%s';", timezone)); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/LogicalTypesIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/LogicalTypesIT.java new file mode 100644 index 000000000..6bc769a94 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/LogicalTypesIT.java @@ -0,0 +1,74 @@ +package net.snowflake.ingest.streaming.internal.datatypes; + +import java.math.BigDecimal; +import java.math.BigInteger; +import org.junit.Test; + +public class LogicalTypesIT extends AbstractDataTypeTest { + + @Test + public void testLogicalTypes() throws Exception { + // Test booleans + testJdbcTypeCompatibility("BOOLEAN", true, new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", false, new BooleanProvider()); + + // Test strings + testJdbcTypeCompatibility("BOOLEAN", "on", true, new StringProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", "off", false, new StringProvider(), new BooleanProvider()); + + // Test integers + testJdbcTypeCompatibility( + "BOOLEAN", Integer.MIN_VALUE, true, new IntProvider(), new BooleanProvider()); + testJdbcTypeCompatibility( + "BOOLEAN", Integer.MAX_VALUE, true, new IntProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", 0, false, new IntProvider(), new BooleanProvider()); + + // Test longs + testJdbcTypeCompatibility( + "BOOLEAN", Long.MIN_VALUE, true, new LongProvider(), new BooleanProvider()); + testJdbcTypeCompatibility( + "BOOLEAN", Long.MAX_VALUE, true, new LongProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", 0, false, new IntProvider(), new BooleanProvider()); + + // Test bytes + testJdbcTypeCompatibility( + "BOOLEAN", Byte.MIN_VALUE, true, new ByteProvider(), new BooleanProvider()); + testJdbcTypeCompatibility( + "BOOLEAN", Byte.MAX_VALUE, true, new ByteProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", 0, false, new IntProvider(), new BooleanProvider()); + + // Test shorts + testJdbcTypeCompatibility( + "BOOLEAN", Short.MIN_VALUE, true, new ShortProvider(), new BooleanProvider()); + testJdbcTypeCompatibility( + "BOOLEAN", Short.MAX_VALUE, true, new ShortProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", 0, false, new IntProvider(), new BooleanProvider()); + + // Test BigIntegers + testIngestion("BOOLEAN", MAX_ALLOWED_BIG_INTEGER, true, new BooleanProvider()); + testIngestion("BOOLEAN", MIN_ALLOWED_BIG_INTEGER, true, new BooleanProvider()); + testIngestion("BOOLEAN", BigInteger.ZERO, false, new BooleanProvider()); + + // Test BigDecimals + testJdbcTypeCompatibility( + "BOOLEAN", MAX_ALLOWED_BIG_DECIMAL, true, new BigDecimalProvider(), new BooleanProvider()); + testJdbcTypeCompatibility( + "BOOLEAN", MIN_ALLOWED_BIG_DECIMAL, true, new BigDecimalProvider(), new BooleanProvider()); + testJdbcTypeCompatibility( + "BOOLEAN", new BigDecimal("0.5"), true, new BigDecimalProvider(), new BooleanProvider()); + testJdbcTypeCompatibility( + "BOOLEAN", BigDecimal.ZERO, false, new BigDecimalProvider(), new BooleanProvider()); + + // Test floats + testJdbcTypeCompatibility("BOOLEAN", 0.5f, true, new FloatProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", -0.5f, true, new FloatProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", 0f, false, new FloatProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", 0.000f, false, new FloatProvider(), new BooleanProvider()); + + // Test double + testJdbcTypeCompatibility("BOOLEAN", 0.5, true, new DoubleProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", -0.5, true, new DoubleProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", 0d, false, new DoubleProvider(), new BooleanProvider()); + testJdbcTypeCompatibility("BOOLEAN", 0.000, false, new DoubleProvider(), new BooleanProvider()); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/NullIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/NullIT.java new file mode 100644 index 000000000..fa01eebf0 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/NullIT.java @@ -0,0 +1,34 @@ +package net.snowflake.ingest.streaming.internal.datatypes; + +import java.util.Arrays; +import org.junit.Test; + +public class NullIT extends AbstractDataTypeTest { + + @Test + public void testNullIngestion() throws Exception { + for (String type : + Arrays.asList( + "INT", + "NUMBER(1, 1)", + "NUMBER(1, 0)", + "BOOLEAN", + "VARCHAR", + "BINARY", + "DATE", + "TIME", + "TIME(0)", + "TIMESTAMP_NTZ", + "TIMESTAMP_NTZ(0)", + "TIMESTAMP_LTZ", + "TIMESTAMP_LTZ(0)", + "TIMESTAMP_TZ", + "TIMESTAMP_TZ(0)", + "VARIANT", + "ARRAY", + "OBJECT")) { + // Provider type does not matter as we are expecting null anyway + testIngestion(type, null, null, new StringProvider()); + } + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/NumericTypesIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/NumericTypesIT.java new file mode 100644 index 000000000..4e6ade712 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/NumericTypesIT.java @@ -0,0 +1,291 @@ +package net.snowflake.ingest.streaming.internal.datatypes; + +import java.math.BigDecimal; +import java.math.BigInteger; +import org.junit.Test; + +public class NumericTypesIT extends AbstractDataTypeTest { + + @Test + public void testIntegers() throws Exception { + // test bytes + testJdbcTypeCompatibility("INT", (short) 0, new ShortProvider()); + testJdbcTypeCompatibility("INT", Short.MAX_VALUE, new ShortProvider()); + testJdbcTypeCompatibility("INT", Short.MIN_VALUE, new ShortProvider()); + + // test shorts + testJdbcTypeCompatibility("INT", (byte) 0, new ByteProvider()); + testJdbcTypeCompatibility("INT", Byte.MAX_VALUE, new ByteProvider()); + testJdbcTypeCompatibility("INT", Byte.MIN_VALUE, new ByteProvider()); + + // test ints + testJdbcTypeCompatibility("INT", 0, new IntProvider()); + testJdbcTypeCompatibility("INT", Integer.MAX_VALUE, new IntProvider()); + testJdbcTypeCompatibility("INT", Integer.MIN_VALUE, new IntProvider()); + + // test longs + testJdbcTypeCompatibility("INT", 0L, new LongProvider()); + testJdbcTypeCompatibility("INT", Long.MAX_VALUE, new LongProvider()); + testJdbcTypeCompatibility("INT", Long.MIN_VALUE, new LongProvider()); + + // test floats + testJdbcTypeCompatibility("INT", 51.000f, 51, new FloatProvider(), new IntProvider()); + testJdbcTypeCompatibility("INT", -51.000f, -51, new FloatProvider(), new IntProvider()); + testJdbcTypeCompatibility("INT", 1.3f, 1, new FloatProvider(), new IntProvider()); + testJdbcTypeCompatibility("INT", -1.3f, -1, new FloatProvider(), new IntProvider()); + + // test doubles + testJdbcTypeCompatibility("INT", 11.000, 11, new DoubleProvider(), new IntProvider()); + testJdbcTypeCompatibility("INT", -11.000, -11, new DoubleProvider(), new IntProvider()); + testJdbcTypeCompatibility("INT", 1.3, 1, new DoubleProvider(), new IntProvider()); + testJdbcTypeCompatibility("INT", -1.3, -1, new DoubleProvider(), new IntProvider()); + + // test BigIntegers (JDBC does not support big integer, so we are reading back big decimals) + testIngestion( + "INT", MAX_ALLOWED_BIG_INTEGER, MAX_ALLOWED_BIG_DECIMAL, new BigDecimalProvider()); + testIngestion( + "INT", MIN_ALLOWED_BIG_INTEGER, MIN_ALLOWED_BIG_DECIMAL, new BigDecimalProvider()); + testIngestion("INT", BigInteger.ZERO, BigDecimal.ZERO, new BigDecimalProvider()); + expectNumberOutOfRangeError("INT", MAX_ALLOWED_BIG_INTEGER.add(BigInteger.ONE), 38); + expectNumberOutOfRangeError("INT", MIN_ALLOWED_BIG_INTEGER.subtract(BigInteger.ONE), 38); + + // test BigDecimals + testJdbcTypeCompatibility("INT", BigDecimal.ZERO, new BigDecimalProvider()); + testJdbcTypeCompatibility("INT", MAX_ALLOWED_BIG_DECIMAL, new BigDecimalProvider()); + testJdbcTypeCompatibility("INT", MIN_ALLOWED_BIG_DECIMAL, new BigDecimalProvider()); + testJdbcTypeCompatibility("INT", new BigDecimal("51.000"), new BigDecimalProvider()); + testJdbcTypeCompatibility("INT", new BigDecimal("-51.000"), new BigDecimalProvider()); + expectNumberOutOfRangeError("INT", MAX_ALLOWED_BIG_DECIMAL.add(BigDecimal.ONE), 38); + expectNumberOutOfRangeError("INT", MIN_ALLOWED_BIG_DECIMAL.subtract(BigDecimal.ONE), 38); + testJdbcTypeCompatibility( + "INT", + new BigDecimal("9999999999999999999999999999999999.1234"), + new BigDecimal("9999999999999999999999999999999999"), + new BigDecimalProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", + new BigDecimal("-9999999999999999999999999999999999.1234"), + new BigDecimal("-9999999999999999999999999999999999"), + new BigDecimalProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", + new BigDecimal("0.4"), + new BigDecimal("0"), + new BigDecimalProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", + new BigDecimal("0.6"), + new BigDecimal("1"), + new BigDecimalProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", + new BigDecimal("-0.4"), + new BigDecimal("0"), + new BigDecimalProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", + new BigDecimal("-0.6"), + new BigDecimal("-1"), + new BigDecimalProvider(), + new BigDecimalProvider()); + + // test Strings (Pass strings, read back as big decimal) + testJdbcTypeCompatibility( + "INT", "1", BigDecimal.ONE, new StringProvider(), new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", "00001", BigDecimal.ONE, new StringProvider(), new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", "8e0", new BigDecimal(8), new StringProvider(), new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", "-10e4", new BigDecimal("-100000"), new StringProvider(), new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", "11.5", new BigDecimal("12"), new StringProvider(), new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", "12.4", new BigDecimal("12"), new StringProvider(), new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", "1.2e1", new BigDecimal("12"), new StringProvider(), new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", "1.25e1", new BigDecimal("13"), new StringProvider(), new BigDecimalProvider()); + testJdbcTypeCompatibility( + "INT", "4.000", new BigDecimal("4"), new StringProvider(), new BigDecimalProvider()); + testIngestion( + "INT", + MAX_ALLOWED_BIG_INTEGER.toString(), + MAX_ALLOWED_BIG_DECIMAL, + new BigDecimalProvider()); + testIngestion( + "INT", + MIN_ALLOWED_BIG_INTEGER.toString(), + MIN_ALLOWED_BIG_DECIMAL, + new BigDecimalProvider()); + } + + @Test + public void testNumbersWithLimitedPrecision() throws Exception { + testJdbcTypeCompatibility("NUMBER(1, 0)", (byte) 0, new ByteProvider()); + testJdbcTypeCompatibility("NUMBER(1, 0)", (byte) 9, new ByteProvider()); + testJdbcTypeCompatibility("NUMBER(1, 0)", (byte) -9, new ByteProvider()); + expectNumberOutOfRangeError("NUMBER(1, 0)", 10, 1); + expectNumberOutOfRangeError("NUMBER(1, 0)", -10, 1); + expectNumberOutOfRangeError("NUMBER(1, 0)", -100, 1); + expectNumberOutOfRangeError("NUMBER(1, 0)", -1000, 1); + } + + @Test + public void testNumbersWithScale() throws Exception { + testJdbcTypeCompatibility("NUMBER(1, 1)", 0, new IntProvider()); + testJdbcTypeCompatibility("NUMBER(1, 1)", 0.00, new DoubleProvider()); + testJdbcTypeCompatibility("NUMBER(1, 1)", 0.2, new DoubleProvider()); + testJdbcTypeCompatibility("NUMBER(1, 1)", -0.2, new DoubleProvider()); + testJdbcTypeCompatibility( + "NUMBER(1, 1)", 0.24, 0.2, new DoubleProvider(), new DoubleProvider()); + testJdbcTypeCompatibility( + "NUMBER(1, 1)", -0.24, -0.2, new DoubleProvider(), new DoubleProvider()); + testJdbcTypeCompatibility( + "NUMBER(1, 1)", 0.25, 0.3, new DoubleProvider(), new DoubleProvider()); + testJdbcTypeCompatibility( + "NUMBER(1, 1)", -0.25, -0.3, new DoubleProvider(), new DoubleProvider()); + expectNumberOutOfRangeError("NUMBER(1, 1)", 1, 0); + expectNumberOutOfRangeError("NUMBER(1, 1)", 10, 0); + expectNumberOutOfRangeError("NUMBER(1, 1)", 1.25, 0); + + testJdbcTypeCompatibility("NUMBER(3, 1)", 0, new IntProvider()); + testJdbcTypeCompatibility("NUMBER(3, 1)", 99, new IntProvider()); + testJdbcTypeCompatibility("NUMBER(3, 1)", -99, new IntProvider()); + testJdbcTypeCompatibility( + "NUMBER(3, 1)", 99.94, 99.9, new DoubleProvider(), new DoubleProvider()); + testJdbcTypeCompatibility( + "NUMBER(3, 1)", -99.94, -99.9, new DoubleProvider(), new DoubleProvider()); + expectNumberOutOfRangeError("NUMBER(3, 1)", 99.95, 2); + expectNumberOutOfRangeError("NUMBER(3, 1)", -99.95, 2); + expectNumberOutOfRangeError("NUMBER(3, 1)", 100, 2); + expectNumberOutOfRangeError("NUMBER(3, 1)", -100, 2); + + testJdbcTypeCompatibility( + "NUMBER(38, 4)", + "9999999999999999999999999999999000", + new BigDecimal("9999999999999999999999999999999000"), + new StringProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "NUMBER(38, 4)", + "9999999999999999999999999999999999", + new BigDecimal("9999999999999999999999999999999999"), + new StringProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "NUMBER(38, 4)", + "9999999999999999999999999999999999.000", + new BigDecimal("9999999999999999999999999999999999"), + new StringProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "NUMBER(38, 4)", + "9999999999999999999999999999999999.9000", + new BigDecimal("9999999999999999999999999999999999.9"), + new StringProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "NUMBER(38, 4)", + "9999999999999999999999999999999999.9999", + new BigDecimal("9999999999999999999999999999999999.9999"), + new StringProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "NUMBER(38, 37)", + "0.0000000000000000000000000000000000000012", + new BigDecimal("0.0000000000000000000000000000000000000"), + new StringProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "NUMBER(38, 37)", + "9.9999999999999999999999999999999999999", + new BigDecimal("9.9999999999999999999999999999999999999"), + new StringProvider(), + new BigDecimalProvider()); + expectNumberOutOfRangeError( + "NUMBER(38, 37)", new BigDecimal("9.99999999999999999999999999999999999999"), 1); + + testJdbcTypeCompatibility( + "NUMBER(3, 1)", + "12.5", + new BigDecimal("12.5"), + new StringProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "NUMBER(3, 1)", + "1.25e1", + new BigDecimal("12.5"), + new StringProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "NUMBER(2, 0)", + "12.5", + new BigDecimal("13"), + new StringProvider(), + new BigDecimalProvider()); + testJdbcTypeCompatibility( + "NUMBER(2, 0)", + "1.25e1", + new BigDecimal("13"), + new StringProvider(), + new BigDecimalProvider()); + } + + @Test + public void testFloatingPointTypes() throws Exception { + // Test double + testJdbcTypeCompatibility("REAL", 1.35, 1.35, new DoubleProvider(), new DoubleProvider()); + testJdbcTypeCompatibility( + "REAL", Double.NaN, Double.NaN, new DoubleProvider(), new DoubleProvider()); + testJdbcTypeCompatibility( + "REAL", + Double.NEGATIVE_INFINITY, + Double.NEGATIVE_INFINITY, + new DoubleProvider(), + new DoubleProvider()); + testJdbcTypeCompatibility( + "REAL", + Double.POSITIVE_INFINITY, + Double.POSITIVE_INFINITY, + new DoubleProvider(), + new DoubleProvider()); + + // Test float + testJdbcTypeCompatibility("REAL", 1.35f, 1.35, new FloatProvider(), new DoubleProvider()); + testJdbcTypeCompatibility( + "REAL", + Float.NEGATIVE_INFINITY, + Double.NEGATIVE_INFINITY, + new FloatProvider(), + new DoubleProvider()); + testJdbcTypeCompatibility( + "REAL", + Float.POSITIVE_INFINITY, + Double.POSITIVE_INFINITY, + new FloatProvider(), + new DoubleProvider()); + testJdbcTypeCompatibility( + "REAL", Float.NaN, Double.NaN, new FloatProvider(), new DoubleProvider()); + + // Test others + testJdbcTypeCompatibility("REAL", 1, 1., new IntProvider(), new DoubleProvider()); + testJdbcTypeCompatibility("REAL", 1L, 1., new LongProvider(), new DoubleProvider()); + testJdbcTypeCompatibility("REAL", "1.35", 1.35, new StringProvider(), new DoubleProvider()); + + testJdbcTypeCompatibility( + "REAL", "Nan", Double.NaN, new StringProvider(), new DoubleProvider()); + testJdbcTypeCompatibility( + "REAL", "Inf", Double.POSITIVE_INFINITY, new StringProvider(), new DoubleProvider()); + testJdbcTypeCompatibility( + "REAL", "-Inf", Double.NEGATIVE_INFINITY, new StringProvider(), new DoubleProvider()); + + testIngestion("REAL", new BigDecimal("1.35"), new BigDecimal("1.35"), new BigDecimalProvider()); + testIngestion("REAL", BigInteger.ONE, BigDecimal.ONE, new BigDecimalProvider()); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/Provider.java b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/Provider.java new file mode 100644 index 000000000..1ad9ae7c3 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/Provider.java @@ -0,0 +1,89 @@ +package net.snowflake.ingest.streaming.internal.datatypes; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +/** + * Implementations of this class are able to provide parameter of a certain type into JDBC + * statement. It is used to abstract over JDBC statements working with various types in tests. + */ +interface Provider { + void provide(PreparedStatement stmt, int parameterIndex, T value) throws SQLException; +} + +class LongProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, Long value) throws SQLException { + stmt.setLong(parameterIndex, value); + } +} + +class IntProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, Integer value) + throws SQLException { + stmt.setInt(parameterIndex, value); + } +} + +class ByteProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, Byte value) throws SQLException { + stmt.setByte(parameterIndex, value); + } +} + +class ShortProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, Short value) throws SQLException { + stmt.setShort(parameterIndex, value); + } +} + +class BigDecimalProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, BigDecimal value) + throws SQLException { + stmt.setBigDecimal(parameterIndex, value); + } +} + +class StringProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, String value) + throws SQLException { + stmt.setString(parameterIndex, value); + } +} + +class FloatProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, Float value) throws SQLException { + stmt.setFloat(parameterIndex, value); + } +} + +class DoubleProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, Double value) + throws SQLException { + stmt.setDouble(parameterIndex, value); + } +} + +class BooleanProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, Boolean value) + throws SQLException { + stmt.setBoolean(parameterIndex, value); + } +} + +class ByteArrayProvider implements Provider { + @Override + public void provide(PreparedStatement stmt, int parameterIndex, byte[] value) + throws SQLException { + stmt.setBytes(parameterIndex, value); + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/SemiStructuredIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/SemiStructuredIT.java new file mode 100644 index 000000000..a3c9d2365 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/SemiStructuredIT.java @@ -0,0 +1,235 @@ +package net.snowflake.ingest.streaming.internal.datatypes; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Assert; +import org.junit.Test; + +public class SemiStructuredIT extends AbstractDataTypeTest { + + // TODO SNOW-664249: There is a few-byte mismatch between the value sent by the user and its + // server-side representation. Validation leaves a small buffer for this difference. + private static final int MAX_ALLOWED_LENGTH = 16 * 1024 * 1024 - 64; + + @Test + public void testVariant() throws Exception { + // Test dates + assertVariant("VARIANT", LocalTime.of(0, 0, 0, 123), "\"00:00:00.000000123\"", "VARCHAR"); + assertVariant( + "VARIANT", + OffsetTime.of(0, 0, 0, 123, ZoneOffset.ofHours(-1)), + "\"00:00:00.000000123-01:00\"", + "VARCHAR"); + assertVariant("VARIANT", LocalDate.of(1970, 1, 1), "\"1970-01-01\"", "VARCHAR"); + assertVariant( + "VARIANT", LocalDateTime.of(1970, 1, 1, 0, 0, 0), "\"1970-01-01T00:00\"", "VARCHAR"); + assertVariant( + "VARIANT", + Instant.EPOCH.atOffset(ZoneOffset.ofHours(-1)), + "\"1969-12-31T23:00-01:00\"", + "VARCHAR"); + assertVariant( + "VARIANT", + Instant.EPOCH.atZone(ZoneId.of("America/Los_Angeles")), + "\"1969-12-31T16:00-08:00\"", + "VARCHAR"); + + // Test arrays + assertVariant("VARIANT", "[]", "[]", "ARRAY"); + assertVariant("VARIANT", "[1]", "[1]", "ARRAY"); + assertVariant( + "VARIANT", new byte[] {Byte.MIN_VALUE, 0, Byte.MAX_VALUE}, "[-128,0,127]", "ARRAY"); + assertVariant( + "VARIANT", Arrays.asList(Byte.MIN_VALUE, 0, Byte.MAX_VALUE), "[-128,0,127]", "ARRAY"); + assertVariant( + "VARIANT", new short[] {Short.MIN_VALUE, 0, Short.MAX_VALUE}, "[-32768,0,32767]", "ARRAY"); + assertVariant( + "VARIANT", Arrays.asList(Short.MIN_VALUE, 0, Short.MAX_VALUE), "[-32768,0,32767]", "ARRAY"); + assertVariant( + "VARIANT", + new int[] {Integer.MIN_VALUE, 0, Integer.MAX_VALUE}, + "[-2147483648,0,2147483647]", + "ARRAY"); + assertVariant( + "VARIANT", + Arrays.asList(Integer.MIN_VALUE, 0, Integer.MAX_VALUE), + "[-2147483648,0,2147483647]", + "ARRAY"); + assertVariant( + "VARIANT", + new long[] {Long.MIN_VALUE, 0L, Long.MAX_VALUE}, + "[-9223372036854775808,0,9223372036854775807]", + "ARRAY"); + assertVariant( + "VARIANT", + Arrays.asList(Long.MIN_VALUE, 0L, Long.MAX_VALUE), + "[-9223372036854775808,0,9223372036854775807]", + "ARRAY"); + assertVariant( + "VARIANT", + new Object[] { + Byte.MIN_VALUE, + Short.MAX_VALUE, + 0, + Long.MAX_VALUE, + new Object[2], + Collections.emptyMap(), + null + }, + "[-128,32767,0,9223372036854775807,[null,null],{}, null]", + "ARRAY"); + assertVariant( + "VARIANT", + Arrays.asList( + Byte.MIN_VALUE, + Short.MAX_VALUE, + 0, + Long.MAX_VALUE, + new Object[2], + Collections.emptyMap(), + null), + "[-128,32767,0,9223372036854775807,[null,null],{}, null]", + "ARRAY"); + assertVariant("VARIANT", new BigInteger[] {BigInteger.TEN}, "[10]", "ARRAY"); + assertVariant("VARIANT", Collections.singletonList(BigInteger.TEN), "[10]", "ARRAY"); + assertVariant("VARIANT", new BigDecimal[] {new BigDecimal("10.5")}, "[10.5]", "ARRAY"); + assertVariant("VARIANT", Collections.singletonList(new BigDecimal("10.5")), "[10.5]", "ARRAY"); + + // Test booleans + assertVariant("VARIANT", "false", "false", "BOOLEAN"); + assertVariant("VARIANT", false, "false", "BOOLEAN"); + assertVariant("VARIANT", true, "true", "BOOLEAN"); + + // Test numbers + assertVariant("VARIANT", 34, "34", "INTEGER"); + assertVariant("VARIANT", "34", "34", "INTEGER"); + + assertVariant("VARIANT", Byte.MAX_VALUE, String.valueOf(Byte.MAX_VALUE), "INTEGER"); + assertVariant("VARIANT", Short.MAX_VALUE, String.valueOf(Short.MAX_VALUE), "INTEGER"); + assertVariant("VARIANT", Integer.MAX_VALUE, String.valueOf(Integer.MAX_VALUE), "INTEGER"); + assertVariant("VARIANT", Long.MAX_VALUE, String.valueOf(Long.MAX_VALUE), "INTEGER"); + assertVariant("VARIANT", Long.MIN_VALUE, String.valueOf(Long.MIN_VALUE), "INTEGER"); + assertVariant("VARIANT", BigInteger.TEN, BigInteger.TEN.toString(), "INTEGER"); + assertVariant( + "VARIANT", new BigDecimal("10.54"), new BigDecimal("10.54").toString(), "DECIMAL"); + assertVariant("VARIANT", "-1000.25", "-1000.25", "DECIMAL"); + assertVariant("VARIANT", -1000.25f, "-1000.25", "DECIMAL"); + assertVariant("VARIANT", -1000.25, "-1000.25", "DECIMAL"); + + // Test objects + assertVariant("VARIANT", "{}", "{}", "OBJECT"); + String testObject = + "{\"a\": \"foo\", \"b\":15, \"c\": false, \"d\": [1, 2, 3], \"e\": {\"q\": false}, \"f\":" + + " null}"; + assertVariant("VARIANT", testObject, testObject, "OBJECT"); + assertVariant( + "VARIANT", + Collections.singletonMap( + "A", + Collections.singletonMap("B", Collections.singletonMap("C", new String[] {"foo"}))), + "{\"A\": {\"B\": {\"C\": [\"foo\"]}}}", + "OBJECT"); + + // Test strings + assertVariant("VARIANT", "\"foo\"", "\"foo\"", "VARCHAR"); + assertVariant("VARIANT", '1', "\"1\"", "VARCHAR"); + assertVariant("VARIANT", 'd', "\"d\"", "VARCHAR"); + assertVariant( + "VARIANT", "\"ž, š, č, ř, c, j, ď, ť, ň\"", "\"ž, š, č, ř, c, j, ď, ť, ň\"", "VARCHAR"); + + // Date/time strings are ingested as varchars + assertVariant("VARIANT", "\"13:05:33.299094\"", "\"13:05:33.299094\"", "VARCHAR"); + assertVariant("VARIANT", "\"13:05:55.684003Z\"", "\"13:05:55.684003Z\"", "VARCHAR"); + assertVariant("VARIANT", "\"2022-09-14\"", "\"2022-09-14\"", "VARCHAR"); + assertVariant( + "VARIANT", "\"2022-09-14T13:04:53.578667\"", "\"2022-09-14T13:04:53.578667\"", "VARCHAR"); + assertVariant( + "VARIANT", + "\"2022-09-14T06:16:09.797704-07:00[America/Los_Angeles]\"", + "\"2022-09-14T06:16:09.797704-07:00[America/Los_Angeles]\"", + "VARCHAR"); + + // Test JSON null + assertVariant("VARIANT", "null", "null", "NULL_VALUE"); + + // Test SQL null, if the value is SQL NULL, the value returned is null + assertVariant("VARIANT", "", null, null); + assertVariant("VARIANT", " ", null, null); + assertVariant("VARIANT", null, null, null); + } + + /** + * For JDBC version > 3.13.3 we dont verify the value returned from max variant, max array and max + * object because of + * https://github.com/snowflakedb/snowflake-sdks-drivers-issues-teamwork/issues/819 + * + * @throws Exception + */ + @Test + public void testMaxVariantAndObject() throws Exception { + String maxObject = createLargeVariantObject(MAX_ALLOWED_LENGTH); + assertVariant("VARIANT", maxObject, maxObject, "OBJECT", true); + assertVariant("OBJECT", maxObject, maxObject, "OBJECT", true); + } + + /** + * For JDBC version > 3.13.3 we dont verify the value returned from max variant, max array and max + * object because of + * https://github.com/snowflakedb/snowflake-sdks-drivers-issues-teamwork/issues/819 + * + * @throws Exception + */ + @Test + public void testMaxArray() throws Exception { + String maxArray = "[" + createLargeVariantObject(MAX_ALLOWED_LENGTH - 2) + "]"; + assertVariant("ARRAY", maxArray, maxArray, "ARRAY", true); + } + + @Test + public void testObject() throws Exception { + assertVariant("OBJECT", "{}", "{}", "OBJECT"); + assertVariant("OBJECT", Collections.emptyMap(), "{}", "OBJECT"); + assertVariant("OBJECT", Collections.singletonMap("1", 2), "{\"1\": 2}", "OBJECT"); + assertVariant( + "OBJECT", Collections.singletonMap("1", new byte[] {1, 2}), "{\"1\": [1,2]}", "OBJECT"); + } + + @Test + public void testArray() throws Exception { + assertVariant("ARRAY", "[]", "[]", "ARRAY"); + assertVariant("ARRAY", Collections.emptyList(), "[]", "ARRAY"); + assertVariant("ARRAY", new Object[0], "[]", "ARRAY"); + assertVariant("ARRAY", new int[0], "[]", "ARRAY"); + assertVariant("ARRAY", new double[0], "[]", "ARRAY"); + + assertVariant("ARRAY", 1, "[1]", "ARRAY"); + assertVariant("ARRAY", "null", "[null]", "ARRAY"); + assertVariant("ARRAY", "{}", "[{}]", "ARRAY"); + assertVariant("ARRAY", Collections.emptyMap(), "[{}]", "ARRAY"); + assertVariant("ARRAY", Collections.singletonMap("1", "2"), "[{\"1\": \"2\"}]", "ARRAY"); + } + + private String createLargeVariantObject(int size) throws JsonProcessingException { + char[] stringContent = new char[size - 17]; // {"a":"11","b":""} + Arrays.fill(stringContent, 'c'); + Map m = new HashMap<>(); + m.put("a", "11"); + m.put("b", new String(stringContent)); + String s = new ObjectMapper().writeValueAsString(m); + Assert.assertEquals(s.length(), size); + return s; + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/StringsIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/StringsIT.java new file mode 100644 index 000000000..63dc515f5 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/datatypes/StringsIT.java @@ -0,0 +1,218 @@ +package net.snowflake.ingest.streaming.internal.datatypes; + +import static net.snowflake.ingest.TestUtils.buildString; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import net.snowflake.ingest.utils.ErrorCode; +import net.snowflake.ingest.utils.SFException; +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; + +public class StringsIT extends AbstractDataTypeTest { + + private static final int MB_16 = 16 * 1024 * 1024; + + @Test + public void testStrings() throws Exception { + testJdbcTypeCompatibility("VARCHAR", "", new StringProvider()); + testJdbcTypeCompatibility("VARCHAR", "foo", new StringProvider()); + testJdbcTypeCompatibility("VARCHAR", " foo \t\n", new StringProvider()); + + // Test strings with limited size + testJdbcTypeCompatibility("VARCHAR(1)", "", new StringProvider()); + testJdbcTypeCompatibility("VARCHAR(2)", "ab", new StringProvider()); + testJdbcTypeCompatibility("VARCHAR(2)", "🍞❄", new StringProvider()); + + // test booleans + testJdbcTypeCompatibility("CHAR(5)", true, "true", new BooleanProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "CHAR(5)", false, "false", new BooleanProvider(), new StringProvider()); + expectNotSupported("CHAR(4)", false); + + // test numbers + testJdbcTypeCompatibility( + "CHAR(4)", (byte) 123, "123", new ByteProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "CHAR(4)", (short) 1111, "1111", new ShortProvider(), new StringProvider()); + testJdbcTypeCompatibility("CHAR(4)", 1111, "1111", new IntProvider(), new StringProvider()); + testJdbcTypeCompatibility("CHAR(4)", 1111L, "1111", new LongProvider(), new StringProvider()); + testIngestion("CHAR(4)", BigInteger.valueOf(1111), "1111", new StringProvider()); + testJdbcTypeCompatibility("CHAR(3)", 1.5f, "1.5", new FloatProvider(), new StringProvider()); + testJdbcTypeCompatibility("CHAR(3)", 1.500f, "1.5", new FloatProvider(), new StringProvider()); + testJdbcTypeCompatibility("CHAR(3)", 1.5d, "1.5", new DoubleProvider(), new StringProvider()); + testJdbcTypeCompatibility("CHAR(3)", 1.500d, "1.5", new DoubleProvider(), new StringProvider()); + + // BigDecimal + testJdbcTypeCompatibility( + "CHAR(4)", + BigDecimal.valueOf(1111), + "1111", + new BigDecimalProvider(), + new StringProvider()); + testJdbcTypeCompatibility( + "CHAR(1)", new BigDecimal("4.0000"), "4", new BigDecimalProvider(), new StringProvider()); + testJdbcTypeCompatibility( + "VARCHAR", + new BigDecimal("4e10"), + "40000000000", + new BigDecimalProvider(), + new StringProvider()); + + // char + testIngestion("CHAR(4)", 'c', "c", new StringProvider()); + } + + @Test + public void testNonAsciiStrings() throws Exception { + testJdbcTypeCompatibility( + "VARCHAR", "❄😃öüß0ö😃üä++ěšíáýšěčí🍞áýřž+šář+🍞ýšš😃čžýříéě+ž❄", new StringProvider()); + } + + @Test + public void testStringCreatedFromInvalidBytes() throws Exception { + byte[] bytes = new byte[256]; + int counter = 0; + while (counter < 256) { + bytes[counter] = (byte) (Byte.MIN_VALUE + counter); + counter++; + } + + String s = new String(bytes, StandardCharsets.UTF_8); + testJdbcTypeCompatibility("VARCHAR", s, new StringProvider()); + } + + @Test + public void testMaxAllowedString() throws Exception { + // 1-byte chars + String maxString = buildString("a", MB_16); + testIngestion("VARCHAR", maxString, new StringProvider()); + expectNotSupported("VARCHAR", maxString + "a"); + + // 2-byte chars + maxString = buildString("š", MB_16 / 2); + testIngestion("VARCHAR", maxString, new StringProvider()); + + expectNotSupported("VARCHAR", maxString + "a"); + + // 3-byte chars + maxString = buildString("❄", MB_16 / 3); + testIngestion("VARCHAR", maxString, new StringProvider()); + expectNotSupported("VARCHAR", maxString + "aa"); + + // 4-byte chars + maxString = buildString("🍞", MB_16 / 4); + testIngestion("VARCHAR", maxString, new StringProvider()); + expectNotSupported("VARCHAR", maxString + "a"); + } + + @Test + public void testPrefixFF() throws Exception { + + // 11x \xFFFF + testIngestion( + "VARCHAR", + "\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF", + new StringProvider()); + // 10x \xFFFF + chars + testIngestion( + "VARCHAR", + "\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFFaaaaaaaaaaaaaaaaaaaaaaaaaa", + new StringProvider()); + + // chars + 15+ times \uFFFF + ingestManyAndMigrate( + "VARCHAR", + "aaaaaaaaa\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF"); + + // chars + 15+ times \uFFFF + chars + ingestManyAndMigrate( + "VARCHAR", + "aaaaaaaaa\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFFaaaaaaaaa"); + + // 15+ times \uFFFF + ingestManyAndMigrate( + "VARCHAR", + "\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF"); + + // 15+ times \uFFFF + chars + ingestManyAndMigrate( + "VARCHAR", + "\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFF\uFFFFaaaaaaaaa"); + } + + @Test + public void testMultiByteCharComparison() throws Exception { + ingestManyAndMigrate("VARCHAR", "a", "❄"); + ingestManyAndMigrate("VARCHAR", "❄", "a"); + + ingestManyAndMigrate( + "VARCHAR", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄"); + ingestManyAndMigrate( + "VARCHAR", + "❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄❄", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + + /** + * Ingests string with length around EP-truncation point and asserts that both shorter, equal and + * longer strings are ingested correctly + */ + @Test + public void testTruncationAndIncrementation() throws Exception { + // Test 1-byte + testIngestion("VARCHAR", buildString("a", 31), new StringProvider()); + testIngestion("VARCHAR", buildString("a", 32), new StringProvider()); + testIngestion("VARCHAR", buildString("a", 33), new StringProvider()); + + // Test 2-byte + testIngestion("VARCHAR", buildString("š", 15), new StringProvider()); + testIngestion("VARCHAR", buildString("š", 16), new StringProvider()); + testIngestion("VARCHAR", buildString("š", 17), new StringProvider()); + testIngestion("VARCHAR", "a" + buildString("š", 15), new StringProvider()); + testIngestion("VARCHAR", "a" + buildString("š", 16), new StringProvider()); + + // Test 3-byte + testIngestion("VARCHAR", buildString("❄", 10), new StringProvider()); + testIngestion("VARCHAR", buildString("❄", 11), new StringProvider()); + testIngestion("VARCHAR", buildString("❄", 12), new StringProvider()); + + // Test 4-byte + testIngestion("VARCHAR", buildString("🍞", 6), new StringProvider()); + testIngestion("VARCHAR", buildString("🍞", 7), new StringProvider()); + testIngestion("VARCHAR", buildString("🍞", 8), new StringProvider()); + + testIngestion("VARCHAR", "a" + buildString("🍞", 7), new StringProvider()); + } + + @Test + @Ignore("Failing due to GS SNOW-690281") + public void testByteSplit() throws Exception { + testIngestion("VARCHAR", "a" + buildString("🍞", 8), new StringProvider()); + testIngestion("VARCHAR", "a" + buildString("🍞", 9), new StringProvider()); + } + + /** + * Verifies that non-nullable collated columns are not supported at all and an exception is thrown + * already while creating the channel. + */ + @Test + public void testCollatedColumnsNotSupported() throws SQLException { + String tableName = getRandomIdentifier(); + conn.createStatement() + .execute( + String.format( + "create or replace table %s (\"create\" string collate 'en-ci')", tableName)); + try { + openChannel(tableName); + Assert.fail("Opening a channel shouldn't have succeeded"); + } catch (SFException e) { + Assert.assertEquals(ErrorCode.OPEN_CHANNEL_FAILURE.getMessageCode(), e.getVendorCode()); + } + } +} diff --git a/src/test/java/net/snowflake/ingest/streaming/internal/it/ColumnNamesIT.java b/src/test/java/net/snowflake/ingest/streaming/internal/it/ColumnNamesIT.java new file mode 100644 index 000000000..254da0d52 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/streaming/internal/it/ColumnNamesIT.java @@ -0,0 +1,226 @@ +package net.snowflake.ingest.streaming.internal.it; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.UUID; +import net.snowflake.ingest.TestUtils; +import net.snowflake.ingest.streaming.InsertValidationResponse; +import net.snowflake.ingest.streaming.OpenChannelRequest; +import net.snowflake.ingest.streaming.SnowflakeStreamingIngestChannel; +import net.snowflake.ingest.streaming.internal.datatypes.AbstractDataTypeTest; +import net.snowflake.ingest.utils.SFException; +import org.junit.Assert; +import org.junit.Test; + +public class ColumnNamesIT extends AbstractDataTypeTest { + private static final int INGEST_VALUE = 1; + + @Test + public void testColumnNamesSupport() throws Exception { + // Test simple case + testColumnNameSupported("FOO", "FOO"); + testColumnNameSupported("FOO", "FoO"); + testColumnNameSupported("FOO", "\"FOO\""); + testColumnNameUnsupported("FOO", "\"Foo\""); + + // Test quoted identifier + testColumnNameSupported("\"FOO\"", "\"FOO\""); + testColumnNameSupported("\"FOO\"", "FOO"); + testColumnNameSupported("\"FOO\"", "Foo"); + testColumnNameUnsupported("\"FOO\"", "\"Foo\""); + + testColumnNameSupported("\"Foo\"", "\"Foo\""); + testColumnNameUnsupported("\"Foo\"", "Foo"); + + // Test keyword + testColumnNameSupported("\"CReATE\"", "\"CReATE\""); + testColumnNameSupported("\"CREATE\"", "CReATE"); + testColumnNameUnsupported("\"CReATE\"", "\"CREATE\""); + testColumnNameUnsupported("\"CReATE\"", "CReATE"); + + // Test escaped space + testColumnNameSupported("fO\\ O", "fO\\ O"); + testColumnNameSupported("fO\\ O", "fO\\ o"); + testColumnNameSupported("fO\\ O", "fO O"); + testColumnNameSupported("fO\\ O", "fO o"); + testColumnNameSupported("fO\\ O", "\"FO O\""); + testColumnNameUnsupported("fO\\ O", "\"FO\\ O\""); + + // Test double quotes + testColumnNameSupported("\"foo\"\"bar\"", "\"foo\"\"bar\""); + testColumnNameSupported("\"FOO\"\"BAR\"", "foo\"bar"); + testColumnNameSupported("\"\"\"\"", "\""); + testColumnNameSupported("\"\"\"\"", "\"\"\"\""); + testColumnNameUnsupported("\"\"\"\"", "\"\"\"\"\"\""); + + // Test quoted column with spaces + testColumnNameSupported("\"FO O\"", "FO O"); + testColumnNameSupported("\"FO O\"", "\"FO O\""); + testColumnNameUnsupported("\"FO O\"", "\"FO\\ O\""); + } + + @Test + public void testNonstandardTableAndColumnNames() throws Exception { + testColumnNameSupported("fo\\ o"); + testColumnNameSupported("foo"); + testColumnNameSupported("\"foo\""); + testColumnNameSupported("\"fo o\""); + testColumnNameSupported("\"alter\""); + testColumnNameSupported("\"table\""); + testColumnNameSupported("\" \""); + testColumnNameSupported("\"\""); + testColumnNameSupported("\"a\"\"b\""); + testColumnNameSupported("\"\"\"\""); + testColumnNameSupported("\" \"\" \"\" \""); + testColumnNameSupported("\" \"\" Ť \"\" \""); + } + + /** Tests that quoted columns are correctly resolved for null-backfill */ + @Test + public void testNullableResolution() throws Exception { + String tableName = "t1"; + conn.createStatement() + .execute( + String.format( + "create or replace table %s (AbC int, \"AbC\" int, \"abC\" int, ab\\ c int, \"Ab" + + " c\" int);", + tableName)); + SnowflakeStreamingIngestChannel channel = openChannel(tableName); + String offsetToken = "token1"; + channel.insertRow(new HashMap<>(), offsetToken); + TestUtils.waitForOffset(channel, offsetToken); + + ResultSet rs = + conn.createStatement().executeQuery(String.format("select * from %s", tableName)); + rs.next(); + Assert.assertNull(rs.getObject(1)); + Assert.assertNull(rs.getObject(2)); + Assert.assertNull(rs.getObject(3)); + Assert.assertNull(rs.getObject(4)); + Assert.assertNull(rs.getObject(5)); + } + + /** + * Test that original user input is used in extra column names validation response (required by + * KC) + */ + @Test + public void testExtraColNames() throws Exception { + String tableName = "t1"; + conn.createStatement() + .execute(String.format("create or replace table %s (\"create\" int);", tableName)); + SnowflakeStreamingIngestChannel channel = + openChannel(tableName, OpenChannelRequest.OnErrorOption.CONTINUE); + + // Test simple input + Map row = new HashMap<>(); + row.put("\"create\"", 4); + row.put("abc", 11); + InsertValidationResponse insertValidationResponse = + channel.insertRow(row, UUID.randomUUID().toString()); + Assert.assertEquals(1, insertValidationResponse.getInsertErrors().size()); + Assert.assertEquals( + Collections.singletonList("abc"), + insertValidationResponse.getInsertErrors().get(0).getExtraColNames()); + + // Test quoted input + row = new HashMap<>(); + row.put("\"create\"", 4); + row.put("\"CrEaTe\"", 11); + insertValidationResponse = channel.insertRow(row, UUID.randomUUID().toString()); + Assert.assertEquals(1, insertValidationResponse.getInsertErrors().size()); + Assert.assertEquals( + Collections.singletonList("\"CrEaTe\""), + insertValidationResponse.getInsertErrors().get(0).getExtraColNames()); + } + + /** Test that display names are shown in missing not null columns validation response */ + @Test + public void testMissingNotNullColNames() throws Exception { + String tableName = "t1"; + conn.createStatement() + .execute( + String.format( + "create or replace table %s (\"CrEaTe\" int not null, a int not null, \"a\" int not" + + " null, \"create\" int);", + tableName)); + SnowflakeStreamingIngestChannel channel = + openChannel(tableName, OpenChannelRequest.OnErrorOption.CONTINUE); + + InsertValidationResponse insertValidationResponse = + channel.insertRow(new HashMap<>(), UUID.randomUUID().toString()); + Assert.assertEquals(1, insertValidationResponse.getInsertErrors().size()); + Assert.assertEquals( + new HashSet<>(Arrays.asList("\"CrEaTe\"", "A", "\"a\"")), + new HashSet<>( + insertValidationResponse.getInsertErrors().get(0).getMissingNotNullColNames())); + } + + /** + * Tests that data can be ingested for specific row key into a specific column + * + * @param createTableColumnName Column name used in CREATE TABLE + * @param ingestColumnName Column name of used in ingestion + */ + private void testColumnNameSupported(String createTableColumnName, String ingestColumnName) + throws SQLException, InterruptedException { + + String tableName = createSimpleTable(createTableColumnName); + String offsetToken = UUID.randomUUID().toString(); + SnowflakeStreamingIngestChannel channel = openChannel(tableName); + Map row = new HashMap<>(); + row.put(ingestColumnName, INGEST_VALUE); + channel.insertRow(row, offsetToken); + + TestUtils.waitForOffset(channel, offsetToken); + + // Verify that supported columns work + ResultSet rs = + conn.createStatement().executeQuery(String.format("select * from %s;", tableName)); + int count = 0; + while (rs.next()) { + count++; + Assert.assertEquals(INGEST_VALUE, rs.getInt(1)); + } + Assert.assertEquals(1, count); + + conn.createStatement().execute(String.format("alter table %s migrate;", tableName)); + } + + private void testColumnNameSupported(String column) throws SQLException, InterruptedException { + testColumnNameSupported(column, column); + } + + /** Verifies that column names that are not support to work, does not work */ + private void testColumnNameUnsupported(String createTableColumnName, String ingestColumnName) + throws SQLException { + + Map row = new HashMap<>(); + row.put(ingestColumnName, INGEST_VALUE); + SnowflakeStreamingIngestChannel channel = openChannel(createSimpleTable(createTableColumnName)); + testInsertRowFails(channel, row); + } + + private void testInsertRowFails( + SnowflakeStreamingIngestChannel channel, Map row) { + try { + channel.insertRow(row, UUID.randomUUID().toString()); + Assert.fail("Ingest row should not succeed"); + } catch (SFException e) { + // all good, expected exception has been thrown + } + } + + private String createSimpleTable(String createTableColumnName) throws SQLException { + String tableName = "a" + UUID.randomUUID().toString().replace("-", "_"); + String createTableSql = + String.format("create table %s (%s int);", tableName, createTableColumnName); + conn.createStatement().execute(createTableSql); + return tableName; + } +} diff --git a/src/test/java/net/snowflake/ingest/utils/HttpUtilCacheTest.java b/src/test/java/net/snowflake/ingest/utils/HttpUtilCacheTest.java new file mode 100644 index 000000000..61d78b915 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/utils/HttpUtilCacheTest.java @@ -0,0 +1,108 @@ +package net.snowflake.ingest.utils; + +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; + +import java.util.Properties; +import net.snowflake.client.core.SFSessionProperty; +import net.snowflake.client.jdbc.internal.apache.http.impl.client.CloseableHttpClient; +import org.junit.After; +import org.junit.Test; + +/** + * Test class for HttpUtil caching functionality. Tests verify that HTTP clients are properly cached + * based on account name and proxy settings. Note: Both JDBC and streaming clients now use unified + * SFSessionProperty constants for proxy configuration. + */ +public class HttpUtilCacheTest { + + @After + public void tearDown() { + // Clean up after each test + HttpUtil.closeAllHttpClients(); + } + + @Test + public void testHttpClientCachingWithSameAccount() { + String accountName = "testaccount"; + + // Get HTTP client twice with same account + CloseableHttpClient client1 = HttpUtil.getHttpClient(accountName); + CloseableHttpClient client2 = HttpUtil.getHttpClient(accountName); + + // Should return the same instance + assertSame("HTTP clients should be the same for same account", client1, client2); + } + + @Test + public void testHttpClientCachingWithDifferentAccounts() { + String accountName1 = "testaccount1"; + String accountName2 = "testaccount2"; + + // Get HTTP clients for different accounts + CloseableHttpClient client1 = HttpUtil.getHttpClient(accountName1); + CloseableHttpClient client2 = HttpUtil.getHttpClient(accountName2); + + // Should return different instances + assertNotSame("HTTP clients should be different for different accounts", client1, client2); + } + + @Test + public void testHttpClientCachingWithSameProxySettings() { + String accountName = "testaccount"; + Properties proxyProps = new Properties(); + proxyProps.setProperty(SFSessionProperty.USE_PROXY.getPropertyKey(), "true"); + proxyProps.setProperty(SFSessionProperty.PROXY_HOST.getPropertyKey(), "proxy.example.com"); + proxyProps.setProperty(SFSessionProperty.PROXY_PORT.getPropertyKey(), "8080"); + + // Get HTTP client twice with same proxy settings + CloseableHttpClient client1 = HttpUtil.getHttpClient(accountName, proxyProps); + CloseableHttpClient client2 = HttpUtil.getHttpClient(accountName, proxyProps); + + // Should return the same instance + assertSame("HTTP clients should be the same for same proxy settings", client1, client2); + } + + @Test + public void testHttpClientCachingWithDifferentProxySettings() { + String accountName = "testaccount"; + + Properties proxyProps1 = new Properties(); + proxyProps1.setProperty(SFSessionProperty.USE_PROXY.getPropertyKey(), "true"); + proxyProps1.setProperty(SFSessionProperty.PROXY_HOST.getPropertyKey(), "proxy1.example.com"); + proxyProps1.setProperty(SFSessionProperty.PROXY_PORT.getPropertyKey(), "8080"); + + Properties proxyProps2 = new Properties(); + proxyProps2.setProperty(SFSessionProperty.USE_PROXY.getPropertyKey(), "true"); + proxyProps2.setProperty(SFSessionProperty.PROXY_HOST.getPropertyKey(), "proxy2.example.com"); + proxyProps2.setProperty(SFSessionProperty.PROXY_PORT.getPropertyKey(), "8080"); + + // Get HTTP clients with different proxy settings + CloseableHttpClient client1 = HttpUtil.getHttpClient(accountName, proxyProps1); + CloseableHttpClient client2 = HttpUtil.getHttpClient(accountName, proxyProps2); + + // Should return different instances + assertNotSame( + "HTTP clients should be different for different proxy settings", client1, client2); + } + + @Test + public void testHttpClientCachingWithProxyAndNoProxy() { + String accountName = "testaccount"; + + Properties proxyProps = new Properties(); + proxyProps.setProperty(SFSessionProperty.USE_PROXY.getPropertyKey(), "true"); + proxyProps.setProperty(SFSessionProperty.PROXY_HOST.getPropertyKey(), "proxy.example.com"); + proxyProps.setProperty(SFSessionProperty.PROXY_PORT.getPropertyKey(), "8080"); + + // Get HTTP client with proxy and without proxy + CloseableHttpClient clientWithProxy = HttpUtil.getHttpClient(accountName, proxyProps); + CloseableHttpClient clientWithoutProxy = HttpUtil.getHttpClient(accountName); + + // Should return different instances + assertNotSame( + "HTTP clients should be different for proxy vs no proxy", + clientWithProxy, + clientWithoutProxy); + } +} diff --git a/src/test/java/net/snowflake/ingest/utils/HttpUtilTest.java b/src/test/java/net/snowflake/ingest/utils/HttpUtilTest.java new file mode 100644 index 000000000..a905848e6 --- /dev/null +++ b/src/test/java/net/snowflake/ingest/utils/HttpUtilTest.java @@ -0,0 +1,49 @@ +package net.snowflake.ingest.utils; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; + +import java.io.IOException; +import java.net.SocketException; +import java.net.UnknownHostException; +import javax.net.ssl.SSLException; +import net.snowflake.client.jdbc.internal.apache.http.HttpRequest; +import net.snowflake.client.jdbc.internal.apache.http.NoHttpResponseException; +import net.snowflake.client.jdbc.internal.apache.http.RequestLine; +import net.snowflake.client.jdbc.internal.apache.http.client.HttpRequestRetryHandler; +import net.snowflake.client.jdbc.internal.apache.http.client.protocol.HttpClientContext; +import org.junit.Test; +import org.mockito.Mockito; + +public class HttpUtilTest { + @Test + public void testRequestRetryHandler() { + HttpRequestRetryHandler httpRequestRetryHandler = HttpUtil.getHttpRequestRetryHandler(); + + HttpClientContext httpContextMock = Mockito.mock(HttpClientContext.class); + RequestLine requestLine = Mockito.mock(RequestLine.class); + HttpRequest httpRequest = Mockito.mock(HttpRequest.class); + + doReturn(httpRequest).when(httpContextMock).getRequest(); + doReturn(requestLine).when(httpRequest).getRequestLine(); + doReturn("/api/v1/status").when(requestLine).getUri(); + + assertTrue( + httpRequestRetryHandler.retryRequest( + new NoHttpResponseException("Test exception"), 1, httpContextMock)); + assertTrue( + httpRequestRetryHandler.retryRequest( + new SSLException("Test exception"), 1, httpContextMock)); + assertTrue( + httpRequestRetryHandler.retryRequest( + new SocketException("Test exception"), 1, httpContextMock)); + assertTrue( + httpRequestRetryHandler.retryRequest( + new UnknownHostException("Test exception"), 1, httpContextMock)); + assertFalse( + httpRequestRetryHandler.retryRequest( + new SSLException("Test exception"), 11, httpContextMock)); + assertFalse(httpRequestRetryHandler.retryRequest(new IOException(), 1, httpContextMock)); + } +} diff --git a/unshaded_deploy.sh b/unshaded_deploy.sh new file mode 100755 index 000000000..a5e607291 --- /dev/null +++ b/unshaded_deploy.sh @@ -0,0 +1,88 @@ +#!/bin/bash -e + +THIS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +export GPG_KEY_ID="Snowflake Computing" +export SONATYPE_USER="$sonatype_user" +export SONATYPE_PWD="$sonatype_password" + +if [ -z "$GPG_KEY_PASSPHRASE" ]; then + echo "[ERROR] GPG passphrase is not specified for $GPG_KEY_ID!" + exit 1 +fi + +if [ -z "$GPG_PRIVATE_KEY" ]; then + echo "[ERROR] GPG private key file is not specified!" + exit 1 +fi + +echo "[INFO] Import PGP Key" +if ! gpg --list-secret-key | grep "$GPG_KEY_ID"; then + gpg --allow-secret-key-import --import "$GPG_PRIVATE_KEY" +fi + +# copy the settings.xml template and inject credential information +OSSRH_DEPLOY_SETTINGS_XML="$THIS_DIR/mvn_settings_ossrh_deploy.xml" +MVN_REPOSITORY_ID=ossrh + +cat > $OSSRH_DEPLOY_SETTINGS_XML << SETTINGS.XML + + + + + $MVN_REPOSITORY_ID + $SONATYPE_USER + $SONATYPE_PWD + + + +SETTINGS.XML + +MVN_OPTIONS+=( + "--settings" "$OSSRH_DEPLOY_SETTINGS_XML" + "--batch-mode" +) + +echo "[Info] Sign unshaded package and deploy to staging area" +project_version=$($THIS_DIR/scripts/get_project_info_from_pom.py $THIS_DIR/pom.xml version) +echo "[Info] Project version: $project_version" +$THIS_DIR/scripts/update_project_version.py pom.xml ${project_version} > generated_public_pom.xml + +mvn deploy ${MVN_OPTIONS[@]} -Dnot-shadeDep -Dossrh-deploy + +echo "[INFO] Close and Release" +snowflake_repositories=$(mvn ${MVN_OPTIONS[@]} \ + org.sonatype.plugins:nexus-staging-maven-plugin:1.6.7:rc-list \ + -DserverId=$MVN_REPOSITORY_ID -Dnot-shadeDep versions:set -DnewVersion=$project_version-unshaded -Dmaven.javadoc.skip=true -Dmaven.source.skip=true \ + -DnexusUrl=https://oss.sonatype.org/ | grep netsnowflake | awk '{print $2}') +IFS=" " +if (( $(echo $snowflake_repositories | wc -l)!=1 )); then + echo "[ERROR] Not single netsnowflake repository is staged. Login https://oss.sonatype.org/ and make sure no netsnowflake remains there." + exit 1 +fi +if ! mvn ${MVN_OPTIONS[@]} \ + org.sonatype.plugins:nexus-staging-maven-plugin:1.6.7:rc-close \ + -DserverId=$MVN_REPOSITORY_ID \ + -DnexusUrl=https://oss.sonatype.org/ \ + -DstagingRepositoryId=$snowflake_repositories \ + -Dnot-shadeDep versions:set -DnewVersion=$project_version-unshaded -Dmaven.javadoc.skip=true -Dmaven.source.skip=true\ + -DstagingDescription="Automated Close"; then + echo "[ERROR] Failed to close. Fix the errors and try this script again" + mvn ${MVN_OPTIONS[@]} \ + nexus-staging:rc-drop \ + -DserverId=$MVN_REPOSITORY_ID \ + -DnexusUrl=https://oss.sonatype.org/ \ + -DstagingRepositoryId=$snowflake_repositories -Dnot-shadeDep versions:set -DnewVersion=$project_version-unshaded -Dmaven.javadoc.skip=true -Dmaven.source.skip=true\ + -DstagingDescription="Failed to close. Dropping..." +fi + +mvn ${MVN_OPTIONS[@]} \ + org.sonatype.plugins:nexus-staging-maven-plugin:1.6.7:rc-release \ + -DserverId=$MVN_REPOSITORY_ID \ + -DnexusUrl=https://oss.sonatype.org/ \ + -DstagingRepositoryId=$snowflake_repositories -Dnot-shadeDep versions:set -DnewVersion=$project_version-unshaded -Dmaven.javadoc.skip=true -Dmaven.source.skip=true\ + -DstagingDescription="Automated Release" + +rm $OSSRH_DEPLOY_SETTINGS_XML diff --git a/unshaded_snapshot_deploy.sh b/unshaded_snapshot_deploy.sh new file mode 100755 index 000000000..a27911e4b --- /dev/null +++ b/unshaded_snapshot_deploy.sh @@ -0,0 +1,55 @@ +#!/bin/bash -e + +THIS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +export GPG_KEY_ID="Snowflake Computing" +export LDAP_USER="$INTERNAL_NEXUS_USERNAME" +export LDAP_PWD="$INTERNAL_NEXUS_PASSWORD" + +if [ -z "$GPG_KEY_PASSPHRASE" ]; then + echo "[ERROR] GPG passphrase is not specified for $GPG_KEY_ID!" + exit 1 +fi + +if [ -z "$GPG_PRIVATE_KEY" ]; then + echo "[ERROR] GPG private key file is not specified!" + exit 1 +fi + +echo "[INFO] Import PGP Key" +if ! gpg --list-secret-key | grep "$GPG_KEY_ID"; then + gpg --allow-secret-key-import --import "$GPG_PRIVATE_KEY" +fi + +# copy the settings.xml template and inject credential information +UNSHADED_SNAPSHOT_DEPLOY_SETTINGS_XML="$THIS_DIR/mvn_settings_unshaded_snapshot_deploy.xml" +MVN_REPOSITORY_ID=snapshot + +cat > $UNSHADED_SNAPSHOT_DEPLOY_SETTINGS_XML << SETTINGS.XML + + + + + $MVN_REPOSITORY_ID + $LDAP_USER + $LDAP_PWD + + + +SETTINGS.XML + +MVN_OPTIONS+=( + "--settings" "$UNSHADED_SNAPSHOT_DEPLOY_SETTINGS_XML" + "--batch-mode" +) + +echo "[Info] Sign unshaded package and deploy to staging area" +project_version=$($THIS_DIR/scripts/get_project_info_from_pom.py $THIS_DIR/pom.xml version) +echo "[Info] Project version: $project_version" +$THIS_DIR/scripts/update_project_version.py pom.xml ${project_version} > generated_public_pom.xml + +mvn deploy ${MVN_OPTIONS[@]} -Dnot-shadeDep -Dsnapshot-deploy + +rm $UNSHADED_SNAPSHOT_DEPLOY_SETTINGS_XML \ No newline at end of file