diff --git a/.gitattributes b/.gitattributes index b3623c426e7..6bbad541ac6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,7 +18,6 @@ .github/ export-ignore .idea/ export-ignore .readthedocs.yml export-ignore -.travis.yml export-ignore _config.yml export-ignore codecov.yml export-ignore licenses-binary/ export-ignore diff --git a/.github/ISSUE_TEMPLATE/dependency.yml b/.github/ISSUE_TEMPLATE/dependency.yml new file mode 100644 index 00000000000..e71c7d1c64a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/dependency.yml @@ -0,0 +1,109 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# See https://gh-community.github.io/issue-template-feedback/structured/ + +name: Dependency +title: ":arrow_up: Upgrade from to " +description: Keep upstream dependencies fresh and stable +labels: [ "kind:build, priority:major, good first issue, help wanted" ] +body: + - type: markdown + attributes: + value: | + Thank you for finding the time to report the issue! We really appreciate the community's efforts to improve Kyuubi. + + It doesn't really matter whether what you are reporting is a bug or not, just feel free to share the problem you have + encountered with the community. For best practices, if it is indeed a bug, please try your best to provide the reproducible + steps. If you want to ask questions or share ideas, please [subscribe to our mailing list](mailto:dev-subscribe@kyuubi.apache.org) + and send emails to [our mailing list](mailto:dev@kyuubi.apache.org), you can also head to our + [Discussions](https://github.com/apache/kyuubi/discussions) tab. + + - type: checkboxes + attributes: + label: Code of Conduct + description: The Code of Conduct helps create a safe space for everyone. We require that everyone agrees to it. + options: + - label: > + I agree to follow this project's [Code of Conduct](https://www.apache.org/foundation/policies/conduct) + required: true + + - type: checkboxes + attributes: + label: Search before asking + options: + - label: > + I have searched in the [issues](https://github.com/apache/kyuubi/issues?q=is%3Aissue) and found no similar + issues. + required: true + + - type: dropdown + id: priority + attributes: + label: Why do we need to upgrade this artifact? + options: + - Common Vulnerabilities and Exposures (CVE) + - Bugfixes + - Usage of New Features + - Performance Improvements + - Regular Updates + validations: + required: true + + - type: input + id: artifact + attributes: + label: Artifact Name + description: Which artifact shall be upgraded? + placeholder: e.g. spark-sql + value: https://mvnrepository.com/search?q= + validations: + required: true + + - type: input + id: versions + attributes: + label: Target Version + description: Which version shall be upgraded? + placeholder: e.g. 1.2.1 + validations: + required: true + + - type: textarea + id: changes + attributes: + label: Notable Changes + description: Please provide notable changes, or release notes if any + validations: + required: false + + - type: checkboxes + attributes: + label: Are you willing to submit PR? + description: > + A pull request is optional, but we are glad to help you in the contribution process + especially if you already know a good understanding of how to implement the fix. + Kyuubi is a community-driven project and we love to bring new contributors in. + options: + - label: Yes. I would be willing to submit a PR with guidance from the Kyuubi community to fix. + - label: No. I cannot submit a PR at this time. + + - type: markdown + attributes: + value: > + After changing the corresponding dependency version and before submitting your pull request, + it is necessary to execute `build/dependency.sh --replace` locally to update `dev/dependencyList`. diff --git a/.github/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE index 6e37b26c46f..bdb71f30fb1 100644 --- a/.github/PULL_REQUEST_TEMPLATE +++ b/.github/PULL_REQUEST_TEMPLATE @@ -20,4 +20,4 @@ Please clarify why the changes are needed. For instance, - [ ] Add screenshots for manual tests if appropriate -- [ ] [Run test](https://kyuubi.apache.org/docs/latest/develop_tools/testing.html#running-tests) locally before make a pull request +- [ ] [Run test](https://kyuubi.readthedocs.io/en/master/develop_tools/testing.html#running-tests) locally before make a pull request diff --git a/.github/actions/cache-engine-archives/action.yaml b/.github/actions/cache-engine-archives/action.yaml new file mode 100644 index 00000000000..86a9ccafb95 --- /dev/null +++ b/.github/actions/cache-engine-archives/action.yaml @@ -0,0 +1,27 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: cache-engine-archives +description: 'Cache download engine archives from Apache Archives website used by Maven download plugin' +runs: + using: composite + steps: + - name: Cache Engine Archives + uses: actions/cache@v3 + with: + path: /tmp/engine-archives + key: engine-archives diff --git a/.github/actions/setup-mvnd/action.yaml b/.github/actions/setup-mvnd/action.yaml new file mode 100644 index 00000000000..dac05c02479 --- /dev/null +++ b/.github/actions/setup-mvnd/action.yaml @@ -0,0 +1,35 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: 'setup-mvnd' +description: 'Setup Maven and Mvnd' +runs: + using: composite + steps: + - name: Cache Mvnd + uses: actions/cache@v3 + with: + path: | + build/maven-mvnd-* + build/apache-maven-* + key: setup-mvnd-${{ runner.os }} + - name: Check Maven + run: build/mvn -v + shell: bash + - name: Check Mvnd + run: build/mvnd -v || true + shell: bash diff --git a/.github/labeler.yml b/.github/labeler.yml index a9f79a5374d..ecec1253274 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -45,7 +45,6 @@ - ".gitattributes" - ".github/**/*" - ".gitignore" - - ".travis.yml" - "LICENSE" - "LICENSE-binary" - "NOTICE" @@ -103,7 +102,8 @@ "module:server": - "bin/kyuubi" - - "kyuubi-server/**/*" + - "kyuubi-server/src/**/*" + - "kyuubi-server/pom.xml" - "extension/server/kyuubi-server-plugin/**/*" "module:spark": @@ -122,3 +122,6 @@ "module:authz": - "extensions/spark/kyuubi-spark-authz/**/*" + +"module:ui": + - "kyuubi-server/web-ui/**/*" diff --git a/.github/workflows/dep.yml b/.github/workflows/dep.yml index 5ea4447cc47..09197951a12 100644 --- a/.github/workflows/dep.yml +++ b/.github/workflows/dep.yml @@ -23,11 +23,13 @@ on: - master - branch-* paths: - # dependency check happens only pom changes + # when pom or dependency workflow changes - '**/pom.xml' + - '.github/workflows/dep.yml' + - .github/actions/setup-mvnd/*.yaml concurrency: - group: dep-${{ github.ref }} + group: dep-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: @@ -43,11 +45,22 @@ jobs: java-version: 8 cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd + - name: Check kyuubi modules available + id: modules-check + run: >- + build/mvnd dependency:resolve validate -q + -DincludeGroupIds="org.apache.kyuubi" -DincludeScope="compile" + -Pfast -Denforcer.skip=false + -pl kyuubi-ctl,kyuubi-server,kyuubi-assembly -am + continue-on-error: true - name: build env: MAVEN_OPTS: -Dorg.slf4j.simpleLogger.defaultLogLevel=error + if: steps.modules-check.conclusion == 'success' && steps.modules-check.outcome == 'failure' run: >- - build/mvn clean install + build/mvnd clean install -Pflink-provided,spark-provided,hive-provided -Dmaven.javadoc.skip=true -Drat.skip=true @@ -57,3 +70,7 @@ jobs: -pl kyuubi-ctl,kyuubi-server,kyuubi-assembly -am - name: Check dependency list run: build/dependency.sh + - name: Dependency Review + uses: actions/dependency-review-action@v3 + with: + fail-on-severity: moderate diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000000..55cb6b8b16b --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,48 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +name: Docs + +on: + pull_request: + branches: + - master + +concurrency: + group: docs-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + sphinx: + name: sphinx-build + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.9' + cache: 'pip' + cache-dependency-path: docs/requirements.txt + - run: pip install -r docs/requirements.txt + - name: make html + run: make -d --directory docs html + - name: upload html + uses: actions/upload-artifact@v3 + with: + path: | + docs/_build/html/ + !docs/_build/html/_sources/ diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml index 43c7a5585a7..77fc14e8078 100644 --- a/.github/workflows/greetings.yml +++ b/.github/workflows/greetings.yml @@ -22,7 +22,7 @@ on: issues jobs: greeting: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: issues: write pull-requests: write diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index eb5d898e900..c4cad7aef2d 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -26,7 +26,7 @@ permissions: jobs: triage: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/labeler@v4 with: diff --git a/.github/workflows/license.yml b/.github/workflows/license.yml index 73ef05864a2..e62605e7f09 100644 --- a/.github/workflows/license.yml +++ b/.github/workflows/license.yml @@ -26,7 +26,7 @@ on: - branch-* concurrency: - group: lincense-${{ github.ref }} + group: license-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: @@ -42,8 +42,10 @@ jobs: java-version: 8 cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd - run: >- - build/mvn org.apache.rat:apache-rat-plugin:check + build/mvnd org.apache.rat:apache-rat-plugin:check -Ptpcds -Pspark-block-cleaner -Pkubernetes-it -Pspark-3.1 -Pspark-3.2 -Pspark-3.3 - name: Upload rat report diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 6bb2658efa1..b8b3f7072ac 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -28,11 +28,13 @@ on: - branch-* concurrency: - group: test-${{ github.ref }} + group: test-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: - MVN_OPT: -Dmaven.javadoc.skip=true -Drat.skip=true -Dscalastyle.skip=true -Dspotless.check.skip -Dorg.slf4j.simpleLogger.defaultLogLevel=warn -Pjdbc-shaded + MVN_OPT: -Dmaven.javadoc.skip=true -Drat.skip=true -Dscalastyle.skip=true -Dspotless.check.skip -Dorg.slf4j.simpleLogger.defaultLogLevel=warn -Pjdbc-shaded -Dmaven.plugin.download.cache.path=/tmp/engine-archives + KUBERNETES_VERSION: v1.26.1 + MINIKUBE_VERSION: v1.29.0 jobs: default: @@ -75,10 +77,14 @@ jobs: java-version: ${{ matrix.java }} cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: Setup Python uses: actions/setup-python@v4 with: - python-version: '3.9' + python-version: '3.9' - name: Build and test Kyuubi and Spark with maven w/o linters run: | TEST_MODULES="dev/kyuubi-codecov" @@ -100,6 +106,7 @@ jobs: path: | **/target/unit-tests.log **/kyuubi-spark-sql-engine.log* + **/kyuubi-spark-batch-submit.log* authz: name: Kyuubi-AuthZ and Spark Test @@ -126,6 +133,10 @@ jobs: java-version: ${{ matrix.java }} cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: Build and test Kyuubi AuthZ with supported Spark versions run: | TEST_MODULES="extensions/spark/kyuubi-spark-authz" @@ -150,20 +161,15 @@ jobs: - 8 - 11 flink: - - '1.14' - '1.15' - '1.16' flink-archive: [ "" ] comment: [ "normal" ] include: - java: 8 - flink: '1.15' - flink-archive: '-Dflink.archive.mirror=https://archive.apache.org/dist/flink/flink-1.14.5 -Dflink.archive.name=flink-1.14.5-bin-scala_2.12.tgz' - comment: 'verify-on-flink-1.14-binary' - - java: 8 - flink: '1.15' - flink-archive: '-Dflink.archive.mirror=https://archive.apache.org/dist/flink/flink-1.16.0 -Dflink.archive.name=flink-1.16.0-bin-scala_2.12.tgz' - comment: 'verify-on-flink-1.16-binary' + flink: '1.16' + flink-archive: '-Dflink.archive.mirror=https://archive.apache.org/dist/flink/flink-1.15.4 -Dflink.archive.name=flink-1.15.4-bin-scala_2.12.tgz' + comment: 'verify-on-flink-1.15-binary' steps: - uses: actions/checkout@v3 - name: Tune Runner VM @@ -175,6 +181,10 @@ jobs: java-version: ${{ matrix.java }} cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: Build Flink with maven w/o linters run: | TEST_MODULES="externals/kyuubi-flink-sql-engine,integration-tests/kyuubi-flink-it" @@ -219,6 +229,10 @@ jobs: java-version: ${{ matrix.java }} cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: Build and test Hive with maven w/o linters run: | TEST_MODULES="externals/kyuubi-hive-sql-engine,integration-tests/kyuubi-hive-it" @@ -254,6 +268,10 @@ jobs: java-version: ${{ matrix.java }} cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: Build and test JDBC with maven w/o linters run: | TEST_MODULES="externals/kyuubi-jdbc-engine,integration-tests/kyuubi-jdbc-it" @@ -289,11 +307,15 @@ jobs: java-version: ${{ matrix.java }} cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: Build and test Trino with maven w/o linters run: | - TEST_MODULES="externals/kyuubi-trino-engine,integration-tests/kyuubi-trino-it" - ./build/mvn ${MVN_OPT} -pl ${TEST_MODULES} -am clean install -DskipTests - ./build/mvn ${MVN_OPT} -pl ${TEST_MODULES} test + TEST_MODULES="kyuubi-server,externals/kyuubi-trino-engine,externals/kyuubi-spark-sql-engine,externals/kyuubi-download,integration-tests/kyuubi-trino-it" + ./build/mvn ${MVN_OPT} -pl ${TEST_MODULES} -am -Pflink-provided -Phive-provided clean install -DskipTests + ./build/mvn -Dmaven.javadoc.skip=true -Drat.skip=true -Dscalastyle.skip=true -Dspotless.check.skip -pl ${TEST_MODULES} -am -Pflink-provided -Phive-provided test -Dtest=none -DwildcardSuites=org.apache.kyuubi.it.trino.operation.TrinoOperationSuite,org.apache.kyuubi.it.trino.server.TrinoFrontendSuite - name: Upload test logs if: failure() uses: actions/upload-artifact@v3 @@ -319,6 +341,10 @@ jobs: java-version: 8 cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: Run TPC-DS Tests run: | TEST_MODULES="kyuubi-server,extensions/spark/kyuubi-spark-connector-tpcds,extensions/spark/kyuubi-spark-connector-tpch" @@ -347,15 +373,19 @@ jobs: file: build/Dockerfile load: true tags: apache/kyuubi:latest - # from https://github.com/marketplace/actions/setup-minikube-kubernetes-cluster + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: Setup Minikube - uses: manusa/actions-setup-minikube@v2.7.2 - with: - minikube version: 'v1.28.0' - kubernetes version: 'v1.25.4' - github token: ${{ secrets.GITHUB_TOKEN }} + run: | + # https://minikube.sigs.k8s.io/docs/start/ + curl -LO https://github.com/kubernetes/minikube/releases/download/${MINIKUBE_VERSION}/minikube-linux-amd64 + sudo install minikube-linux-amd64 /usr/local/bin/minikube + minikube start --cpus 2 --memory 4096 --kubernetes-version=${KUBERNETES_VERSION} --force + # https://minikube.sigs.k8s.io/docs/handbook/pushing/#7-loading-directly-to-in-cluster-container-runtime + minikube image load apache/kyuubi:latest - name: kubectl pre-check run: | + kubectl get nodes kubectl get serviceaccount kubectl create serviceaccount kyuubi kubectl create clusterrolebinding kyuubi-role --clusterrole=edit --serviceaccount=default:kyuubi @@ -394,14 +424,14 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - # from https://github.com/marketplace/actions/setup-minikube-kubernetes-cluster + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: Setup Minikube - uses: manusa/actions-setup-minikube@v2.7.2 - with: - minikube version: 'v1.25.2' - kubernetes version: 'v1.23.3' - driver: docker - start args: '--extra-config=kubeadm.ignore-preflight-errors=NumCPU --force --cpus 2 --memory 4096' + run: | + # https://minikube.sigs.k8s.io/docs/start/ + curl -LO https://github.com/kubernetes/minikube/releases/download/${MINIKUBE_VERSION}/minikube-linux-amd64 + sudo install minikube-linux-amd64 /usr/local/bin/minikube + minikube start --cpus 2 --memory 4096 --kubernetes-version=${KUBERNETES_VERSION} --force # in case: https://spark.apache.org/docs/latest/running-on-kubernetes.html#rbac - name: Create Service Account run: | @@ -413,7 +443,6 @@ jobs: run: >- ./build/mvn ${MVN_OPT} clean install -Pflink-provided,hive-provided - -Pspark-3.2 -Pkubernetes-it -Dtest=none -DwildcardSuites=org.apache.kyuubi.kubernetes.test.spark - name: Print Driver Pod logs @@ -451,6 +480,10 @@ jobs: java-version: ${{ matrix.java }} cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd + - name: Cache Engine Archives + uses: ./.github/actions/cache-engine-archives - name: zookeeper integration tests run: | export KYUUBI_IT_ZOOKEEPER_VERSION=${{ matrix.zookeeper }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 149da6d82b3..b53a7d29294 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -43,6 +43,8 @@ jobs: java-version: 8 cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd - name: Build with Maven run: ./build/mvn clean install ${{ matrix.profiles }} -Dmaven.javadoc.skip=true -V - name: Upload test logs diff --git a/.github/workflows/docker-image.yml b/.github/workflows/publish-snapshot-docker.yml similarity index 60% rename from .github/workflows/docker-image.yml rename to .github/workflows/publish-snapshot-docker.yml index b403e46b53b..3afccee7aa8 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/publish-snapshot-docker.yml @@ -15,33 +15,37 @@ # limitations under the License. # -name: Publish Docker image +name: Publish Snapshot Docker Image on: - push: - branches: - - master + schedule: + - cron: '0 0 * * *' jobs: push_to_registry: - name: Push Docker image to Docker Hub + name: Push Snapshot Docker Image to Docker Hub if: ${{ startsWith(github.repository, 'apache/') }} runs-on: ubuntu-22.04 - concurrency: - # this group should be global unique - group: push-docker-image - cancel-in-progress: true steps: - name: Checkout uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 - name: Login to Docker Hub uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build Kyuubi Docker Image - run: docker build --tag apache/kyuubi:master-snapshot --file build/Dockerfile . - - name: Docker image - run: docker images - - name: Push Docker image - run: docker push apache/kyuubi:master-snapshot + - name: Build and Push Kyuubi Docker Image + uses: docker/build-push-action@v4 + with: + # build cache on Github Actions, See: https://docs.docker.com/build/cache/backends/gha/#using-dockerbuild-push-action + cache-from: type=gha + cache-to: type=gha,mode=max + context: . + file: build/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: apache/kyuubi:master-snapshot diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot-nexus.yml similarity index 69% rename from .github/workflows/publish-snapshot.yml rename to .github/workflows/publish-snapshot-nexus.yml index acd04bfab80..0d4222b044a 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot-nexus.yml @@ -15,11 +15,11 @@ # limitations under the License. # -name: Publish Snapshot +name: Publish Snapshot Nexus on: schedule: - - cron: '0 0 * * *' + - cron: '0 0 * * *' jobs: publish-snapshot: @@ -41,19 +41,19 @@ jobs: - branch: branch-1.6 profiles: -Pflink-provided,spark-provided,hive-provided,spark-3.3 steps: - - name: Checkout repository - uses: actions/checkout@v3 - with: - ref: ${{ matrix.branch }} - - name: Setup JDK 8 - uses: actions/setup-java@v3 - with: - distribution: temurin - java-version: 8 - cache: 'maven' - check-latest: false - - name: Publish snapshot - ${{ matrix.branch }} - env: - ASF_USERNAME: ${{ secrets.NEXUS_USER }} - ASF_PASSWORD: ${{ secrets.NEXUS_PW }} - run: ./build/mvn clean deploy -s ./build/release/asf-settings.xml -DskipTests ${{ matrix.profiles }} + - name: Checkout repository + uses: actions/checkout@v3 + with: + ref: ${{ matrix.branch }} + - name: Setup JDK 8 + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 8 + cache: 'maven' + check-latest: false + - name: Publish Snapshot Jar to Nexus - ${{ matrix.branch }} + env: + ASF_USERNAME: ${{ secrets.NEXUS_USER }} + ASF_PASSWORD: ${{ secrets.NEXUS_PW }} + run: build/mvn clean deploy -s build/release/asf-settings.xml -DskipTests ${{ matrix.profiles }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e1dfde6f47f..d189cd205db 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -23,11 +23,11 @@ on: jobs: stale: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: pull-requests: write steps: - - uses: actions/stale@v6 + - uses: actions/stale@v7 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-pr-message: > diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index c848a2f8c42..2824e597288 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -24,7 +24,7 @@ on: - branch-* concurrency: - group: linter-${{ github.ref }} + group: linter-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: @@ -34,10 +34,12 @@ jobs: strategy: matrix: profiles: - - '-Pflink-provided,hive-provided,spark-provided,spark-block-cleaner,spark-3.3,spark-3.2,spark-3.1,tpcds' + - '-Pflink-provided,hive-provided,spark-provided,spark-block-cleaner,spark-3.3,spark-3.2,spark-3.1,tpcds,kubernetes-it' steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 - name: Setup JDK 8 uses: actions/setup-java@v3 with: @@ -45,14 +47,16 @@ jobs: java-version: 8 cache: 'maven' check-latest: false + - name: Setup Maven and Mvnd + uses: ./.github/actions/setup-mvnd - name: Setup Python 3 uses: actions/setup-python@v4 with: python-version: '3.9' cache: 'pip' - - name: Check kyuubi modules avaliable + - name: Check kyuubi modules available id: modules-check - run: build/mvn dependency:resolve -DincludeGroupIds="org.apache.kyuubi" -DincludeScope="compile" -DexcludeTransitive=true ${{ matrix.profiles }} + run: build/mvnd dependency:resolve -DincludeGroupIds="org.apache.kyuubi" -DincludeScope="compile" -DexcludeTransitive=true -q ${{ matrix.profiles }} continue-on-error: true - name: Install @@ -61,13 +65,13 @@ jobs: if: steps.modules-check.conclusion == 'success' && steps.modules-check.outcome == 'failure' run: | MVN_OPT="-DskipTests -Dorg.slf4j.simpleLogger.defaultLogLevel=warn -Dmaven.javadoc.skip=true -Drat.skip=true -Dscalastyle.skip=true -Dspotless.check.skip" - build/mvn clean install ${MVN_OPT} -Pflink-provided,hive-provided,spark-provided,spark-block-cleaner,spark-3.2,tpcds - build/mvn clean install ${MVN_OPT} -pl extensions/spark/kyuubi-extension-spark-3-1 -Pspark-3.1 - build/mvn clean install ${MVN_OPT} -pl extensions/spark/kyuubi-extension-spark-3-3,extensions/spark/kyuubi-spark-connector-kudu,extensions/spark/kyuubi-spark-connector-hive -Pspark-3.3 + build/mvnd clean install ${MVN_OPT} -Pflink-provided,hive-provided,spark-provided,spark-block-cleaner,spark-3.2,tpcds + build/mvnd clean install ${MVN_OPT} -pl extensions/spark/kyuubi-extension-spark-3-1 -Pspark-3.1 + build/mvnd clean install ${MVN_OPT} -pl extensions/spark/kyuubi-extension-spark-3-3,extensions/spark/kyuubi-spark-connector-kudu,extensions/spark/kyuubi-spark-connector-hive -Pspark-3.3 - name: Scalastyle with maven id: scalastyle-check - run: build/mvn scalastyle:check ${{ matrix.profiles }} + run: build/mvnd scalastyle:check -q ${{ matrix.profiles }} - name: Print scalastyle error report if: failure() && steps.scalastyle-check.outcome != 'success' run: >- @@ -81,7 +85,7 @@ jobs: run: | SPOTLESS_BLACK_VERSION=$(build/mvn help:evaluate -Dexpression=spotless.python.black.version -q -DforceStdout) pip install black==$SPOTLESS_BLACK_VERSION - build/mvn spotless:check ${{ matrix.profiles }} -Pspotless-python + build/mvnd spotless:check -q ${{ matrix.profiles }} -Pspotless-python - name: setup npm uses: actions/setup-node@v3 with: @@ -89,7 +93,7 @@ jobs: - name: Web UI Style with node run: | cd ./kyuubi-server/web-ui - npm install pnpm -g + npm install pnpm@7 -g pnpm install pnpm run lint echo "---------------------------------------Notice------------------------------------" @@ -102,10 +106,32 @@ jobs: echo "---------------------------------------------------------------------------------" shellcheck: - name: Shellcheck + name: Super Linter and Shellcheck runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 + - name: Super Linter Checks + uses: github/super-linter/slim@v4 + env: + CREATE_LOG_FILE: true + ERROR_ON_MISSING_EXEC_BIT: true + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IGNORE_GENERATED_FILES: true + IGNORE_GITIGNORED_FILES: true + LINTER_RULES_PATH: / + LOG_LEVEL: NOTICE + SUPPRESS_POSSUM: true + VALIDATE_BASH_EXEC: true + VALIDATE_ENV: true + VALIDATE_JSONC: true + VALIDATE_POWERSHELL: true + VALIDATE_XML: true + - name: Upload Super Linter logs + if: failure() + uses: actions/upload-artifact@v3 + with: + name: super-linter-log + path: super-linter.log - name: check bin directory uses: ludeeus/action-shellcheck@1.1.0 with: diff --git a/.github/workflows/web-ui.yml b/.github/workflows/web-ui.yml index 08c97cfc9e0..2a48eeaa1ea 100644 --- a/.github/workflows/web-ui.yml +++ b/.github/workflows/web-ui.yml @@ -11,7 +11,7 @@ on: - branch-* concurrency: - group: web-ui-${{ github.ref }} + group: web-ui-${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: @@ -28,7 +28,7 @@ jobs: - name: npm run coverage & build run: | cd ./kyuubi-server/web-ui - npm install pnpm -g + npm install pnpm@7 -g pnpm install pnpm run coverage pnpm run build diff --git a/.gitignore b/.gitignore index d2c1ba3b7d0..190294d06f3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,11 +32,7 @@ .ensime_lucene .generated-mima* .vscode/ -# The star is required for further !/.idea/ to work, see https://git-scm.com/docs/gitignore -/.idea/* -# Icon for JetBrains Toolbox -!/.idea/icon.png -!/.idea/vcs.xml +.idea/ .idea_modules/ .project .pydevproject @@ -44,6 +40,7 @@ .scala_dependencies .settings build/apache-maven* +build/maven-mvnd* build/release/tmp build/scala* build/test @@ -59,10 +56,9 @@ hs_err_pid* spark-warehouse/ metastore_db derby.log -ldap +rest-audit.log **/dependency-reduced-pom.xml -metrics/report.json -metrics/.report.json.crc +metrics/ /kyuubi-ha/embedded_zookeeper/ embedded_zookeeper/ /externals/kyuubi-spark-sql-engine/operation_logs/ diff --git a/.rat-excludes b/.rat-excludes index 86c38ec9925..7a841cf9c6c 100644 --- a/.rat-excludes +++ b/.rat-excludes @@ -32,6 +32,7 @@ NOTICE* docs/** build/apache-maven-*/** +build/maven-mvnd-*/** build/scala-*/** **/**/operation_logs/**/** **/**/server_operation_logs/**/** @@ -50,6 +51,8 @@ build/scala-*/** **/metadata-store-schema*.sql **/*.derby.sql **/*.mysql.sql +**/node/** +**/web-ui/dist/** **/pnpm-lock.yaml **/node_modules/** **/gen/* diff --git a/.scalafmt.conf b/.scalafmt.conf index 2ccb453ddb6..e682a17f71f 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = 3.6.1 +version = 3.7.1 runner.dialect=scala212 project.git=true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c09fa9566e4..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,71 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -sudo: required -dist: focal -arch: arm64-graviton2 -group: edge -virt: vm -env: SPARK_LOCAL_IP=localhost - -branches: - only: - - master - -language: java - -matrix: - include: - - name: Build Kyuubi common on Linux ARM64 - script: - - ./build/mvn test $MVN_ARGS -pl kyuubi-common,kyuubi-zookeeper,kyuubi-ha,kyuubi-ctl,kyuubi-metrics,kyuubi-hive-beeline,kyuubi-hive-jdbc,extensions/server/kyuubi-server-plugin -am - - name: Build Kyuubi Flink on Linux ARM64 - script: - - ./build/mvn test $MVN_ARGS -pl externals/kyuubi-flink-sql-engine,integration-tests/kyuubi-flink-it - - name: Build Kyuubi Spark on Linux ARM64 - script: - - ./build/mvn test $MVN_ARGS -pl externals/kyuubi-spark-sql-engine - - ./build/mvn test $MVN_ARGS -pl kyuubi-server -DwildcardSuites=org.apache.kyuubi.operation.KyuubiOperationPerUserSuite - - name: Build Kyuubi Trino on Linux ARM64 - script: - - ./build/mvn test $MVN_ARGS -pl externals/kyuubi-trino-engine,integration-tests/kyuubi-trino-it - - name: Build Kyuubi Hive on Linux ARM64 - script: - - ./build/mvn test $MVN_ARGS -pl externals/kyuubi-hive-sql-engine,integration-tests/kyuubi-hive-it - -cache: - directories: - - $HOME/.m2 - -install: - - sudo apt update - - sudo apt install -y openjdk-8-jdk - - export JAVA_HOME="/usr/lib/jvm/java-8-openjdk-${TRAVIS_CPU_ARCH}" - - export PATH="$JAVA_HOME/bin:/usr/share/maven/bin:$PATH" - - ./build/mvn --version - -before_script: - - export MVN_ARGS="-Dmaven.javadoc.skip=true -Drat.skip=true -Dscalastyle.skip=true -Dspotless.check.skip -V -B -ntp -Dorg.slf4j.simpleLogger.defaultLogLevel=warn -Pjdbc-shaded" - - ./build/mvn clean install -DskipTests $MVN_ARGS - - -after_success: - - echo "Travis exited with ${TRAVIS_TEST_RESULT}" - -after_failure: - - echo "Travis exited with ${TRAVIS_TEST_RESULT}" - - for log in `find * -name "unit-tests.log"`; do echo "=========$log========="; grep "ERROR" $log -A 100 -B 5; done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b6348cd29a..ef28d560e36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,25 +1,25 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Contributing to Apache Kyuubi Thanks for your interest in the Apache Kyuubi project. Contributions are welcome and are greatly appreciated! -Every little bit helps, and a credit will always be given. +Every little effort helps, and a credit will always be given. This page provides some orientation and resources we have for you to get involved. It also offers recommendations on getting the best results when engaging with the community. @@ -31,7 +31,7 @@ In the process of using Apache Kyuubi, if you have any questions, suggestions, o - Join the [Mailing Lists](https://kyuubi.apache.org/mailing_lists.html) - the best way to keep up-to-date with the community. - [Issue Tracker](https://kyuubi.apache.org/issue_tracking.html) - tracking bugs, ideas, plans, etc. -- [Github Discussions](https://github.com/apache/kyuubi/discussions) - second to mailing list for anything else you want to share or ask +- [GitHub Discussions](https://github.com/apache/kyuubi/discussions) - second to mailing list for anything else you want to share or ask - [Slack](https://join.slack.com/t/apachekyuubi/shared_invite/zt-1e1qw68g4-yE5HJsVVDin~ABtZISyuxg) - chat with our community User && Developer anytime! ## Contributing Guide @@ -44,8 +44,8 @@ There are many ways to make valuable contributions to the project and community. You can make various types of contributions to Kyuubi, including the following but not limited to, - Answer questions in the [Mailing Lists](https://kyuubi.apache.org/mailing_lists.html) -- [Share your success stories with us](https://github.com/apache/kyuubi/discussions/925) -- Improve Documentation - [![Documentation Status](https://readthedocs.org/projects/kyuubi/badge/?version=latest)](https://kyuubi.apache.org/docs/latest/) +- [Share your success stories with us](https://github.com/apache/kyuubi/discussions/925) +- Improve Documentation - [![Documentation Status](https://readthedocs.org/projects/kyuubi/badge/?version=latest)](https://kyuubi.readthedocs.io/en/master/) - Test latest releases - [![Latest tag](https://img.shields.io/github/v/tag/apache/kyuubi?label=tag)](https://github.com/apache/kyuubi/tags) - Improve test coverage - [![codecov](https://codecov.io/gh/apache/kyuubi/branch/master/graph/badge.svg)](https://codecov.io/gh/apache/kyuubi) - Report bugs and better help developers to reproduce @@ -59,4 +59,5 @@ You can make various types of contributions to Kyuubi, including the following b TBD, please be patient for the surprise. ## IDE Setup Guide + [IntelliJ IDEA Setup Guide](https://kyuubi.readthedocs.io/en/master/develop_tools/idea_setup.html) diff --git a/LICENSE-binary b/LICENSE-binary index e80398a431a..a52ea95fbf0 100644 --- a/LICENSE-binary +++ b/LICENSE-binary @@ -319,9 +319,14 @@ io.swagger.core.v3:swagger-models io.vertx:vertx-core io.vertx:vertx-grpc org.apache.zookeeper:zookeeper +com.squareup.retrofit2:retrofit +com.squareup.okhttp3:okhttp BSD ------------ +org.antlr:antlr-runtime +org.antlr:antlr4-runtime +org.antlr:ST4 jline:jline com.thoughtworks.paranamer:paranamer dk.brics.automaton:automaton @@ -353,6 +358,9 @@ org.codehaus.mojo:animal-sniffer-annotations org.slf4j:slf4j-api org.slf4j:jcl-over-slf4j org.slf4j:jul-over-slf4j +com.theokanning.openai-gpt3-java:api +com.theokanning.openai-gpt3-java:client +com.theokanning.openai-gpt3-java:service kyuubi-server/src/main/resources/org/apache/kyuubi/ui/static/assets/fonts/* kyuubi-server/src/main/resources/org/apache/kyuubi/ui/static/icon.min.css @@ -448,6 +456,8 @@ is auto-generated by `pnpm licenses list --prod`. ├────────────────────────────────────┼──────────────┤ │ csstype │ MIT │ ├────────────────────────────────────┼──────────────┤ +│ date-fns │ MIT │ +├────────────────────────────────────┼──────────────┤ │ dayjs │ MIT │ ├────────────────────────────────────┼──────────────┤ │ delayed-stream │ MIT │ diff --git a/README.md b/README.md index 6ac866c3071..e54f6fac00d 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,58 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> + +

+ Kyuubi logo +

+ +

+ + + + + + + + + + + + + + + +

+

+ Project + - + Documentation + - + Who's using +

# Apache Kyuubi -Kyuubi logo - -[![License](https://img.shields.io/badge/license-Apache%202-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) -[![Release](https://img.shields.io/github/v/release/apache/kyuubi?label=release)](https://github.com/apache/kyuubi/releases) -[![](https://tokei.rs/b1/github.com/apache/kyuubi)](https://github.com/apache/kyuubi) -[![codecov](https://codecov.io/gh/apache/kyuubi/branch/master/graph/badge.svg)](https://codecov.io/gh/apache/kyuubi) -![GitHub Workflow Status](https://img.shields.io/github/workflow/status/apache/kyuubi/Kyuubi/master?style=plastic) -[![Travis](https://api.travis-ci.com/apache/kyuubi.svg?branch=master)](https://travis-ci.com/apache/kyuubi) -[![Documentation Status](https://readthedocs.org/projects/kyuubi/badge/?version=latest)](https://kyuubi.apache.org/docs/latest/) -![GitHub top language](https://img.shields.io/github/languages/top/apache/kyuubi) -[![Commit activity](https://img.shields.io/github/commit-activity/m/apache/kyuubi)](https://github.com/apache/kyuubi/graphs/commit-activity) -[![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/apache/kyuubi.svg)](http://isitmaintained.com/project/apache/kyuubi "Average time to resolve an issue") -[![Percentage of issues still open](http://isitmaintained.com/badge/open/apache/kyuubi.svg)](http://isitmaintained.com/project/apache/kyuubi "Percentage of issues still open") +Apache Kyuubi™ is a distributed and multi-tenant gateway to provide serverless +SQL on data warehouses and lakehouses. + ## What is Kyuubi? -Apache Kyuubi™ is a distributed and multi-tenant gateway to provide serverless -SQL on data warehouses and lakehouses. - Kyuubi provides a pure SQL gateway through Thrift JDBC/ODBC interface for end-users to manipulate large-scale data with pre-programmed and extensible Spark SQL engines. This "out-of-the-box" model minimizes the barriers and costs for end-users to use Spark at the client side. At the server-side, Kyuubi server and engines' multi-tenant architecture provides the administrators a way to achieve computing resource isolation, data security, high availability, high client concurrency, etc. ![](./docs/imgs/kyuubi_positioning.png) @@ -45,12 +61,10 @@ Kyuubi provides a pure SQL gateway through Thrift JDBC/ODBC interface for end-us - [x] Multi-tenant Spark Support - [x] Running Spark in a serverless way - ### Target Users Kyuubi's goal is to make it easy and efficient for `anyone` to use Spark(maybe other engines soon) and facilitate users to handle big data like ordinary data. Here, `anyone` means that users do not need to have a Spark technical background but a human language, SQL only. Sometimes, SQL skills are unnecessary when integrating Kyuubi with Apache Superset, which supports rich visualizations and dashboards. - In typical big data production environments with Kyuubi, there should be system administrators and end-users. - System administrators: A small group consists of Spark experts responsible for Kyuubi deployment, configuration, and tuning. @@ -58,7 +72,6 @@ In typical big data production environments with Kyuubi, there should be system Additionally, the Kyuubi community will continuously optimize the whole system with various features, such as History-Based Optimizer, Auto-tuning, Materialized View, SQL Dialects, Functions, e.t.c. - ### Usage scenarios #### Port workloads from HiveServer2 to Spark SQL @@ -71,7 +84,6 @@ HiveServer2 can identify and authenticate a caller, and then if the caller also Kyuubi extends the use of STS in a multi-tenant model based on a unified interface and relies on the concept of multi-tenancy to interact with cluster managers to finally gain the ability of resources sharing/isolation and data security. The loosely coupled architecture of the Kyuubi server and engine dramatically improves the client concurrency and service stability of the service itself. - #### DataLake/LakeHouse Support The vision of Kyuubi is to unify the portal and become an easy-to-use data lake management platform. Different kinds of workloads, such as ETL processing and BI analytics, can be supported by one platform, using one copy of data, with one SQL interface. @@ -80,25 +92,19 @@ The vision of Kyuubi is to unify the portal and become an easy-to-use data lake - Multiple Catalogs support - SQL Standard Authorization support for DataLake(coming) - #### Cloud Native Support Kyuubi can deploy its engines on different kinds of Cluster Managers, such as, Hadoop YARN, Kubernetes, etc. - ![](./docs/imgs/kyuubi_migrating_yarn_to_k8s.png) - ### The Kyuubi Ecosystem(present and future) - The figure below shows our vision for the Kyuubi Ecosystem. Some of them have been realized, some in development, and others would not be possible without your help. ![](./docs/imgs/kyuubi_ecosystem.drawio.png) - - ## Online Documentation Since Kyuubi 1.3.0-incubating, the Kyuubi online documentation is hosted by [https://kyuubi.apache.org/](https://kyuubi.apache.org/). diff --git a/bin/docker-image-tool.sh b/bin/docker-image-tool.sh index 509da0afb24..14d5fe7b09d 100755 --- a/bin/docker-image-tool.sh +++ b/bin/docker-image-tool.sh @@ -27,19 +27,21 @@ function error { if [ -z "${KYUUBI_HOME}" ]; then KYUUBI_HOME="$(cd "`dirname "$0"`"/..; pwd)" fi - -CTX_DIR="$KYUUBI_HOME/target/tmp/docker" +KYUUBI_IMAGE_NAME="kyuubi" function is_dev_build { [ ! -f "$KYUUBI_HOME/RELEASE" ] } -function cleanup_ctx_dir { - if is_dev_build; then - rm -rf "$CTX_DIR" - fi -} -trap cleanup_ctx_dir EXIT +if is_dev_build; then + cat <] [--tgz] [--flink-provided] [--spark-provided] [--hive-provided] |" - echo "| [--mvn ] |" - echo "+------------------------------------------------------------------------------------------------------+" + echo "+----------------------------------------------------------------------------------------------+" + echo "| ./build/dist [--name ] [--tgz] [--web-ui] [--flink-provided] [--hive-provided] |" + echo "| [--spark-provided] [--mvn ] |" + echo "+----------------------------------------------------------------------------------------------+" echo "name: - custom binary name, using project version if undefined" echo "tgz: - whether to make a whole bundled package" + echo "web-ui: - whether to include web ui" echo "flink-provided: - whether to make a package without Flink binary" - echo "spark-provided: - whether to make a package without Spark binary" echo "hive-provided: - whether to make a package without Hive binary" + echo "spark-provided: - whether to make a package without Spark binary" echo "mvn: - external maven executable location" echo "" } @@ -67,6 +69,9 @@ while (( "$#" )); do --tgz) MAKE_TGZ=true ;; + --web-ui) + ENABLE_WEBUI=true + ;; --flink-provided) FLINK_PROVIDED=true ;; @@ -212,6 +217,10 @@ fi MVN_DIST_OPT="-DskipTests" +if [[ "$ENABLE_WEBUI" == "true" ]]; then + MVN_DIST_OPT="$MVN_DIST_OPT -Pweb-ui" +fi + if [[ "$SPARK_PROVIDED" == "true" ]]; then MVN_DIST_OPT="$MVN_DIST_OPT -Pspark-provided" fi @@ -238,14 +247,16 @@ echo -e "\$ ${BUILD_COMMAND[@]}\n" rm -rf "$DISTDIR" mkdir -p "$DISTDIR/pid" mkdir -p "$DISTDIR/logs" -mkdir -p "$DISTDIR/jars" mkdir -p "$DISTDIR/work" +mkdir -p "$DISTDIR/jars" +mkdir -p "$DISTDIR/beeline-jars" +mkdir -p "$DISTDIR/web-ui" mkdir -p "$DISTDIR/externals/engines/flink" mkdir -p "$DISTDIR/externals/engines/spark" mkdir -p "$DISTDIR/externals/engines/trino" mkdir -p "$DISTDIR/externals/engines/hive" mkdir -p "$DISTDIR/externals/engines/jdbc" -mkdir -p "$DISTDIR/beeline-jars" +mkdir -p "$DISTDIR/externals/engines/chat" echo "Kyuubi $VERSION $GITREVSTRING built for" > "$DISTDIR/RELEASE" echo "Java $JAVA_VERSION" >> "$DISTDIR/RELEASE" echo "Scala $SCALA_VERSION" >> "$DISTDIR/RELEASE" @@ -303,6 +314,18 @@ for jar in $(ls "$DISTDIR/jars/"); do fi done +# Copy chat engines +cp "$KYUUBI_HOME/externals/kyuubi-chat-engine/target/kyuubi-chat-engine_${SCALA_VERSION}-${VERSION}.jar" "$DISTDIR/externals/engines/chat/" +cp -r "$KYUUBI_HOME"/externals/kyuubi-chat-engine/target/scala-$SCALA_VERSION/jars/*.jar "$DISTDIR/externals/engines/chat/" + +# Share the jars w/ server to reduce binary size +# shellcheck disable=SC2045 +for jar in $(ls "$DISTDIR/jars/"); do + if [[ -f "$DISTDIR/externals/engines/chat/$jar" ]]; then + (cd $DISTDIR/externals/engines/chat; ln -snf "../../../jars/$jar" "$DISTDIR/externals/engines/chat/$jar") + fi +done + # Copy kyuubi tools if [[ -f "$KYUUBI_HOME/tools/spark-block-cleaner/target/spark-block-cleaner_${SCALA_VERSION}-${VERSION}.jar" ]]; then mkdir -p "$DISTDIR/tools/spark-block-cleaner/kubernetes" @@ -321,6 +344,11 @@ for SPARK_EXTENSION_VERSION in ${SPARK_EXTENSION_VERSIONS[@]}; do fi done +if [[ "$ENABLE_WEBUI" == "true" ]]; then + # Copy web ui dist + cp -r "$KYUUBI_HOME/kyuubi-server/web-ui/dist" "$DISTDIR/web-ui/" +fi + if [[ "$FLINK_PROVIDED" != "true" ]]; then # Copy flink binary dist FLINK_BUILTIN="$(find "$KYUUBI_HOME/externals/kyuubi-download/target" -name 'flink-*' -type d)" diff --git a/build/kyuubi-build-info b/build/kyuubi-build-info index a3da7ccae01..8ac7ee2e20e 100755 --- a/build/kyuubi-build-info +++ b/build/kyuubi-build-info @@ -32,6 +32,7 @@ echo_build_properties() { echo kyuubi_trino_version="$9" echo user="$USER" echo revision=$(git rev-parse HEAD) + echo revision_time=$(git show -s --format=%ci HEAD) echo branch=$(git rev-parse --abbrev-ref HEAD) echo date=$(date -u +%Y-%m-%dT%H:%M:%SZ) echo url=$(git config --get remote.origin.url) diff --git a/build/mvn b/build/mvn index d67638ba274..67aa02b4f79 100755 --- a/build/mvn +++ b/build/mvn @@ -76,7 +76,7 @@ install_mvn() { fi # See simple version normalization: http://stackoverflow.com/questions/16989598/bash-comparing-version-numbers function version { echo "$@" | awk -F. '{ printf("%03d%03d%03d\n", $1,$2,$3); }'; } - if [ $(version $MVN_DETECTED_VERSION) -lt $(version $MVN_VERSION) ]; then + if [ $(version $MVN_DETECTED_VERSION) -ne $(version $MVN_VERSION) ]; then local APACHE_MIRROR=${APACHE_MIRROR:-'https://archive.apache.org/dist/'} install_app \ diff --git a/build/mvnd b/build/mvnd new file mode 100755 index 00000000000..81a6f5c20a5 --- /dev/null +++ b/build/mvnd @@ -0,0 +1,139 @@ +#!/usr/bin/env bash + +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Determine the current working directory +_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Preserve the calling directory +_CALLING_DIR="$(pwd)" +# Options used during compilation +_COMPILE_JVM_OPTS="-Xms2g -Xmx2g -XX:ReservedCodeCacheSize=1g -Xss128m" + +if [ "$CI" ]; then + export MAVEN_CLI_OPTS="-Dmvnd.minThreads=4 --no-transfer-progress --errors --fail-fast -Dstyle.color=always" +fi + +# Installs any application tarball given a URL, the expected tarball name, +# and, optionally, a checkable binary path to determine if the binary has +# already been installed +## Arg1 - URL +## Arg2 - Tarball Name +## Arg3 - Checkable Binary +install_app() { + local remote_tarball="$1/$2" + local local_tarball="${_DIR}/$2" + local binary="${_DIR}/$3" + + # setup `curl` and `wget` silent options if we're running on Jenkins + local curl_opts="-L" + local wget_opts="" + curl_opts="--progress-bar ${curl_opts}" + wget_opts="--progress=bar:force ${wget_opts}" + + if [ -z "$3" ] || [ ! -f "$binary" ]; then + # check if we already have the tarball + # check if we have curl installed + # download application + rm -f "$local_tarball" + [ ! -f "${local_tarball}" ] && [ "$(command -v curl)" ] && \ + echo "exec: curl ${curl_opts} ${remote_tarball}" 1>&2 && \ + curl ${curl_opts} "${remote_tarball}" > "${local_tarball}" + # if the file still doesn't exist, lets try `wget` and cross our fingers + [ ! -f "${local_tarball}" ] && [ "$(command -v wget)" ] && \ + echo "exec: wget ${wget_opts} ${remote_tarball}" 1>&2 && \ + wget ${wget_opts} -O "${local_tarball}" "${remote_tarball}" + # if both were unsuccessful, exit + [ ! -f "${local_tarball}" ] && \ + echo -n "ERROR: Cannot download $2 with cURL or wget; " && \ + echo "please install manually and try again." && \ + exit 2 + cd "${_DIR}" && tar -xzf "$2" + rm -rf "$local_tarball" + fi +} + +function get_os_type() { + local unameOsOut=$(uname -s) + local osType + case "${unameOsOut}" in + Linux*) osType=linux ;; + Darwin*) osType=darwin ;; + CYGWIN*) osType=windows ;; + MINGW*) osType=windows ;; + *) osType="UNKNOWN:${unameOsOut}" ;; + esac + echo "$osType" +} + +function get_os_arch() { + local unameArchOut="$(uname -m)" + local arch + case "${unameArchOut}" in + x86_64*) arch=amd64 ;; + arm64*) arch=aarch64 ;; + *) arch="UNKNOWN:${unameOsOut}" ;; + esac + echo "$arch" +} + +# Determine the Mvnd version from the root pom.xml file and +# install mvnd under the build/ folder if needed. +function install_mvnd() { + local MVND_VERSION=$(grep "" "${_DIR}/../pom.xml" | head -n1 | awk -F '[<>]' '{print $3}') + local MVN_VERSION=$(grep "" "${_DIR}/../pom.xml" | head -n1 | awk -F '[<>]' '{print $3}') + MVND_BIN="$(command -v mvnd)" + if [ "$MVND_BIN" ]; then + local MVND_DETECTED_VERSION="$(mvnd -v 2>&1 | grep '(mvnd)' | awk '{print $5}')" + local MVN_DETECTED_VERSION="$(mvnd -v 2>&1 | grep 'Apache Maven' | awk 'NR==2 {print $3}')" + fi + # See simple version normalization: http://stackoverflow.com/questions/16989598/bash-comparing-version-numbers + function version { echo "$@" | awk -F. '{ printf("%03d%03d%03d\n", $1,$2,$3); }'; } + + if [ $(version $MVND_DETECTED_VERSION) -ne $(version $MVND_VERSION) ]; then + local APACHE_MIRROR=${APACHE_MIRROR:-'https://downloads.apache.org'} + local OS_TYPE=$(get_os_type) + local ARCH=$(get_os_arch) + + install_app \ + "${APACHE_MIRROR}/maven/mvnd/${MVND_VERSION}" \ + "maven-mvnd-${MVND_VERSION}-${OS_TYPE}-${ARCH}.tar.gz" \ + "maven-mvnd-${MVND_VERSION}-${OS_TYPE}-${ARCH}/bin/mvnd" + + MVND_BIN="${_DIR}/maven-mvnd-${MVND_VERSION}-${OS_TYPE}-${ARCH}/bin/mvnd" + else + if [ "$(version $MVN_DETECTED_VERSION)" -ne "$(version $MVN_VERSION)" ]; then + echo "Mvnd $MVND_DETECTED_VERSION embedded maven version $MVN_DETECTED_VERSION is not equivalent to $MVN_VERSION required in pom." + exit 1 + fi + fi +} + +install_mvnd + +cd "${_CALLING_DIR}" + +# Set any `mvn` options if not already present +export MAVEN_OPTS=${MAVEN_OPTS:-"$_COMPILE_JVM_OPTS"} + +echo "Using \`mvnd\` from path: $MVND_BIN" 1>&2 + +if [ "$MAVEN_CLI_OPTS" != "" ]; then + echo "MAVEN_CLI_OPTS=$MAVEN_CLI_OPTS" +fi + +${MVND_BIN} $MAVEN_CLI_OPTS "$@" diff --git a/build/release/create-package.sh b/build/release/create-package.sh index c98e7c0f88b..28a89165e80 100755 --- a/build/release/create-package.sh +++ b/build/release/create-package.sh @@ -75,7 +75,7 @@ package_binary() { echo "Creating binary release tarball ${BIN_TGZ_FILE}" - ${KYUUBI_DIR}/build/dist --tgz --spark-provided --flink-provided --hive-provided + ${KYUUBI_DIR}/build/dist --tgz --web-ui --spark-provided --flink-provided --hive-provided cp "${BIN_TGZ_FILE}" "${RELEASE_DIR}" diff --git a/build/release/release.sh b/build/release/release.sh index 4afac386520..fefcce6a913 100755 --- a/build/release/release.sh +++ b/build/release/release.sh @@ -85,7 +85,7 @@ upload_svn_staging() { svn add "${SVN_STAGING_DIR}/${RELEASE_TAG}" - echo "Uploading release tarballs to ${SVN_STAGING_DIR}/${RELEASE_TAG}" + echo "Uploading release tarballs to ${SVN_STAGING_REPO}/${RELEASE_TAG}" ( cd "${SVN_STAGING_DIR}" && \ svn commit --username "${ASF_USERNAME}" --password "${ASF_PASSWORD}" --message "Apache Kyuubi ${RELEASE_TAG}" @@ -94,8 +94,6 @@ upload_svn_staging() { } upload_nexus_staging() { - ${KYUUBI_DIR}/build/mvn clean deploy -DskipTests -Papache-release,flink-provided,spark-provided,hive-provided \ - -s "${KYUUBI_DIR}/build/release/asf-settings.xml" ${KYUUBI_DIR}/build/mvn clean deploy -DskipTests -Papache-release,flink-provided,spark-provided,hive-provided,spark-3.1 \ -s "${KYUUBI_DIR}/build/release/asf-settings.xml" \ -pl extensions/spark/kyuubi-extension-spark-3-1 -am @@ -103,8 +101,7 @@ upload_nexus_staging() { -s "${KYUUBI_DIR}/build/release/asf-settings.xml" \ -pl extensions/spark/kyuubi-extension-spark-3-2 -am ${KYUUBI_DIR}/build/mvn clean deploy -DskipTests -Papache-release,flink-provided,spark-provided,hive-provided,spark-3.3 \ - -s "${KYUUBI_DIR}/build/release/asf-settings.xml" \ - -pl extensions/spark/kyuubi-extension-spark-3-3 -am + -s "${KYUUBI_DIR}/build/release/asf-settings.xml" } finalize_svn() { diff --git a/build/release/script/announce.sh b/build/release/script/announce.sh old mode 100644 new mode 100755 diff --git a/build/release/script/dev_kyuubi_vote.sh b/build/release/script/dev_kyuubi_vote.sh old mode 100644 new mode 100755 diff --git a/charts/kyuubi/Chart.yaml b/charts/kyuubi/Chart.yaml index 6b377ecc5d1..0abec9e5ef3 100644 --- a/charts/kyuubi/Chart.yaml +++ b/charts/kyuubi/Chart.yaml @@ -20,7 +20,7 @@ name: kyuubi description: A Helm chart for Kyuubi server type: application version: 0.1.0 -appVersion: "master-snapshot" +appVersion: 1.7.0 home: https://kyuubi.apache.org icon: https://raw.githubusercontent.com/apache/kyuubi/master/docs/imgs/logo.png sources: diff --git a/charts/kyuubi/README.md b/charts/kyuubi/README.md new file mode 100644 index 00000000000..ef54c322605 --- /dev/null +++ b/charts/kyuubi/README.md @@ -0,0 +1,43 @@ + + +# Helm Chart for Apache Kyuubi + +[Apache Kyuubi](https://airflow.apache.org/) is a distributed and multi-tenant gateway to provide serverless SQL on Data Warehouses and Lakehouses. + + +## Introduction + +This chart will bootstrap an [Kyuubi](https://kyuubi.apache.org) deployment on a [Kubernetes](http://kubernetes.io) +cluster using the [Helm](https://helm.sh) package manager. + +## Requirements + +- Kubernetes cluster +- Helm 3.0+ + + + +## Documentation + +Configuration guide documentation for Kyuubi lives [on the website](https://kyuubi.readthedocs.io/en/master/deployment/settings.html#kyuubi-configurations). (Not just for Helm Chart) + +## Contributing + +Want to help build Apache Kyuubi? Check out our [contributing documentation](https://kyuubi.readthedocs.io/en/master/community/CONTRIBUTING.html). \ No newline at end of file diff --git a/charts/kyuubi/templates/NOTES.txt b/charts/kyuubi/templates/NOTES.txt index 44a35b6b736..2693f5ef6ff 100644 --- a/charts/kyuubi/templates/NOTES.txt +++ b/charts/kyuubi/templates/NOTES.txt @@ -1,21 +1,47 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# +{{/* + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at -Get kyuubi expose URL by running these commands: - export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "kyuubi.fullname" . }}-nodeport) - export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") - echo $NODE_IP:$NODE_PORT \ No newline at end of file + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/}} + +The chart has been installed! + +In order to check the release status, use: + helm status {{ .Release.Name }} -n {{ .Release.Namespace }} + or for more detailed info + helm get all {{ .Release.Name }} -n {{ .Release.Namespace }} + +************************ +******* Services ******* +************************ +{{- range $name, $frontend := .Values.server }} +{{- if $frontend.enabled }} +{{ $name | snakecase | upper }}: +- To access {{ $.Release.Name }}-{{ $name | kebabcase }} service within the cluster, use the following URL: + {{ $.Release.Name }}-{{ $name | kebabcase }}.{{ $.Release.Namespace }}.svc.cluster.local +{{- if $.Values.kyuubiConf.kyuubiDefaults }} +{{- if regexMatch "(^|\\s)kyuubi.frontend.bind.host\\s*=?\\s*(localhost|127\\.0\\.0\\.1)($|\\s)" $.Values.kyuubiConf.kyuubiDefaults }} +- To access {{ $.Release.Name }}-{{ $name | kebabcase }} service from outside the cluster for debugging, run the following command: + kubectl port-forward svc/{{ $.Release.Name }}-{{ $name | kebabcase }} {{ tpl $frontend.service.port $ }}:{{ tpl $frontend.service.port $ }} -n {{ $.Release.Namespace }} + and use 127.0.0.1:{{ tpl $frontend.service.port $ }} +{{- end }} +{{- end }} +{{- if eq $frontend.service.type "NodePort" }} +- To access {{ $.Release.Name }}-{{ $name | kebabcase }} service from outside the cluster through configured NodePort, run the following commands: + export NODE_PORT=$(kubectl get service {{ $.Release.Name }}-{{ $name | kebabcase }} -n {{ $.Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}") + export NODE_IP=$(kubectl get nodes -n {{ $.Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- end }} +{{- end }} +{{- end }} diff --git a/charts/kyuubi/templates/_helpers.tpl b/charts/kyuubi/templates/_helpers.tpl index 684c1f354b1..cd4865a1288 100644 --- a/charts/kyuubi/templates/_helpers.tpl +++ b/charts/kyuubi/templates/_helpers.tpl @@ -16,33 +16,18 @@ */}} {{/* -Expand the name of the chart. +A comma separated string of enabled frontend protocols, e.g. "REST,THRIFT_BINARY". +For details, see 'kyuubi.frontend.protocols': https://kyuubi.readthedocs.io/en/master/deployment/settings.html#frontend */}} -{{- define "kyuubi.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "kyuubi.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- define "kyuubi.frontend.protocols" -}} +{{- $protocols := list }} +{{- range $name, $frontend := .Values.server }} + {{- if $frontend.enabled }} + {{- $protocols = $name | snakecase | upper | append $protocols }} + {{- end }} {{- end }} +{{- if not $protocols }} + {{ fail "At least one frontend protocol must be enabled!" }} {{- end }} +{{- $protocols | join "," }} {{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "kyuubi.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} \ No newline at end of file diff --git a/charts/kyuubi/templates/kyuubi-configmap.yaml b/charts/kyuubi/templates/kyuubi-configmap.yaml index ada9e3dc876..4964e651cdb 100644 --- a/charts/kyuubi/templates/kyuubi-configmap.yaml +++ b/charts/kyuubi/templates/kyuubi-configmap.yaml @@ -26,22 +26,26 @@ metadata: app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }} app.kubernetes.io/managed-by: {{ .Release.Service }} data: - {{- with .Values.server.conf.kyuubiEnv }} + {{- with .Values.kyuubiConf.kyuubiEnv }} kyuubi-env.sh: | #!/usr/bin/env bash {{- tpl . $ | nindent 4 }} {{- end }} kyuubi-defaults.conf: | ## Helm chart provided Kyuubi configurations - kyuubi.frontend.bind.host={{ .Values.server.bind.host }} - kyuubi.frontend.bind.port={{ .Values.server.bind.port }} kyuubi.kubernetes.namespace={{ .Release.Namespace }} + kyuubi.frontend.connection.url.use.hostname=false + kyuubi.frontend.thrift.binary.bind.port={{ .Values.server.thriftBinary.port }} + kyuubi.frontend.thrift.http.bind.port={{ .Values.server.thriftHttp.port }} + kyuubi.frontend.rest.bind.port={{ .Values.server.rest.port }} + kyuubi.frontend.mysql.bind.port={{ .Values.server.mysql.port }} + kyuubi.frontend.protocols={{ include "kyuubi.frontend.protocols" . }} ## User provided Kyuubi configurations - {{- with .Values.server.conf.kyuubiDefaults }} + {{- with .Values.kyuubiConf.kyuubiDefaults }} {{- tpl . $ | nindent 4 }} {{- end }} - {{- with .Values.server.conf.log4j2 }} + {{- with .Values.kyuubiConf.log4j2 }} log4j2.xml: | {{- tpl . $ | nindent 4 }} {{- end }} diff --git a/charts/kyuubi/templates/kyuubi-deployment.yaml b/charts/kyuubi/templates/kyuubi-deployment.yaml index 941fdf164c6..43899b6fc51 100644 --- a/charts/kyuubi/templates/kyuubi-deployment.yaml +++ b/charts/kyuubi/templates/kyuubi-deployment.yaml @@ -50,6 +50,12 @@ spec: - name: kyuubi-server image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.command }} + command: {{- tpl (toYaml .) $ | nindent 12 }} + {{- end }} + {{- with .Values.args }} + args: {{- tpl (toYaml .) $ | nindent 12 }} + {{- end }} {{- with .Values.env }} env: {{- tpl (toYaml .) $ | nindent 12 }} {{- end }} @@ -57,13 +63,16 @@ spec: envFrom: {{- tpl (toYaml .) $ | nindent 12 }} {{- end }} ports: - - name: frontend-port - containerPort: {{ .Values.server.bind.port }} - protocol: TCP + {{- range $name, $frontend := .Values.server }} + {{- if $frontend.enabled }} + - name: {{ $name | kebabcase }} + containerPort: {{ $frontend.port }} + {{- end }} + {{- end }} {{- if .Values.probe.liveness.enabled }} livenessProbe: - tcpSocket: - port: {{ .Values.server.bind.port }} + exec: + command: ["/bin/bash", "-c", "bin/kyuubi status"] initialDelaySeconds: {{ .Values.probe.liveness.initialDelaySeconds }} periodSeconds: {{ .Values.probe.liveness.periodSeconds }} timeoutSeconds: {{ .Values.probe.liveness.timeoutSeconds }} @@ -72,8 +81,8 @@ spec: {{- end }} {{- if .Values.probe.readiness.enabled }} readinessProbe: - tcpSocket: - port: {{ .Values.server.bind.port }} + exec: + command: ["/bin/bash", "-c", "$KYUUBI_HOME/bin/kyuubi status"] initialDelaySeconds: {{ .Values.probe.readiness.initialDelaySeconds }} periodSeconds: {{ .Values.probe.readiness.periodSeconds }} timeoutSeconds: {{ .Values.probe.readiness.timeoutSeconds }} @@ -85,7 +94,7 @@ spec: {{- end }} volumeMounts: - name: conf - mountPath: {{ .Values.server.confDir }} + mountPath: {{ .Values.kyuubiConfDir }} {{- with .Values.volumeMounts }} {{- tpl (toYaml .) $ | nindent 12 }} {{- end }} diff --git a/charts/kyuubi/templates/kyuubi-service.yaml b/charts/kyuubi/templates/kyuubi-service.yaml index 0152bd23d1f..963f1fcc709 100644 --- a/charts/kyuubi/templates/kyuubi-service.yaml +++ b/charts/kyuubi/templates/kyuubi-service.yaml @@ -15,27 +15,34 @@ # limitations under the License. # +{{- range $name, $frontend := .Values.server }} +{{- if $frontend.enabled }} apiVersion: v1 kind: Service metadata: - name: {{ .Release.Name }} + name: {{ $.Release.Name }}-{{ $name | kebabcase }} labels: - helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} - app.kubernetes.io/name: {{ .Chart.Name }} - app.kubernetes.io/instance: {{ .Release.Name }} - app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }} - app.kubernetes.io/managed-by: {{ .Release.Service }} - {{- with .Values.service.annotations }} + helm.sh/chart: {{ $.Chart.Name }}-{{ $.Chart.Version }} + app.kubernetes.io/name: {{ $.Chart.Name }} + app.kubernetes.io/instance: {{ $.Release.Name }} + app.kubernetes.io/version: {{ $.Values.image.tag | default $.Chart.AppVersion | quote }} + app.kubernetes.io/managed-by: {{ $.Release.Service }} + {{- with $frontend.service.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: + type: {{ $frontend.service.type }} ports: - - name: http - nodePort: {{ .Values.service.port }} - port: {{ .Values.server.bind.port }} - protocol: TCP - type: {{ .Values.service.type }} + - name: {{ $name | kebabcase }} + port: {{ tpl $frontend.service.port $ }} + targetPort: {{ $frontend.port }} + {{- if and (eq $frontend.service.type "NodePort") ($frontend.service.nodePort) }} + nodePort: {{ $frontend.service.nodePort }} + {{- end }} selector: - app.kubernetes.io/name: {{ .Chart.Name }} - app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/name: {{ $.Chart.Name }} + app.kubernetes.io/instance: {{ $.Release.Name }} +--- +{{- end }} +{{- end }} diff --git a/charts/kyuubi/values.yaml b/charts/kyuubi/values.yaml index 22ae9d5a914..7eca7211393 100644 --- a/charts/kyuubi/values.yaml +++ b/charts/kyuubi/values.yaml @@ -24,7 +24,7 @@ replicaCount: 2 image: repository: apache/kyuubi - pullPolicy: Always + pullPolicy: IfNotPresent tag: ~ imagePullSecrets: [] @@ -58,22 +58,83 @@ probe: successThreshold: 1 server: - bind: - host: 0.0.0.0 + # Thrift Binary protocol (HiveServer2 compatible) + thriftBinary: + enabled: true port: 10009 - confDir: /opt/kyuubi/conf - conf: - # The value (templated string) is used for kyuubi-env.sh file - # See https://kyuubi.apache.org/docs/latest/deployment/settings.html#environments for more details - kyuubiEnv: ~ - - # The value (templated string) is used for kyuubi-defaults.conf file - # See https://kyuubi.apache.org/docs/latest/deployment/settings.html#kyuubi-configurations for more details - kyuubiDefaults: ~ - - # The value (templated string) is used for log4j2.xml file - # See https://kyuubi.apache.org/docs/latest/deployment/settings.html#logging for more details - log4j2: ~ + service: + type: ClusterIP + port: "{{ .Values.server.thriftBinary.port }}" + nodePort: ~ + annotations: {} + + # Thrift HTTP protocol (HiveServer2 compatible) + thriftHttp: + enabled: false + port: 10010 + service: + type: ClusterIP + port: "{{ .Values.server.thriftHttp.port }}" + nodePort: ~ + annotations: {} + + # REST API protocol (experimental) + rest: + enabled: true + port: 10099 + service: + type: ClusterIP + port: "{{ .Values.server.rest.port }}" + nodePort: ~ + annotations: {} + + # MySQL compatible text protocol (experimental) + mysql: + enabled: false + port: 3309 + service: + type: ClusterIP + port: "{{ .Values.server.mysql.port }}" + nodePort: ~ + annotations: {} + +kyuubiConfDir: /opt/kyuubi/conf +kyuubiConf: + # The value (templated string) is used for kyuubi-env.sh file + # Example: + # + # kyuubiEnv: | + # export JAVA_HOME=/usr/jdk64/jdk1.8.0_152 + # export SPARK_HOME=/opt/spark + # export FLINK_HOME=/opt/flink + # export HIVE_HOME=/opt/hive + # + # See example at conf/kyuubi-env.sh.template and https://kyuubi.readthedocs.io/en/master/deployment/settings.html#environments for more details + kyuubiEnv: ~ + + # The value (templated string) is used for kyuubi-defaults.conf file + # Example: + # + # kyuubiDefaults: | + # kyuubi.authentication=NONE + # kyuubi.frontend.bind.host=10.0.0.1 + # kyuubi.engine.type=SPARK_SQL + # kyuubi.engine.share.level=USER + # kyuubi.session.engine.initialize.timeout=PT3M + # kyuubi.ha.addresses=zk1:2181,zk2:2181,zk3:2181 + # kyuubi.ha.namespace=kyuubi + # + # See https://kyuubi.readthedocs.io/en/master/deployment/settings.html#kyuubi-configurations for more details + kyuubiDefaults: ~ + + # The value (templated string) is used for log4j2.xml file + # See example at conf/log4j2.xml.template https://kyuubi.readthedocs.io/en/master/deployment/settings.html#logging for more details + log4j2: ~ + +# Command to launch Kyuubi server (templated) +command: ~ +# Arguments to launch Kyuubi server (templated) +args: ~ # Environment variables (templated) env: [] @@ -89,15 +150,6 @@ initContainers: [] # Additional containers for Kyuubi pod (templated) containers: [] -service: - type: NodePort - # The default port limit of kubernetes is 30000-32767 - # to change: - # vim kube-apiserver.yaml (usually under path: /etc/kubernetes/manifests/) - # add or change line 'service-node-port-range=1-32767' under kube-apiserver - port: 30009 - annotations: {} - resources: {} # Used to specify resource, default unlimited. # If you do want to specify resources: diff --git a/conf/kyuubi-defaults.conf.template b/conf/kyuubi-defaults.conf.template index 5a4b9b2a791..c93971d9150 100644 --- a/conf/kyuubi-defaults.conf.template +++ b/conf/kyuubi-defaults.conf.template @@ -18,9 +18,19 @@ ## Kyuubi Configurations # -# kyuubi.authentication NONE -# kyuubi.frontend.bind.host localhost -# kyuubi.frontend.bind.port 10009 +# kyuubi.authentication NONE +# +# kyuubi.frontend.bind.host 10.0.0.1 +# kyuubi.frontend.protocols THRIFT_BINARY,REST +# kyuubi.frontend.thrift.binary.bind.port 10009 +# kyuubi.frontend.rest.bind.port 10099 +# +# kyuubi.engine.type SPARK_SQL +# kyuubi.engine.share.level USER +# kyuubi.session.engine.initialize.timeout PT3M +# +# kyuubi.ha.addresses zk1:2181,zk2:2181,zk3:2181 +# kyuubi.ha.namespace kyuubi # -# Details in https://kyuubi.apache.org/docs/latest/deployment/settings.html +# Details in https://kyuubi.readthedocs.io/en/master/deployment/settings.html diff --git a/dev/dependencyList b/dev/dependencyList index 440f8a44773..ab7697d3516 100644 --- a/dev/dependencyList +++ b/dev/dependencyList @@ -16,11 +16,16 @@ # HikariCP/4.0.3//HikariCP-4.0.3.jar +ST4/4.3.4//ST4-4.3.4.jar animal-sniffer-annotations/1.21//animal-sniffer-annotations-1.21.jar annotations/4.1.1.4//annotations-4.1.1.4.jar +antlr-runtime/3.5.3//antlr-runtime-3.5.3.jar antlr4-runtime/4.9.3//antlr4-runtime-4.9.3.jar aopalliance-repackaged/2.6.1//aopalliance-repackaged-2.6.1.jar -automaton/1.11-8//automaton-1.11-8.jar +arrow-format/11.0.0//arrow-format-11.0.0.jar +arrow-memory-core/11.0.0//arrow-memory-core-11.0.0.jar +arrow-memory-netty/11.0.0//arrow-memory-netty-11.0.0.jar +arrow-vector/11.0.0//arrow-vector-11.0.0.jar classgraph/4.8.138//classgraph-4.8.138.jar commons-codec/1.15//commons-codec-1.15.jar commons-collections/3.2.2//commons-collections-3.2.2.jar @@ -34,8 +39,8 @@ derby/10.14.2.0//derby-10.14.2.0.jar error_prone_annotations/2.14.0//error_prone_annotations-2.14.0.jar failsafe/2.4.4//failsafe-2.4.4.jar failureaccess/1.0.1//failureaccess-1.0.1.jar +flatbuffers-java/1.12.0//flatbuffers-java-1.12.0.jar fliptables/1.0.2//fliptables-1.0.2.jar -generex/1.0.2//generex-1.0.2.jar grpc-api/1.48.0//grpc-api-1.48.0.jar grpc-context/1.48.0//grpc-context-1.48.0.jar grpc-core/1.48.0//grpc-core-1.48.0.jar @@ -44,8 +49,8 @@ grpc-netty/1.48.0//grpc-netty-1.48.0.jar grpc-protobuf-lite/1.48.0//grpc-protobuf-lite-1.48.0.jar grpc-protobuf/1.48.0//grpc-protobuf-1.48.0.jar grpc-stub/1.48.0//grpc-stub-1.48.0.jar -gson/2.8.9//gson-2.8.9.jar -guava/30.1-jre//guava-30.1-jre.jar +gson/2.9.0//gson-2.9.0.jar +guava/31.1-jre//guava-31.1-jre.jar hadoop-client-api/3.3.4//hadoop-client-api-3.3.4.jar hadoop-client-runtime/3.3.4//hadoop-client-runtime-3.3.4.jar hive-common/3.1.3//hive-common-3.1.3.jar @@ -59,19 +64,20 @@ hive-storage-api/2.7.0//hive-storage-api-2.7.0.jar hk2-api/2.6.1//hk2-api-2.6.1.jar hk2-locator/2.6.1//hk2-locator-2.6.1.jar hk2-utils/2.6.1//hk2-utils-2.6.1.jar -httpclient/4.5.13//httpclient-4.5.13.jar -httpcore/4.4.15//httpcore-4.4.15.jar +httpclient/4.5.14//httpclient-4.5.14.jar +httpcore/4.4.16//httpcore-4.4.16.jar +httpmime/4.5.14//httpmime-4.5.14.jar j2objc-annotations/1.3//j2objc-annotations-1.3.jar -jackson-annotations/2.14.1//jackson-annotations-2.14.1.jar -jackson-core/2.14.1//jackson-core-2.14.1.jar -jackson-databind/2.14.1//jackson-databind-2.14.1.jar -jackson-dataformat-yaml/2.14.1//jackson-dataformat-yaml-2.14.1.jar -jackson-datatype-jdk8/2.12.3//jackson-datatype-jdk8-2.12.3.jar -jackson-datatype-jsr310/2.14.1//jackson-datatype-jsr310-2.14.1.jar -jackson-jaxrs-base/2.14.1//jackson-jaxrs-base-2.14.1.jar -jackson-jaxrs-json-provider/2.14.1//jackson-jaxrs-json-provider-2.14.1.jar -jackson-module-jaxb-annotations/2.14.1//jackson-module-jaxb-annotations-2.14.1.jar -jackson-module-scala_2.12/2.14.1//jackson-module-scala_2.12-2.14.1.jar +jackson-annotations/2.14.2//jackson-annotations-2.14.2.jar +jackson-core/2.14.2//jackson-core-2.14.2.jar +jackson-databind/2.14.2//jackson-databind-2.14.2.jar +jackson-dataformat-yaml/2.14.2//jackson-dataformat-yaml-2.14.2.jar +jackson-datatype-jdk8/2.14.2//jackson-datatype-jdk8-2.14.2.jar +jackson-datatype-jsr310/2.14.2//jackson-datatype-jsr310-2.14.2.jar +jackson-jaxrs-base/2.14.2//jackson-jaxrs-base-2.14.2.jar +jackson-jaxrs-json-provider/2.14.2//jackson-jaxrs-json-provider-2.14.2.jar +jackson-module-jaxb-annotations/2.14.2//jackson-module-jaxb-annotations-2.14.2.jar +jackson-module-scala_2.12/2.14.2//jackson-module-scala_2.12-2.14.2.jar jakarta.annotation-api/1.3.5//jakarta.annotation-api-1.3.5.jar jakarta.inject/2.6.1//jakarta.inject-2.6.1.jar jakarta.servlet-api/4.0.4//jakarta.servlet-api-4.0.4.jar @@ -80,13 +86,14 @@ jakarta.ws.rs-api/2.1.6//jakarta.ws.rs-api-2.1.6.jar jakarta.xml.bind-api/2.3.2//jakarta.xml.bind-api-2.3.2.jar javassist/3.25.0-GA//javassist-3.25.0-GA.jar jcl-over-slf4j/1.7.36//jcl-over-slf4j-1.7.36.jar -jersey-client/2.38//jersey-client-2.38.jar -jersey-common/2.38//jersey-common-2.38.jar -jersey-container-servlet-core/2.38//jersey-container-servlet-core-2.38.jar -jersey-entity-filtering/2.38//jersey-entity-filtering-2.38.jar -jersey-hk2/2.38//jersey-hk2-2.38.jar -jersey-media-json-jackson/2.38//jersey-media-json-jackson-2.38.jar -jersey-server/2.38//jersey-server-2.38.jar +jersey-client/2.39//jersey-client-2.39.jar +jersey-common/2.39//jersey-common-2.39.jar +jersey-container-servlet-core/2.39//jersey-container-servlet-core-2.39.jar +jersey-entity-filtering/2.39//jersey-entity-filtering-2.39.jar +jersey-hk2/2.39//jersey-hk2-2.39.jar +jersey-media-json-jackson/2.39//jersey-media-json-jackson-2.39.jar +jersey-media-multipart/2.39//jersey-media-multipart-2.39.jar +jersey-server/2.39//jersey-server-2.39.jar jetcd-api/0.7.3//jetcd-api-0.7.3.jar jetcd-common/0.7.3//jetcd-common-0.7.3.jar jetcd-core/0.7.3//jetcd-core-0.7.3.jar @@ -100,55 +107,59 @@ jetty-util-ajax/9.4.50.v20221201//jetty-util-ajax-9.4.50.v20221201.jar jetty-util/9.4.50.v20221201//jetty-util-9.4.50.v20221201.jar jline/0.9.94//jline-0.9.94.jar jul-to-slf4j/1.7.36//jul-to-slf4j-1.7.36.jar -kubernetes-client/5.12.1//kubernetes-client-5.12.1.jar -kubernetes-model-admissionregistration/5.12.1//kubernetes-model-admissionregistration-5.12.1.jar -kubernetes-model-apiextensions/5.12.1//kubernetes-model-apiextensions-5.12.1.jar -kubernetes-model-apps/5.12.1//kubernetes-model-apps-5.12.1.jar -kubernetes-model-autoscaling/5.12.1//kubernetes-model-autoscaling-5.12.1.jar -kubernetes-model-batch/5.12.1//kubernetes-model-batch-5.12.1.jar -kubernetes-model-certificates/5.12.1//kubernetes-model-certificates-5.12.1.jar -kubernetes-model-common/5.12.1//kubernetes-model-common-5.12.1.jar -kubernetes-model-coordination/5.12.1//kubernetes-model-coordination-5.12.1.jar -kubernetes-model-core/5.12.1//kubernetes-model-core-5.12.1.jar -kubernetes-model-discovery/5.12.1//kubernetes-model-discovery-5.12.1.jar -kubernetes-model-events/5.12.1//kubernetes-model-events-5.12.1.jar -kubernetes-model-extensions/5.12.1//kubernetes-model-extensions-5.12.1.jar -kubernetes-model-flowcontrol/5.12.1//kubernetes-model-flowcontrol-5.12.1.jar -kubernetes-model-metrics/5.12.1//kubernetes-model-metrics-5.12.1.jar -kubernetes-model-networking/5.12.1//kubernetes-model-networking-5.12.1.jar -kubernetes-model-node/5.12.1//kubernetes-model-node-5.12.1.jar -kubernetes-model-policy/5.12.1//kubernetes-model-policy-5.12.1.jar -kubernetes-model-rbac/5.12.1//kubernetes-model-rbac-5.12.1.jar -kubernetes-model-scheduling/5.12.1//kubernetes-model-scheduling-5.12.1.jar -kubernetes-model-storageclass/5.12.1//kubernetes-model-storageclass-5.12.1.jar +kubernetes-client-api/6.4.1//kubernetes-client-api-6.4.1.jar +kubernetes-client/6.4.1//kubernetes-client-6.4.1.jar +kubernetes-httpclient-okhttp/6.4.1//kubernetes-httpclient-okhttp-6.4.1.jar +kubernetes-model-admissionregistration/6.4.1//kubernetes-model-admissionregistration-6.4.1.jar +kubernetes-model-apiextensions/6.4.1//kubernetes-model-apiextensions-6.4.1.jar +kubernetes-model-apps/6.4.1//kubernetes-model-apps-6.4.1.jar +kubernetes-model-autoscaling/6.4.1//kubernetes-model-autoscaling-6.4.1.jar +kubernetes-model-batch/6.4.1//kubernetes-model-batch-6.4.1.jar +kubernetes-model-certificates/6.4.1//kubernetes-model-certificates-6.4.1.jar +kubernetes-model-common/6.4.1//kubernetes-model-common-6.4.1.jar +kubernetes-model-coordination/6.4.1//kubernetes-model-coordination-6.4.1.jar +kubernetes-model-core/6.4.1//kubernetes-model-core-6.4.1.jar +kubernetes-model-discovery/6.4.1//kubernetes-model-discovery-6.4.1.jar +kubernetes-model-events/6.4.1//kubernetes-model-events-6.4.1.jar +kubernetes-model-extensions/6.4.1//kubernetes-model-extensions-6.4.1.jar +kubernetes-model-flowcontrol/6.4.1//kubernetes-model-flowcontrol-6.4.1.jar +kubernetes-model-gatewayapi/6.4.1//kubernetes-model-gatewayapi-6.4.1.jar +kubernetes-model-metrics/6.4.1//kubernetes-model-metrics-6.4.1.jar +kubernetes-model-networking/6.4.1//kubernetes-model-networking-6.4.1.jar +kubernetes-model-node/6.4.1//kubernetes-model-node-6.4.1.jar +kubernetes-model-policy/6.4.1//kubernetes-model-policy-6.4.1.jar +kubernetes-model-rbac/6.4.1//kubernetes-model-rbac-6.4.1.jar +kubernetes-model-scheduling/6.4.1//kubernetes-model-scheduling-6.4.1.jar +kubernetes-model-storageclass/6.4.1//kubernetes-model-storageclass-6.4.1.jar libfb303/0.9.3//libfb303-0.9.3.jar libthrift/0.9.3//libthrift-0.9.3.jar -log4j-1.2-api/2.19.0//log4j-1.2-api-2.19.0.jar -log4j-api/2.19.0//log4j-api-2.19.0.jar -log4j-core/2.19.0//log4j-core-2.19.0.jar -log4j-slf4j-impl/2.19.0//log4j-slf4j-impl-2.19.0.jar +log4j-1.2-api/2.20.0//log4j-1.2-api-2.20.0.jar +log4j-api/2.20.0//log4j-api-2.20.0.jar +log4j-core/2.20.0//log4j-core-2.20.0.jar +log4j-slf4j-impl/2.20.0//log4j-slf4j-impl-2.20.0.jar logging-interceptor/3.12.12//logging-interceptor-3.12.12.jar metrics-core/4.2.8//metrics-core-4.2.8.jar metrics-jmx/4.2.8//metrics-jmx-4.2.8.jar metrics-json/4.2.8//metrics-json-4.2.8.jar metrics-jvm/4.2.8//metrics-jvm-4.2.8.jar -netty-all/4.1.84.Final//netty-all-4.1.84.Final.jar -netty-buffer/4.1.84.Final//netty-buffer-4.1.84.Final.jar -netty-codec-dns/4.1.84.Final//netty-codec-dns-4.1.84.Final.jar -netty-codec-http/4.1.84.Final//netty-codec-http-4.1.84.Final.jar -netty-codec-http2/4.1.84.Final//netty-codec-http2-4.1.84.Final.jar -netty-codec-socks/4.1.84.Final//netty-codec-socks-4.1.84.Final.jar -netty-codec/4.1.84.Final//netty-codec-4.1.84.Final.jar -netty-common/4.1.84.Final//netty-common-4.1.84.Final.jar -netty-handler-proxy/4.1.84.Final//netty-handler-proxy-4.1.84.Final.jar -netty-handler/4.1.84.Final//netty-handler-4.1.84.Final.jar -netty-resolver-dns/4.1.84.Final//netty-resolver-dns-4.1.84.Final.jar -netty-resolver/4.1.84.Final//netty-resolver-4.1.84.Final.jar -netty-transport-classes-epoll/4.1.84.Final//netty-transport-classes-epoll-4.1.84.Final.jar -netty-transport-native-epoll/4.1.84.Final/linux-aarch_64/netty-transport-native-epoll-4.1.84.Final-linux-aarch_64.jar -netty-transport-native-epoll/4.1.84.Final/linux-x86_64/netty-transport-native-epoll-4.1.84.Final-linux-x86_64.jar -netty-transport-native-unix-common/4.1.84.Final//netty-transport-native-unix-common-4.1.84.Final.jar -netty-transport/4.1.84.Final//netty-transport-4.1.84.Final.jar +mimepull/1.9.15//mimepull-1.9.15.jar +netty-all/4.1.89.Final//netty-all-4.1.89.Final.jar +netty-buffer/4.1.89.Final//netty-buffer-4.1.89.Final.jar +netty-codec-dns/4.1.89.Final//netty-codec-dns-4.1.89.Final.jar +netty-codec-http/4.1.89.Final//netty-codec-http-4.1.89.Final.jar +netty-codec-http2/4.1.89.Final//netty-codec-http2-4.1.89.Final.jar +netty-codec-socks/4.1.89.Final//netty-codec-socks-4.1.89.Final.jar +netty-codec/4.1.89.Final//netty-codec-4.1.89.Final.jar +netty-common/4.1.89.Final//netty-common-4.1.89.Final.jar +netty-handler-proxy/4.1.89.Final//netty-handler-proxy-4.1.89.Final.jar +netty-handler/4.1.89.Final//netty-handler-4.1.89.Final.jar +netty-resolver-dns/4.1.89.Final//netty-resolver-dns-4.1.89.Final.jar +netty-resolver/4.1.89.Final//netty-resolver-4.1.89.Final.jar +netty-transport-classes-epoll/4.1.89.Final//netty-transport-classes-epoll-4.1.89.Final.jar +netty-transport-native-epoll/4.1.89.Final/linux-aarch_64/netty-transport-native-epoll-4.1.89.Final-linux-aarch_64.jar +netty-transport-native-epoll/4.1.89.Final/linux-x86_64/netty-transport-native-epoll-4.1.89.Final-linux-x86_64.jar +netty-transport-native-unix-common/4.1.89.Final//netty-transport-native-unix-common-4.1.89.Final.jar +netty-transport/4.1.89.Final//netty-transport-4.1.89.Final.jar okhttp-urlconnection/3.14.9//okhttp-urlconnection-3.14.9.jar okhttp/3.12.12//okhttp-3.12.12.jar okio/1.15.0//okio-1.15.0.jar @@ -169,7 +180,7 @@ simpleclient_tracer_common/0.16.0//simpleclient_tracer_common-0.16.0.jar simpleclient_tracer_otel/0.16.0//simpleclient_tracer_otel-0.16.0.jar simpleclient_tracer_otel_agent/0.16.0//simpleclient_tracer_otel_agent-0.16.0.jar slf4j-api/1.7.36//slf4j-api-1.7.36.jar -snakeyaml/1.31//snakeyaml-1.31.jar +snakeyaml/1.33//snakeyaml-1.33.jar swagger-annotations/2.2.1//swagger-annotations-2.2.1.jar swagger-core/2.2.1//swagger-core-2.2.1.jar swagger-integration/2.2.1//swagger-integration-2.2.1.jar diff --git a/dev/kyuubi-codecov/pom.xml b/dev/kyuubi-codecov/pom.xml index 1d1dcb574b5..ba15ec0f823 100644 --- a/dev/kyuubi-codecov/pom.xml +++ b/dev/kyuubi-codecov/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../pom.xml diff --git a/dev/kyuubi-tpcds/README.md b/dev/kyuubi-tpcds/README.md index adffb6726bd..a9a6487aa12 100644 --- a/dev/kyuubi-tpcds/README.md +++ b/dev/kyuubi-tpcds/README.md @@ -1,21 +1,22 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Introduction + This module includes TPC-DS data generator and benchmark tool. # How to use @@ -27,12 +28,12 @@ package jar with following command: Support options: -| key | default | description | -|--------------|-----------------|-----------------------------------| -| db | default | the database to write data | -| scaleFactor | 1 | the scale factor of TPC-DS | -| format | parquet | the format of table to store data | -| parallel | scaleFactor * 2 | the parallelism of Spark job | +| key | default | description | +|-------------|-----------------|-----------------------------------| +| db | default | the database to write data | +| scaleFactor | 1 | the scale factor of TPC-DS | +| format | parquet | the format of table to store data | +| parallel | scaleFactor * 2 | the parallelism of Spark job | Example: the following command to generate 10GB data with new database `tpcds_sf10`. @@ -47,7 +48,7 @@ $SPARK_HOME/bin/spark-submit \ Support options: -| key | default | description | +| key | default | description | |-------------|------------------------|---------------------------------------------------------------| | db | none(required) | the TPC-DS database | | benchmark | tpcds-v2.4-benchmark | the name of application | @@ -65,6 +66,7 @@ $SPARK_HOME/bin/spark-submit \ ``` We also support run one of the TPC-DS query: + ```shell $SPARK_HOME/bin/spark-submit \ --class org.apache.kyuubi.tpcds.benchmark.RunBenchmark \ @@ -73,6 +75,7 @@ $SPARK_HOME/bin/spark-submit \ The result of TPC-DS benchmark like: -| name | minTimeMs | maxTimeMs | avgTimeMs | stdDev | stdDevPercent | -|---------|-----------|-------------|------------|----------|----------------| -| q1-v2.4 | 50.522384 | 868.010383 | 323.398267 | 471.6482 | 145.8413108576 | +| name | minTimeMs | maxTimeMs | avgTimeMs | stdDev | stdDevPercent | +|---------|-----------|------------|------------|----------|----------------| +| q1-v2.4 | 50.522384 | 868.010383 | 323.398267 | 471.6482 | 145.8413108576 | + diff --git a/dev/kyuubi-tpcds/pom.xml b/dev/kyuubi-tpcds/pom.xml index 2921cbe8b0f..1bc69f9f2ce 100644 --- a/dev/kyuubi-tpcds/pom.xml +++ b/dev/kyuubi-tpcds/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../pom.xml diff --git a/docker/Dockerfile b/docker/Dockerfile index 588f99b1fb5..0440022de64 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -24,7 +24,7 @@ # -t the target repo and tag name # more options can be found with -h -ARG BASE_IMAGE=openjdk:8-jre-slim +ARG BASE_IMAGE=eclipse-temurin:8-jdk-focal ARG spark_provided="spark_builtin" FROM ${BASE_IMAGE} as builder_spark_provided @@ -34,7 +34,7 @@ ONBUILD ENV SPARK_HOME ${spark_home_in_docker} FROM ${BASE_IMAGE} as builder_spark_builtin ONBUILD ENV SPARK_HOME /opt/spark -ONBUILD RUN mkdir -p ${SPARK_HOME} +ONBUILD RUN mkdir -p ${SPARK_HOME} ONBUILD COPY spark-binary ${SPARK_HOME} FROM builder_${spark_provided} @@ -50,7 +50,8 @@ ENV KYUUBI_WORK_DIR_ROOT ${KYUUBI_HOME}/work RUN set -ex && \ sed -i 's/http:\/\/deb.\(.*\)/https:\/\/deb.\1/g' /etc/apt/sources.list && \ apt-get update && \ - apt install -y bash tini libc6 libpam-modules krb5-user libnss3 procps && \ + apt-get install -y bash tini libc6 libpam-modules krb5-user libnss3 procps && \ + ln -snf /bin/bash /bin/sh && \ useradd -u ${kyuubi_uid} -g root kyuubi -d /home/kyuubi -m && \ mkdir -p ${KYUUBI_HOME} ${KYUUBI_LOG_DIR} ${KYUUBI_PID_DIR} ${KYUUBI_WORK_DIR_ROOT} && \ rm -rf /var/cache/apt/* @@ -59,6 +60,7 @@ COPY LICENSE NOTICE RELEASE ${KYUUBI_HOME}/ COPY bin ${KYUUBI_HOME}/bin COPY jars ${KYUUBI_HOME}/jars COPY beeline-jars ${KYUUBI_HOME}/beeline-jars +COPY web-ui ${KYUUBI_HOME}/web-ui COPY externals/engines/spark ${KYUUBI_HOME}/externals/engines/spark WORKDIR ${KYUUBI_HOME} diff --git a/docker/kyuubi-configmap.yaml b/docker/kyuubi-configmap.yaml index 13835493b8f..9b799359625 100644 --- a/docker/kyuubi-configmap.yaml +++ b/docker/kyuubi-configmap.yaml @@ -52,4 +52,4 @@ data: # kyuubi.frontend.bind.port 10009 # - # Details in https://kyuubi.apache.org/docs/latest/deployment/settings.html + # Details in https://kyuubi.readthedocs.io/en/master/deployment/settings.html diff --git a/docker/playground/.env b/docker/playground/.env index d50e964cf16..ea214551182 100644 --- a/docker/playground/.env +++ b/docker/playground/.env @@ -15,16 +15,16 @@ # limitations under the License. # -AWS_JAVA_SDK_VERSION=1.12.239 -HADOOP_VERSION=3.3.1 +AWS_JAVA_SDK_VERSION=1.12.316 +HADOOP_VERSION=3.3.5 HIVE_VERSION=2.3.9 -ICEBERG_VERSION=1.1.0 -KYUUBI_VERSION=1.6.1-incubating +ICEBERG_VERSION=1.2.0 +KYUUBI_VERSION=1.7.0 KYUUBI_HADOOP_VERSION=3.3.4 POSTGRES_VERSION=12 POSTGRES_JDBC_VERSION=42.3.4 SCALA_BINARY_VERSION=2.12 -SPARK_VERSION=3.3.1 +SPARK_VERSION=3.3.2 SPARK_BINARY_VERSION=3.3 SPARK_HADOOP_VERSION=3.3.2 ZOOKEEPER_VERSION=3.6.3 diff --git a/docker/playground/README.md b/docker/playground/README.md index d9e227c2c7e..66dca2af0ab 100644 --- a/docker/playground/README.md +++ b/docker/playground/README.md @@ -1,5 +1,5 @@ Playground -=== +========== ## For Users @@ -45,3 +45,4 @@ Kyuubi supply some built-in dataset, after Kyuubi started, you can run the follo 1. Build images `docker/playground/build-image.sh`; 2. Optional to use `buildx` to build and publish cross-platform images `BUILDX=1 docker/playground/build-image.sh`; + diff --git a/docker/playground/build-image.sh b/docker/playground/build-image.sh index 84845125732..98b16fd0394 100755 --- a/docker/playground/build-image.sh +++ b/docker/playground/build-image.sh @@ -64,7 +64,6 @@ ${BUILD_CMD} \ --build-arg MAVEN_MIRROR=${MAVEN_MIRROR} \ --build-arg KYUUBI_VERSION=${KYUUBI_VERSION} \ --build-arg AWS_JAVA_SDK_VERSION=${AWS_JAVA_SDK_VERSION} \ - --build-arg CLICKHOUSE_JDBC_VERSION=${CLICKHOUSE_JDBC_VERSION} \ --build-arg SPARK_HADOOP_VERSION=${SPARK_HADOOP_VERSION} \ --build-arg ICEBERG_VERSION=${ICEBERG_VERSION} \ --build-arg POSTGRES_JDBC_VERSION=${POSTGRES_JDBC_VERSION} \ diff --git a/docker/playground/compose.yml b/docker/playground/compose.yml index 069624ee2a9..b0d2b1ea89f 100644 --- a/docker/playground/compose.yml +++ b/docker/playground/compose.yml @@ -17,7 +17,7 @@ services: minio: - image: alekcander/bitnami-minio-multiarch:RELEASE.2022-05-26T05-48-41Z + image: bitnami/minio:2023-debian-11 environment: MINIO_ROOT_USER: minio MINIO_ROOT_PASSWORD: minio_minio @@ -68,6 +68,7 @@ services: ports: - 4040-4050:4040-4050 - 10009:10009 + - 10099:10099 volumes: - ./conf/core-site.xml:/etc/hadoop/conf/core-site.xml - ./conf/hive-site.xml:/etc/hive/conf/hive-site.xml diff --git a/docker/playground/conf/kyuubi-defaults.conf b/docker/playground/conf/kyuubi-defaults.conf index 4906c5de4c0..15b3fbf6e4b 100644 --- a/docker/playground/conf/kyuubi-defaults.conf +++ b/docker/playground/conf/kyuubi-defaults.conf @@ -18,8 +18,10 @@ ## Kyuubi Configurations kyuubi.authentication=NONE -kyuubi.frontend.thrift.binary.bind.host=0.0.0.0 +kyuubi.frontend.bind.host=0.0.0.0 +kyuubi.frontend.protocols=THRIFT_BINARY,REST kyuubi.frontend.thrift.binary.bind.port=10009 +kyuubi.frontend.rest.bind.port=10099 kyuubi.ha.addresses=zookeeper:2181 kyuubi.session.engine.idle.timeout=PT5M kyuubi.operation.incremental.collect=true diff --git a/docs/appendix/terminology.md b/docs/appendix/terminology.md index 77d4deace33..b81fa25fe87 100644 --- a/docs/appendix/terminology.md +++ b/docs/appendix/terminology.md @@ -1,19 +1,19 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Terminologies @@ -26,7 +26,7 @@ Kyuubi is a unified multi-tenant JDBC interface for large-scale data processing > The Java Database Connectivity (JDBC) API is the industry standard for database-independent connectivity between the Java programming language and a wide range of databases SQL databases and other tabular data sources, > such as spreadsheets or flat files. > The JDBC API provides a call-level API for SQL-based database access. - +> > JDBC technology allows you to use the Java programming language to exploit "Write Once, Run Anywhere" capabilities for applications that require access to enterprise data. > With a JDBC technology-enabled driver, you can connect all corporate data even in a heterogeneous environment. @@ -121,7 +121,7 @@ As an enterprise service, SLA commitment is essential. Deploying Kyuubi in High #### Apache Curator -> Apache Curator is a Java/JVM client library for Apache ZooKeeper, a distributed coordination service. It includes a highlevel API framework and utilities to make using Apache ZooKeeper much easier and more reliable. It also includes recipes for common use cases and extensions such as service discovery and a Java 8 asynchronous DSL. +> Apache Curator is a Java/JVM client library for Apache ZooKeeper, a distributed coordination service. It includes a high-level API framework and utilities to make using Apache ZooKeeper much easier and more reliable. It also includes recipes for common use cases and extensions such as service discovery and a Java 8 asynchronous DSL.

@@ -139,7 +139,7 @@ Kyuubi unifies DataLake & LakeHouse access in the simplest pure SQL way, meanwhi

-http://iceberg.apache.org/ +https://iceberg.apache.org/

@@ -162,3 +162,4 @@ Kyuubi unifies DataLake & LakeHouse access in the simplest pure SQL way, meanwhi https://hudi.apache.org

+ diff --git a/docs/client/advanced/features/plan_only.md b/docs/client/advanced/features/plan_only.md index 9f9431164ab..bcedb2f025f 100644 --- a/docs/client/advanced/features/plan_only.md +++ b/docs/client/advanced/features/plan_only.md @@ -1,19 +1,19 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Plan Only Execution Mode @@ -23,24 +23,31 @@ Configure the kyuubi.operation.plan.only.mode parameter, the value can be 'parse ## Application-level parameter setting -You can add parameters to the URL when establishing a JDBC connection, the parameter is kyuubi.operation.plan.only.mode=parse/analyze/optimize. +You can add parameters to the URL when establishing a JDBC connection, the parameter is kyuubi.operation.plan.only.mode=parse/analyze/optimize. JDBC URLs have the following format: + ```shell - jdbc:hive2://:/;?kyuubi.operation.plan.only.mode=parse/analyze/optimize/optimize_with_stats/physical/execution/none;#<[spark|hive]Vars> +jdbc:hive2://:/;?kyuubi.operation.plan.only.mode=parse/analyze/optimize/optimize_with_stats/physical/execution/none;#<[spark|hive]Vars> ``` + Refer to [hive_jdbc doc](../../jdbc/hive_jdbc.md) for details of others parameters ### Example: -Using beeline tool to connect to the local service, the Shell command is: +Using beeline tool to connect to the local service, the Shell command is: + ```shell - beeline -u 'jdbc:hive2://0.0.0.0:10009/default?kyuubi.operation.plan.only.mode=parse' -n {user_name} +beeline -u 'jdbc:hive2://0.0.0.0:10009/default?kyuubi.operation.plan.only.mode=parse' -n {user_name} ``` + Running the following SQL: + ```sql SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id ``` + The results are as follows: + ```shell # SQL: 0: jdbc:hive2://0.0.0.0:10009/default> SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id; @@ -62,15 +69,20 @@ The results are as follows: ## Session-level parameter setting You can also set the kyuubi.operation.plan.only.mode parameter by executing the set command after the connection has been established + ```shell - beeline -u 'jdbc:hive2://0.0.0.0:10009/default' -n {user_name} +beeline -u 'jdbc:hive2://0.0.0.0:10009/default' -n {user_name} ``` + Running the following SQL: + ```sql set kyuubi.operation.plan.only.mode=parse; SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id ``` + The results are as follows: + ```shell #set command: 0: jdbc:hive2://0.0.0.0:10009/default> set kyuubi.operation.plan.only.mode=parse; @@ -99,3 +111,4 @@ The results are as follows: 1 row selected (0.404 seconds) 0: jdbc:hive2://0.0.0.0:10009/default> ``` + diff --git a/docs/client/advanced/kerberos.md b/docs/client/advanced/kerberos.md index 6bdcd765979..4962dd2c8b2 100644 --- a/docs/client/advanced/kerberos.md +++ b/docs/client/advanced/kerberos.md @@ -1,24 +1,24 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Configure Kerberos for clients to Access Kerberized Kyuubi ## Instructions + When Kyuubi is secured by Kerberos, the authentication procedure becomes a little complicated. ![](../../imgs/kyuubi_kerberos_authentication.png) @@ -35,15 +35,18 @@ The graph above shows a simplified kerberos authentication procedure: In the rest part of this page, we will describe steps needed to pass through this authentication. ## Install Kerberos Client + Usually, Kerberos client is installed as default. You can validate it using klist tool. Linux command and output: + ```bash $ klist -V Kerberos 5 version 1.15.1 ``` MacOS command and output: + ```bash $ klist --version klist (Heimdal 1.5.1apple1) @@ -52,32 +55,35 @@ Send bug-reports to heimdal-bugs@h5l.org ``` Windows command and output: + ```cmd > klist -V Kerberos for Windows ``` If the client is not installed, you should install it ahead based on the OS platform. -We recommend you to install the MIT Kerberos Distribution as all commands in this guide is based on it. +We recommend you to install the MIT Kerberos Distribution as all commands in this guide is based on it. ## Configure Kerberos Client + Kerberos client needs a configuration file for tuning up the creation of Kerberos ticket cache. Following is the configuration file's default location on different OS: -OS | Path ----| --- -Linux | /etc/krb5.conf -MacOS | /etc/krb5.conf -Windows | %ProgramData%\MIT\Kerberos5\krb5.ini +| OS | Path | +|---------|--------------------------------------| +| Linux | /etc/krb5.conf | +| MacOS | /etc/krb5.conf | +| Windows | %ProgramData%\MIT\Kerberos5\krb5.ini | You can use `KRB5_CONFIG` environment variable to overwrite the default location. The configuration file should be configured to point to the same KDC as Kyuubi points to. ## Get Kerberos TGT + Execute `kinit` command to get TGT from KDC. -Suppose user principal is `kyuubi_user@KYUUBI.APACHE.ORG` and user keytab file name is `kyuubi_user.keytab`, +Suppose user principal is `kyuubi_user@KYUUBI.APACHE.ORG` and user keytab file name is `kyuubi_user.keytab`, the command should be: ``` @@ -111,28 +117,29 @@ Valid starting Expires Service principal (Command is identical on different OS platform. Ticket cache location may be different.) ``` -Ticket cache may have different storage type on different OS platform. +Ticket cache may have different storage type on different OS platform. For example, -OS | Default Ticket Cache Type and Location ----| --- -Linux | FILE:/tmp/krb5cc_%{uid} -MacOS | KCM:%{uid}:%{gid} -Windows | API:krb5cc +| OS | Default Ticket Cache Type and Location | +|---------|----------------------------------------| +| Linux | FILE:/tmp/krb5cc_%{uid} | +| MacOS | KCM:%{uid}:%{gid} | +| Windows | API:krb5cc | You can find your ticket cache type and location in the `Ticket cache` part of `klist` output. **Note**: - Ensure your ticket cache type is `FILE` as JVM can only read ticket cache stored as file. -- Do not store TGT into default ticket cache if you are running Kyuubi and execute `kinit` on the same +- Do not store TGT into default ticket cache if you are running Kyuubi and execute `kinit` on the same host with the same OS user. The default ticket cache is already used by Kyuubi server. -Either because the default ticket cache is not a file, or because it is used by Kyuubi server, you +Either because the default ticket cache is not a file, or because it is used by Kyuubi server, you should store ticket cache in another file location. This can be achieved by specifying a file location with `-c` argument in `kinit` command. For example, + ``` $ kinit -c /tmp/krb5cc_beeline -kt kyuubi_user.keytab kyuubi_user@KYUUBI.APACHE.ORG @@ -142,6 +149,7 @@ $ kinit -c /tmp/krb5cc_beeline -kt kyuubi_user.keytab kyuubi_user@KYUUBI.APACHE. To check the ticket cache, specify the file location with `-c` argument in `klist` command. For example, + ``` $ klist -c /tmp/krb5cc_beeline @@ -149,19 +157,21 @@ $ klist -c /tmp/krb5cc_beeline ``` ## Add Kerberos Client Configuration File to JVM Search Path + The JVM, which JDBC client is running on, also needs to read the Kerberos client configuration file. However, JVM uses different default locations from Kerberos client, and does not honour `KRB5_CONFIG` environment variable. -OS | JVM Search Paths ----| --- -Linux | System scope: `/etc/krb5.conf` -MacOS | User scope: `$HOME/Library/Preferences/edu.mit.Kerberos`
System scope: `/etc/krb5.conf` -Windows | User scoep: `%USERPROFILE%\krb5.ini`
System scope: `%windir%\krb5.ini` +| OS | JVM Search Paths | +|---------|---------------------------------------------------------------------------------------------| +| Linux | System scope: `/etc/krb5.conf` | +| MacOS | User scope: `$HOME/Library/Preferences/edu.mit.Kerberos`
System scope: `/etc/krb5.conf` | +| Windows | User scope: `%USERPROFILE%\krb5.ini`
System scope: `%windir%\krb5.ini` | You can use JVM system property, `java.security.krb5.conf`, to overwrite the default location. ## Add Kerberos Ticket Cache to JVM Search Path + JVM determines the ticket cache location in the following order: 1. Path specified by `KRB5CCNAME` environment variable. Path must start with `FILE:`. 2. `/tmp/krb5cc_%{uid}` on Unix-like OS, e.g. Linux, MacOS @@ -171,24 +181,27 @@ JVM determines the ticket cache location in the following order: **Note**: - `${user.home}` and `${user.name}` are JVM system properties. - `${user.home}` should be replaced with `${user.dir}` if `${user.home}` is null. - -Ensure your ticket cache is stored as a file and put it in one of the above locations. + +Ensure your ticket cache is stored as a file and put it in one of the above locations. ## Ensure core-site.xml Exists in Classpath -Like hadoop clients, `hadoop.security.authentication` should be set to `KERBEROS` in `core-site.xml` -to let Hive JDBC driver use Kerberos authentication. `core-site.xml` should be placed under beeline's + +Like hadoop clients, `hadoop.security.authentication` should be set to `KERBEROS` in `core-site.xml` +to let Hive JDBC driver use Kerberos authentication. `core-site.xml` should be placed under beeline's classpath or BI tools' classpath. ### Beeline + Here are the usual locations where `core-site.xml` should exist for different beeline distributions: -Client | Location | Note ---- | --- | --- -Hive beeline | `$HADOOP_HOME/etc/hadoop` | Hive resolves `$HADOOP_HOME` and use `$HADOOP_HOME/bin/hadoop` command to launch beeline. `$HADOOP_HOME/etc/hadoop` is in `hadoop` command's classpath. -Spark beeline | `$HADOOP_CONF_DIR` | In `$SPARK_HOME/conf/spark-env.sh`, `$HADOOP_CONF_DIR` often be set to the directory containing hadoop client configuration files. -Kyuubi beeline | `$HADOOP_CONF_DIR` | In `$KYUUBI_HOME/conf/kyuubi-env.sh`, `$HADOOP_CONF_DIR` often be set to the directory containing hadoop client configuration files. +| Client | Location | Note | +|----------------|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| Hive beeline | `$HADOOP_HOME/etc/hadoop` | Hive resolves `$HADOOP_HOME` and use `$HADOOP_HOME/bin/hadoop` command to launch beeline. `$HADOOP_HOME/etc/hadoop` is in `hadoop` command's classpath. | +| Spark beeline | `$HADOOP_CONF_DIR` | In `$SPARK_HOME/conf/spark-env.sh`, `$HADOOP_CONF_DIR` often be set to the directory containing hadoop client configuration files. | +| Kyuubi beeline | `$HADOOP_CONF_DIR` | In `$KYUUBI_HOME/conf/kyuubi-env.sh`, `$HADOOP_CONF_DIR` often be set to the directory containing hadoop client configuration files. | If `core-site.xml` is not found in above locations, create one with the following content: + ```xml @@ -199,6 +212,7 @@ If `core-site.xml` is not found in above locations, create one with the followin ``` ### BI Tools + As to BI tools, ways to add `core-site.xml` varies. Take DBeaver as an example. We can add files to DBeaver's classpath through its `Global libraries` preference. As `Global libraries` only accepts jar files, you should package `core-site.xml` into a jar file. @@ -210,13 +224,16 @@ $ jar -c -f core-site.jar core-site.xml ``` ## Connect with JDBC URL + The last step is to connect to Kyuubi with the right JDBC URL. -The JDBC URL should be in format: +The JDBC URL should be in format: ``` jdbc:hive2://:/;principal= ``` + or + ``` jdbc:hive2://:/;kyuubiServerPrincipal= ``` diff --git a/docs/client/bi_tools/datagrip.md b/docs/client/bi_tools/datagrip.md index 6d22444073c..5dbebf4383e 100644 --- a/docs/client/bi_tools/datagrip.md +++ b/docs/client/bi_tools/datagrip.md @@ -1,43 +1,58 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # DataGrip + ## What is DataGrip + [DataGrip](https://www.jetbrains.com/datagrip/) is a multi-engine database environment released by JetBrains, supporting MySQL and PostgreSQL, Microsoft SQL Server and Oracle, Sybase, DB2, SQLite, HyperSQL, Apache Derby, and H2. ## Preparation + ### Get DataGrip And Install + Please go to [Download DataGrip](https://www.jetbrains.com/datagrip/download) to get and install an appropriate version for yourself. + ### Get Kyuubi Started + [Get kyuubi server started](../../quick_start/quick_start.html) before you try DataGrip with kyuubi. For debugging purpose, you can use `tail -f` or `tailf` to track the server log. + ## Configurations + ### Start DataGrip + After you install DataGrip, just launch it. + ### Select Database + Substantially, this step is to choose a JDBC Driver type to use later. We can choose Apache Hive to set up a driver for Kyuubi. ![select database](../../imgs/datagrip/select_database.png) + ### Datasource Driver -You should first download the missing driver files. Just click on the link below, DataGrip will download and install those. + +You should first download the missing driver files. Just click on the link below, DataGrip will download and install those. ![datasource and driver](../../imgs/datagrip/datasource_and_driver.png) + ### Generic JDBC Connection Settings + After install drivers, you should configure the right host and port which you can find in kyuubi server log. By default, we use `localhost` and `10009` to configure. Of course, you can fill other configs. @@ -45,7 +60,9 @@ Of course, you can fill other configs. After generic configs, you can use test connection to test. ![configuration](../../imgs/datagrip/configuration.png) + ## Interacting With Kyuubi Server + Now, you can interact with Kyuubi server. The left side of the photo is the table, and the right side of the photo is the console. @@ -53,5 +70,7 @@ The left side of the photo is the table, and the right side of the photo is the You can interact through the visual interface or code. ![workspace](../../imgs/datagrip/workspace.png) + ## The End -There are many other amazing features in both Kyuubi and DataGrip and here is just the tip of the iceberg. The rest is for you to discover. \ No newline at end of file + +There are many other amazing features in both Kyuubi and DataGrip and here is just the tip of the iceberg. The rest is for you to discover. diff --git a/docs/client/bi_tools/hue.md b/docs/client/bi_tools/hue.md index 3582006c400..e2b2a97f97f 100644 --- a/docs/client/bi_tools/hue.md +++ b/docs/client/bi_tools/hue.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Cloudera Hue @@ -43,8 +42,8 @@ Welcome to ## Run Hue in Docker -Here we demo running Kyuubi on macOS and Hue on [Docker for Mac](https://docs.docker.com/docker-for-mac/), -there are several known limitations of network, and you can find +Here we demo running Kyuubi on macOS and Hue on [Docker for Mac](https://docs.docker.com/docker-for-mac/), +there are several known limitations of network, and you can find [workarounds from here](https://docs.docker.com/docker-for-mac/networking/#known-limitations-use-cases-and-workarounds). ### Configuration @@ -98,7 +97,7 @@ Having fun with Hue and Kyuubi! ## For CDH 6.x Users -If you are using CDH 6.x, there is a trick that CDH 6.x blocks Spark in default, you need to modify the configuration to +If you are using CDH 6.x, there is a trick that CDH 6.x blocks Spark in default, you need to modify the configuration to overwrite the `desktop.app_blacklist` to remove this restriction. Config Hue in Cloudera Manager. @@ -106,6 +105,7 @@ Config Hue in Cloudera Manager. ![](../../imgs/hue/cloudera_manager.png) Refer following configuration and tune it to fit your environment. + ``` [desktop] app_blacklist=zookeeper,hbase,impala,search,sqoop,security diff --git a/docs/client/cli/hive_beeline.rst b/docs/client/cli/hive_beeline.rst index fda925aa108..f75e00819f1 100644 --- a/docs/client/cli/hive_beeline.rst +++ b/docs/client/cli/hive_beeline.rst @@ -17,7 +17,7 @@ Hive Beeline ============ Kyuubi supports Apache Hive beeline that works with Kyuubi server. -Hive beeline is a `SQLLine CLI `_ based on the `Hive JDBC Driver <../jdbc/hive_jdbc.html>`_. +Hive beeline is a `SQLLine CLI `_ based on the `Hive JDBC Driver <../jdbc/hive_jdbc.html>`_. Prerequisites ------------- diff --git a/docs/client/cli/index.rst b/docs/client/cli/index.rst index 61be9ad8c0c..19122ced4ab 100644 --- a/docs/client/cli/index.rst +++ b/docs/client/cli/index.rst @@ -21,3 +21,4 @@ Command Line Interface(CLI)s kyuubi_beeline hive_beeline + trino_cli diff --git a/docs/client/cli/trino_cli.md b/docs/client/cli/trino_cli.md new file mode 100644 index 00000000000..68ebd830020 --- /dev/null +++ b/docs/client/cli/trino_cli.md @@ -0,0 +1,88 @@ + + +# Trino command line interface + +The Trino CLI provides a terminal-based, interactive shell for running queries. We can use it to connect Kyuubi server now. + +## Start Kyuubi Trino Server + +First we should configure the trino protocol and the service port in the `kyuubi.conf` + +``` +kyuubi.frontend.protocols TRINO +kyuubi.frontend.trino.bind.port 10999 #default port +``` + +## Install + +Download [trino-cli-363-executable.jar](https://repo1.maven.org/maven2/io/trino/trino-jdbc/363/trino-jdbc-363.jar), rename it to `trino`, make it executable with `chmod +x`, and run it to show the version of the CLI: + +``` +wget https://repo1.maven.org/maven2/io/trino/trino-jdbc/363/trino-jdbc-363.jar +mv trino-jdbc-363.jar trino +chmod +x trino +./trino --version +``` + +## Running the CLI + +The minimal command to start the CLI in interactive mode specifies the URL of the kyuubi server with the Trino protocol: + +``` +./trino --server http://localhost:10999 +``` + +If successful, you will get a prompt to execute commands. Use the help command to see a list of supported commands. Use the clear command to clear the terminal. To stop and exit the CLI, run exit or quit.: + +``` +trino> help + +Supported commands: +QUIT +EXIT +CLEAR +EXPLAIN [ ( option [, ...] ) ] + options: FORMAT { TEXT | GRAPHVIZ | JSON } + TYPE { LOGICAL | DISTRIBUTED | VALIDATE | IO } +DESCRIBE +SHOW COLUMNS FROM
+SHOW FUNCTIONS +SHOW CATALOGS [LIKE ] +SHOW SCHEMAS [FROM ] [LIKE ] +SHOW TABLES [FROM ] [LIKE ] +USE [.] +``` + +You can now run SQL statements. After processing, the CLI will show results and statistics. + +``` +trino> select 1; + _col0 +------- + 1 +(1 row) + +Query 20230216_125233_00806_examine_6hxus, FINISHED, 1 node +Splits: 1 total, 1 done (100.00%) +0.29 [0 rows, 0B] [0 rows/s, 0B/s] + +trino> +``` + +Many other options are available to further configure the CLI in interactive mode to +refer https://trino.io/docs/current/client/cli.html#running-the-cli diff --git a/docs/client/jdbc/hive_jdbc.md b/docs/client/jdbc/hive_jdbc.md index 186ad83b901..42d2f7b5a33 100644 --- a/docs/client/jdbc/hive_jdbc.md +++ b/docs/client/jdbc/hive_jdbc.md @@ -1,36 +1,34 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Hive JDBC Driver - ## Instructions Kyuubi does not provide its own JDBC Driver so far, as it is fully compatible with Hive JDBC and ODBC drivers that let you connect to popular Business Intelligence (BI) tools to query, analyze and visualize data though Spark SQL engines. - ## Install Hive JDBC For programing, the easiest way to get `hive-jdbc` is from [the maven central](https://mvnrepository.com/artifact/org.apache.hive/hive-jdbc). For example, - **maven** + ```xml org.apache.hive @@ -40,11 +38,13 @@ For programing, the easiest way to get `hive-jdbc` is from [the maven central](h ``` - **sbt** + ```scala libraryDependencies += "org.apache.hive" % "hive-jdbc" % "2.3.8" ``` - **gradle** + ```gradle implementation group: 'org.apache.hive', name: 'hive-jdbc', version: '2.3.8' ``` @@ -53,7 +53,6 @@ For BI tools, please refer to [Quick Start](../../quick_start/index.html) to che If you find there is no specific document for the BI tool that you are using, don't worry, the configuration part for all BI tools are basically the same. Also, we will appreciate if you can help us to improve the document. - ## JDBC URL JDBC URLs have the following format: @@ -62,14 +61,14 @@ JDBC URLs have the following format: jdbc:hive2://:/;?#<[spark|hive]Vars> ``` -JDBC Parameter | Description ----------------| ----------- -host | The cluster node hosting Kyuubi Server. -port | The port number to which is Kyuubi Server listening. -dbName | Optional database name to set the current database to run the query against, use `default` if absent. -sessionVars | Optional `Semicolon(;)` separated `key=value` parameters for the JDBC/ODBC driver. Such as `user`, `password` and `hive.server2.proxy.user`. -kyuubiConfs | Optional `Semicolon(;)` separated `key=value` parameters for Kyuubi server to create the corresponding engine, dismissed if engine exists. -[spark|hive]Vars | Optional `Semicolon(;)` separated `key=value` parameters for Spark/Hive variables used for variable substitution. +| JDBC Parameter | Description | +|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------| +| host | The cluster node hosting Kyuubi Server. | +| port | The port number to which is Kyuubi Server listening. | +| dbName | Optional database name to set the current database to run the query against, use `default` if absent. | +| sessionVars | Optional `Semicolon(;)` separated `key=value` parameters for the JDBC/ODBC driver. Such as `user`, `password` and `hive.server2.proxy.user`. | +| kyuubiConfs | Optional `Semicolon(;)` separated `key=value` parameters for Kyuubi server to create the corresponding engine, dismissed if engine exists. | +| [spark|hive]Vars | Optional `Semicolon(;)` separated `key=value` parameters for Spark/Hive variables used for variable substitution. | ## Example @@ -80,3 +79,4 @@ jdbc:hive2://localhost:10009/default;hive.server2.proxy.user=proxy_user?kyuubi.e ## Unsupported Hive Features - Connect to HiveServer2 using HTTP transport. ```transportMode=http``` + diff --git a/docs/client/jdbc/index.rst b/docs/client/jdbc/index.rst index 31871f1382f..abcd6a452f2 100644 --- a/docs/client/jdbc/index.rst +++ b/docs/client/jdbc/index.rst @@ -22,4 +22,5 @@ JDBC Drivers kyuubi_jdbc hive_jdbc mysql_jdbc + trino_jdbc diff --git a/docs/client/jdbc/trino_jdbc.md b/docs/client/jdbc/trino_jdbc.md new file mode 100644 index 00000000000..0f91c4337e6 --- /dev/null +++ b/docs/client/jdbc/trino_jdbc.md @@ -0,0 +1,92 @@ + + +# Trino JDBC Driver + +## Instructions + +Kyuubi currently supports the Trino connection protocol, so we can use Trino-JDBC to connect to the kyuubi server +and submit SQL to Spark, Trino and other engines for execution. + +## Start Kyuubi Trino Server + +First we should configure the trino protocol and the service port in the `kyuubi.conf` + +``` +kyuubi.frontend.protocols TRINO +kyuubi.frontend.trino.bind.port 10999 #default port +``` + +## Install Trino JDBC + +Download [trino-jdbc-363.jar](https://repo1.maven.org/maven2/io/trino/trino-jdbc/363/trino-jdbc-363.jar) and add it to the classpath of your Java application. + +The driver is also available from Maven Central: + +```xml + + io.trino + trino-jdbc + 363 + +``` + +## JDBC URL + +When your driver is loaded, registered and configured, you are ready to connect to Trino from your application. The following JDBC URL formats are supported: + +``` +jdbc:trino://host:port +``` + +Trino JDBC example + +```java +String trinoHost = "localhost"; +String trinoPort = "10999"; +String trinoUser = "default"; +String trinoPassword = null; +Connection connection = null; +ResultSet rs = null; + +try { + // Create the connection using the JDBC URL + connection = DriverManager.getConnection("jdbc:trino://" + trinoHost + ":" + trinoPort, trinoUser, trinoPassword); + + // Do whatever you need to do with the connection + Statement stmt = connection.createStatement(); + rs = stmt.executeQuery("SELECT 1"); + + while (rs.next()) { + // retrieve data from the ResultSet + } + +} catch (Exception e) { + e.printStackTrace(); +} finally { + try { + // Close the connection when you're done with it + if (rs != null) rs.close(); + if (connection != null) connection.close(); + } catch (Exception e) { + e.printStackTrace(); + } +} +``` + +The configuration of the connection parameters can be found in the official trino documentation at: https://trino.io/docs/current/client/jdbc.html#connection-parameters + diff --git a/docs/client/python/pyhive.md b/docs/client/python/pyhive.md index f77e3908b19..dbebf684fc0 100644 --- a/docs/client/python/pyhive.md +++ b/docs/client/python/pyhive.md @@ -1,26 +1,26 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # PyHive [PyHive](https://github.com/dropbox/PyHive) is a collection of Python DB-API and SQLAlchemy interfaces for Hive. PyHive can connect with the Kyuubi server serving in thrift protocol as HiveServer2. ## Requirements + PyHive works with Python 2.7 / Python 3. Install PyHive via pip for the Hive interface. ``` @@ -28,6 +28,7 @@ pip install 'pyhive[hive]' ``` ## Usage + Use the Kyuubi server's host and thrift protocol port to connect. For further information about usages and features, e.g. DB-API async fetching, using in SQLAlchemy, please refer to [project homepage](https://github.com/dropbox/PyHive). @@ -42,8 +43,8 @@ print(cursor.fetchone()) print(cursor.fetchall()) ``` - ### Use PyHive with Pandas + PyHive provides a handy way to establish a SQLAlchemy compatible connection and works with Pandas dataframe for executing SQL and reading data via [`pandas.read_sql`](https://pandas.pydata.org/docs/reference/api/pandas.read_sql.html). ```python @@ -57,12 +58,13 @@ conn = hive.Connection(host=kyuubi_host,port=10009) dataframe = pd.read_sql("SELECT id, name FROM test.example_table", conn) ``` - ### Authentication + If password is provided for connection, make sure the `auth` param set to either `CUSTOM` or `LDAP`. ```python # open connection conn = hive.Connection(host=kyuubi_host,port=10009, user='user', password='password', auth='CUSTOM') -``` \ No newline at end of file +``` + diff --git a/docs/client/python/pyspark.md b/docs/client/python/pyspark.md index cb459996d0d..b4fcb08e732 100644 --- a/docs/client/python/pyspark.md +++ b/docs/client/python/pyspark.md @@ -1,26 +1,26 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # PySpark [PySpark](https://spark.apache.org/docs/latest/api/python/index.html) is an interface for Apache Spark in Python. Kyuubi can be used as JDBC source in PySpark. ## Requirements + PySpark works with Python 3.7 and above. Install PySpark with Spark SQL and optional pandas support on Spark using PyPI as follows: @@ -33,20 +33,20 @@ For installation using Conda or manually downloading, please refer to [PySpark i ## Preparation +### Prepare JDBC driver -### Prepare JDBC driver Prepare JDBC driver jar file. Supported Hive compatible JDBC Driver as below: -| Driver | Driver Class Name | Remarks| -| ---------- | ----------------- | ----- | -| Kyuubi Hive Driver ([doc](../jdbc/kyuubi_jdbc.html))| org.apache.kyuubi.jdbc.KyuubiHiveDriver | Compile for the driver on master branch, as [KYUUBI #3484](https://github.com/apache/kyuubi/pull/3485) required by Spark JDBC source not yet included in released version. -| Hive Driver ([doc](../jdbc/hive_jdbc.html))| org.apache.hive.jdbc.HiveDriver | +| Driver | Driver Class Name | Remarks | +|------------------------------------------------------|-----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Kyuubi Hive Driver ([doc](../jdbc/kyuubi_jdbc.html)) | org.apache.kyuubi.jdbc.KyuubiHiveDriver | Compile for the driver on master branch, as [KYUUBI #3484](https://github.com/apache/kyuubi/pull/3485) required by Spark JDBC source not yet included in released version. | +| Hive Driver ([doc](../jdbc/hive_jdbc.html)) | org.apache.hive.jdbc.HiveDriver | Refer to docs of the driver and prepare the JDBC driver jar file. ### Prepare JDBC Hive Dialect extension -Hive Dialect support is required by Spark for wrapping SQL correctly and sending it to the JDBC driver. Kyuubi provides a JDBC dialect extension with auto-registered Hive Daliect support for Spark. Follow the instructions in [Hive Dialect Support](../../extensions/engines/spark/jdbc-dialect.html) to prepare the plugin jar file `kyuubi-extension-spark-jdbc-dialect_-*.jar`. +Hive Dialect support is required by Spark for wrapping SQL correctly and sending it to the JDBC driver. Kyuubi provides a JDBC dialect extension with auto-registered Hive Dialect support for Spark. Follow the instructions in [Hive Dialect Support](../../extensions/engines/spark/jdbc-dialect.html) to prepare the plugin jar file `kyuubi-extension-spark-jdbc-dialect_-*.jar`. ### Including jars of JDBC driver and Hive Dialect extension @@ -73,8 +73,6 @@ spark = SparkSession.builder \ .getOrCreate() ``` - - ## Usage For further information about PySpark JDBC usage and options, please refer to Spark's [JDBC To Other Databases](https://spark.apache.org/docs/latest/sql-data-sources-jdbc.html). @@ -98,7 +96,6 @@ jdbcDF = spark.read \ From Spark 3.2.0, [`CREATE DATASOURCE TABLE`](https://spark.apache.org/docs/latest/sql-ref-syntax-ddl-create-table-datasource.html) is supported to create jdbc source with SQL. - ```python # create JDBC Datasource table with DDL spark.sql("""CREATE TABLE kyuubi_table USING JDBC @@ -120,13 +117,12 @@ df.writeTo("kyuubi_table").overwrite spark.sql("INSERT INTO kyuubi_table SELECT * FROM some_table") ``` - ### Use PySpark with Pandas + From PySpark 3.2.0, PySpark supports pandas API on Spark which allows you to scale your pandas workload out. Pandas-on-Spark DataFrame and Spark DataFrame are virtually interchangeable. More instructions in [From/to pandas and PySpark DataFrames](https://spark.apache.org/docs/latest/api/python/user_guide/pandas_on_spark/pandas_pyspark.html#pyspark). - ```python import pyspark.pandas as ps @@ -134,3 +130,4 @@ psdf = ps.range(10) sdf = psdf.to_spark().filter("id > 5") sdf.show() ``` + diff --git a/docs/client/rest/rest_api.md b/docs/client/rest/rest_api.md index b1c8edfeacf..fbff59f0500 100644 --- a/docs/client/rest/rest_api.md +++ b/docs/client/rest/rest_api.md @@ -1,19 +1,19 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # REST API v1 @@ -57,10 +57,10 @@ Get an information detail of a session #### Response Body -| Name | Description | Type | -|:----------|:-------------------------------------------|:-------| -| infoType | The type of session information | String | -| infoValue | The value of session information | String | +| Name | Description | Type | +|:----------|:---------------------------------|:-------| +| infoType | The type of session information | String | +| infoValue | The value of session information | String | ### GET /sessions/count @@ -68,9 +68,9 @@ Get the current open session count #### Response Body -| Name | Description | Type | -|:-----------------|:----------------------------------|:-----| -| openSessionCount | The count of opening session | Int | +| Name | Description | Type | +|:-----------------|:-----------------------------|:-----| +| openSessionCount | The count of opening session | Int | ### GET /sessions/execPool/statistic @@ -89,19 +89,16 @@ Create a session #### Request Parameters -| Name | Description | Type | -|:----------------|:-----------------------------------------|:-------| -| protocolVersion | The protocol version of Hive CLI service | Int | -| user | The user name | String | -| password | The user password | String | -| ipAddr | The user client IP address | String | -| configs | The configuration of the session | Map | +| Name | Description | Type | +|:--------|:---------------------------------|:-----| +| configs | The configuration of the session | Map | #### Response Body -| Name | Description | Type | -|:-----------|:------------------------------|:-------| -| identifier | The session handle identifier | String | +| Name | Description | Type | +|:---------------|:---------------------------------------------------------------------------------------------------|:-------| +| identifier | The session handle identifier | String | +| kyuubiInstance | The Kyuubi instance that holds the session and to call for the following operations in the session | String | ### DELETE /sessions/${sessionHandle} @@ -113,11 +110,12 @@ Create an operation with EXECUTE_STATEMENT type #### Request Body -| Name | Description | Type | -|:-------------|:---------------------------------------------------------------|:--------| -| statement | The SQL statement that you execute | String | -| runAsync | The flag indicates whether the query runs synchronously or not | Boolean | -| queryTimeout | The interval of query time out | Long | +| Name | Description | Type | +|:-------------|:---------------------------------------------------------------|:---------------| +| statement | The SQL statement that you execute | String | +| runAsync | The flag indicates whether the query runs synchronously or not | Boolean | +| queryTimeout | The interval of query time out | Long | +| confOverlay | The conf to overlay only for current operation | Map of key=val | #### Response Body @@ -335,7 +333,7 @@ Returns all the batches. #### Request Parameters | Name | Description | Type | -| :--------- |:----------------------------------------------------------------------------------------------------| :----- | +|:-----------|:----------------------------------------------------------------------------------------------------|:-------| | batchType | The batch type, such as spark/flink, if no batchType is specified,
return all types | String | | batchState | The valid batch state can be one of the following:
PENDING, RUNNING, FINISHED, ERROR, CANCELED | String | | batchUser | The user name that created the batch | String | @@ -347,7 +345,7 @@ Returns all the batches. #### Response Body | Name | Description | Type | -| :------ |:-----------------------------------| :--- | +|:--------|:-----------------------------------|:-----| | from | The start index of fetched batches | Int | | total | Number of batches fetched | Int | | batches | [Batch](#batch) List | List | @@ -358,8 +356,11 @@ Create a new batch. #### Request Body +- Media type: `application-json` +- JSON structure: + | Name | Description | Type | -| :-------- |:---------------------------------------------------|:-----------------| +|:----------|:---------------------------------------------------|:-----------------| | batchType | The batch type, such as Spark, Flink | String | | resource | The resource containing the application to execute | Path (required) | | className | Application main class | String(required) | @@ -371,7 +372,33 @@ Create a new batch. The created [Batch](#batch) object. -### GET /batches/{batchId} +### POST /batches (with uploading resource) + +Create a new batch with uploading resource file. + +Example of using `curl` command to send POST request to `/v1/batches` in `multipart-formdata` media type with uploading resource file from local path. + +```shell +curl --location --request POST 'http://localhost:10099/api/v1/batches' \ +--form 'batchRequest="{\"batchType\":\"SPARK\",\"className\":\"org.apache.spark.examples.SparkPi\",\"name\":\"Spark Pi\"}";type=application/json' \ +--form 'resourceFile=@"/local_path/example.jar"' +``` + +#### Request Body + +- Media type: `multipart-formdata` +- Request body structure in multiparts: + +| Name | Description | Media Type | +|:-------------|:--------------------------------------------------------------------------------------------------|:-----------------| +| batchRequest | The batch request in JSON format as request body requried in [POST /batches](#post-batches) | application/json | +| resourceFile | The resource to upload and execute, which will be cached on server and cleaned up after execution | File | + +#### Response Body + +The created [Batch](#batch) object. + +### GET /batches/${batchId} Returns the batch information. @@ -386,13 +413,13 @@ Kill the batch if it is still running. #### Request Parameters | Name | Description | Type | -| :---------------------- | :---------------------------- | :--------------- | +|:------------------------|:------------------------------|:-----------------| | hive.server2.proxy.user | the proxy user to impersonate | String(optional) | #### Response Body | Name | Description | Type | -| :------ |:--------------------------------------| :------ | +|:--------|:--------------------------------------|:--------| | success | Whether killed the batch successfully | Boolean | | msg | The kill batch message | String | @@ -403,14 +430,14 @@ Gets the local log lines from this batch. #### Request Parameters | Name | Description | Type | -| :--- |:--------------------------------------------------| :--- | +|:-----|:--------------------------------------------------|:-----| | from | Offset | Int | | size | Max number of log lines to return, 100 by default | Int | #### Response Body | Name | Description | Type | -| :-------- | :---------------- |:----------------| +|:----------|:------------------|:----------------| | logRowSet | The log lines | List of Strings | | rowCount | The log row count | Int | @@ -431,7 +458,7 @@ Delete the specified engine. #### Request Parameters | Name | Description | Type | -|:------------------------|:------------------------------| :--------------- | +|:------------------------|:------------------------------|:-----------------| | type | the engine type | String(optional) | | sharelevel | the engine share level | String(optional) | | subdomain | the engine subdomain | String(optional) | @@ -444,13 +471,14 @@ Get a list of satisfied engines. #### Request Parameters | Name | Description | Type | -|:------------------------|:------------------------------| :--------------- | +|:------------------------|:------------------------------|:-----------------| | type | the engine type | String(optional) | | sharelevel | the engine share level | String(optional) | | subdomain | the engine subdomain | String(optional) | | hive.server2.proxy.user | the proxy user to impersonate | String(optional) | #### Response Body + The [Engine](#engine) List. ## REST Objects @@ -458,11 +486,12 @@ The [Engine](#engine) List. ### Batch | Name | Description | Type | -| :------------- |:------------------------------------------------------------------| :----- | +|:---------------|:------------------------------------------------------------------|:-------| | id | The batch id | String | | user | The user created the batch | String | | batchType | The batch type | String | | name | The batch name | String | +| appStartTime | The batch application start time | Long | | appId | The batch application Id | String | | appUrl | The batch application tracking url | String | | appState | The batch application state | String | @@ -500,11 +529,11 @@ The [Engine](#engine) List. | statementId | The unique identifier of a single operation | String | | remoteId | The unique identifier of a single operation at engine side | String | | statement | The sql that you execute | String | -| shouldRunAsync | The flag indicating whether the query runs synchronously or not | Boolean | +| shouldRunAsync | The flag indicating whether the query runs synchronously or not | Boolean | | state | The current operation state | String | | eventTime | The time when the event created & logged | Long | -| createTime | The time for changing to the current operation state | Long | -| startTime | The time the query start to time of this operation | Long | +| createTime | The time for changing to the current operation state | Long | +| startTime | The time the query start to time of this operation | Long | | completeTime | Time time the query ends | Long | | exception | Caught exception if have | Throwable | | sessionId | The identifier of the parent session | String | @@ -517,8 +546,8 @@ The [Engine](#engine) List. | columnName | The name of the column | String | | dataType | The type descriptor for this column | String | | columnIndex | The index of this column in the schema | Int | -| precision | The precision of the column | Int | -| scale | The scale of the column | Int | +| precision | The precision of the column | Int | +| scale | The scale of the column | Int | | comment | The comment of the column | String | ### Row @@ -536,12 +565,13 @@ The [Engine](#engine) List. ### Engine -| Name | Description | Type | -| :------------- |:-------------------------------------------------------------------| :----- | -| version | The version of the Kyuubi server that creates this engine instance | String | -| user | The user created the engine | String | -| engineType | The engine type | String | -| sharelevel | The engine share level | String | -| subdomain | The engine subdomain | String | -| instance | host:port for the engine node | String | -| namespace | The namespace used to expose the engine to KyuubiServers | String | +| Name | Description | Type | +|:-----------|:-------------------------------------------------------------------|:-------| +| version | The version of the Kyuubi server that creates this engine instance | String | +| user | The user created the engine | String | +| engineType | The engine type | String | +| sharelevel | The engine share level | String | +| subdomain | The engine subdomain | String | +| instance | host:port for the engine node | String | +| namespace | The namespace used to expose the engine to KyuubiServers | String | + diff --git a/docs/community/collaborators.md b/docs/community/collaborators.md index c9ff9e2e816..264e8b3c92f 100644 --- a/docs/community/collaborators.md +++ b/docs/community/collaborators.md @@ -1,19 +1,19 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Collaborators diff --git a/docs/community/release.md b/docs/community/release.md index f3c15983511..8252669c0dc 100644 --- a/docs/community/release.md +++ b/docs/community/release.md @@ -1,24 +1,25 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> Kyuubi Release Guide -=== +==================== ## Introduction + The Apache Kyuubi project periodically declares and publishes releases. A release is one or more packages of the project artifact(s) that are approved for general public distribution and use. They may come with various degrees of caveat regarding their perceived quality and potential for change, such as "alpha", "beta", "incubating", @@ -32,8 +33,8 @@ Each release is executed by a Release Manager, who is selected among the Kyuubi the process that the Release Manager follows to perform a release. Any changes to this process should be discussed and adopted on the [dev mailing list](mailto:dev@kyuubi.apache.org). -Please remember that publishing software has legal consequences. This guide complements the foundation-wide -[Product Release Policy](https://www.apache.org/dev/release.html) and +Please remember that publishing software has legal consequences. This guide complements the foundation-wide +[Product Release Policy](https://www.apache.org/dev/release.html) and [Release Distribution Policy](https://www.apache.org/dev/release-distribution). ### Overview @@ -42,12 +43,14 @@ The release process consists of several steps: 1. Decide to release 2. Prepare for the release -3. Cut branch off for __major__ release +3. Cut branch off for __feature__ release 4. Build a release candidate 5. Vote on the release candidate 6. If necessary, fix any issues and go back to step 3. 7. Finalize the release 8. Promote the release +9. Remove the dist repo directories for deprecated release candidates +10. Publish docker image ## Decide to release @@ -86,6 +89,7 @@ export ASF_PASSWORD= ``` #### Java Home + An available environment variable `JAVA_HOME`, you can do `echo $JAVA_HOME` to check it. Note that, the Java version should be 8. @@ -101,11 +105,13 @@ You need to have a GPG key to sign the release artifacts. Please be aware of the with your Apache account, please create one according to the guidelines. Determine your Apache GPG Key and Key ID, as follows: + ```shell gpg --list-keys --keyid-format SHORT ``` This will list your GPG keys. One of these should reflect your Apache account, for example: + ```shell pub rsa4096 2021-08-30 [SC] 8FC8075E1FDC303276C676EE8001952629BCC75D @@ -118,16 +124,18 @@ sub rsa4096 2021-08-30 [E] Here, the key ID is the 8-digit hex string in the pub line: `29BCC75D`. To export the PGP public key, using: + ```shell gpg --armor --export 29BCC75D ``` If you have more than one gpg key, you can specify the default key as the following: + ``` echo 'default-key ' > ~/.gnupg/gpg.conf ``` -The last step is to update the KEYS file with your code signing key +The last step is to update the KEYS file with your code signing key https://www.apache.org/dev/openpgp.html#export-public-key ```shell @@ -145,12 +153,12 @@ gpg --keyserver hkp://keyserver.ubuntu.com --send-keys ${PUBLIC_KEY} # send publ gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys ${PUBLIC_KEY} # verify ``` -## Cut branch if for major release +## Cut branch if for feature release Kyuubi use version pattern `{MAJOR_VERSION}.{MINOR_VERSION}.{PATCH_VERSION}[-{OPTIONAL_SUFFIX}]`, e.g. `1.7.0`. -__Major Release__ means `MAJOR_VERSION` or `MINOR_VERSION` changed, and __Patch Release__ means `PATCH_VERSION` changed. +__Feature Release__ means `MAJOR_VERSION` or `MINOR_VERSION` changed, and __Patch Release__ means `PATCH_VERSION` changed. -The main step towards preparing a major release is to create a release branch. This is done via standard Git branching +The main step towards preparing a feature release is to create a release branch. This is done via standard Git branching mechanism and should be announced to the community once the branch is created. > Note: If you are releasing a patch version, you can ignore this step. @@ -161,31 +169,49 @@ After cutting release branch, don't forget bump version in `master` branch. ## Build a release candidate -> Don't forget to switch to the release branch! +> Don't forget to switch to the release branch! -1. Set environment variables. +- Set environment variables. ```shell export RELEASE_VERSION= export RELEASE_RC_NO= +export NEXT_VERSION= ``` -2. Bump version. +- Bump version, and create a git tag for the release candidate. + +Considering that other committers may merge PRs during your release period, you should accomplish the version change +first, and then come back to the release candidate tag to continue the rest release process. + +The tag pattern is `v${RELEASE_VERSION}-rc${RELEASE_RC_NO}`, e.g. `v1.7.0-rc0` + +> NOTE: After all the voting passed, be sure to create a final tag with the pattern: `v${RELEASE_VERSION}` ```shell +# Bump to the release version build/mvn versions:set -DgenerateBackupPoms=false -DnewVersion="${RELEASE_VERSION}" - git commit -am "[RELEASE] Bump ${RELEASE_VERSION}" -``` -3. Create a git tag for the release candidate. +# Create tag +git tag v${RELEASE_VERSION}-rc${RELEASE_RC_NO} -The tag pattern is `v${RELEASE_VERSION}-rc${RELEASE_RC_NO}`, e.g. `v1.7.0-rc0` +# Prepare for the next development version +build/mvn versions:set -DgenerateBackupPoms=false -DnewVersion="${NEXT_VERSION}-SNAPSHOT" +git commit -am "[RELEASE] Bump ${NEXT_VERSION}-SNAPSHOT" -> NOTE: After all the voting passed, be sure to create a final tag with the pattern: `v${RELEASE_VERSION}` +# Push branch to apache remote repo +git push apache + +# Push tag to apache remote repo +git push apache v${RELEASE_VERSION}-rc${RELEASE_RC_NO} -4. Package the release binaries & sources, and upload them to the Apache staging SVN repo. Publish jars to the Apache -staging Maven repo. +# Go back to release candidate tag +git checkout v${RELEASE_VERSION}-rc${RELEASE_RC_NO} +``` + +- Package source and binary artifacts, and upload them to the Apache staging SVN repo. Publish jars to the Apache + staging Maven repo. ```shell build/release/release.sh publish @@ -193,7 +219,7 @@ build/release/release.sh publish To make your release available in the staging repository, you must close the staging repo in the [Apache Nexus](https://repository.apache.org/#stagingRepositories). Until you close, you can re-run deploying to staging multiple times. But once closed, it will create a new staging repo. So ensure you close this, so that the next RC (if need be) is on a new repo. Once everything is good, close the staging repository on Apache Nexus. -5. Generate a pre-release note from GitHub for the subsequent voting. +- Generate a pre-release note from GitHub for the subsequent voting. Goto the [release page](https://github.com/apache/kyuubi/releases) and click the "Draft a new release" button, then it would jump to a new page to prepare the release. @@ -209,7 +235,7 @@ The release voting takes place on the Apache Kyuubi developers list. - Recommend represent voting closing time in UTC format. - Make sure the email is in text format and the links are correct. -> Note: you can generate the voting mail content for dev ML automatically via invoke the `build/release/script/dev_kyuubi_vote.sh` script. +> Note: you can generate the voting mail content for dev ML automatically via invoke the `build/release/script/dev_kyuubi_vote.sh` script. Once the vote is done, you should also send out a summary email with the totals, with a subject that looks something like __[VOTE][RESULT] Release Apache Kyuubi ...__ @@ -225,7 +251,7 @@ After the vote passes, to upload the binaries to Apache mirrors, you move the bi be where they are voted) to release directory. This "moving" is the only way you can add stuff to the actual release directory. (Note: only PMC members can move to release directory) -Move the sub-directory in "dev" to the corresponding directory in "release". If you've added your signing key to the +Move the subdirectory in "dev" to the corresponding directory in "release". If you've added your signing key to the KEYS file, also update the release copy. ```shell @@ -237,7 +263,7 @@ This will be mirrored throughout the Apache network. For Maven Central Repository, you can Release from the [Apache Nexus Repository Manager](https://repository.apache.org/). Log in, open "Staging Repositories", find the one voted on, select and click "Release" and confirm. If successful, it -should show up under https://repository.apache.org/content/repositories/releases/org/apache/kyuubi/ and the same under +should show up under https://repository.apache.org/content/repositories/releases/org/apache/kyuubi/ and the same under https://repository.apache.org/content/groups/maven-staging-group/org/apache/kyuubi/ (look for the correct release version). After some time this will be synced to [Maven Central](https://search.maven.org/) automatically. @@ -249,8 +275,7 @@ Fork and clone [Apache Kyuubi website](https://github.com/apache/kyuubi-website) 1. Add a new markdown file in `src/zh/news/`, `src/en/news/` 2. Add a new markdown file in `src/zh/release/`, `src/en/release/` -3. Follow [Build Document](../develop_tools/build_document.md) to build documents, then copy `apache/kyuubi`'s - folder `docs/_build/html` to `apache/kyuubi-website`'s folder `content/docs/r{RELEASE_VERSION}` +3. Update `releases` defined in `hugo.toml`'s `[params]` part. ### Create an Announcement @@ -262,10 +287,9 @@ Note that, you must use the apache.org email to send announce to `announce@apach Enjoy an adult beverage of your choice, and congratulations on making a Kyuubi release. - ## Remove the dist repo directories for deprecated release candidates -Remove the deprecated dist repo directories at last. +Remove the deprecated dist repo directories at last. ```shell cd work/svn-dev @@ -274,3 +298,7 @@ svn delete https://dist.apache.org/repos/dist/dev/kyuubi/{RELEASE_TAG} \ --password "${ASF_PASSWORD}" \ --message "Remove deprecated Apache Kyuubi ${RELEASE_TAG}" ``` + +## Publish docker image + +See steps in `https://github.com/apache/kyuubi-docker/blob/master/release/release_guide.md` diff --git a/docs/conf.py b/docs/conf.py index 3df98c6e34c..dcf038314c5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -64,7 +64,7 @@ author = 'Apache Kyuubi Community' # The full version, including alpha/beta/rc tags -release = subprocess.getoutput("cd .. && build/mvn help:evaluate -Dexpression=project.version|grep -v Using|grep -v INFO|grep -v WARNING|tail -n 1").split('\n')[-1] +release = subprocess.getoutput("grep 'kyuubi-parent' -C1 ../pom.xml | grep '' | awk -F '[<>]' '{print $3}'") # -- General configuration --------------------------------------------------- diff --git a/docs/connector/flink/index.rst b/docs/connector/flink/index.rst index c9d91091f71..e7d40fd43b9 100644 --- a/docs/connector/flink/index.rst +++ b/docs/connector/flink/index.rst @@ -19,6 +19,6 @@ Connectors For Flink SQL Query Engine .. toctree:: :maxdepth: 2 - flink_table_store + paimon hudi iceberg diff --git a/docs/connector/flink/flink_table_store.rst b/docs/connector/flink/paimon.rst similarity index 50% rename from docs/connector/flink/flink_table_store.rst rename to docs/connector/flink/paimon.rst index c2fd6679d3d..b67101488e8 100644 --- a/docs/connector/flink/flink_table_store.rst +++ b/docs/connector/flink/paimon.rst @@ -13,57 +13,56 @@ See the License for the specific language governing permissions and limitations under the License. -`Flink Table Store`_ -========== +`Apache Paimon (Incubating)`_ +============================= -Flink Table Store is a unified storage to build dynamic tables for both streaming and batch processing in Flink, -supporting high-speed data ingestion and timely data query. +Apache Paimon (Incubating) is a streaming data lake platform that supports high-speed data ingestion, change data tracking, and efficient real-time analytics. .. tip:: - This article assumes that you have mastered the basic knowledge and operation of `Flink Table Store`_. - For the knowledge about Flink Table Store not mentioned in this article, + This article assumes that you have mastered the basic knowledge and operation of `Apache Paimon (Incubating)`_. + For the knowledge not mentioned in this article, you can obtain it from its `Official Documentation`_. -By using kyuubi, we can run SQL queries towards Flink Table Store which is more -convenient, easy to understand, and easy to expand than directly using -flink to manipulate Flink Table Store. +By using kyuubi, we can run SQL queries towards Apache Paimon (Incubating) which is more +convenient, easy to understand, and easy to expand than directly using flink. -Flink Table Store Integration -------------------- +Apache Paimon (Incubating) Integration +-------------------------------------- -To enable the integration of kyuubi flink sql engine and Flink Table Store, you need to: +To enable the integration of kyuubi flink sql engine and Apache Paimon (Incubating), you need to: -- Referencing the Flink Table Store :ref:`dependencies` +- Referencing the Apache Paimon (Incubating) :ref:`dependencies` -.. _flink-table-store-deps: +.. _flink-paimon-deps: Dependencies ************ -The **classpath** of kyuubi flink sql engine with Flink Table Store supported consists of +The **classpath** of kyuubi flink sql engine with Apache Paimon (Incubating) supported consists of 1. kyuubi-flink-sql-engine-\ |release|\ _2.12.jar, the engine jar deployed with Kyuubi distributions 2. a copy of flink distribution -3. flink-table-store-dist-.jar (example: flink-table-store-dist-0.2.jar), which can be found in the `Maven Central`_ +3. paimon-flink-.jar (example: paimon-flink-1.16-0.4-SNAPSHOT.jar), which can be found in the `Apache Paimon (Incubating) Supported Engines Flink`_ +4. flink-shaded-hadoop-2-uber-.jar, which code can be found in the `Pre-bundled Hadoop Jar`_ -In order to make the Flink Table Store packages visible for the runtime classpath of engines, we can use these methods: +In order to make the Apache Paimon (Incubating) packages visible for the runtime classpath of engines, you need to: -1. Put the Flink Table Store packages into ``$FLINK_HOME/lib`` directly +1. Put the Apache Paimon (Incubating) packages into ``$FLINK_HOME/lib`` directly 2. Setting the HADOOP_CLASSPATH environment variable or copy the `Pre-bundled Hadoop Jar`_ to flink/lib. .. warning:: - Please mind the compatibility of different Flink Table Store and Flink versions, which can be confirmed on the page of `Flink Table Store multi engine support`_. + Please mind the compatibility of different Apache Paimon (Incubating) and Flink versions, which can be confirmed on the page of `Apache Paimon (Incubating) multi engine support`_. -Flink Table Store Operations ------------------- +Apache Paimon (Incubating) Operations +------------------------------------- Taking ``CREATE CATALOG`` as a example, .. code-block:: sql CREATE CATALOG my_catalog WITH ( - 'type'='table-store', - 'warehouse'='hdfs://nn:8020/warehouse/path' -- or 'file:///tmp/foo/bar' + 'type'='paimon', + 'warehouse'='file:/tmp/paimon' ); USE CATALOG my_catalog; @@ -96,7 +95,7 @@ Taking ``Streaming Query`` as a example, SET 'execution.runtime-mode' = 'streaming'; SELECT * FROM MyTable /*+ OPTIONS ('log.scan'='latest') */; -Taking ``Rescale Bucket` as a example, +Taking ``Rescale Bucket`` as a example, .. code-block:: sql @@ -104,8 +103,8 @@ Taking ``Rescale Bucket` as a example, INSERT OVERWRITE my_table PARTITION (dt = '2022-01-01'); -.. _Flink Table Store: https://nightlies.apache.org/flink/flink-table-store-docs-stable/ -.. _Official Documentation: https://nightlies.apache.org/flink/flink-table-store-docs-stable/ -.. _Maven Central: https://mvnrepository.com/artifact/org.apache.flink/flink-table-store-dist -.. _Pre-bundled Hadoop Jar: https://flink.apache.org/downloads.html -.. _Flink Table Store multi engine support: https://nightlies.apache.org/flink/flink-table-store-docs-stable/docs/engines/overview/ +.. _Apache Paimon (Incubating): https://paimon.apache.org/ +.. _Official Documentation: https://paimon.apache.org/docs/master/ +.. _Apache Paimon (Incubating) Supported Engines Flink: https://paimon.apache.org/docs/master/engines/flink/#preparing-paimon-jar-file +.. _Pre-bundled Hadoop Jar: https://flink.apache.org/downloads/#additional-components +.. _Apache Paimon (Incubating) multi engine support: https://paimon.apache.org/docs/master/engines/overview/ diff --git a/docs/connector/hive/index.rst b/docs/connector/hive/index.rst index 2b2b863a67e..d96f8b04188 100644 --- a/docs/connector/hive/index.rst +++ b/docs/connector/hive/index.rst @@ -19,4 +19,5 @@ Connectors for Hive SQL Query Engine .. toctree:: :maxdepth: 2 + paimon iceberg diff --git a/docs/connector/hive/paimon.rst b/docs/connector/hive/paimon.rst new file mode 100644 index 00000000000..000d2d7e83c --- /dev/null +++ b/docs/connector/hive/paimon.rst @@ -0,0 +1,100 @@ +.. Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + +.. http://www.apache.org/licenses/LICENSE-2.0 + +.. Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +`Apache Paimon (Incubating)`_ +========== + +Apache Paimon(incubating) is a streaming data lake platform that supports high-speed data ingestion, change data tracking and efficient real-time analytics. + +.. tip:: + This article assumes that you have mastered the basic knowledge and operation of `Apache Paimon (Incubating)`_. + For the knowledge about Apache Paimon (Incubating) not mentioned in this article, + you can obtain it from its `Official Documentation`_. + +By using Kyuubi, we can run SQL queries towards Apache Paimon (Incubating) which is more +convenient, easy to understand, and easy to expand than directly using +Hive to manipulate Apache Paimon (Incubating). + +Apache Paimon (Incubating) Integration +------------------- + +To enable the integration of kyuubi hive sql engine and Apache Paimon (Incubating), you need to: + +- Referencing the Apache Paimon (Incubating) :ref:`dependencies` +- Setting the environment variable :ref:`configurations` + +.. _hive-paimon-deps: + +Dependencies +************ + +The **classpath** of kyuubi hive sql engine with Iceberg supported consists of + +1. kyuubi-hive-sql-engine-\ |release|\ _2.12.jar, the engine jar deployed with Kyuubi distributions +2. a copy of hive distribution +3. paimon-hive-connector--.jar (example: paimon-hive-connector-3.1-0.4-SNAPSHOT.jar), which can be found in the `Apache Paimon (Incubating) Supported Engines Hive`_ + +In order to make the Hive packages visible for the runtime classpath of engines, we can use one of these methods: + +1. You can create an auxlib folder under the root directory of Hive, and copy paimon-hive-connector-3.1-.jar into auxlib. +2. Execute ADD JAR statement in the Kyuubi to add dependencies to Hive’s auxiliary classpath. For example: + +.. code-block:: sql + + ADD JAR /path/to/paimon-hive-connector-3.1-.jar; + +.. warning:: + The second method is not recommended. If you’re using the MR execution engine and running a join statement, you may be faced with the exception + ``org.apache.hive.com.esotericsoftware.kryo.kryoexception: unable to find class.`` + +.. warning:: + Please mind the compatibility of different Apache Paimon (Incubating) and Hive versions, which can be confirmed on the page of `Apache Paimon (Incubating) multi engine support`_. + +.. _hive-paimon-conf: + +Configurations +************** + +If you are using HDFS, make sure that the environment variable HADOOP_HOME or HADOOP_CONF_DIR is set. + +Apache Paimon (Incubating) Operations +------------------ + +Apache Paimon (Incubating) only supports only reading table store tables through Hive. +A common scenario is to write data with Spark or Flink and read data with Hive. +You can follow this document `Apache Paimon (Incubating) Quick Start with Paimon Hive Catalog`_ to write data to a table which can also be accessed directly from Hive. +and then use Kyuubi Hive SQL engine to query the table with the following SQL ``SELECT`` statement. + +Taking ``Query Data`` as an example, + +.. code-block:: sql + + SELECT a, b FROM test_table ORDER BY a; + +Taking ``Query External Table`` as an example, + +.. code-block:: sql + + CREATE EXTERNAL TABLE external_test_table + STORED BY 'org.apache.paimon.hive.PaimonStorageHandler' + LOCATION '/path/to/table/store/warehouse/default.db/test_table'; + + SELECT a, b FROM test_table ORDER BY a; + +.. _Apache Paimon (Incubating): https://paimon.apache.org/ +.. _Official Documentation: https://paimon.apache.org/docs/master/ +.. _Apache Paimon (Incubating) Quick Start with Paimon Hive Catalog: https://paimon.apache.org/docs/master/engines/hive/#quick-start-with-paimon-hive-catalog +.. _Apache Paimon (Incubating) Supported Engines Hive: https://paimon.apache.org/docs/master/engines/hive/ +.. _Apache Paimon (Incubating) multi engine support: https://paimon.apache.org/docs/master/engines/overview/ diff --git a/docs/connector/spark/flink_table_store.rst b/docs/connector/spark/flink_table_store.rst deleted file mode 100644 index ee4c2b352c2..00000000000 --- a/docs/connector/spark/flink_table_store.rst +++ /dev/null @@ -1,90 +0,0 @@ -.. Licensed to the Apache Software Foundation (ASF) under one or more - contributor license agreements. See the NOTICE file distributed with - this work for additional information regarding copyright ownership. - The ASF licenses this file to You under the Apache License, Version 2.0 - (the "License"); you may not use this file except in compliance with - the License. You may obtain a copy of the License at - -.. http://www.apache.org/licenses/LICENSE-2.0 - -.. Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -`Flink Table Store`_ -========== - -Flink Table Store is a unified storage to build dynamic tables for both streaming and batch processing in Flink, -supporting high-speed data ingestion and timely data query. - -.. tip:: - This article assumes that you have mastered the basic knowledge and operation of `Flink Table Store`_. - For the knowledge about Flink Table Store not mentioned in this article, - you can obtain it from its `Official Documentation`_. - -By using kyuubi, we can run SQL queries towards Flink Table Store which is more -convenient, easy to understand, and easy to expand than directly using -spark to manipulate Flink Table Store. - -Flink Table Store Integration -------------------- - -To enable the integration of kyuubi spark sql engine and Flink Table Store through -Apache Spark Datasource V2 and Catalog APIs, you need to: - -- Referencing the Flink Table Store :ref:`dependencies` -- Setting the spark extension and catalog :ref:`configurations` - -.. _spark-flink-table-store-deps: - -Dependencies -************ - -The **classpath** of kyuubi spark sql engine with Flink Table Store supported consists of - -1. kyuubi-spark-sql-engine-\ |release|\ _2.12.jar, the engine jar deployed with Kyuubi distributions -2. a copy of spark distribution -3. flink-table-store-spark-.jar (example: flink-table-store-spark-0.2.jar), which can be found in the `Maven Central`_ - -In order to make the Flink Table Store packages visible for the runtime classpath of engines, we can use one of these methods: - -1. Put the Flink Table Store packages into ``$SPARK_HOME/jars`` directly -2. Set ``spark.jars=/path/to/flink-table-store-spark`` - -.. warning:: - Please mind the compatibility of different Flink Table Store and Spark versions, which can be confirmed on the page of `Flink Table Store multi engine support`_. - -.. _spark-flink-table-store-conf: - -Configurations -************** - -To activate functionality of Flink Table Store, we can set the following configurations: - -.. code-block:: properties - - spark.sql.catalog.tablestore=org.apache.flink.table.store.spark.SparkCatalog - spark.sql.catalog.tablestore.warehouse=file:/tmp/warehouse - -Flink Table Store Operations ------------------- - -Flink Table Store supports reading table store tables through Spark. -A common scenario is to write data with Flink and read data with Spark. -You can follow this document `Flink Table Store Quick Start`_ to write data to a table store table -and then use kyuubi spark sql engine to query the table with the following SQL ``SELECT`` statement. - - -.. code-block:: sql - - select * from table_store.default.word_count; - - - -.. _Flink Table Store: https://nightlies.apache.org/flink/flink-table-store-docs-stable/ -.. _Flink Table Store Quick Start: https://nightlies.apache.org/flink/flink-table-store-docs-stable/docs/try-table-store/quick-start/ -.. _Official Documentation: https://nightlies.apache.org/flink/flink-table-store-docs-stable/ -.. _Maven Central: https://mvnrepository.com/artifact/org.apache.flink -.. _Flink Table Store multi engine support: https://nightlies.apache.org/flink/flink-table-store-docs-stable/docs/engines/overview/ diff --git a/docs/connector/spark/index.rst b/docs/connector/spark/index.rst index 790e804f268..d1503443c63 100644 --- a/docs/connector/spark/index.rst +++ b/docs/connector/spark/index.rst @@ -23,7 +23,7 @@ By default, it provides accessibility to hive warehouses with various file forma supported, such as parquet, orc, json, etc. Also,it can easily integrate with other third-party libraries, such as Hudi, -Iceberg, Delta Lake, Kudu, Flink Table Store, HBase,Cassandra, etc. +Iceberg, Delta Lake, Kudu, Apache Paimon (Incubating), HBase,Cassandra, etc. We also provide sample data sources like TDC-DS, TPC-H for testing and benchmarking purpose. @@ -37,7 +37,7 @@ purpose. iceberg kudu hive - flink_table_store + paimon tidb tpcds tpch diff --git a/docs/connector/spark/kudu.md b/docs/connector/spark/kudu.md index ca02eb95cd7..ce2d1e88cc1 100644 --- a/docs/connector/spark/kudu.md +++ b/docs/connector/spark/kudu.md @@ -1,19 +1,19 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kudu @@ -26,6 +26,7 @@ When you are reading this documentation, we suppose that you are not necessary t Anything missing on this page about Apache Kudu background knowledge, you can refer to its official website. ## Why Kyuubi on Kudu + Basically, Kyuubi can take place of HiveServer2 as a multi tenant ad-hoc SQL on Hadoop solution, with the advantages of speed and power coming from Spark SQL. You can run SQL queries towards both data source and Hive tables whose data is secured only with computing resources you are authorized. > Spark SQL supports operating on a variety of data sources through the DataFrame interface. A DataFrame can be operated on using relational transformations and can also be used to create a temporary view. Registering a DataFrame as a temporary view allows you to run SQL queries over its data. This section describes the general methods for loading and saving data using the Spark Data Sources and then goes into specific options that are available for the built-in data sources. @@ -33,11 +34,13 @@ Basically, Kyuubi can take place of HiveServer2 as a multi tenant ad-hoc SQL on In Kyuubi, we can register Kudu tables and other data source tables as Spark temporary views to enable federated union queries across Hive, Kudu, and other data sources. ## Kudu Integration with Apache Spark + Before integrating Kyuubi with Kudu, we strongly suggest that you integrate and test Spark with Kudu first. You may find the guide from Kudu's online documentation -- [Kudu Integration with Spark](https://kudu.apache.org/docs/developing.html#_kudu_integration_with_spark) ## Kudu Integration with Kyuubi #### Install Kudu Spark Dependency + Confirm your Kudu cluster version and download the corresponding kudu spark dependency library, such as [org.apache.kudu:kudu-spark3_2.12-1.14.0](https://repo1.maven.org/maven2/org/apache/kudu/kudu-spark3_2.12/1.14.0/kudu-spark3_2.12-1.14.0.jar) to `$SPARK_HOME`/jars. #### Start Kyuubi @@ -97,7 +100,6 @@ options ( 5 rows selected (1.083 seconds) ``` - #### Join Kudu table with Hive table ```sql @@ -179,6 +181,7 @@ No rows selected (0.611 seconds) ``` ## References + [https://kudu.apache.org/](https://kudu.apache.org/) [https://kudu.apache.org/docs/developing.html#_kudu_integration_with_spark](https://kudu.apache.org/docs/developing.html#_kudu_integration_with_spark) [https://github.com/apache/kyuubi](https://github.com/apache/kyuubi) diff --git a/docs/connector/spark/paimon.rst b/docs/connector/spark/paimon.rst new file mode 100644 index 00000000000..14e74195503 --- /dev/null +++ b/docs/connector/spark/paimon.rst @@ -0,0 +1,110 @@ +.. Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + +.. http://www.apache.org/licenses/LICENSE-2.0 + +.. Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +`Apache Paimon (Incubating)`_ +========== + +Apache Paimon(incubating) is a streaming data lake platform that supports high-speed data ingestion, change data tracking and efficient real-time analytics. + +.. tip:: + This article assumes that you have mastered the basic knowledge and operation of `Apache Paimon (Incubating)`_. + For the knowledge about Apache Paimon (Incubating) not mentioned in this article, + you can obtain it from its `Official Documentation`_. + +By using kyuubi, we can run SQL queries towards Apache Paimon (Incubating) which is more +convenient, easy to understand, and easy to expand than directly using +spark to manipulate Apache Paimon (Incubating). + +Apache Paimon (Incubating) Integration +------------------- + +To enable the integration of kyuubi spark sql engine and Apache Paimon (Incubating), you need to set the following configurations: + +- Referencing the Apache Paimon (Incubating) :ref:`dependencies` +- Setting the spark extension and catalog :ref:`configurations` + +.. _spark-paimon-deps: + +Dependencies +************ + +The **classpath** of kyuubi spark sql engine with Apache Paimon (Incubating) consists of + +1. kyuubi-spark-sql-engine-\ |release|\ _2.12.jar, the engine jar deployed with Kyuubi distributions +2. a copy of spark distribution +3. paimon-spark-.jar (example: paimon-spark-3.3-0.4-20230323.002035-5.jar), which can be found in the `Apache Paimon (Incubating) Supported Engines Spark3`_ + +In order to make the Apache Paimon (Incubating) packages visible for the runtime classpath of engines, we can use one of these methods: + +1. Put the Apache Paimon (Incubating) packages into ``$SPARK_HOME/jars`` directly +2. Set ``spark.jars=/path/to/paimon-spark-.jar`` + +.. warning:: + Please mind the compatibility of different Apache Paimon (Incubating) and Spark versions, which can be confirmed on the page of `Apache Paimon (Incubating) multi engine support`_. + +.. _spark-paimon-conf: + +Configurations +************** + +To activate functionality of Apache Paimon (Incubating), we can set the following configurations: + +.. code-block:: properties + + spark.sql.catalog.paimon=org.apache.paimon.spark.SparkCatalog + spark.sql.catalog.paimon.warehouse=file:/tmp/paimon + +Apache Paimon (Incubating) Operations +------------------ + + +Taking ``CREATE NAMESPACE`` as a example, + +.. code-block:: sql + + CREATE DATABASE paimon.default; + USE paimon.default; + +Taking ``CREATE TABLE`` as a example, + +.. code-block:: sql + + create table my_table ( + k int, + v string + ) tblproperties ( + 'primary-key' = 'k' + ); + +Taking ``SELECT`` as a example, + +.. code-block:: sql + + SELECT * FROM my_table; + + +Taking ``INSERT`` as a example, + +.. code-block:: sql + + INSERT INTO my_table VALUES (1, 'Hi Again'), (3, 'Test'); + + + + +.. _Apache Paimon (Incubating): https://paimon.apache.org/ +.. _Official Documentation: https://paimon.apache.org/docs/master/ +.. _Apache Paimon (Incubating) Supported Engines Spark3: https://paimon.apache.org/docs/master/engines/spark3/ +.. _Apache Paimon (Incubating) multi engine support: https://paimon.apache.org/docs/master/engines/overview/ diff --git a/docs/connector/trino/flink_table_store.rst b/docs/connector/trino/flink_table_store.rst deleted file mode 100644 index 8dd0c4061f8..00000000000 --- a/docs/connector/trino/flink_table_store.rst +++ /dev/null @@ -1,94 +0,0 @@ -.. Licensed to the Apache Software Foundation (ASF) under one or more - contributor license agreements. See the NOTICE file distributed with - this work for additional information regarding copyright ownership. - The ASF licenses this file to You under the Apache License, Version 2.0 - (the "License"); you may not use this file except in compliance with - the License. You may obtain a copy of the License at - -.. http://www.apache.org/licenses/LICENSE-2.0 - -.. Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -`Flink Table Store`_ -========== - -Flink Table Store is a unified storage to build dynamic tables for both streaming and batch processing in Flink, -supporting high-speed data ingestion and timely data query. - -.. tip:: - This article assumes that you have mastered the basic knowledge and operation of `Flink Table Store`_. - For the knowledge about Flink Table Store not mentioned in this article, - you can obtain it from its `Official Documentation`_. - -By using kyuubi, we can run SQL queries towards Flink Table Store which is more -convenient, easy to understand, and easy to expand than directly using -trino to manipulate Flink Table Store. - -Flink Table Store Integration -------------------- - -To enable the integration of kyuubi trino sql engine and Flink Table Store, you need to: - -- Referencing the Flink Table Store :ref:`dependencies` -- Setting the trino extension and catalog :ref:`configurations` - -.. _trino-flink-table-store-deps: - -Dependencies -************ - -The **classpath** of kyuubi trino sql engine with Flink Table Store supported consists of - -1. kyuubi-trino-sql-engine-\ |release|\ _2.12.jar, the engine jar deployed with Kyuubi distributions -2. a copy of trino distribution -3. flink-table-store-trino-.jar (example: flink-table-store-trino-0.2.jar), which code can be found in the `Source Code`_ -4. flink-shaded-hadoop-2-uber-2.8.3-10.0.jar, which code can be found in the `Pre-bundled Hadoop 2.8.3`_ - -In order to make the Flink Table Store packages visible for the runtime classpath of engines, we can use these methods: - -1. Build the flink-table-store-trino-.jar by reference to `Flink Table Store Trino README`_ -2. Put the flink-table-store-trino-.jar and flink-shaded-hadoop-2-uber-2.8.3-10.0.jar packages into ``$TRINO_SERVER_HOME/plugin/tablestore`` directly - -.. warning:: - Please mind the compatibility of different Flink Table Store and Trino versions, which can be confirmed on the page of `Flink Table Store multi engine support`_. - -.. _trino-flink-table-store-conf: - -Configurations -************** - -To activate functionality of Flink Table Store, we can set the following configurations: - -Catalogs are registered by creating a catalog properties file in the $TRINO_SERVER_HOME/etc/catalog directory. -For example, create $TRINO_SERVER_HOME/etc/catalog/tablestore.properties with the following contents to mount the tablestore connector as the tablestore catalog: - -.. code-block:: properties - - connector.name=tablestore - warehouse=file:///tmp/warehouse - -Flink Table Store Operations ------------------- - -Flink Table Store supports reading table store tables through Trino. -A common scenario is to write data with Flink and read data with Trino. -You can follow this document `Flink Table Store Quick Start`_ to write data to a table store table -and then use kyuubi trino sql engine to query the table with the following SQL ``SELECT`` statement. - - -.. code-block:: sql - - SELECT * FROM tablestore.default.t1 - - -.. _Flink Table Store: https://nightlies.apache.org/flink/flink-table-store-docs-stable/ -.. _Flink Table Store Quick Start: https://nightlies.apache.org/flink/flink-table-store-docs-stable/docs/try-table-store/quick-start/ -.. _Official Documentation: https://nightlies.apache.org/flink/flink-table-store-docs-stable/ -.. _Source Code: https://github.com/JingsongLi/flink-table-store-trino -.. _Flink Table Store multi engine support: https://nightlies.apache.org/flink/flink-table-store-docs-stable/docs/engines/overview/ -.. _Pre-bundled Hadoop 2.8.3: https://repo.maven.apache.org/maven2/org/apache/flink/flink-shaded-hadoop-2-uber/2.8.3-10.0/flink-shaded-hadoop-2-uber-2.8.3-10.0.jar -.. _Flink Table Store Trino README: https://github.com/JingsongLi/flink-table-store-trino#readme diff --git a/docs/connector/trino/hudi.rst b/docs/connector/trino/hudi.rst new file mode 100644 index 00000000000..5c965a0b64b --- /dev/null +++ b/docs/connector/trino/hudi.rst @@ -0,0 +1,80 @@ +.. Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + +.. http://www.apache.org/licenses/LICENSE-2.0 + +.. Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +`Hudi`_ +======== + +Apache Hudi (pronounced “hoodie”) is the next generation streaming data lake platform. +Apache Hudi brings core warehouse and database functionality directly to a data lake. + +.. tip:: + This article assumes that you have mastered the basic knowledge and operation of `Hudi`_. + For the knowledge about Hudi not mentioned in this article, + you can obtain it from its `Official Documentation`_. + +By using Kyuubi, we can run SQL queries towards Hudi which is more convenient, easy to understand, +and easy to expand than directly using Trino to manipulate Hudi. + +Hudi Integration +---------------- + +To enable the integration of Kyuubi Trino SQL engine and Hudi, you need to: + +- Setting the Trino extension and catalog :ref:`configurations` + +.. _trino-hudi-conf: + +Configurations +************** + +Catalogs are registered by creating a file of catalog properties in the `$TRINO_SERVER_HOME/etc/catalog` directory. +For example, we can create a `$TRINO_SERVER_HOME/etc/catalog/hudi.properties` with the following contents to mount the Hudi connector as a Hudi catalog: + +.. code-block:: properties + + connector.name=hudi + hive.metastore.uri=thrift://example.net:9083 + +Note: You need to replace $TRINO_SERVER_HOME above to your Trino server home path like `/opt/trino-server-406`. + +More configuration properties can be found in the `Hudi connector in Trino document`_. + +.. tip:: + Trino version 398 or higher, it is recommended to use the Hudi connector. + You don't need to install any dependencies in version 398 or higher. + +Hudi Operations +--------------- +The globally available and read operation statements are supported in Trino. +These statements can be found in `Trino SQL Support`_. +Currently, Trino cannot write data to a Hudi table. +A common scenario is to write data with Spark/Flink and read data with Trino. +You can use the Kyuubi Trino SQL engine to query the table with the following SQL ``SELECT`` statement. + +Taking ``Query Data`` as a example, + +.. code-block:: sql + + USE example.example_schema; + + SELECT symbol, max(ts) + FROM stock_ticks_cow + GROUP BY symbol + HAVING symbol = 'GOOG'; + +.. _Hudi: https://hudi.apache.org/ +.. _Official Documentation: https://hudi.apache.org/docs/overview +.. _Hudi connector in Trino document: https://trino.io/docs/current/connector/hudi.html +.. _Trino SQL Support: https://trino.io/docs/current/language/sql-support.html# diff --git a/docs/connector/trino/index.rst b/docs/connector/trino/index.rst index a5c5675ce70..290966a5cf7 100644 --- a/docs/connector/trino/index.rst +++ b/docs/connector/trino/index.rst @@ -19,5 +19,6 @@ Connectors For Trino SQL Engine .. toctree:: :maxdepth: 2 - flink_table_store + paimon + hudi iceberg \ No newline at end of file diff --git a/docs/connector/trino/paimon.rst b/docs/connector/trino/paimon.rst new file mode 100644 index 00000000000..5ac892234f8 --- /dev/null +++ b/docs/connector/trino/paimon.rst @@ -0,0 +1,92 @@ +.. Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + +.. http://www.apache.org/licenses/LICENSE-2.0 + +.. Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +`Apache Paimon (Incubating)`_ +========== + +Apache Paimon(incubating) is a streaming data lake platform that supports high-speed data ingestion, change data tracking and efficient real-time analytics. + +.. tip:: + This article assumes that you have mastered the basic knowledge and operation of `Apache Paimon (Incubating)`_. + For the knowledge about Apache Paimon (Incubating) not mentioned in this article, + you can obtain it from its `Official Documentation`_. + +By using kyuubi, we can run SQL queries towards Apache Paimon (Incubating) which is more +convenient, easy to understand, and easy to expand than directly using +trino to manipulate Apache Paimon (Incubating). + +Apache Paimon (Incubating) Integration +------------------- + +To enable the integration of kyuubi trino sql engine and Apache Paimon (Incubating), you need to: + +- Referencing the Apache Paimon (Incubating) :ref:`dependencies` +- Setting the trino extension and catalog :ref:`configurations` + +.. _trino-paimon-deps: + +Dependencies +************ + +The **classpath** of kyuubi trino sql engine with Apache Paimon (Incubating) supported consists of + +1. kyuubi-trino-sql-engine-\ |release|\ _2.12.jar, the engine jar deployed with Kyuubi distributions +2. a copy of trino distribution +3. paimon-trino-.jar (example: paimon-trino-0.2.jar), which code can be found in the `Source Code`_ +4. flink-shaded-hadoop-2-uber-.jar, which code can be found in the `Pre-bundled Hadoop`_ + +In order to make the Apache Paimon (Incubating) packages visible for the runtime classpath of engines, you need to: + +1. Build the paimon-trino-.jar by reference to `Apache Paimon (Incubating) Trino README`_ +2. Put the paimon-trino-.jar and flink-shaded-hadoop-2-uber-.jar packages into ``$TRINO_SERVER_HOME/plugin/tablestore`` directly + +.. warning:: + Please mind the compatibility of different Apache Paimon (Incubating) and Trino versions, which can be confirmed on the page of `Apache Paimon (Incubating) multi engine support`_. + +.. _trino-paimon-conf: + +Configurations +************** + +To activate functionality of Apache Paimon (Incubating), we can set the following configurations: + +Catalogs are registered by creating a catalog properties file in the $TRINO_SERVER_HOME/etc/catalog directory. +For example, create $TRINO_SERVER_HOME/etc/catalog/tablestore.properties with the following contents to mount the tablestore connector as the tablestore catalog: + +.. code-block:: properties + + connector.name=tablestore + warehouse=file:///tmp/warehouse + +Apache Paimon (Incubating) Operations +------------------ + +Apache Paimon (Incubating) supports reading table store tables through Trino. +A common scenario is to write data with Spark or Flink and read data with Trino. +You can follow this document `Apache Paimon (Incubating) Engines Flink Quick Start`_ to write data to a table store table +and then use kyuubi trino sql engine to query the table with the following SQL ``SELECT`` statement. + + +.. code-block:: sql + + SELECT * FROM tablestore.default.t1 + +.. _Apache Paimon (Incubating): https://paimon.apache.org/ +.. _Apache Paimon (Incubating) multi engine support: https://paimon.apache.org/docs/master/engines/overview/ +.. _Apache Paimon (Incubating) Engines Flink Quick Start: https://paimon.apache.org/docs/master/engines/flink/#quick-start +.. _Official Documentation: https://paimon.apache.org/docs/master/ +.. _Source Code: https://github.com/JingsongLi/paimon-trino +.. _Pre-bundled Hadoop: https://flink.apache.org/downloads/#additional-components +.. _Apache Paimon (Incubating) Trino README: https://github.com/JingsongLi/paimon-trino#readme diff --git a/docs/deployment/engine_lifecycle.md b/docs/deployment/engine_lifecycle.md index 35944fa232e..63b1a80a233 100644 --- a/docs/deployment/engine_lifecycle.md +++ b/docs/deployment/engine_lifecycle.md @@ -1,19 +1,19 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # The TTL Of Kyuubi Engines @@ -26,7 +26,6 @@ To better improve the overall resource utilization of the cluster, - The time to wait for the resource to be allocated, such as the scheduling delay, the start/stop cost. - A longer time-to-live(TTL) for allocated resources can significantly reduce such time costs within an application. - - The time being idle of the resource. - A shorter time to live for allocated resources can make all resources in rapid turnarounds across applications. @@ -45,7 +44,7 @@ To better improve the overall resource utilization of the cluster, ### Engine TTL -| Key | Default | Meaning | Type | Since | +| Key | Default | Meaning | Type | Since | |----------------------------------------------|--------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------|--------------------------------------| | kyuubi\.session\.engine
\.check\.interval |
PT5M
|
The check interval for engine timeout
|
duration
|
1.0.0
| | kyuubi\.session\.engine
\.idle\.timeout |
PT30M
|
engine timeout, the engine will self-terminate when it's not accessed for this duration. 0 or negative means not to self-terminate.
|
duration
|
1.0.0
| diff --git a/docs/deployment/engine_on_kubernetes.md b/docs/deployment/engine_on_kubernetes.md index 6f3e73a4a43..44fca1602e3 100644 --- a/docs/deployment/engine_on_kubernetes.md +++ b/docs/deployment/engine_on_kubernetes.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Deploy Kyuubi engines on Kubernetes @@ -22,7 +21,7 @@ When you want to run Kyuubi's Spark SQL engines on Kubernetes, you'd better have cognition upon the following things. -* Read about [Running Spark On Kubernetes](http://spark.apache.org/docs/latest/running-on-kubernetes.html) +* Read about [Running Spark On Kubernetes](https://spark.apache.org/docs/latest/running-on-kubernetes.html) * An active Kubernetes cluster * [Kubectl](https://kubernetes.io/docs/reference/kubectl/overview/) * KubeConfig of the target cluster @@ -98,7 +97,7 @@ As it known to us all, Kubernetes can use configurations to mount volumes into d * persistentVolumeClaim: mounts a PersistentVolume into a pod. Note: Please -see [the Security section of this document](http://spark.apache.org/docs/latest/running-on-kubernetes.html#security) for security issues related to volume mounts. +see [the Security section of this document](https://spark.apache.org/docs/latest/running-on-kubernetes.html#security) for security issues related to volume mounts. ``` spark.kubernetes.driver.volumes...options.path= @@ -108,7 +107,7 @@ spark.kubernetes.executor.volumes...options.path= spark.kubernetes.executor.volumes...mount.path= ``` -Read [Using Kubernetes Volumes](http://spark.apache.org/docs/latest/running-on-kubernetes.html#using-kubernetes-volumes) for more about volumes. +Read [Using Kubernetes Volumes](https://spark.apache.org/docs/latest/running-on-kubernetes.html#using-kubernetes-volumes) for more about volumes. ### PodTemplateFile @@ -118,4 +117,4 @@ To do so, specify the spark properties `spark.kubernetes.driver.podTemplateFile` ### Other -You can read Spark's official documentation for [Running on Kubernetes](http://spark.apache.org/docs/latest/running-on-kubernetes.html) for more information. \ No newline at end of file +You can read Spark's official documentation for [Running on Kubernetes](https://spark.apache.org/docs/latest/running-on-kubernetes.html) for more information. diff --git a/docs/deployment/engine_on_yarn.md b/docs/deployment/engine_on_yarn.md index 54f8b508f0f..6812afa46db 100644 --- a/docs/deployment/engine_on_yarn.md +++ b/docs/deployment/engine_on_yarn.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Deploy Kyuubi engines on Yarn @@ -24,11 +23,11 @@ When you want to deploy Kyuubi's Spark SQL engines on YARN, you'd better have cognition upon the following things. -- Knowing the basics about [Running Spark on YARN](http://spark.apache.org/docs/latest/running-on-yarn.html) +- Knowing the basics about [Running Spark on YARN](https://spark.apache.org/docs/latest/running-on-yarn.html) - A binary distribution of Spark which is built with YARN support - You can use the built-in Spark distribution - You can get it from [Spark official website](https://spark.apache.org/downloads.html) directly - - You can [Build Spark](http://spark.apache.org/docs/latest/building-spark.html#specifying-the-hadoop-version-and-enabling-yarn) with `-Pyarn` maven option + - You can [Build Spark](https://spark.apache.org/docs/latest/building-spark.html#specifying-the-hadoop-version-and-enabling-yarn) with `-Pyarn` maven option - An active [Apache Hadoop YARN](https://hadoop.apache.org/docs/current/hadoop-yarn/hadoop-yarn-site/YARN.html) cluster - An active Apache Hadoop HDFS cluster - Setup Hadoop client configurations at the machine the Kyuubi server locates @@ -40,6 +39,7 @@ When you want to deploy Kyuubi's Spark SQL engines on YARN, you'd better have co Either `HADOOP_CONF_DIR` or `YARN_CONF_DIR` is configured and points to the Hadoop client configurations directory, usually, `$HADOOP_HOME/etc/hadoop`. If the `HADOOP_CONF_DIR` points the YARN and HDFS cluster correctly, you should be able to run the `SparkPi` example on YARN. + ```bash $ HADOOP_CONF_DIR=/path/to/hadoop/conf $SPARK_HOME/bin/spark-submit \ --class org.apache.spark.examples.SparkPi \ @@ -81,34 +81,34 @@ the QUEUE configured at Kyuubi server side will be used as default. Pass the configurations below through the JDBC connection string to set how many instances of Spark executor will be used and how many cpus and memory will Spark driver, ApplicationMaster and each executor take. -Name | Default | Meaning ---- | --- | --- -spark.executor.instances | 1 | The number of executors for static allocation -spark.executor.cores | 1 | The number of cores to use on each executor -spark.yarn.am.memory | 512m | Amount of memory to use for the YARN Application Master in client mode -spark.yarn.am.memoryOverhead | amMemory * 0.10, with minimum of 384 | Amount of non-heap memory to be allocated per am process in client mode -spark.driver.memory | 1g | Amount of memory to use for the driver process -spark.driver.memoryOverhead | driverMemory * 0.10, with minimum of 384 | Amount of non-heap memory to be allocated per driver process in cluster mode -spark.executor.memory | 1g | Amount of memory to use for the executor process -spark.executor.memoryOverhead | executorMemory * 0.10, with minimum of 384 | Amount of additional memory to be allocated per executor process. This is memory that accounts for things like VM overheads, interned strings other native overheads, etc - -It is recommended to use [Dynamic Allocation](http://spark.apache.org/docs/3.0.1/configuration.html#dynamic-allocation) with Kyuubi, +| Name | Default | Meaning | +|-------------------------------|--------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| spark.executor.instances | 1 | The number of executors for static allocation | +| spark.executor.cores | 1 | The number of cores to use on each executor | +| spark.yarn.am.memory | 512m | Amount of memory to use for the YARN Application Master in client mode | +| spark.yarn.am.memoryOverhead | amMemory * 0.10, with minimum of 384 | Amount of non-heap memory to be allocated per am process in client mode | +| spark.driver.memory | 1g | Amount of memory to use for the driver process | +| spark.driver.memoryOverhead | driverMemory * 0.10, with minimum of 384 | Amount of non-heap memory to be allocated per driver process in cluster mode | +| spark.executor.memory | 1g | Amount of memory to use for the executor process | +| spark.executor.memoryOverhead | executorMemory * 0.10, with minimum of 384 | Amount of additional memory to be allocated per executor process. This is memory that accounts for things like VM overheads, interned strings other native overheads, etc | + +It is recommended to use [Dynamic Allocation](https://spark.apache.org/docs/3.0.1/configuration.html#dynamic-allocation) with Kyuubi, since the SQL engine will be long-running for a period, execute user's queries from clients periodically, and the demand for computing resources is not the same for those queries. -It is better for Spark to release some executors when either the query is lightweight, or the SQL engine is being idled. +It is better for Spark to release some executors when either the query is lightweight, or the SQL engine is being idled. ##### Tuning You can specify `spark.yarn.archive` or `spark.yarn.jars` to point to a world-readable location that contains Spark jars on HDFS, -which allows YARN to cache it on nodes so that it doesn't need to be distributed each time an application runs. +which allows YARN to cache it on nodes so that it doesn't need to be distributed each time an application runs. ##### Others -Please refer to [Spark properties](http://spark.apache.org/docs/latest/running-on-yarn.html#spark-properties) to check other acceptable configs. +Please refer to [Spark properties](https://spark.apache.org/docs/latest/running-on-yarn.html#spark-properties) to check other acceptable configs. ### Kerberos -Kyuubi currently does not support Spark's [YARN-specific Kerberos Configuration](http://spark.apache.org/docs/3.0.1/running-on-yarn.html#kerberos), +Kyuubi currently does not support Spark's [YARN-specific Kerberos Configuration](https://spark.apache.org/docs/3.0.1/running-on-yarn.html#kerberos), so `spark.kerberos.keytab` and `spark.kerberos.principal` should not use now. Instead, you can schedule a periodically `kinit` process via `crontab` task on the local machine that hosts Kyuubi server or simply use [Kyuubi Kinit](settings.html#kinit). @@ -142,6 +142,7 @@ yarn.application.id: application_00000000XX_00XX Either `HADOOP_CONF_DIR` or `YARN_CONF_DIR` is configured and points to the Hadoop client configurations directory, usually, `$HADOOP_HOME/etc/hadoop`. If the `HADOOP_CONF_DIR` points to the YARN and HDFS cluster correctly, and the `HADOOP_CLASSPATH` environment variable is set, you can launch a Flink on YARN session, and submit an example job: + ```bash # we assume to be in the root directory of # the unzipped Flink distribution @@ -162,7 +163,7 @@ export HADOOP_CLASSPATH=`hadoop classpath` # (4) Stop YARN session (replace the application id based # on the output of the yarn-session.sh command) echo "stop" | ./bin/yarn-session.sh -id application_XXXXX_XXX - ``` +``` If the `TopSpeedWindowing` passes, configure it in `$KYUUBI_HOME/conf/kyuubi-env.sh` @@ -174,9 +175,9 @@ $ echo "export HADOOP_CONF_DIR=/path/to/hadoop/conf" >> $KYUUBI_HOME/conf/kyuubi The `FLINK_HADOOP_CLASSPATH` is required, too. -For users who are using Hadoop 3.x, Hadoop shaded client is recommended instead of Hadoop vanilla jars. -For users who are using Hadoop 2.x, `FLINK_HADOOP_CLASSPATH` should be set to hadoop classpath to use Hadoop -vanilla jars. For users which does not use Hadoop services, e.g. HDFS, YARN at all, Hadoop client jars +For users who are using Hadoop 3.x, Hadoop shaded client is recommended instead of Hadoop vanilla jars. +For users who are using Hadoop 2.x, `FLINK_HADOOP_CLASSPATH` should be set to hadoop classpath to use Hadoop +vanilla jars. For users which does not use Hadoop services, e.g. HDFS, YARN at all, Hadoop client jars is also required, and recommend to use Hadoop shaded client as Hadoop 3.x's users do. See [HADOOP-11656](https://issues.apache.org/jira/browse/HADOOP-11656) for details of Hadoop shaded client. @@ -186,11 +187,13 @@ To use Hadoop shaded client, please configure $KYUUBI_HOME/conf/kyuubi-env.sh as ```bash $ echo "export FLINK_HADOOP_CLASSPATH=/path/to/hadoop-client-runtime-3.3.2.jar:/path/to/hadoop-client-api-3.3.2.jar" >> $KYUUBI_HOME/conf/kyuubi-env.sh ``` + To use Hadoop vanilla jars, please configure $KYUUBI_HOME/conf/kyuubi-env.sh as follows: ```bash $ echo "export FLINK_HADOOP_CLASSPATH=`hadoop classpath`" >> $KYUUBI_HOME/conf/kyuubi-env.sh ``` + ### Deployment Modes Supported by Flink on YARN For experiment use, we recommend deploying Kyuubi Flink SQL engine in [Session Mode](https://nightlies.apache.org/flink/flink-docs-stable/docs/deployment/resource-providers/yarn/#session-mode). @@ -240,11 +243,11 @@ If the `Hive SQL` passes and there is a job in Yarn Web UI, It indicates the hiv #### Required Environment Variable -The `HIVE_HADOOP_CLASSPATH` is required, too. It should contain `commons-collections-*.jar`, +The `HIVE_HADOOP_CLASSPATH` is required, too. It should contain `commons-collections-*.jar`, `hadoop-client-runtime-*.jar`, `hadoop-client-api-*.jar` and `htrace-core4-*.jar`. -All four jars are in the `HADOOP_HOME`. +All four jars are in the `HADOOP_HOME`. -For example, in Hadoop 3.1.0 version, the following is their location. +For example, in Hadoop 3.1.0 version, the following is their location. - `${HADOOP_HOME}/share/hadoop/common/lib/commons-collections-3.2.2.jar` - `${HADOOP_HOME}/share/hadoop/client/hadoop-client-runtime-3.1.0.jar` - `${HADOOP_HOME}/share/hadoop/client/hadoop-client-api-3.1.0.jar` @@ -256,3 +259,4 @@ Configure them in `$KYUUBI_HOME/conf/kyuubi-env.sh` or `$HIVE_HOME/conf/hive-env $ echo "export HADOOP_CONF_DIR=/path/to/hadoop/conf" >> $KYUUBI_HOME/conf/kyuubi-env.sh $ echo "export HIVE_HADOOP_CLASSPATH=${HADOOP_HOME}/share/hadoop/common/lib/commons-collections-3.2.2.jar:${HADOOP_HOME}/share/hadoop/client/hadoop-client-runtime-3.1.0.jar:${HADOOP_HOME}/share/hadoop/client/hadoop-client-api-3.1.0.jar:${HADOOP_HOME}/share/hadoop/common/lib/htrace-core4-4.1.0-incubating.jar" >> $KYUUBI_HOME/conf/kyuubi-env.sh ``` + diff --git a/docs/deployment/engine_share_level.md b/docs/deployment/engine_share_level.md index 2272c9d1ced..4a7b680cb4e 100644 --- a/docs/deployment/engine_share_level.md +++ b/docs/deployment/engine_share_level.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # The Share Level Of Kyuubi Engines @@ -32,9 +31,9 @@ Using Spark to process data is like driving an all-wheel-drive hefty horsepower However, - Cars have their limit of 0-60 times. -In a similar way, all Spark applications also have to warm up before go full speed. + In a similar way, all Spark applications also have to warm up before go full speed. - Cars have a constant number of seats and are not allowed to be overloaded. -Due to the master-slave architecture of Spark and the resource configured ahead, the overall workload of a single application is predictable. + Due to the master-slave architecture of Spark and the resource configured ahead, the overall workload of a single application is predictable. - Cars have various shapes to meet our needs. With this feature, Kyuubi give you a more flexible way to handle different big data workloads. @@ -43,15 +42,15 @@ With this feature, Kyuubi give you a more flexible way to handle different big d The current supported share levels are, -| Share Level | Syntax | Scenario | Isolation Degree | Sharability | -| --- | --- | ---- | --- | --- | -| **CONNECTION** | One engine per session | Large-scale ETL
Ad hoc | High | Low | -| **USER** | One engine per user | Ad hoc
Small-scale ETL | Medium | Medium| -| **GROUP** | One engine per primary group | Ad hoc
Small-scale ETL | Low | High | -| **SERVER**| One engine per cluster | Admin | Highest If Secured
Lowest If Unsecured | Admin ONLY If Secured | +| Share Level | Syntax | Scenario | Isolation Degree | Shareability | +|----------------|------------------------------|------------------------------|----------------------------------------------|-----------------------| +| **CONNECTION** | One engine per session | Large-scale ETL
Ad hoc | High | Low | +| **USER** | One engine per user | Ad hoc
Small-scale ETL | Medium | Medium | +| **GROUP** | One engine per primary group | Ad hoc
Small-scale ETL | Low | High | +| **SERVER** | One engine per cluster | Admin | Highest If Secured
Lowest If Unsecured | Admin ONLY If Secured | - Better isolation degree of engines gives us better stability of an engine and the query executions running on it. -- Better sharability of engines means we are more likely to reuse an engine which is already in full speed. +- Better shareability of engines means we are more likely to reuse an engine which is already in full speed. ### CONNECTION @@ -79,6 +78,7 @@ When closing session, the corresponding engine will be shutdown at the same time
*Figure.2 USER Share Level* +
All sessions with USER share level use the same engine if and only if the session user is the same. @@ -102,7 +102,6 @@ This TTL allows new sessions to be established quickly without waiting for the e - An engine will be shared by all sessions created by all users belong to the same primary group name. The engine will be launched by the group name as the effective username, so here the group name is kind of special user who is able to visit the compute resources/data of a team. It follows the [Hadoop GroupsMapping](https://hadoop.apache.org/docs/current/hadoop-project-dist/hadoop-common/GroupsMapping.html) to map user to a primary group. If the primary group is not found, it falls back to the USER level. @@ -138,14 +137,14 @@ The `kyuubi.engine.share.level.subdomain` shall be configured in the JDBC connec ### Hybrid -All supported share levels can be used together in a single Kyuubi server or cluster. +All supported share levels can be used together in a single Kyuubi server or cluster. ## Related Configurations - kyuubi.engine.share.level(kyuubi.session.engine.share.level) - Default: USER - Candidates: USER, CONNECTION, GROUP, SERVER - - Meaning: The base level for how an engine is created, cached and shared to sessions. + - Meaning: The base level for how an engine is created, cached and shared to sessions. - Usage: It can be set both in the server configuration file and also connection URL. The latter has higher priority. - kyuubi.session.engine.idle.timeout - Default: PT30M (30 min) @@ -160,4 +159,4 @@ All supported share levels can be used together in a single Kyuubi server or clu ## Conclusion -With This feature, end-users are able to leverage engines in different ways to handle their different workloads, such as large-scale ETL jobs and interactive ad hoc queries. +With this feature, end-users are able to leverage engines in different ways to handle their different workloads, such as large-scale ETL jobs and interactive ad hoc queries. diff --git a/docs/deployment/high_availability_guide.md b/docs/deployment/high_availability_guide.md index 0189432d65f..353e549ebba 100644 --- a/docs/deployment/high_availability_guide.md +++ b/docs/deployment/high_availability_guide.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kyuubi High Availability Guide @@ -22,20 +21,19 @@ As an enterprise-class ad-hoc SQL query service built on top of [Apache Spark](h Running Kyuubi in HA mode is to use groups of computers or containers that support SQL query service on Kyuubi that can be reliably utilized with a minimum amount of down-time. Kyuubi operates by using [Apache ZooKeeper](https://zookeeper.apache.org/) to harness redundant service instances in groups that provide continuous service when one or more components fail. -Without HA, if a server crashes, Kyuubi will be unavailable until the crashed server is fixed. With HA, this situation will be remedied by hardware/software faults auto-detecting, and immediately another Kyuubi service instance will be ready to serve without requiring human intervention. +Without HA, if a server crashes, Kyuubi will be unavailable until the crashed server is fixed. With HA, this situation will be remedied by hardware/software faults auto-detecting, and immediately another Kyuubi service instance will be ready to serve without requiring human intervention. ## HA Architecture Currently, Kyuubi supports load balancing to make the whole system highly available. Load balancing aims to optimize all Kyuubi service unit's usage, maximize throughput, minimize response time, and avoid overload of a single unit. -Using multiple Kyuubi service units with load balancing instead of a single unit may increase reliability and availability through redundancy. +Using multiple Kyuubi service units with load balancing instead of a single unit may increase reliability and availability through redundancy.
- ### Key Benefits - High concurrency @@ -46,7 +44,6 @@ Using multiple Kyuubi service units with load balancing instead of a single unit After all connection are released, it stops then. - The dependencies of Kyuubi engines are free to change, such as bump up versions, modify configurations, add external jars, relocate to another engine home. Everything will be reloaded during start and stop. - ## System-side Deployment When applying HA to Kyuubi deployment, we need to be aware of the below two thing basically, @@ -67,7 +64,6 @@ But it doesn't have any availability to being highly available. For production deployment purpose, an external zookeeper cluster is required for `kyuubi.ha.zookeeper.quorum`. In this mode, multiple `k.i.`s can be registered to the same ServerSpace configured by `kyuubi.ha.zookeeper.namespace` and serve together. - ## Client-side Usage With [Kyuubi Hive JDBC Driver](https://mvnrepository.com/artifact/org.apache.kyuubi/kyuubi-hive-jdbc) or vanilla Hive JDBC Driver, a client can specify service discovery mode in JDBC connection string, i.e. `serviceDiscoveryMode=zooKeeper;` and set `zooKeeperNamespace=kyuubi;`, then it can randomly pick one of the Kyuubi service uris from the specified ZooKeeper addresses in the `/kyuubi` path. @@ -82,19 +78,19 @@ bin/beeline -u 'jdbc:hive2://10.242.189.214:2181/;serviceDiscoveryMode=zooKeeper Kyuubi supports hot upgrade one of server in a HA cluster which is transparent to users. -- If you have specified a custom port for Kyuubi server +- If you have specified a custom port for Kyuubi server For example, the Kyuubi server started at host `kyuubi.host` with port `10009`, you can run the following cmd using `bin/kyuubi-ctl`: - + ```shell ./bin/kyuubi-ctl delete server --host "kyuubi.host" --port "10009" ``` - + Kyuubi server will stop until all session closed, and then you can start a new Kyuubi server. - If you use a random port for Kyuubi server - You can just start the new Kyuubi Server, then runing cmd using `bin/kyuubi-ctl`: + You can just start the new Kyuubi Server, and then run cmd using `bin/kyuubi-ctl`: ```shell ./bin/kyuubi-ctl delete server --host "kyuubi.host" --port "${PORT_FPR_OLD_KYUUBI_SERVER}" @@ -105,4 +101,6 @@ Kyuubi supports hot upgrade one of server in a HA cluster which is transparent t ```shell grep "server.KyuubiThriftBinaryFrontendService: Starting and exposing JDBC connection at" logs/kyuubi-*.out ``` + Note that, you do not need to care when the old Kyuubi server actually stopped since the new coming session are routed to the new Kyuubi server and others. + diff --git a/docs/deployment/hive_metastore.md b/docs/deployment/hive_metastore.md index d4592b75b84..f60465a1aad 100644 --- a/docs/deployment/hive_metastore.md +++ b/docs/deployment/hive_metastore.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Integration with Hive Metastore @@ -31,7 +30,7 @@ In this section, you will learn how to configure Kyuubi to interact with Hive Me - A Spark binary distribution built with `-Phive` support - Use the built-in one in the Kyuubi distribution - Download from [Spark official website](https://spark.apache.org/downloads.html) - - Build from Spark source, [Building With Hive and JDBC Support](http://spark.apache.org/docs/latest/building-spark.html#building-with-hive-and-jdbc-support) + - Build from Spark source, [Building With Hive and JDBC Support](https://spark.apache.org/docs/latest/building-spark.html#building-with-hive-and-jdbc-support) - A copy of Hive client configuration So the whole thing here is to let Spark applications use this copy of Hive configuration to start a Hive metastore client for their own to talk to the Hive metastore server. @@ -90,6 +89,7 @@ Beeline version 2.3.7 by Apache Hive +-----------+------------+--------------+ No rows selected (0.04 seconds) ``` + Using this mode for experimental purposes only. In a real production environment, we always have a communal standalone metadata store, @@ -104,18 +104,18 @@ Use remote metastore database or server mode depends on the server-side configur ### Remote Metastore Database -Name | Value | Meaning ---- | --- | --- -javax.jdo.option.ConnectionURL | jdbc:mysql://<hostname>/<databaseName>?
createDatabaseIfNotExist=true | metadata is stored in a MySQL server -javax.jdo.option.ConnectionDriverName | com.mysql.jdbc.Driver | MySQL JDBC driver class -javax.jdo.option.ConnectionUserName | <username> | user name for connecting to MySQL server -javax.jdo.option.ConnectionPassword | <password> | password for connecting to MySQL server +| Name | Value | Meaning | +|---------------------------------------|--------------------------------------------------------------------------------------|------------------------------------------| +| javax.jdo.option.ConnectionURL | jdbc:mysql://<hostname>/<databaseName>?
createDatabaseIfNotExist=true | metadata is stored in a MySQL server | +| javax.jdo.option.ConnectionDriverName | com.mysql.jdbc.Driver | MySQL JDBC driver class | +| javax.jdo.option.ConnectionUserName | <username> | user name for connecting to MySQL server | +| javax.jdo.option.ConnectionPassword | <password> | password for connecting to MySQL server | ### Remote Metastore Server -Name | Value | Meaning ---- | --- | --- -hive.metastore.uris | thrift://<host>:<port>,thrift://<host1>:<port1> |
host and port for the Thrift metastore server.
+| Name | Value | Meaning | +|---------------------|-------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------| +| hive.metastore.uris | thrift://<host>:<port>,thrift://<host1>:<port1> |
host and port for the Thrift metastore server.
| ## Activate Configurations @@ -199,12 +199,13 @@ Caused by: org.apache.thrift.TApplicationException: Invalid method name: 'get_ta ... 93 more ``` -To prevent this problem, we can use Spark's [Interacting with Different Versions of Hive Metastore](http://spark.apache.org/docs/latest/sql-data-sources-hive-tables.html#interacting-with-different-versions-of-hive-metastore). +To prevent this problem, we can use Spark's [Interacting with Different Versions of Hive Metastore](https://spark.apache.org/docs/latest/sql-data-sources-hive-tables.html#interacting-with-different-versions-of-hive-metastore). ## Further Readings - Hive Wiki - [Hive Metastore Administration](https://cwiki.apache.org/confluence/display/Hive/AdminManual+Metastore+Administration) - Spark Online Documentation - - [Custom Hadoop/Hive Configuration](http://spark.apache.org/docs/latest/configuration.html#custom-hadoophive-configuration) - - [Hive Tables](http://spark.apache.org/docs/latest/sql-data-sources-hive-tables.html) + - [Custom Hadoop/Hive Configuration](https://spark.apache.org/docs/latest/configuration.html#custom-hadoophive-configuration) + - [Hive Tables](https://spark.apache.org/docs/latest/sql-data-sources-hive-tables.html) + diff --git a/docs/deployment/kyuubi_on_kubernetes.md b/docs/deployment/kyuubi_on_kubernetes.md index 03836629e7d..8bb1d88c3fe 100644 --- a/docs/deployment/kyuubi_on_kubernetes.md +++ b/docs/deployment/kyuubi_on_kubernetes.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Deploy Kyuubi On Kubernetes @@ -28,7 +27,7 @@ If you want to deploy Kyuubi on Kubernetes, you'd better get a sense of the foll * [Kubectl](https://kubernetes.io/docs/reference/kubectl/overview/) * KubeConfig of the target cluster -## Kyuubi Official Docker Image +## Kyuubi Official Docker Image You can find the official docker image at [Apache Kyuubi Docker Hub](https://registry.hub.docker.com/r/apache/kyuubi). @@ -37,6 +36,7 @@ You can find the official docker image at [Apache Kyuubi Docker Hub](https://reg You can build custom Docker images from the `${KYUUBI_HOME}/bin/docker-image-tool.sh` contained in the binary package. Examples: + ```shell - Build and push image with tag "v1.4.0" to docker.io/myrepo $0 -r docker.io/myrepo -t v1.4.0 build @@ -102,18 +102,22 @@ If you want to know kyuubi engine on kubernetes configurations, you can refer to If you do not use Service or HostNetwork to get the IP address of the node where Kyuubi deployed. You should connect like: + ```shell kubectl exec -it kyuubi-example -- /bin/bash ${KYUUBI_HOME}/bin/beeline -u 'jdbc:hive2://localhost:10009' ``` Or you can submit tasks directly through local beeline: + ```shell ${KYUUBI_HOME}/bin/beeline -u 'jdbc:hive2://${hostname}:${port}' ``` + As using service nodePort, port means nodePort and hostname means any hostname of kubernetes node. As using HostNetwork, port means kyuubi containerPort and hostname means hostname of node where Kyuubi deployed. -## TODO +## TODO + Kyuubi will provide other connection methods in the future, like `Ingress`, `Load Balance`. diff --git a/docs/deployment/migration-guide.md b/docs/deployment/migration-guide.md index 86efd7a0cb5..fc916048c43 100644 --- a/docs/deployment/migration-guide.md +++ b/docs/deployment/migration-guide.md @@ -1,36 +1,43 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kyuubi Migration Guide -## Upgrading from Kyuubi 1.6 to 1.7 +## Upgrading from Kyuubi 1.7.0 to 1.7.1 + +* Since Kyuubi 1.7.1, `protocolVersion` is removed from the request parameters of the REST API `Open(create) a session`. All removed or unknown parameters will be silently ignored and affects nothing. +* Since Kyuubi 1.7.1, `confOverlay` is supported in the request parameters of the REST API `Create an operation with EXECUTE_STATEMENT type`. + +## Upgrading from Kyuubi 1.6 to 1.7 + * In Kyuubi 1.7, `kyuubi.ha.zookeeper.engine.auth.type` does not fallback to `kyuubi.ha.zookeeper.auth.type`. When Kyuubi engine does Kerberos authentication with Zookeeper, user needs to explicitly set `kyuubi.ha.zookeeper.engine.auth.type` to `KERBEROS`. * Since Kyuubi 1.7, Kyuubi returns engine's information for `GetInfo` request instead of server. To restore the previous behavior, set `kyuubi.server.info.provider` to `SERVER`. * Since Kyuubi 1.7, Kyuubi session type `SQL` is refactored to `INTERACTIVE`, because Kyuubi supports not only `SQL` session, but also `SCALA` and `PYTHON` sessions. User need to use `INTERACTIVE` sessionType to look up the session event. -* Since Kyuubi 1.7, the REST API of `Open(create) a session` will not contains parameters `user` `password` and `IpAddr`. User and password should be set in `Authorization` of http request if needed. +* Since Kyuubi 1.7, the REST API of `Open(create) a session` will not contain parameters `user` `password` and `IpAddr`. User and password should be set in `Authorization` of http request if needed. ## Upgrading from Kyuubi 1.6.0 to 1.6.1 + * Since Kyuubi 1.6.1, `kyuubi.ha.zookeeper.engine.auth.type` does not fallback to `kyuubi.ha.zookeeper.auth.type`. When Kyuubi engine does Kerberos authentication with Zookeeper, user needs to explicitly set `kyuubi.ha.zookeeper.engine.auth.type` to `KERBEROS`. ## Upgrading from Kyuubi 1.5 to 1.6 + * Kyuubi engine gets Zookeeper principal & keytab from `kyuubi.ha.zookeeper.auth.principal` & `kyuubi.ha.zookeeper.auth.keytab`. `kyuubi.ha.zookeeper.auth.principal` & `kyuubi.ha.zookeeper.auth.keytab` fallback to `kyuubi.kinit.principal` & `kyuubi.kinit.keytab` when not set. Since Kyuubi 1.6, `kyuubi.kinit.principal` & `kyuubi.kinit.keytab` are filtered out from Kyuubi engine's conf for better security. diff --git a/docs/deployment/settings.md b/docs/deployment/settings.md index 539ca823f6b..960f2c328e8 100644 --- a/docs/deployment/settings.md +++ b/docs/deployment/settings.md @@ -1,548 +1,455 @@ - - - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> + # Introduction to the Kyuubi Configurations System Kyuubi provides several ways to configure the system and corresponding engines. - ## Environments - -You can configure the environment variables in `$KYUUBI_HOME/conf/kyuubi-env.sh`, e.g, `JAVA_HOME`, then this java runtime will be used both for Kyuubi server instance and the applications it launches. You can also change the variable in the subprocess's env configuration file, e.g.`$SPARK_HOME/conf/spark-env.sh` to use more specific ENV for SQL engine applications. -```bash -#!/usr/bin/env bash -# -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# -# - JAVA_HOME Java runtime to use. By default use "java" from PATH. -# -# -# - KYUUBI_CONF_DIR Directory containing the Kyuubi configurations to use. -# (Default: $KYUUBI_HOME/conf) -# - KYUUBI_LOG_DIR Directory for Kyuubi server-side logs. -# (Default: $KYUUBI_HOME/logs) -# - KYUUBI_PID_DIR Directory stores the Kyuubi instance pid file. -# (Default: $KYUUBI_HOME/pid) -# - KYUUBI_MAX_LOG_FILES Maximum number of Kyuubi server logs can rotate to. -# (Default: 5) -# - KYUUBI_JAVA_OPTS JVM options for the Kyuubi server itself in the form "-Dx=y". -# (Default: none). -# - KYUUBI_CTL_JAVA_OPTS JVM options for the Kyuubi ctl itself in the form "-Dx=y". -# (Default: none). -# - KYUUBI_BEELINE_OPTS JVM options for the Kyuubi BeeLine in the form "-Dx=Y". -# (Default: none) -# - KYUUBI_NICENESS The scheduling priority for Kyuubi server. -# (Default: 0) -# - KYUUBI_WORK_DIR_ROOT Root directory for launching sql engine applications. -# (Default: $KYUUBI_HOME/work) -# - HADOOP_CONF_DIR Directory containing the Hadoop / YARN configuration to use. -# - YARN_CONF_DIR Directory containing the YARN configuration to use. -# -# - SPARK_HOME Spark distribution which you would like to use in Kyuubi. -# - SPARK_CONF_DIR Optional directory where the Spark configuration lives. -# (Default: $SPARK_HOME/conf) -# - FLINK_HOME Flink distribution which you would like to use in Kyuubi. -# - FLINK_CONF_DIR Optional directory where the Flink configuration lives. -# (Default: $FLINK_HOME/conf) -# - FLINK_HADOOP_CLASSPATH Required Hadoop jars when you use the Kyuubi Flink engine. -# - HIVE_HOME Hive distribution which you would like to use in Kyuubi. -# - HIVE_CONF_DIR Optional directory where the Hive configuration lives. -# (Default: $HIVE_HOME/conf) -# - HIVE_HADOOP_CLASSPATH Required Hadoop jars when you use the Kyuubi Hive engine. -# - - -## Examples ## - -# export JAVA_HOME=/usr/jdk64/jdk1.8.0_152 -# export SPARK_HOME=/opt/spark -# export FLINK_HOME=/opt/flink -# export HIVE_HOME=/opt/hive -# export FLINK_HADOOP_CLASSPATH=/path/to/hadoop-client-runtime-3.3.2.jar:/path/to/hadoop-client-api-3.3.2.jar -# export HIVE_HADOOP_CLASSPATH=${HADOOP_HOME}/share/hadoop/common/lib/commons-collections-3.2.2.jar:${HADOOP_HOME}/share/hadoop/client/hadoop-client-runtime-3.1.0.jar:${HADOOP_HOME}/share/hadoop/client/hadoop-client-api-3.1.0.jar:${HADOOP_HOME}/share/hadoop/common/lib/htrace-core4-4.1.0-incubating.jar -# export HADOOP_CONF_DIR=/usr/ndp/current/mapreduce_client/conf -# export YARN_CONF_DIR=/usr/ndp/current/yarn/conf -# export KYUUBI_JAVA_OPTS="-Xmx10g -XX:+UnlockDiagnosticVMOptions -XX:ParGCCardsPerStrideChunk=4096 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSConcurrentMTEnabled -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly -XX:+CMSClassUnloadingEnabled -XX:+CMSParallelRemarkEnabled -XX:+UseCondCardMark -XX:MaxDirectMemorySize=1024m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./logs -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -Xloggc:./logs/kyuubi-server-gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=5M -XX:NewRatio=3 -XX:MetaspaceSize=512m" -# export KYUUBI_BEELINE_OPTS="-Xmx2g -XX:+UnlockDiagnosticVMOptions -XX:ParGCCardsPerStrideChunk=4096 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSConcurrentMTEnabled -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly -XX:+CMSClassUnloadingEnabled -XX:+CMSParallelRemarkEnabled -XX:+UseCondCardMark" -``` - -For the environment variables that only needed to be transferred into engine side, you can set it with a Kyuubi configuration item formatted `kyuubi.engineEnv.VAR_NAME`. For example, with `kyuubi.engineEnv.SPARK_DRIVER_MEMORY=4g`, the environment variable `SPARK_DRIVER_MEMORY` with value `4g` would be transferred into engine side. With `kyuubi.engineEnv.SPARK_CONF_DIR=/apache/confs/spark/conf`, the value of `SPARK_CONF_DIR` in engine side is set to `/apache/confs/spark/conf`. - -## Kyuubi Configurations - -You can configure the Kyuubi properties in `$KYUUBI_HOME/conf/kyuubi-defaults.conf`. For example: -```bash -# -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# +You can configure the environment variables in `$KYUUBI_HOME/conf/kyuubi-env.sh`, e.g, `JAVA_HOME`, then this java runtime will be used both for Kyuubi server instance and the applications it launches. You can also change the variable in the subprocess's env configuration file, e.g.`$SPARK_HOME/conf/spark-env.sh` to use more specific ENV for SQL engine applications. see `$KYUUBI_HOME/conf/kyuubi-env.sh.template` as an example. +For the environment variables that only needed to be transferred into engine side, you can set it with a Kyuubi configuration item formatted `kyuubi.engineEnv.VAR_NAME`. For example, with `kyuubi.engineEnv.SPARK_DRIVER_MEMORY=4g`, the environment variable `SPARK_DRIVER_MEMORY` with value `4g` would be transferred into engine side. With `kyuubi.engineEnv.SPARK_CONF_DIR=/apache/confs/spark/conf`, the value of `SPARK_CONF_DIR` on the engine side is set to `/apache/confs/spark/conf`. ## Kyuubi Configurations -# -# kyuubi.authentication NONE -# kyuubi.frontend.bind.host localhost -# kyuubi.frontend.bind.port 10009 -# - -# Details in https://kyuubi.apache.org/docs/latest/deployment/settings.html -``` +You can configure the Kyuubi properties in `$KYUUBI_HOME/conf/kyuubi-defaults.conf`, see `$KYUUBI_HOME/conf/kyuubi-defaults.conf.template` as an example. ### Authentication -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.authentication|NONE|A comma separated list of client authentication types.
  • NOSASL: raw transport.
  • NONE: no authentication check.
  • KERBEROS: Kerberos/GSSAPI authentication.
  • CUSTOM: User-defined authentication.
  • JDBC: JDBC query authentication.
  • LDAP: Lightweight Directory Access Protocol authentication.
Note that: For KERBEROS, it is SASL/GSSAPI mechanism, and for NONE, CUSTOM and LDAP, they are all SASL/PLAIN mechanism. If only NOSASL is specified, the authentication will be NOSASL. For SASL authentication, KERBEROS and PLAIN auth type are supported at the same time, and only the first specified PLAIN auth type is valid.|seq|1.0.0 -kyuubi.authentication.custom.class|<undefined>|User-defined authentication implementation of org.apache.kyuubi.service.authentication.PasswdAuthenticationProvider|string|1.3.0 -kyuubi.authentication.jdbc.driver.class|<undefined>|Driver class name for JDBC Authentication Provider.|string|1.6.0 -kyuubi.authentication.jdbc.password|<undefined>|Database password for JDBC Authentication Provider.|string|1.6.0 -kyuubi.authentication.jdbc.query|<undefined>|Query SQL template with placeholders for JDBC Authentication Provider to execute. Authentication passes if the result set is not empty.The SQL statement must start with the `SELECT` clause. Available placeholders are `${user}` and `${password}`.|string|1.6.0 -kyuubi.authentication.jdbc.url|<undefined>|JDBC URL for JDBC Authentication Provider.|string|1.6.0 -kyuubi.authentication.jdbc.user|<undefined>|Database user for JDBC Authentication Provider.|string|1.6.0 -kyuubi.authentication.ldap.base.dn|<undefined>|LDAP base DN.|string|1.0.0 -kyuubi.authentication.ldap.domain|<undefined>|LDAP domain.|string|1.0.0 -kyuubi.authentication.ldap.guidKey|uid|LDAP attribute name whose values are unique in this LDAP server.For example:uid or cn.|string|1.2.0 -kyuubi.authentication.ldap.url|<undefined>|SPACE character separated LDAP connection URL(s).|string|1.0.0 -kyuubi.authentication.sasl.qop|auth|Sasl QOP enable higher levels of protection for Kyuubi communication with clients.
  • auth - authentication only (default)
  • auth-int - authentication plus integrity protection
  • auth-conf - authentication plus integrity and confidentiality protection. This is applicable only if Kyuubi is configured to use Kerberos authentication.
|string|1.0.0 - +| Key | Default | Meaning | Type | Since | +|-----------------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|-------| +| kyuubi.authentication | NONE | A comma-separated list of client authentication types.
  • NOSASL: raw transport.
  • NONE: no authentication check.
  • KERBEROS: Kerberos/GSSAPI authentication.
  • CUSTOM: User-defined authentication.
  • JDBC: JDBC query authentication.
  • LDAP: Lightweight Directory Access Protocol authentication.
The following tree describes the catalog of each option.
  • NOSASL
  • SASL
    • SASL/PLAIN
      • NONE
      • LDAP
      • JDBC
      • CUSTOM
    • SASL/GSSAPI
      • KERBEROS
Note that: for SASL authentication, KERBEROS and PLAIN auth types are supported at the same time, and only the first specified PLAIN auth type is valid. | seq | 1.0.0 | +| kyuubi.authentication.custom.class | <undefined> | User-defined authentication implementation of org.apache.kyuubi.service.authentication.PasswdAuthenticationProvider | string | 1.3.0 | +| kyuubi.authentication.jdbc.driver.class | <undefined> | Driver class name for JDBC Authentication Provider. | string | 1.6.0 | +| kyuubi.authentication.jdbc.password | <undefined> | Database password for JDBC Authentication Provider. | string | 1.6.0 | +| kyuubi.authentication.jdbc.query | <undefined> | Query SQL template with placeholders for JDBC Authentication Provider to execute. Authentication passes if the result set is not empty.The SQL statement must start with the `SELECT` clause. Available placeholders are `${user}` and `${password}`. | string | 1.6.0 | +| kyuubi.authentication.jdbc.url | <undefined> | JDBC URL for JDBC Authentication Provider. | string | 1.6.0 | +| kyuubi.authentication.jdbc.user | <undefined> | Database user for JDBC Authentication Provider. | string | 1.6.0 | +| kyuubi.authentication.ldap.baseDN | <undefined> | LDAP base DN. | string | 1.7.0 | +| kyuubi.authentication.ldap.binddn | <undefined> | The user with which to bind to the LDAP server, and search for the full domain name of the user being authenticated. This should be the full domain name of the user, and should have search access across all users in the LDAP tree. If not specified, then the user being authenticated will be used as the bind user. For example: CN=bindUser,CN=Users,DC=subdomain,DC=domain,DC=com | string | 1.7.0 | +| kyuubi.authentication.ldap.bindpw | <undefined> | The password for the bind user, to be used to search for the full name of the user being authenticated. If the username is specified, this parameter must also be specified. | string | 1.7.0 | +| kyuubi.authentication.ldap.customLDAPQuery | <undefined> | A full LDAP query that LDAP Atn provider uses to execute against LDAP Server. If this query returns a null resultset, the LDAP Provider fails the Authentication request, succeeds if the user is part of the resultset.For example: `(&(objectClass=group)(objectClass=top)(instanceType=4)(cn=Domain*))`, `(&(objectClass=person)(|(sAMAccountName=admin)(|(memberOf=CN=Domain Admins,CN=Users,DC=domain,DC=com)(memberOf=CN=Administrators,CN=Builtin,DC=domain,DC=com))))` | string | 1.7.0 | +| kyuubi.authentication.ldap.domain | <undefined> | LDAP domain. | string | 1.0.0 | +| kyuubi.authentication.ldap.groupClassKey | groupOfNames | LDAP attribute name on the group entry that is to be used in LDAP group searches. For example: group, groupOfNames or groupOfUniqueNames. | string | 1.7.0 | +| kyuubi.authentication.ldap.groupDNPattern | <undefined> | COLON-separated list of patterns to use to find DNs for group entities in this directory. Use %s where the actual group name is to be substituted for. For example: CN=%s,CN=Groups,DC=subdomain,DC=domain,DC=com. | string | 1.7.0 | +| kyuubi.authentication.ldap.groupFilter || COMMA-separated list of LDAP Group names (short name not full DNs). For example: HiveAdmins,HadoopAdmins,Administrators | seq | 1.7.0 | +| kyuubi.authentication.ldap.groupMembershipKey | member | LDAP attribute name on the group object that contains the list of distinguished names for the user, group, and contact objects that are members of the group. For example: member, uniqueMember or memberUid | string | 1.7.0 | +| kyuubi.authentication.ldap.guidKey | uid | LDAP attribute name whose values are unique in this LDAP server. For example: uid or CN. | string | 1.2.0 | +| kyuubi.authentication.ldap.url | <undefined> | SPACE character separated LDAP connection URL(s). | string | 1.0.0 | +| kyuubi.authentication.ldap.userDNPattern | <undefined> | COLON-separated list of patterns to use to find DNs for users in this directory. Use %s where the actual group name is to be substituted for. For example: CN=%s,CN=Users,DC=subdomain,DC=domain,DC=com. | string | 1.7.0 | +| kyuubi.authentication.ldap.userFilter || COMMA-separated list of LDAP usernames (just short names, not full DNs). For example: hiveuser,impalauser,hiveadmin,hadoopadmin | seq | 1.7.0 | +| kyuubi.authentication.ldap.userMembershipKey | <undefined> | LDAP attribute name on the user object that contains groups of which the user is a direct member, except for the primary group, which is represented by the primaryGroupId. For example: memberOf | string | 1.7.0 | +| kyuubi.authentication.sasl.qop | auth | Sasl QOP enable higher levels of protection for Kyuubi communication with clients.
  • auth - authentication only (default)
  • auth-int - authentication plus integrity protection
  • auth-conf - authentication plus integrity and confidentiality protection. This is applicable only if Kyuubi is configured to use Kerberos authentication.
| string | 1.0.0 | ### Backend -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.backend.engine.exec.pool.keepalive.time|PT1M|Time(ms) that an idle async thread of the operation execution thread pool will wait for a new task to arrive before terminating in SQL engine applications|duration|1.0.0 -kyuubi.backend.engine.exec.pool.shutdown.timeout|PT10S|Timeout(ms) for the operation execution thread pool to terminate in SQL engine applications|duration|1.0.0 -kyuubi.backend.engine.exec.pool.size|100|Number of threads in the operation execution thread pool of SQL engine applications|int|1.0.0 -kyuubi.backend.engine.exec.pool.wait.queue.size|100|Size of the wait queue for the operation execution thread pool in SQL engine applications|int|1.0.0 -kyuubi.backend.server.event.json.log.path|file:///tmp/kyuubi/events|The location of server events go for the builtin JSON logger|string|1.4.0 -kyuubi.backend.server.event.loggers||A comma separated list of server history loggers, where session/operation etc events go.
  • JSON: the events will be written to the location of kyuubi.backend.server.event.json.log.path
  • JDBC: to be done
  • CUSTOM: User-defined event handlers.
Note that: Kyuubi supports custom event handlers with the Java SPI. To register a custom event handler, user need to implement a class which is a child of org.apache.kyuubi.events.handler.CustomEventHandlerProvider which has zero-arg constructor.|seq|1.4.0 -kyuubi.backend.server.exec.pool.keepalive.time|PT1M|Time(ms) that an idle async thread of the operation execution thread pool will wait for a new task to arrive before terminating in Kyuubi server|duration|1.0.0 -kyuubi.backend.server.exec.pool.shutdown.timeout|PT10S|Timeout(ms) for the operation execution thread pool to terminate in Kyuubi server|duration|1.0.0 -kyuubi.backend.server.exec.pool.size|100|Number of threads in the operation execution thread pool of Kyuubi server|int|1.0.0 -kyuubi.backend.server.exec.pool.wait.queue.size|100|Size of the wait queue for the operation execution thread pool of Kyuubi server|int|1.0.0 - +| Key | Default | Meaning | Type | Since | +|--------------------------------------------------|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.backend.engine.exec.pool.keepalive.time | PT1M | Time(ms) that an idle async thread of the operation execution thread pool will wait for a new task to arrive before terminating in SQL engine applications | duration | 1.0.0 | +| kyuubi.backend.engine.exec.pool.shutdown.timeout | PT10S | Timeout(ms) for the operation execution thread pool to terminate in SQL engine applications | duration | 1.0.0 | +| kyuubi.backend.engine.exec.pool.size | 100 | Number of threads in the operation execution thread pool of SQL engine applications | int | 1.0.0 | +| kyuubi.backend.engine.exec.pool.wait.queue.size | 100 | Size of the wait queue for the operation execution thread pool in SQL engine applications | int | 1.0.0 | +| kyuubi.backend.server.event.json.log.path | file:///tmp/kyuubi/events | The location of server events go for the built-in JSON logger | string | 1.4.0 | +| kyuubi.backend.server.event.loggers || A comma-separated list of server history loggers, where session/operation etc events go.
  • JSON: the events will be written to the location of kyuubi.backend.server.event.json.log.path
  • JDBC: to be done
  • CUSTOM: User-defined event handlers.
Note that: Kyuubi supports custom event handlers with the Java SPI. To register a custom event handler, the user needs to implement a class which is a child of org.apache.kyuubi.events.handler.CustomEventHandlerProvider which has a zero-arg constructor. | seq | 1.4.0 | +| kyuubi.backend.server.exec.pool.keepalive.time | PT1M | Time(ms) that an idle async thread of the operation execution thread pool will wait for a new task to arrive before terminating in Kyuubi server | duration | 1.0.0 | +| kyuubi.backend.server.exec.pool.shutdown.timeout | PT10S | Timeout(ms) for the operation execution thread pool to terminate in Kyuubi server | duration | 1.0.0 | +| kyuubi.backend.server.exec.pool.size | 100 | Number of threads in the operation execution thread pool of Kyuubi server | int | 1.0.0 | +| kyuubi.backend.server.exec.pool.wait.queue.size | 100 | Size of the wait queue for the operation execution thread pool of Kyuubi server | int | 1.0.0 | ### Batch -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.batch.application.check.interval|PT5S|The interval to check batch job application information.|duration|1.6.0 -kyuubi.batch.application.starvation.timeout|PT3M|Threshold above which to warn batch application may be starved.|duration|1.7.0 -kyuubi.batch.conf.ignore.list||A comma separated list of ignored keys for batch conf. If the batch conf contains any of them, the key and the corresponding value will be removed silently during batch job submission. Note that this rule is for server-side protection defined via administrators to prevent some essential configs from tampering. You can also pre-define some config for batch job submission with prefix: kyuubi.batchConf.[batchType]. For example, you can pre-define `spark.master` for spark batch job with key `kyuubi.batchConf.spark.spark.master`.|seq|1.6.0 -kyuubi.batch.session.idle.timeout|PT6H|Batch session idle timeout, it will be closed when it's not accessed for this duration|duration|1.6.2 - +| Key | Default | Meaning | Type | Since | +|---------------------------------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.batch.application.check.interval | PT5S | The interval to check batch job application information. | duration | 1.6.0 | +| kyuubi.batch.application.starvation.timeout | PT3M | Threshold above which to warn batch application may be starved. | duration | 1.7.0 | +| kyuubi.batch.conf.ignore.list || A comma-separated list of ignored keys for batch conf. If the batch conf contains any of them, the key and the corresponding value will be removed silently during batch job submission. Note that this rule is for server-side protection defined via administrators to prevent some essential configs from tampering. You can also pre-define some config for batch job submission with the prefix: kyuubi.batchConf.[batchType]. For example, you can pre-define `spark.master` for the Spark batch job with key `kyuubi.batchConf.spark.spark.master`. | seq | 1.6.0 | +| kyuubi.batch.session.idle.timeout | PT6H | Batch session idle timeout, it will be closed when it's not accessed for this duration | duration | 1.6.2 | ### Credentials -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.credentials.check.interval|PT5M|The interval to check the expiration of cached pairs.|duration|1.6.0 -kyuubi.credentials.hadoopfs.enabled|true|Whether to renew Hadoop filesystem delegation tokens|boolean|1.4.0 -kyuubi.credentials.hadoopfs.uris||Extra Hadoop filesystem URIs for which to request delegation tokens. The filesystem that hosts fs.defaultFS does not need to be listed here.|seq|1.4.0 -kyuubi.credentials.hive.enabled|true|Whether to renew Hive metastore delegation token|boolean|1.4.0 -kyuubi.credentials.idle.timeout|PT6H|inactive users' credentials will be expired after a configured timeout|duration|1.6.0 -kyuubi.credentials.renewal.interval|PT1H|How often Kyuubi renews one user's delegation tokens|duration|1.4.0 -kyuubi.credentials.renewal.retry.wait|PT1M|How long to wait before retrying to fetch new credentials after a failure.|duration|1.4.0 -kyuubi.credentials.update.wait.timeout|PT1M|How long to wait until credentials are ready.|duration|1.5.0 - +| Key | Default | Meaning | Type | Since | +|----------------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.credentials.check.interval | PT5M | The interval to check the expiration of cached pairs. | duration | 1.6.0 | +| kyuubi.credentials.hadoopfs.enabled | true | Whether to renew Hadoop filesystem delegation tokens | boolean | 1.4.0 | +| kyuubi.credentials.hadoopfs.uris || Extra Hadoop filesystem URIs for which to request delegation tokens. The filesystem that hosts fs.defaultFS does not need to be listed here. | seq | 1.4.0 | +| kyuubi.credentials.hive.enabled | true | Whether to renew Hive metastore delegation token | boolean | 1.4.0 | +| kyuubi.credentials.idle.timeout | PT6H | The inactive users' credentials will be expired after a configured timeout | duration | 1.6.0 | +| kyuubi.credentials.renewal.interval | PT1H | How often Kyuubi renews one user's delegation tokens | duration | 1.4.0 | +| kyuubi.credentials.renewal.retry.wait | PT1M | How long to wait before retrying to fetch new credentials after a failure. | duration | 1.4.0 | +| kyuubi.credentials.update.wait.timeout | PT1M | How long to wait until the credentials are ready. | duration | 1.5.0 | ### Ctl -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.ctl.batch.log.on.failure.timeout|PT10S|The timeout for fetching remaining batch logs if the batch failed.|duration|1.6.1 -kyuubi.ctl.batch.log.query.interval|PT3S|The interval for fetching batch logs.|duration|1.6.0 -kyuubi.ctl.rest.auth.schema|basic|The authentication schema. Valid values are: basic, spnego.|string|1.6.0 -kyuubi.ctl.rest.base.url|<undefined>|The REST API base URL, which contains the scheme (http:// or https://), host name, port number|string|1.6.0 -kyuubi.ctl.rest.connect.timeout|PT30S|The timeout[ms] for establishing the connection with the kyuubi server.A timeout value of zero is interpreted as an infinite timeout.|duration|1.6.0 -kyuubi.ctl.rest.request.attempt.wait|PT3S|How long to wait between attempts of ctl rest request.|duration|1.6.0 -kyuubi.ctl.rest.request.max.attempts|3|The max attempts number for ctl rest request.|int|1.6.0 -kyuubi.ctl.rest.socket.timeout|PT2M|The timeout[ms] for waiting for data packets after connection is established.A timeout value of zero is interpreted as an infinite timeout.|duration|1.6.0 -kyuubi.ctl.rest.spnego.host|<undefined>|When auth schema is spnego, need to config spnego host.|string|1.6.0 - +| Key | Default | Meaning | Type | Since | +|-----------------------------------------|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.ctl.batch.log.on.failure.timeout | PT10S | The timeout for fetching remaining batch logs if the batch failed. | duration | 1.6.1 | +| kyuubi.ctl.batch.log.query.interval | PT3S | The interval for fetching batch logs. | duration | 1.6.0 | +| kyuubi.ctl.rest.auth.schema | basic | The authentication schema. Valid values are: basic, spnego. | string | 1.6.0 | +| kyuubi.ctl.rest.base.url | <undefined> | The REST API base URL, which contains the scheme (http:// or https://), hostname, port number | string | 1.6.0 | +| kyuubi.ctl.rest.connect.timeout | PT30S | The timeout[ms] for establishing the connection with the kyuubi server. A timeout value of zero is interpreted as an infinite timeout. | duration | 1.6.0 | +| kyuubi.ctl.rest.request.attempt.wait | PT3S | How long to wait between attempts of ctl rest request. | duration | 1.6.0 | +| kyuubi.ctl.rest.request.max.attempts | 3 | The max attempts number for ctl rest request. | int | 1.6.0 | +| kyuubi.ctl.rest.socket.timeout | PT2M | The timeout[ms] for waiting for data packets after connection is established. A timeout value of zero is interpreted as an infinite timeout. | duration | 1.6.0 | +| kyuubi.ctl.rest.spnego.host | <undefined> | When auth schema is spnego, need to config spnego host. | string | 1.6.0 | ### Delegation -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.delegation.key.update.interval|PT24H|unused yet|duration|1.0.0 -kyuubi.delegation.token.gc.interval|PT1H|unused yet|duration|1.0.0 -kyuubi.delegation.token.max.lifetime|PT168H|unused yet|duration|1.0.0 -kyuubi.delegation.token.renew.interval|PT168H|unused yet|duration|1.0.0 - +| Key | Default | Meaning | Type | Since | +|----------------------------------------|---------|------------|----------|-------| +| kyuubi.delegation.key.update.interval | PT24H | unused yet | duration | 1.0.0 | +| kyuubi.delegation.token.gc.interval | PT1H | unused yet | duration | 1.0.0 | +| kyuubi.delegation.token.max.lifetime | PT168H | unused yet | duration | 1.0.0 | +| kyuubi.delegation.token.renew.interval | PT168H | unused yet | duration | 1.0.0 | ### Engine -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.engine.connection.url.use.hostname|true|(deprecated) When true, engine register with hostname to zookeeper. When spark run on k8s with cluster mode, set to false to ensure that server can connect to engine|boolean|1.3.0 -kyuubi.engine.deregister.exception.classes||A comma separated list of exception classes. If there is any exception thrown, whose class matches the specified classes, the engine would deregister itself.|seq|1.2.0 -kyuubi.engine.deregister.exception.messages||A comma separated list of exception messages. If there is any exception thrown, whose message or stacktrace matches the specified message list, the engine would deregister itself.|seq|1.2.0 -kyuubi.engine.deregister.exception.ttl|PT30M|Time to live(TTL) for exceptions pattern specified in kyuubi.engine.deregister.exception.classes and kyuubi.engine.deregister.exception.messages to deregister engines. Once the total error count hits the kyuubi.engine.deregister.job.max.failures within the TTL, an engine will deregister itself and wait for self-terminated. Otherwise, we suppose that the engine has recovered from temporary failures.|duration|1.2.0 -kyuubi.engine.deregister.job.max.failures|4|Number of failures of job before deregistering the engine.|int|1.2.0 -kyuubi.engine.event.json.log.path|file:///tmp/kyuubi/events|The location of all the engine events go for the builtin JSON logger.
  • Local Path: start with 'file://'
  • HDFS Path: start with 'hdfs://'
|string|1.3.0 -kyuubi.engine.event.loggers|SPARK|A comma separated list of engine history loggers, where engine/session/operation etc events go.
  • SPARK: the events will be written to the spark listener bus.
  • JSON: the events will be written to the location of kyuubi.engine.event.json.log.path
  • JDBC: to be done
  • CUSTOM: User-defined event handlers.
Note that: Kyuubi supports custom event handlers with the Java SPI. To register a custom event handler, user need to implement a class which is a child of org.apache.kyuubi.events.handler.CustomEventHandlerProvider which has zero-arg constructor.|seq|1.3.0 -kyuubi.engine.flink.extra.classpath|<undefined>|The extra classpath for the flink sql engine, for configuring location of hadoop client jars, etc|string|1.6.0 -kyuubi.engine.flink.java.options|<undefined>|The extra java options for the flink sql engine|string|1.6.0 -kyuubi.engine.flink.memory|1g|The heap memory for the flink sql engine|string|1.6.0 -kyuubi.engine.hive.event.loggers|JSON|A comma separated list of engine history loggers, where engine/session/operation etc events go.
  • JSON: the events will be written to the location of kyuubi.engine.event.json.log.path
  • JDBC: to be done
  • CUSTOM: to be done.
|seq|1.7.0 -kyuubi.engine.hive.extra.classpath|<undefined>|The extra classpath for the hive query engine, for configuring location of hadoop client jars, etc|string|1.6.0 -kyuubi.engine.hive.java.options|<undefined>|The extra java options for the hive query engine|string|1.6.0 -kyuubi.engine.hive.memory|1g|The heap memory for the hive query engine|string|1.6.0 -kyuubi.engine.initialize.sql|SHOW DATABASES|SemiColon-separated list of SQL statements to be initialized in the newly created engine before queries. i.e. use `SHOW DATABASES` to eagerly active HiveClient. This configuration can not be used in JDBC url due to the limitation of Beeline/JDBC driver.|seq|1.2.0 -kyuubi.engine.jdbc.connection.password|<undefined>|The password is used for connecting to server|string|1.6.0 -kyuubi.engine.jdbc.connection.properties||The additional properties are used for connecting to server|seq|1.6.0 -kyuubi.engine.jdbc.connection.provider|<undefined>|The connection provider is used for getting a connection from server|string|1.6.0 -kyuubi.engine.jdbc.connection.url|<undefined>|The server url that engine will connect to|string|1.6.0 -kyuubi.engine.jdbc.connection.user|<undefined>|The user is used for connecting to server|string|1.6.0 -kyuubi.engine.jdbc.driver.class|<undefined>|The driver class for jdbc engine connection|string|1.6.0 -kyuubi.engine.jdbc.extra.classpath|<undefined>|The extra classpath for the jdbc query engine, for configuring location of jdbc driver, etc|string|1.6.0 -kyuubi.engine.jdbc.java.options|<undefined>|The extra java options for the jdbc query engine|string|1.6.0 -kyuubi.engine.jdbc.memory|1g|The heap memory for the jdbc query engine|string|1.6.0 -kyuubi.engine.jdbc.type|<undefined>|The short name of jdbc type|string|1.6.0 -kyuubi.engine.operation.convert.catalog.database.enabled|true|When set to true, The engine converts the JDBC methods of set/get Catalog and set/get Schema to the implementation of different engines|boolean|1.6.0 -kyuubi.engine.operation.log.dir.root|engine_operation_logs|Root directory for query operation log at engine-side.|string|1.4.0 -kyuubi.engine.pool.name|engine-pool|The name of engine pool.|string|1.5.0 -kyuubi.engine.pool.selectPolicy|RANDOM|The select policy of an engine from the corresponding engine pool engine for a session.
  • RANDOM - Randomly use the engine in the pool
  • POLLING - Polling use the engine in the pool
|string|1.7.0 -kyuubi.engine.pool.size|-1|The size of engine pool. Note that, if the size is less than 1, the engine pool will not be enabled; otherwise, the size of the engine pool will be min(this, kyuubi.engine.pool.size.threshold).|int|1.4.0 -kyuubi.engine.pool.size.threshold|9|This parameter is introduced as a server-side parameter, and controls the upper limit of the engine pool.|int|1.4.0 -kyuubi.engine.session.initialize.sql||SemiColon-separated list of SQL statements to be initialized in the newly created engine session before queries. This configuration can not be used in JDBC url due to the limitation of Beeline/JDBC driver.|seq|1.3.0 -kyuubi.engine.share.level|USER|Engines will be shared in different levels, available configs are:
  • CONNECTION: engine will not be shared but only used by the current client connection
  • USER: engine will be shared by all sessions created by a unique username, see also kyuubi.engine.share.level.subdomain
  • GROUP: engine will be shared by all sessions created by all users belong to the same primary group name. The engine will be launched by the group name as the effective username, so here the group name is kind of special user who is able to visit the compute resources/data of a team. It follows the [Hadoop GroupsMapping](https://reurl.cc/xE61Y5) to map user to a primary group. If the primary group is not found, it fallback to the USER level.
  • SERVER: the App will be shared by Kyuubi servers
|string|1.2.0 -kyuubi.engine.share.level.sub.domain|<undefined>|(deprecated) - Using kyuubi.engine.share.level.subdomain instead|string|1.2.0 -kyuubi.engine.share.level.subdomain|<undefined>|Allow end-users to create a subdomain for the share level of an engine. A subdomain is a case-insensitive string values that must be a valid zookeeper sub path. For example, for `USER` share level, an end-user can share a certain engine within a subdomain, not for all of its clients. End-users are free to create multiple engines in the `USER` share level. When disable engine pool, use 'default' if absent.|string|1.4.0 -kyuubi.engine.single.spark.session|false|When set to true, this engine is running in a single session mode. All the JDBC/ODBC connections share the temporary views, function registries, SQL configuration and the current database.|boolean|1.3.0 -kyuubi.engine.spark.event.loggers|SPARK|A comma separated list of engine loggers, where engine/session/operation etc events go.
  • SPARK: the events will be written to the spark listener bus.
  • JSON: the events will be written to the location of kyuubi.engine.event.json.log.path
  • JDBC: to be done
  • CUSTOM: to be done.
|seq|1.7.0 -kyuubi.engine.spark.python.env.archive|<undefined>|Portable python env archive used for Spark engine python language mode.|string|1.7.0 -kyuubi.engine.spark.python.env.archive.exec.path|bin/python|The python exec path under the python env archive.|string|1.7.0 -kyuubi.engine.spark.python.home.archive|<undefined>|Spark archive containing $SPARK_HOME/python directory, which is used to init session python worker for python language mode.|string|1.7.0 -kyuubi.engine.trino.event.loggers|JSON|A comma separated list of engine history loggers, where engine/session/operation etc events go.
  • JSON: the events will be written to the location of kyuubi.engine.event.json.log.path
  • JDBC: to be done
  • CUSTOM: to be done.
|seq|1.7.0 -kyuubi.engine.trino.extra.classpath|<undefined>|The extra classpath for the trino query engine, for configuring other libs which may need by the trino engine |string|1.6.0 -kyuubi.engine.trino.java.options|<undefined>|The extra java options for the trino query engine|string|1.6.0 -kyuubi.engine.trino.memory|1g|The heap memory for the trino query engine|string|1.6.0 -kyuubi.engine.type|SPARK_SQL|Specify the detailed engine that supported by the Kyuubi. The engine type bindings to SESSION scope. This configuration is experimental. Currently, available configs are:
  • SPARK_SQL: specify this engine type will launch a Spark engine which can provide all the capacity of the Apache Spark. Note, it's a default engine type.
  • FLINK_SQL: specify this engine type will launch a Flink engine which can provide all the capacity of the Apache Flink.
  • TRINO: specify this engine type will launch a Trino engine which can provide all the capacity of the Trino.
  • HIVE_SQL: specify this engine type will launch a Hive engine which can provide all the capacity of the Hive Server2.
  • JDBC: specify this engine type will launch a JDBC engine which can provide a mysql protocol connector, for now we only support Doris dialect.
|string|1.4.0 -kyuubi.engine.ui.retainedSessions|200|The number of SQL client sessions kept in the Kyuubi Query Engine web UI.|int|1.4.0 -kyuubi.engine.ui.retainedStatements|200|The number of statements kept in the Kyuubi Query Engine web UI.|int|1.4.0 -kyuubi.engine.ui.stop.enabled|true|When true, allows Kyuubi engine to be killed from the Spark Web UI.|boolean|1.3.0 -kyuubi.engine.user.isolated.spark.session|true|When set to false, if the engine is running in a group or server share level, all the JDBC/ODBC connections will be isolated against the user. Including: the temporary views, function registries, SQL configuration and the current database. Note that, it does not affect if the share level is connection or user.|boolean|1.6.0 -kyuubi.engine.user.isolated.spark.session.idle.interval|PT1M|The interval to check if the user isolated spark session is timeout.|duration|1.6.0 -kyuubi.engine.user.isolated.spark.session.idle.timeout|PT6H|If kyuubi.engine.user.isolated.spark.session is false, we will release the spark session if its corresponding user is inactive after this configured timeout.|duration|1.6.0 - +| Key | Default | Meaning | Type | Since | +|----------------------------------------------------------|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.engine.chat.extra.classpath | <undefined> | The extra classpath for the Chat engine, for configuring the location of the SDK and etc. | string | 1.8.0 | +| kyuubi.engine.chat.gpt.apiKey | <undefined> | The key to access OpenAI open API, which could be got at https://platform.openai.com/account/api-keys | string | 1.8.0 | +| kyuubi.engine.chat.gpt.http.connect.timeout | PT2M | The timeout[ms] for establishing the connection with the Chat GPT server. A timeout value of zero is interpreted as an infinite timeout. | duration | 1.8.0 | +| kyuubi.engine.chat.gpt.http.proxy | <undefined> | HTTP proxy url for API calling in Chat GPT engine. e.g. http://127.0.0.1:1087 | string | 1.8.0 | +| kyuubi.engine.chat.gpt.http.socket.timeout | PT2M | The timeout[ms] for waiting for data packets after Chat GPT server connection is established. A timeout value of zero is interpreted as an infinite timeout. | duration | 1.8.0 | +| kyuubi.engine.chat.gpt.model | gpt-3.5-turbo | ID of the model used in ChatGPT. Available models refer to OpenAI's [Model overview](https://platform.openai.com/docs/models/overview). | string | 1.8.0 | +| kyuubi.engine.chat.java.options | <undefined> | The extra Java options for the Chat engine | string | 1.8.0 | +| kyuubi.engine.chat.memory | 1g | The heap memory for the Chat engine | string | 1.8.0 | +| kyuubi.engine.chat.provider | ECHO | The provider for the Chat engine. Candidates:
  • ECHO: simply replies a welcome message.
  • GPT: a.k.a ChatGPT, powered by OpenAI.
| string | 1.8.0 | +| kyuubi.engine.connection.url.use.hostname | true | (deprecated) When true, the engine registers with hostname to zookeeper. When Spark runs on K8s with cluster mode, set to false to ensure that server can connect to engine | boolean | 1.3.0 | +| kyuubi.engine.deregister.exception.classes || A comma-separated list of exception classes. If there is any exception thrown, whose class matches the specified classes, the engine would deregister itself. | seq | 1.2.0 | +| kyuubi.engine.deregister.exception.messages || A comma-separated list of exception messages. If there is any exception thrown, whose message or stacktrace matches the specified message list, the engine would deregister itself. | seq | 1.2.0 | +| kyuubi.engine.deregister.exception.ttl | PT30M | Time to live(TTL) for exceptions pattern specified in kyuubi.engine.deregister.exception.classes and kyuubi.engine.deregister.exception.messages to deregister engines. Once the total error count hits the kyuubi.engine.deregister.job.max.failures within the TTL, an engine will deregister itself and wait for self-terminated. Otherwise, we suppose that the engine has recovered from temporary failures. | duration | 1.2.0 | +| kyuubi.engine.deregister.job.max.failures | 4 | Number of failures of job before deregistering the engine. | int | 1.2.0 | +| kyuubi.engine.event.json.log.path | file:///tmp/kyuubi/events | The location where all the engine events go for the built-in JSON logger.
  • Local Path: start with 'file://'
  • HDFS Path: start with 'hdfs://'
| string | 1.3.0 | +| kyuubi.engine.event.loggers | SPARK | A comma-separated list of engine history loggers, where engine/session/operation etc events go.
  • SPARK: the events will be written to the Spark listener bus.
  • JSON: the events will be written to the location of kyuubi.engine.event.json.log.path
  • JDBC: to be done
  • CUSTOM: User-defined event handlers.
Note that: Kyuubi supports custom event handlers with the Java SPI. To register a custom event handler, the user needs to implement a subclass of `org.apache.kyuubi.events.handler.CustomEventHandlerProvider` which has a zero-arg constructor. | seq | 1.3.0 | +| kyuubi.engine.flink.extra.classpath | <undefined> | The extra classpath for the Flink SQL engine, for configuring the location of hadoop client jars, etc | string | 1.6.0 | +| kyuubi.engine.flink.java.options | <undefined> | The extra Java options for the Flink SQL engine | string | 1.6.0 | +| kyuubi.engine.flink.memory | 1g | The heap memory for the Flink SQL engine | string | 1.6.0 | +| kyuubi.engine.hive.event.loggers | JSON | A comma-separated list of engine history loggers, where engine/session/operation etc events go.
  • JSON: the events will be written to the location of kyuubi.engine.event.json.log.path
  • JDBC: to be done
  • CUSTOM: to be done.
| seq | 1.7.0 | +| kyuubi.engine.hive.extra.classpath | <undefined> | The extra classpath for the Hive query engine, for configuring location of the hadoop client jars and etc. | string | 1.6.0 | +| kyuubi.engine.hive.java.options | <undefined> | The extra Java options for the Hive query engine | string | 1.6.0 | +| kyuubi.engine.hive.memory | 1g | The heap memory for the Hive query engine | string | 1.6.0 | +| kyuubi.engine.initialize.sql | SHOW DATABASES | SemiColon-separated list of SQL statements to be initialized in the newly created engine before queries. i.e. use `SHOW DATABASES` to eagerly active HiveClient. This configuration can not be used in JDBC url due to the limitation of Beeline/JDBC driver. | seq | 1.2.0 | +| kyuubi.engine.jdbc.connection.password | <undefined> | The password is used for connecting to server | string | 1.6.0 | +| kyuubi.engine.jdbc.connection.properties || The additional properties are used for connecting to server | seq | 1.6.0 | +| kyuubi.engine.jdbc.connection.provider | <undefined> | The connection provider is used for getting a connection from the server | string | 1.6.0 | +| kyuubi.engine.jdbc.connection.url | <undefined> | The server url that engine will connect to | string | 1.6.0 | +| kyuubi.engine.jdbc.connection.user | <undefined> | The user is used for connecting to server | string | 1.6.0 | +| kyuubi.engine.jdbc.driver.class | <undefined> | The driver class for JDBC engine connection | string | 1.6.0 | +| kyuubi.engine.jdbc.extra.classpath | <undefined> | The extra classpath for the JDBC query engine, for configuring the location of the JDBC driver and etc. | string | 1.6.0 | +| kyuubi.engine.jdbc.java.options | <undefined> | The extra Java options for the JDBC query engine | string | 1.6.0 | +| kyuubi.engine.jdbc.memory | 1g | The heap memory for the JDBC query engine | string | 1.6.0 | +| kyuubi.engine.jdbc.type | <undefined> | The short name of JDBC type | string | 1.6.0 | +| kyuubi.engine.operation.convert.catalog.database.enabled | true | When set to true, The engine converts the JDBC methods of set/get Catalog and set/get Schema to the implementation of different engines | boolean | 1.6.0 | +| kyuubi.engine.operation.log.dir.root | engine_operation_logs | Root directory for query operation log at engine-side. | string | 1.4.0 | +| kyuubi.engine.pool.name | engine-pool | The name of the engine pool. | string | 1.5.0 | +| kyuubi.engine.pool.selectPolicy | RANDOM | The select policy of an engine from the corresponding engine pool engine for a session.
  • RANDOM - Randomly use the engine in the pool
  • POLLING - Polling use the engine in the pool
| string | 1.7.0 | +| kyuubi.engine.pool.size | -1 | The size of the engine pool. Note that, if the size is less than 1, the engine pool will not be enabled; otherwise, the size of the engine pool will be min(this, kyuubi.engine.pool.size.threshold). | int | 1.4.0 | +| kyuubi.engine.pool.size.threshold | 9 | This parameter is introduced as a server-side parameter controlling the upper limit of the engine pool. | int | 1.4.0 | +| kyuubi.engine.session.initialize.sql || SemiColon-separated list of SQL statements to be initialized in the newly created engine session before queries. This configuration can not be used in JDBC url due to the limitation of Beeline/JDBC driver. | seq | 1.3.0 | +| kyuubi.engine.share.level | USER | Engines will be shared in different levels, available configs are:
  • CONNECTION: engine will not be shared but only used by the current client connection
  • USER: engine will be shared by all sessions created by a unique username, see also kyuubi.engine.share.level.subdomain
  • GROUP: the engine will be shared by all sessions created by all users belong to the same primary group name. The engine will be launched by the group name as the effective username, so here the group name is in value of special user who is able to visit the computing resources/data of the team. It follows the [Hadoop GroupsMapping](https://reurl.cc/xE61Y5) to map user to a primary group. If the primary group is not found, it fallback to the USER level.
  • SERVER: the App will be shared by Kyuubi servers
| string | 1.2.0 | +| kyuubi.engine.share.level.sub.domain | <undefined> | (deprecated) - Using kyuubi.engine.share.level.subdomain instead | string | 1.2.0 | +| kyuubi.engine.share.level.subdomain | <undefined> | Allow end-users to create a subdomain for the share level of an engine. A subdomain is a case-insensitive string values that must be a valid zookeeper subpath. For example, for the `USER` share level, an end-user can share a certain engine within a subdomain, not for all of its clients. End-users are free to create multiple engines in the `USER` share level. When disable engine pool, use 'default' if absent. | string | 1.4.0 | +| kyuubi.engine.single.spark.session | false | When set to true, this engine is running in a single session mode. All the JDBC/ODBC connections share the temporary views, function registries, SQL configuration and the current database. | boolean | 1.3.0 | +| kyuubi.engine.spark.event.loggers | SPARK | A comma-separated list of engine loggers, where engine/session/operation etc events go.
  • SPARK: the events will be written to the Spark listener bus.
  • JSON: the events will be written to the location of kyuubi.engine.event.json.log.path
  • JDBC: to be done
  • CUSTOM: to be done.
| seq | 1.7.0 | +| kyuubi.engine.spark.python.env.archive | <undefined> | Portable Python env archive used for Spark engine Python language mode. | string | 1.7.0 | +| kyuubi.engine.spark.python.env.archive.exec.path | bin/python | The Python exec path under the Python env archive. | string | 1.7.0 | +| kyuubi.engine.spark.python.home.archive | <undefined> | Spark archive containing $SPARK_HOME/python directory, which is used to init session Python worker for Python language mode. | string | 1.7.0 | +| kyuubi.engine.submit.timeout | PT30S | Period to tolerant Driver Pod ephemerally invisible after submitting. In some Resource Managers, e.g. K8s, the Driver Pod is not visible immediately after `spark-submit` is returned. | duration | 1.7.1 | +| kyuubi.engine.trino.event.loggers | JSON | A comma-separated list of engine history loggers, where engine/session/operation etc events go.
  • JSON: the events will be written to the location of kyuubi.engine.event.json.log.path
  • JDBC: to be done
  • CUSTOM: to be done.
| seq | 1.7.0 | +| kyuubi.engine.trino.extra.classpath | <undefined> | The extra classpath for the Trino query engine, for configuring other libs which may need by the Trino engine | string | 1.6.0 | +| kyuubi.engine.trino.java.options | <undefined> | The extra Java options for the Trino query engine | string | 1.6.0 | +| kyuubi.engine.trino.memory | 1g | The heap memory for the Trino query engine | string | 1.6.0 | +| kyuubi.engine.type | SPARK_SQL | Specify the detailed engine supported by Kyuubi. The engine type bindings to SESSION scope. This configuration is experimental. Currently, available configs are:
  • SPARK_SQL: specify this engine type will launch a Spark engine which can provide all the capacity of the Apache Spark. Note, it's a default engine type.
  • FLINK_SQL: specify this engine type will launch a Flink engine which can provide all the capacity of the Apache Flink.
  • TRINO: specify this engine type will launch a Trino engine which can provide all the capacity of the Trino.
  • HIVE_SQL: specify this engine type will launch a Hive engine which can provide all the capacity of the Hive Server2.
  • JDBC: specify this engine type will launch a JDBC engine which can provide a MySQL protocol connector, for now we only support Doris dialect.
  • CHAT: specify this engine type will launch a Chat engine.
| string | 1.4.0 | +| kyuubi.engine.ui.retainedSessions | 200 | The number of SQL client sessions kept in the Kyuubi Query Engine web UI. | int | 1.4.0 | +| kyuubi.engine.ui.retainedStatements | 200 | The number of statements kept in the Kyuubi Query Engine web UI. | int | 1.4.0 | +| kyuubi.engine.ui.stop.enabled | true | When true, allows Kyuubi engine to be killed from the Spark Web UI. | boolean | 1.3.0 | +| kyuubi.engine.user.isolated.spark.session | true | When set to false, if the engine is running in a group or server share level, all the JDBC/ODBC connections will be isolated against the user. Including the temporary views, function registries, SQL configuration, and the current database. Note that, it does not affect if the share level is connection or user. | boolean | 1.6.0 | +| kyuubi.engine.user.isolated.spark.session.idle.interval | PT1M | The interval to check if the user-isolated Spark session is timeout. | duration | 1.6.0 | +| kyuubi.engine.user.isolated.spark.session.idle.timeout | PT6H | If kyuubi.engine.user.isolated.spark.session is false, we will release the Spark session if its corresponding user is inactive after this configured timeout. | duration | 1.6.0 | ### Event -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.event.async.pool.keepalive.time|PT1M|Time(ms) that an idle async thread of the async event handler thread pool will wait for a new task to arrive before terminating|duration|1.7.0 -kyuubi.event.async.pool.size|8|Number of threads in the async event handler thread pool|int|1.7.0 -kyuubi.event.async.pool.wait.queue.size|100|Size of the wait queue for the async event handler thread pool|int|1.7.0 - +| Key | Default | Meaning | Type | Since | +|-----------------------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.event.async.pool.keepalive.time | PT1M | Time(ms) that an idle async thread of the async event handler thread pool will wait for a new task to arrive before terminating | duration | 1.7.0 | +| kyuubi.event.async.pool.size | 8 | Number of threads in the async event handler thread pool | int | 1.7.0 | +| kyuubi.event.async.pool.wait.queue.size | 100 | Size of the wait queue for the async event handler thread pool | int | 1.7.0 | ### Frontend -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.frontend.backoff.slot.length|PT0.1S|(deprecated) Time to back off during login to the thrift frontend service.|duration|1.0.0 -kyuubi.frontend.bind.host|<undefined>|Hostname or IP of the machine on which to run the frontend services.|string|1.0.0 -kyuubi.frontend.bind.port|10009|(deprecated) Port of the machine on which to run the thrift frontend service via binary protocol.|int|1.0.0 -kyuubi.frontend.connection.url.use.hostname|true|When true, frontend services prefer hostname, otherwise, ip address. Note that, the default value is set to `false` when engine running on Kubernetes to prevent potential network issue.|boolean|1.5.0 -kyuubi.frontend.login.timeout|PT20S|(deprecated) Timeout for Thrift clients during login to the thrift frontend service.|duration|1.0.0 -kyuubi.frontend.max.message.size|104857600|(deprecated) Maximum message size in bytes a Kyuubi server will accept.|int|1.0.0 -kyuubi.frontend.max.worker.threads|999|(deprecated) Maximum number of threads in the of frontend worker thread pool for the thrift frontend service|int|1.0.0 -kyuubi.frontend.min.worker.threads|9|(deprecated) Minimum number of threads in the of frontend worker thread pool for the thrift frontend service|int|1.0.0 -kyuubi.frontend.mysql.bind.host|<undefined>|Hostname or IP of the machine on which to run the MySQL frontend service.|string|1.4.0 -kyuubi.frontend.mysql.bind.port|3309|Port of the machine on which to run the MySQL frontend service.|int|1.4.0 -kyuubi.frontend.mysql.max.worker.threads|999|Maximum number of threads in the command execution thread pool for the MySQL frontend service|int|1.4.0 -kyuubi.frontend.mysql.min.worker.threads|9|Minimum number of threads in the command execution thread pool for the MySQL frontend service|int|1.4.0 -kyuubi.frontend.mysql.netty.worker.threads|<undefined>|Number of thread in the netty worker event loop of MySQL frontend service. Use min(cpu_cores, 8) in default.|int|1.4.0 -kyuubi.frontend.mysql.worker.keepalive.time|PT1M|Time(ms) that an idle async thread of the command execution thread pool will wait for a new task to arrive before terminating in MySQL frontend service|duration|1.4.0 -kyuubi.frontend.protocols|THRIFT_BINARY|A comma separated list for all frontend protocols
  • THRIFT_BINARY - HiveServer2 compatible thrift binary protocol.
  • THRIFT_HTTP - HiveServer2 compatible thrift http protocol.
  • REST - Kyuubi defined REST API(experimental).
  • MYSQL - MySQL compatible text protocol(experimental).
  • TRINO - Trino compatible http protocol(experimental).
|seq|1.4.0 -kyuubi.frontend.proxy.http.client.ip.header|X-Real-IP|The http header to record the real client ip address. If your server is behind a load balancer or other proxy, the server will see this load balancer or proxy IP address as the client IP address, to get around this common issue, most load balancers or proxies offer the ability to record the real remote IP address in an HTTP header that will be added to the request for other devices to use. Note that, because the header value can be specified to any ip address, so it will not be used for authentication.|string|1.6.0 -kyuubi.frontend.rest.bind.host|<undefined>|Hostname or IP of the machine on which to run the REST frontend service.|string|1.4.0 -kyuubi.frontend.rest.bind.port|10099|Port of the machine on which to run the REST frontend service.|int|1.4.0 -kyuubi.frontend.rest.max.worker.threads|999|Maximum number of threads in the of frontend worker thread pool for the rest frontend service|int|1.6.2 -kyuubi.frontend.ssl.keystore.algorithm|<undefined>|SSL certificate keystore algorithm.|string|1.7.0 -kyuubi.frontend.ssl.keystore.password|<undefined>|SSL certificate keystore password.|string|1.7.0 -kyuubi.frontend.ssl.keystore.path|<undefined>|SSL certificate keystore location.|string|1.7.0 -kyuubi.frontend.ssl.keystore.type|<undefined>|SSL certificate keystore type.|string|1.7.0 -kyuubi.frontend.thrift.backoff.slot.length|PT0.1S|Time to back off during login to the thrift frontend service.|duration|1.4.0 -kyuubi.frontend.thrift.binary.bind.host|<undefined>|Hostname or IP of the machine on which to run the thrift frontend service via binary protocol.|string|1.4.0 -kyuubi.frontend.thrift.binary.bind.port|10009|Port of the machine on which to run the thrift frontend service via binary protocol.|int|1.4.0 -kyuubi.frontend.thrift.binary.ssl.disallowed.protocols|SSLv2,SSLv3|SSL versions to disallow for Kyuubi thrift binary frontend.|seq|1.7.0 -kyuubi.frontend.thrift.binary.ssl.enabled|false|Set this to true for using SSL encryption in thrift binary frontend server.|boolean|1.7.0 -kyuubi.frontend.thrift.binary.ssl.include.ciphersuites||A comma separated list of include SSL cipher suite names for thrift binary frontend.|seq|1.7.0 -kyuubi.frontend.thrift.http.allow.user.substitution|true|Allow alternate user to be specified as part of open connection request when using HTTP transport mode.|boolean|1.6.0 -kyuubi.frontend.thrift.http.bind.host|<undefined>|Hostname or IP of the machine on which to run the thrift frontend service via http protocol.|string|1.6.0 -kyuubi.frontend.thrift.http.bind.port|10010|Port of the machine on which to run the thrift frontend service via http protocol.|int|1.6.0 -kyuubi.frontend.thrift.http.compression.enabled|true|Enable thrift http compression via Jetty compression support|boolean|1.6.0 -kyuubi.frontend.thrift.http.cookie.auth.enabled|true|When true, Kyuubi in HTTP transport mode, will use cookie based authentication mechanism|boolean|1.6.0 -kyuubi.frontend.thrift.http.cookie.domain|<undefined>|Domain for the Kyuubi generated cookies|string|1.6.0 -kyuubi.frontend.thrift.http.cookie.is.httponly|true|HttpOnly attribute of the Kyuubi generated cookie.|boolean|1.6.0 -kyuubi.frontend.thrift.http.cookie.max.age|86400|Maximum age in seconds for server side cookie used by Kyuubi in HTTP mode.|int|1.6.0 -kyuubi.frontend.thrift.http.cookie.path|<undefined>|Path for the Kyuubi generated cookies|string|1.6.0 -kyuubi.frontend.thrift.http.max.idle.time|PT30M|Maximum idle time for a connection on the server when in HTTP mode.|duration|1.6.0 -kyuubi.frontend.thrift.http.path|cliservice|Path component of URL endpoint when in HTTP mode.|string|1.6.0 -kyuubi.frontend.thrift.http.request.header.size|6144|Request header size in bytes, when using HTTP transport mode. Jetty defaults used.|int|1.6.0 -kyuubi.frontend.thrift.http.response.header.size|6144|Response header size in bytes, when using HTTP transport mode. Jetty defaults used.|int|1.6.0 -kyuubi.frontend.thrift.http.ssl.exclude.ciphersuites||A comma separated list of exclude SSL cipher suite names for thrift http frontend.|seq|1.7.0 -kyuubi.frontend.thrift.http.ssl.keystore.password|<undefined>|SSL certificate keystore password.|string|1.6.0 -kyuubi.frontend.thrift.http.ssl.keystore.path|<undefined>|SSL certificate keystore location.|string|1.6.0 -kyuubi.frontend.thrift.http.ssl.protocol.blacklist|SSLv2,SSLv3|SSL Versions to disable when using HTTP transport mode.|seq|1.6.0 -kyuubi.frontend.thrift.http.use.SSL|false|Set this to true for using SSL encryption in http mode.|boolean|1.6.0 -kyuubi.frontend.thrift.http.xsrf.filter.enabled|false|If enabled, Kyuubi will block any requests made to it over http if an X-XSRF-HEADER header is not present|boolean|1.6.0 -kyuubi.frontend.thrift.login.timeout|PT20S|Timeout for Thrift clients during login to the thrift frontend service.|duration|1.4.0 -kyuubi.frontend.thrift.max.message.size|104857600|Maximum message size in bytes a Kyuubi server will accept.|int|1.4.0 -kyuubi.frontend.thrift.max.worker.threads|999|Maximum number of threads in the of frontend worker thread pool for the thrift frontend service|int|1.4.0 -kyuubi.frontend.thrift.min.worker.threads|9|Minimum number of threads in the of frontend worker thread pool for the thrift frontend service|int|1.4.0 -kyuubi.frontend.thrift.worker.keepalive.time|PT1M|Keep-alive time (in milliseconds) for an idle worker thread|duration|1.4.0 -kyuubi.frontend.trino.bind.host|<undefined>|Hostname or IP of the machine on which to run the TRINO frontend service.|string|1.7.0 -kyuubi.frontend.trino.bind.port|10999|Port of the machine on which to run the TRINO frontend service.|int|1.7.0 -kyuubi.frontend.trino.max.worker.threads|999|Maximum number of threads in the of frontend worker thread pool for the trino frontend service|int|1.7.0 -kyuubi.frontend.worker.keepalive.time|PT1M|(deprecated) Keep-alive time (in milliseconds) for an idle worker thread|duration|1.0.0 - +| Key | Default | Meaning | Type | Since | +|--------------------------------------------------------|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.frontend.backoff.slot.length | PT0.1S | (deprecated) Time to back off during login to the thrift frontend service. | duration | 1.0.0 | +| kyuubi.frontend.bind.host | <undefined> | Hostname or IP of the machine on which to run the frontend services. | string | 1.0.0 | +| kyuubi.frontend.bind.port | 10009 | (deprecated) Port of the machine on which to run the thrift frontend service via the binary protocol. | int | 1.0.0 | +| kyuubi.frontend.connection.url.use.hostname | true | When true, frontend services prefer hostname, otherwise, ip address. Note that, the default value is set to `false` when engine running on Kubernetes to prevent potential network issues. | boolean | 1.5.0 | +| kyuubi.frontend.login.timeout | PT20S | (deprecated) Timeout for Thrift clients during login to the thrift frontend service. | duration | 1.0.0 | +| kyuubi.frontend.max.message.size | 104857600 | (deprecated) Maximum message size in bytes a Kyuubi server will accept. | int | 1.0.0 | +| kyuubi.frontend.max.worker.threads | 999 | (deprecated) Maximum number of threads in the frontend worker thread pool for the thrift frontend service | int | 1.0.0 | +| kyuubi.frontend.min.worker.threads | 9 | (deprecated) Minimum number of threads in the frontend worker thread pool for the thrift frontend service | int | 1.0.0 | +| kyuubi.frontend.mysql.bind.host | <undefined> | Hostname or IP of the machine on which to run the MySQL frontend service. | string | 1.4.0 | +| kyuubi.frontend.mysql.bind.port | 3309 | Port of the machine on which to run the MySQL frontend service. | int | 1.4.0 | +| kyuubi.frontend.mysql.max.worker.threads | 999 | Maximum number of threads in the command execution thread pool for the MySQL frontend service | int | 1.4.0 | +| kyuubi.frontend.mysql.min.worker.threads | 9 | Minimum number of threads in the command execution thread pool for the MySQL frontend service | int | 1.4.0 | +| kyuubi.frontend.mysql.netty.worker.threads | <undefined> | Number of thread in the netty worker event loop of MySQL frontend service. Use min(cpu_cores, 8) in default. | int | 1.4.0 | +| kyuubi.frontend.mysql.worker.keepalive.time | PT1M | Time(ms) that an idle async thread of the command execution thread pool will wait for a new task to arrive before terminating in MySQL frontend service | duration | 1.4.0 | +| kyuubi.frontend.protocols | THRIFT_BINARY,REST | A comma-separated list for all frontend protocols
  • THRIFT_BINARY - HiveServer2 compatible thrift binary protocol.
  • THRIFT_HTTP - HiveServer2 compatible thrift http protocol.
  • REST - Kyuubi defined REST API(experimental).
  • MYSQL - MySQL compatible text protocol(experimental).
  • TRINO - Trino compatible http protocol(experimental).
| seq | 1.4.0 | +| kyuubi.frontend.proxy.http.client.ip.header | X-Real-IP | The HTTP header to record the real client IP address. If your server is behind a load balancer or other proxy, the server will see this load balancer or proxy IP address as the client IP address, to get around this common issue, most load balancers or proxies offer the ability to record the real remote IP address in an HTTP header that will be added to the request for other devices to use. Note that, because the header value can be specified to any IP address, so it will not be used for authentication. | string | 1.6.0 | +| kyuubi.frontend.rest.bind.host | <undefined> | Hostname or IP of the machine on which to run the REST frontend service. | string | 1.4.0 | +| kyuubi.frontend.rest.bind.port | 10099 | Port of the machine on which to run the REST frontend service. | int | 1.4.0 | +| kyuubi.frontend.rest.max.worker.threads | 999 | Maximum number of threads in the frontend worker thread pool for the rest frontend service | int | 1.6.2 | +| kyuubi.frontend.ssl.keystore.algorithm | <undefined> | SSL certificate keystore algorithm. | string | 1.7.0 | +| kyuubi.frontend.ssl.keystore.password | <undefined> | SSL certificate keystore password. | string | 1.7.0 | +| kyuubi.frontend.ssl.keystore.path | <undefined> | SSL certificate keystore location. | string | 1.7.0 | +| kyuubi.frontend.ssl.keystore.type | <undefined> | SSL certificate keystore type. | string | 1.7.0 | +| kyuubi.frontend.thrift.backoff.slot.length | PT0.1S | Time to back off during login to the thrift frontend service. | duration | 1.4.0 | +| kyuubi.frontend.thrift.binary.bind.host | <undefined> | Hostname or IP of the machine on which to run the thrift frontend service via the binary protocol. | string | 1.4.0 | +| kyuubi.frontend.thrift.binary.bind.port | 10009 | Port of the machine on which to run the thrift frontend service via the binary protocol. | int | 1.4.0 | +| kyuubi.frontend.thrift.binary.ssl.disallowed.protocols | SSLv2,SSLv3 | SSL versions to disallow for Kyuubi thrift binary frontend. | seq | 1.7.0 | +| kyuubi.frontend.thrift.binary.ssl.enabled | false | Set this to true for using SSL encryption in thrift binary frontend server. | boolean | 1.7.0 | +| kyuubi.frontend.thrift.binary.ssl.include.ciphersuites || A comma-separated list of include SSL cipher suite names for thrift binary frontend. | seq | 1.7.0 | +| kyuubi.frontend.thrift.http.allow.user.substitution | true | Allow alternate user to be specified as part of open connection request when using HTTP transport mode. | boolean | 1.6.0 | +| kyuubi.frontend.thrift.http.bind.host | <undefined> | Hostname or IP of the machine on which to run the thrift frontend service via http protocol. | string | 1.6.0 | +| kyuubi.frontend.thrift.http.bind.port | 10010 | Port of the machine on which to run the thrift frontend service via http protocol. | int | 1.6.0 | +| kyuubi.frontend.thrift.http.compression.enabled | true | Enable thrift http compression via Jetty compression support | boolean | 1.6.0 | +| kyuubi.frontend.thrift.http.cookie.auth.enabled | true | When true, Kyuubi in HTTP transport mode, will use cookie-based authentication mechanism | boolean | 1.6.0 | +| kyuubi.frontend.thrift.http.cookie.domain | <undefined> | Domain for the Kyuubi generated cookies | string | 1.6.0 | +| kyuubi.frontend.thrift.http.cookie.is.httponly | true | HttpOnly attribute of the Kyuubi generated cookie. | boolean | 1.6.0 | +| kyuubi.frontend.thrift.http.cookie.max.age | 86400 | Maximum age in seconds for server side cookie used by Kyuubi in HTTP mode. | int | 1.6.0 | +| kyuubi.frontend.thrift.http.cookie.path | <undefined> | Path for the Kyuubi generated cookies | string | 1.6.0 | +| kyuubi.frontend.thrift.http.max.idle.time | PT30M | Maximum idle time for a connection on the server when in HTTP mode. | duration | 1.6.0 | +| kyuubi.frontend.thrift.http.path | cliservice | Path component of URL endpoint when in HTTP mode. | string | 1.6.0 | +| kyuubi.frontend.thrift.http.request.header.size | 6144 | Request header size in bytes, when using HTTP transport mode. Jetty defaults used. | int | 1.6.0 | +| kyuubi.frontend.thrift.http.response.header.size | 6144 | Response header size in bytes, when using HTTP transport mode. Jetty defaults used. | int | 1.6.0 | +| kyuubi.frontend.thrift.http.ssl.exclude.ciphersuites || A comma-separated list of exclude SSL cipher suite names for thrift http frontend. | seq | 1.7.0 | +| kyuubi.frontend.thrift.http.ssl.keystore.password | <undefined> | SSL certificate keystore password. | string | 1.6.0 | +| kyuubi.frontend.thrift.http.ssl.keystore.path | <undefined> | SSL certificate keystore location. | string | 1.6.0 | +| kyuubi.frontend.thrift.http.ssl.protocol.blacklist | SSLv2,SSLv3 | SSL Versions to disable when using HTTP transport mode. | seq | 1.6.0 | +| kyuubi.frontend.thrift.http.use.SSL | false | Set this to true for using SSL encryption in http mode. | boolean | 1.6.0 | +| kyuubi.frontend.thrift.http.xsrf.filter.enabled | false | If enabled, Kyuubi will block any requests made to it over HTTP if an X-XSRF-HEADER header is not present | boolean | 1.6.0 | +| kyuubi.frontend.thrift.login.timeout | PT20S | Timeout for Thrift clients during login to the thrift frontend service. | duration | 1.4.0 | +| kyuubi.frontend.thrift.max.message.size | 104857600 | Maximum message size in bytes a Kyuubi server will accept. | int | 1.4.0 | +| kyuubi.frontend.thrift.max.worker.threads | 999 | Maximum number of threads in the frontend worker thread pool for the thrift frontend service | int | 1.4.0 | +| kyuubi.frontend.thrift.min.worker.threads | 9 | Minimum number of threads in the frontend worker thread pool for the thrift frontend service | int | 1.4.0 | +| kyuubi.frontend.thrift.worker.keepalive.time | PT1M | Keep-alive time (in milliseconds) for an idle worker thread | duration | 1.4.0 | +| kyuubi.frontend.trino.bind.host | <undefined> | Hostname or IP of the machine on which to run the TRINO frontend service. | string | 1.7.0 | +| kyuubi.frontend.trino.bind.port | 10999 | Port of the machine on which to run the TRINO frontend service. | int | 1.7.0 | +| kyuubi.frontend.trino.max.worker.threads | 999 | Maximum number of threads in the frontend worker thread pool for the Trino frontend service | int | 1.7.0 | +| kyuubi.frontend.worker.keepalive.time | PT1M | (deprecated) Keep-alive time (in milliseconds) for an idle worker thread | duration | 1.0.0 | ### Ha -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.ha.addresses||The connection string for the discovery ensemble|string|1.6.0 -kyuubi.ha.client.class|org.apache.kyuubi.ha.client.zookeeper.ZookeeperDiscoveryClient|Class name for service discovery client.
  • Zookeeper: org.apache.kyuubi.ha.client.zookeeper.ZookeeperDiscoveryClient
  • Etcd: org.apache.kyuubi.ha.client.etcd.EtcdDiscoveryClient
|string|1.6.0 -kyuubi.ha.etcd.lease.timeout|PT10S|Timeout for etcd keep alive lease. The kyuubi server will known unexpected loss of engine after up to this seconds.|duration|1.6.0 -kyuubi.ha.etcd.ssl.ca.path|<undefined>|Where the etcd CA certificate file is stored.|string|1.6.0 -kyuubi.ha.etcd.ssl.client.certificate.path|<undefined>|Where the etcd SSL certificate file is stored.|string|1.6.0 -kyuubi.ha.etcd.ssl.client.key.path|<undefined>|Where the etcd SSL key file is stored.|string|1.6.0 -kyuubi.ha.etcd.ssl.enabled|false|When set to true, will build a ssl secured etcd client.|boolean|1.6.0 -kyuubi.ha.namespace|kyuubi|The root directory for the service to deploy its instance uri|string|1.6.0 -kyuubi.ha.zookeeper.acl.enabled|false|Set to true if the zookeeper ensemble is kerberized|boolean|1.0.0 -kyuubi.ha.zookeeper.auth.digest|<undefined>|The digest auth string is used for zookeeper authentication, like: username:password.|string|1.3.2 -kyuubi.ha.zookeeper.auth.keytab|<undefined>|Location of Kyuubi server's keytab is used for zookeeper authentication.|string|1.3.2 -kyuubi.ha.zookeeper.auth.principal|<undefined>|Name of the Kerberos principal is used for zookeeper authentication.|string|1.3.2 -kyuubi.ha.zookeeper.auth.type|NONE|The type of zookeeper authentication, all candidates are
  • NONE
  • KERBEROS
  • DIGEST
|string|1.3.2 -kyuubi.ha.zookeeper.connection.base.retry.wait|1000|Initial amount of time to wait between retries to the zookeeper ensemble|int|1.0.0 -kyuubi.ha.zookeeper.connection.max.retries|3|Max retry times for connecting to the zookeeper ensemble|int|1.0.0 -kyuubi.ha.zookeeper.connection.max.retry.wait|30000|Max amount of time to wait between retries for BOUNDED_EXPONENTIAL_BACKOFF policy can reach, or max time until elapsed for UNTIL_ELAPSED policy to connect the zookeeper ensemble|int|1.0.0 -kyuubi.ha.zookeeper.connection.retry.policy|EXPONENTIAL_BACKOFF|The retry policy for connecting to the zookeeper ensemble, all candidates are:
  • ONE_TIME
  • N_TIME
  • EXPONENTIAL_BACKOFF
  • BOUNDED_EXPONENTIAL_BACKOFF
  • UNTIL_ELAPSED
|string|1.0.0 -kyuubi.ha.zookeeper.connection.timeout|15000|The timeout(ms) of creating the connection to the zookeeper ensemble|int|1.0.0 -kyuubi.ha.zookeeper.engine.auth.type|NONE|The type of zookeeper authentication for engine, all candidates are
  • NONE
  • KERBEROS
  • DIGEST
|string|1.3.2 -kyuubi.ha.zookeeper.namespace|kyuubi|(deprecated) The root directory for the service to deploy its instance uri|string|1.0.0 -kyuubi.ha.zookeeper.node.creation.timeout|PT2M|Timeout for creating zookeeper node|duration|1.2.0 -kyuubi.ha.zookeeper.publish.configs|false|When set to true, publish Kerberos configs to Zookeeper.Note that the Hive driver needs to be greater than 1.3 or 2.0 or apply HIVE-11581 patch.|boolean|1.4.0 -kyuubi.ha.zookeeper.quorum||(deprecated) The connection string for the zookeeper ensemble|string|1.0.0 -kyuubi.ha.zookeeper.session.timeout|60000|The timeout(ms) of a connected session to be idled|int|1.0.0 - +| Key | Default | Meaning | Type | Since | +|------------------------------------------------|----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.ha.addresses || The connection string for the discovery ensemble | string | 1.6.0 | +| kyuubi.ha.client.class | org.apache.kyuubi.ha.client.zookeeper.ZookeeperDiscoveryClient | Class name for service discovery client.
  • Zookeeper: org.apache.kyuubi.ha.client.zookeeper.ZookeeperDiscoveryClient
  • Etcd: org.apache.kyuubi.ha.client.etcd.EtcdDiscoveryClient
| string | 1.6.0 | +| kyuubi.ha.etcd.lease.timeout | PT10S | Timeout for etcd keep alive lease. The kyuubi server will know the unexpected loss of engine after up to this seconds. | duration | 1.6.0 | +| kyuubi.ha.etcd.ssl.ca.path | <undefined> | Where the etcd CA certificate file is stored. | string | 1.6.0 | +| kyuubi.ha.etcd.ssl.client.certificate.path | <undefined> | Where the etcd SSL certificate file is stored. | string | 1.6.0 | +| kyuubi.ha.etcd.ssl.client.key.path | <undefined> | Where the etcd SSL key file is stored. | string | 1.6.0 | +| kyuubi.ha.etcd.ssl.enabled | false | When set to true, will build an SSL secured etcd client. | boolean | 1.6.0 | +| kyuubi.ha.namespace | kyuubi | The root directory for the service to deploy its instance uri | string | 1.6.0 | +| kyuubi.ha.zookeeper.acl.enabled | false | Set to true if the ZooKeeper ensemble is kerberized | boolean | 1.0.0 | +| kyuubi.ha.zookeeper.auth.digest | <undefined> | The digest auth string is used for ZooKeeper authentication, like: username:password. | string | 1.3.2 | +| kyuubi.ha.zookeeper.auth.keytab | <undefined> | Location of the Kyuubi server's keytab is used for ZooKeeper authentication. | string | 1.3.2 | +| kyuubi.ha.zookeeper.auth.principal | <undefined> | Name of the Kerberos principal is used for ZooKeeper authentication. | string | 1.3.2 | +| kyuubi.ha.zookeeper.auth.type | NONE | The type of ZooKeeper authentication, all candidates are
  • NONE
  • KERBEROS
  • DIGEST
| string | 1.3.2 | +| kyuubi.ha.zookeeper.connection.base.retry.wait | 1000 | Initial amount of time to wait between retries to the ZooKeeper ensemble | int | 1.0.0 | +| kyuubi.ha.zookeeper.connection.max.retries | 3 | Max retry times for connecting to the ZooKeeper ensemble | int | 1.0.0 | +| kyuubi.ha.zookeeper.connection.max.retry.wait | 30000 | Max amount of time to wait between retries for BOUNDED_EXPONENTIAL_BACKOFF policy can reach, or max time until elapsed for UNTIL_ELAPSED policy to connect the zookeeper ensemble | int | 1.0.0 | +| kyuubi.ha.zookeeper.connection.retry.policy | EXPONENTIAL_BACKOFF | The retry policy for connecting to the ZooKeeper ensemble, all candidates are:
  • ONE_TIME
  • N_TIME
  • EXPONENTIAL_BACKOFF
  • BOUNDED_EXPONENTIAL_BACKOFF
  • UNTIL_ELAPSED
| string | 1.0.0 | +| kyuubi.ha.zookeeper.connection.timeout | 15000 | The timeout(ms) of creating the connection to the ZooKeeper ensemble | int | 1.0.0 | +| kyuubi.ha.zookeeper.engine.auth.type | NONE | The type of ZooKeeper authentication for the engine, all candidates are
  • NONE
  • KERBEROS
  • DIGEST
| string | 1.3.2 | +| kyuubi.ha.zookeeper.namespace | kyuubi | (deprecated) The root directory for the service to deploy its instance uri | string | 1.0.0 | +| kyuubi.ha.zookeeper.node.creation.timeout | PT2M | Timeout for creating ZooKeeper node | duration | 1.2.0 | +| kyuubi.ha.zookeeper.publish.configs | false | When set to true, publish Kerberos configs to Zookeeper. Note that the Hive driver needs to be greater than 1.3 or 2.0 or apply HIVE-11581 patch. | boolean | 1.4.0 | +| kyuubi.ha.zookeeper.quorum || (deprecated) The connection string for the ZooKeeper ensemble | string | 1.0.0 | +| kyuubi.ha.zookeeper.session.timeout | 60000 | The timeout(ms) of a connected session to be idled | int | 1.0.0 | ### Kinit -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.kinit.interval|PT1H|How often will Kyuubi server run `kinit -kt [keytab] [principal]` to renew the local Kerberos credentials cache|duration|1.0.0 -kyuubi.kinit.keytab|<undefined>|Location of Kyuubi server's keytab.|string|1.0.0 -kyuubi.kinit.max.attempts|10|How many times will `kinit` process retry|int|1.0.0 -kyuubi.kinit.principal|<undefined>|Name of the Kerberos principal.|string|1.0.0 - +| Key | Default | Meaning | Type | Since | +|---------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.kinit.interval | PT1H | How often will the Kyuubi server run `kinit -kt [keytab] [principal]` to renew the local Kerberos credentials cache | duration | 1.0.0 | +| kyuubi.kinit.keytab | <undefined> | Location of Kyuubi server's keytab. | string | 1.0.0 | +| kyuubi.kinit.max.attempts | 10 | How many times will `kinit` process retry | int | 1.0.0 | +| kyuubi.kinit.principal | <undefined> | Name of the Kerberos principal. | string | 1.0.0 | ### Kubernetes -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.kubernetes.authenticate.caCertFile|<undefined>|Path to the CA cert file for connecting to the Kubernetes API server over TLS from the kyuubi. Specify this as a path as opposed to a URI (i.e. do not provide a scheme)|string|1.7.0 -kyuubi.kubernetes.authenticate.clientCertFile|<undefined>|Path to the client cert file for connecting to the Kubernetes API server over TLS from the kyuubi. Specify this as a path as opposed to a URI (i.e. do not provide a scheme)|string|1.7.0 -kyuubi.kubernetes.authenticate.clientKeyFile|<undefined>|Path to the client key file for connecting to the Kubernetes API server over TLS from the kyuubi. Specify this as a path as opposed to a URI (i.e. do not provide a scheme)|string|1.7.0 -kyuubi.kubernetes.authenticate.oauthToken|<undefined>|The OAuth token to use when authenticating against the Kubernetes API server. Note that unlike the other authentication options, this must be the exact string value of the token to use for the authentication.|string|1.7.0 -kyuubi.kubernetes.authenticate.oauthTokenFile|<undefined>|Path to the file containing the OAuth token to use when authenticating against the Kubernetes API server. Specify this as a path as opposed to a URI (i.e. do not provide a scheme)|string|1.7.0 -kyuubi.kubernetes.context|<undefined>|The desired context from your kubernetes config file used to configure the K8S client for interacting with the cluster.|string|1.6.0 -kyuubi.kubernetes.master.address|<undefined>|The internal Kubernetes master (API server) address to be used for kyuubi.|string|1.7.0 -kyuubi.kubernetes.namespace|default|The namespace that will be used for running the kyuubi pods and find engines.|string|1.7.0 -kyuubi.kubernetes.trust.certificates|false|If set to true then client can submit to kubernetes cluster only with token|boolean|1.7.0 - +| Key | Default | Meaning | Type | Since | +|-----------------------------------------------------|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.kubernetes.authenticate.caCertFile | <undefined> | Path to the CA cert file for connecting to the Kubernetes API server over TLS from the kyuubi. Specify this as a path as opposed to a URI (i.e. do not provide a scheme) | string | 1.7.0 | +| kyuubi.kubernetes.authenticate.clientCertFile | <undefined> | Path to the client cert file for connecting to the Kubernetes API server over TLS from the kyuubi. Specify this as a path as opposed to a URI (i.e. do not provide a scheme) | string | 1.7.0 | +| kyuubi.kubernetes.authenticate.clientKeyFile | <undefined> | Path to the client key file for connecting to the Kubernetes API server over TLS from the kyuubi. Specify this as a path as opposed to a URI (i.e. do not provide a scheme) | string | 1.7.0 | +| kyuubi.kubernetes.authenticate.oauthToken | <undefined> | The OAuth token to use when authenticating against the Kubernetes API server. Note that unlike, the other authentication options, this must be the exact string value of the token to use for the authentication. | string | 1.7.0 | +| kyuubi.kubernetes.authenticate.oauthTokenFile | <undefined> | Path to the file containing the OAuth token to use when authenticating against the Kubernetes API server. Specify this as a path as opposed to a URI (i.e. do not provide a scheme) | string | 1.7.0 | +| kyuubi.kubernetes.context | <undefined> | The desired context from your kubernetes config file used to configure the K8s client for interacting with the cluster. | string | 1.6.0 | +| kyuubi.kubernetes.master.address | <undefined> | The internal Kubernetes master (API server) address to be used for kyuubi. | string | 1.7.0 | +| kyuubi.kubernetes.namespace | default | The namespace that will be used for running the kyuubi pods and find engines. | string | 1.7.0 | +| kyuubi.kubernetes.terminatedApplicationRetainPeriod | PT5M | The period for which the Kyuubi server retains application information after the application terminates. | duration | 1.7.1 | +| kyuubi.kubernetes.trust.certificates | false | If set to true then client can submit to kubernetes cluster only with token | boolean | 1.7.0 | ### Metadata -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.metadata.cleaner.enabled|true|Whether to clean the metadata periodically. If it is enabled, Kyuubi will clean the metadata that is in terminate state with max age limitation.|boolean|1.6.0 -kyuubi.metadata.cleaner.interval|PT30M|The interval to check and clean expired metadata.|duration|1.6.0 -kyuubi.metadata.max.age|PT72H|The maximum age of metadata, the metadata that exceeds the age will be cleaned.|duration|1.6.0 -kyuubi.metadata.recovery.threads|10|The number of threads for recovery from metadata store when Kyuubi server restarting.|int|1.6.0 -kyuubi.metadata.request.retry.interval|PT5S|The interval to check and trigger the metadata request retry tasks.|duration|1.6.0 -kyuubi.metadata.request.retry.queue.size|65536|The maximum queue size for buffering metadata requests in memory when the external metadata storage is down. Requests will be dropped if the queue exceeds.|int|1.6.0 -kyuubi.metadata.request.retry.threads|10|Number of threads in the metadata request retry manager thread pool. The metadata store might be unavailable sometimes and the requests will fail, to tolerant for this case and unblock the main thread, we support to retry the failed requests in async way.|int|1.6.0 -kyuubi.metadata.store.class|org.apache.kyuubi.server.metadata.jdbc.JDBCMetadataStore|Fully qualified class name for server metadata store.|string|1.6.0 -kyuubi.metadata.store.jdbc.database.schema.init|true|Whether to init the jdbc metadata store database schema.|boolean|1.6.0 -kyuubi.metadata.store.jdbc.database.type|DERBY|The database type for server jdbc metadata store.
  • DERBY: Apache Derby, jdbc driver `org.apache.derby.jdbc.AutoloadedDriver`.
  • MYSQL: MySQL, jdbc driver `com.mysql.jdbc.Driver`.
  • CUSTOM: User-defined database type, need to specify corresponding jdbc driver.
  • Note that: The jdbc datasource is powered by HiKariCP, for datasource properties, please specify them with prefix: kyuubi.metadata.store.jdbc.datasource. For example, kyuubi.metadata.store.jdbc.datasource.connectionTimeout=10000.|string|1.6.0 -kyuubi.metadata.store.jdbc.driver|<undefined>|JDBC driver class name for server jdbc metadata store.|string|1.6.0 -kyuubi.metadata.store.jdbc.password||The password for server jdbc metadata store.|string|1.6.0 -kyuubi.metadata.store.jdbc.url|jdbc:derby:memory:kyuubi_state_store_db;create=true|The jdbc url for server jdbc metadata store. By defaults, it is a DERBY in-memory database url, and the state information is not shared across kyuubi instances. To enable multiple kyuubi instances high available, please specify a production jdbc url.|string|1.6.0 -kyuubi.metadata.store.jdbc.user||The username for server jdbc metadata store.|string|1.6.0 - +| Key | Default | Meaning | Type | Since | +|-------------------------------------------------|----------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.metadata.cleaner.enabled | true | Whether to clean the metadata periodically. If it is enabled, Kyuubi will clean the metadata that is in the terminate state with max age limitation. | boolean | 1.6.0 | +| kyuubi.metadata.cleaner.interval | PT30M | The interval to check and clean expired metadata. | duration | 1.6.0 | +| kyuubi.metadata.max.age | PT72H | The maximum age of metadata, the metadata exceeding the age will be cleaned. | duration | 1.6.0 | +| kyuubi.metadata.recovery.threads | 10 | The number of threads for recovery from the metadata store when the Kyuubi server restarts. | int | 1.6.0 | +| kyuubi.metadata.request.async.retry.enabled | true | Whether to retry in async when metadata request failed. When true, return success response immediately even the metadata request failed, and schedule it in background until success, to tolerate long-time metadata store outages w/o blocking the submission request. | boolean | 1.7.0 | +| kyuubi.metadata.request.async.retry.queue.size | 65536 | The maximum queue size for buffering metadata requests in memory when the external metadata storage is down. Requests will be dropped if the queue exceeds. Only take affect when kyuubi.metadata.request.async.retry.enabled is `true`. | int | 1.6.0 | +| kyuubi.metadata.request.async.retry.threads | 10 | Number of threads in the metadata request async retry manager thread pool. Only take affect when kyuubi.metadata.request.async.retry.enabled is `true`. | int | 1.6.0 | +| kyuubi.metadata.request.retry.interval | PT5S | The interval to check and trigger the metadata request retry tasks. | duration | 1.6.0 | +| kyuubi.metadata.store.class | org.apache.kyuubi.server.metadata.jdbc.JDBCMetadataStore | Fully qualified class name for server metadata store. | string | 1.6.0 | +| kyuubi.metadata.store.jdbc.database.schema.init | true | Whether to init the JDBC metadata store database schema. | boolean | 1.6.0 | +| kyuubi.metadata.store.jdbc.database.type | DERBY | The database type for server jdbc metadata store.
    • DERBY: Apache Derby, JDBC driver `org.apache.derby.jdbc.AutoloadedDriver`.
    • MYSQL: MySQL, JDBC driver `com.mysql.jdbc.Driver`.
    • CUSTOM: User-defined database type, need to specify corresponding JDBC driver.
    • Note that: The JDBC datasource is powered by HiKariCP, for datasource properties, please specify them with the prefix: kyuubi.metadata.store.jdbc.datasource. For example, kyuubi.metadata.store.jdbc.datasource.connectionTimeout=10000. | string | 1.6.0 | +| kyuubi.metadata.store.jdbc.driver | <undefined> | JDBC driver class name for server jdbc metadata store. | string | 1.6.0 | +| kyuubi.metadata.store.jdbc.password || The password for server JDBC metadata store. | string | 1.6.0 | +| kyuubi.metadata.store.jdbc.url | jdbc:derby:memory:kyuubi_state_store_db;create=true | The JDBC url for server JDBC metadata store. By default, it is a DERBY in-memory database url, and the state information is not shared across kyuubi instances. To enable high availability for multiple kyuubi instances, please specify a production JDBC url. | string | 1.6.0 | +| kyuubi.metadata.store.jdbc.user || The username for server JDBC metadata store. | string | 1.6.0 | ### Metrics -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.metrics.console.interval|PT5S|How often should report metrics to console|duration|1.2.0 -kyuubi.metrics.enabled|true|Set to true to enable kyuubi metrics system|boolean|1.2.0 -kyuubi.metrics.json.interval|PT5S|How often should report metrics to json file|duration|1.2.0 -kyuubi.metrics.json.location|metrics|Where the json metrics file located|string|1.2.0 -kyuubi.metrics.prometheus.path|/metrics|URI context path of prometheus metrics HTTP server|string|1.2.0 -kyuubi.metrics.prometheus.port|10019|Prometheus metrics HTTP server port|int|1.2.0 -kyuubi.metrics.reporters|JSON|A comma separated list for all metrics reporters
      • CONSOLE - ConsoleReporter which outputs measurements to CONSOLE periodically.
      • JMX - JmxReporter which listens for new metrics and exposes them as MBeans.
      • JSON - JsonReporter which outputs measurements to json file periodically.
      • PROMETHEUS - PrometheusReporter which exposes metrics in prometheus format.
      • SLF4J - Slf4jReporter which outputs measurements to system log periodically.
      |seq|1.2.0 -kyuubi.metrics.slf4j.interval|PT5S|How often should report metrics to SLF4J logger|duration|1.2.0 - +| Key | Default | Meaning | Type | Since | +|---------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.metrics.console.interval | PT5S | How often should report metrics to console | duration | 1.2.0 | +| kyuubi.metrics.enabled | true | Set to true to enable kyuubi metrics system | boolean | 1.2.0 | +| kyuubi.metrics.json.interval | PT5S | How often should report metrics to JSON file | duration | 1.2.0 | +| kyuubi.metrics.json.location | metrics | Where the JSON metrics file located | string | 1.2.0 | +| kyuubi.metrics.prometheus.path | /metrics | URI context path of prometheus metrics HTTP server | string | 1.2.0 | +| kyuubi.metrics.prometheus.port | 10019 | Prometheus metrics HTTP server port | int | 1.2.0 | +| kyuubi.metrics.reporters | JSON | A comma-separated list for all metrics reporters
      • CONSOLE - ConsoleReporter which outputs measurements to CONSOLE periodically.
      • JMX - JmxReporter which listens for new metrics and exposes them as MBeans.
      • JSON - JsonReporter which outputs measurements to json file periodically.
      • PROMETHEUS - PrometheusReporter which exposes metrics in Prometheus format.
      • SLF4J - Slf4jReporter which outputs measurements to system log periodically.
      | seq | 1.2.0 | +| kyuubi.metrics.slf4j.interval | PT5S | How often should report metrics to SLF4J logger | duration | 1.2.0 | ### Operation -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.operation.idle.timeout|PT3H|Operation will be closed when it's not accessed for this duration of time|duration|1.0.0 -kyuubi.operation.interrupt.on.cancel|true|When true, all running tasks will be interrupted if one cancels a query. When false, all running tasks will remain until finished.|boolean|1.2.0 -kyuubi.operation.language|SQL|Choose a programing language for the following inputs
      • SQL: (Default) Run all following statements as SQL queries.
      • SCALA: Run all following input a scala codes
      |string|1.5.0 -kyuubi.operation.log.dir.root|server_operation_logs|Root directory for query operation log at server-side.|string|1.4.0 -kyuubi.operation.plan.only.excludes|ResetCommand,SetCommand,SetNamespaceCommand,UseStatement,SetCatalogAndNamespace|Comma-separated list of query plan names, in the form of simple class names, i.e, for `set abc=xyz`, the value will be `SetCommand`. For those auxiliary plans, such as `switch databases`, `set properties`, or `create temporary view` e.t.c, which are used for setup evaluating environments for analyzing actual queries, we can use this config to exclude them and let them take effect. See also kyuubi.operation.plan.only.mode.|seq|1.5.0 -kyuubi.operation.plan.only.mode|none|Configures the statement performed mode, The value can be 'parse', 'analyze', 'optimize', 'optimize_with_stats', 'physical', 'execution', or 'none', when it is 'none', indicate to the statement will be fully executed, otherwise only way without executing the query. different engines currently support different modes, the Spark engine supports all modes, and the Flink engine supports 'parse', 'physical', and 'execution', other engines do not support planOnly currently.|string|1.4.0 -kyuubi.operation.plan.only.output.style|plain|Configures the planOnly output style, The value can be 'plain' and 'json', default value is 'plain', this configuration supports only the output styles of the Spark engine|string|1.7.0 -kyuubi.operation.progress.enabled|false|Whether to enable the operation progress. When true, the operation progress will be returned in `GetOperationStatus`.|boolean|1.6.0 -kyuubi.operation.query.timeout|<undefined>|Timeout for query executions at server-side, take affect with client-side timeout(`java.sql.Statement.setQueryTimeout`) together, a running query will be cancelled automatically if timeout. It's off by default, which means only client-side take fully control whether the query should timeout or not. If set, client-side timeout capped at this point. To cancel the queries right away without waiting task to finish, consider enabling kyuubi.operation.interrupt.on.cancel together.|duration|1.2.0 -kyuubi.operation.result.format|thrift|Specify the result format, available configs are:
      • THRIFT: the result will convert to TRow at the engine driver side.
      • ARROW: the result will be encoded as Arrow at the executor side before collecting by the driver, and deserialized at the client side. note that it only takes effect for kyuubi-hive-jdbc clients now.
      |string|1.7.0 -kyuubi.operation.result.max.rows|0|Max rows of Spark query results. Rows that exceeds the limit would be ignored. By setting this value to 0 to disable the max rows limit.|int|1.6.0 -kyuubi.operation.scheduler.pool|<undefined>|The scheduler pool of job. Note that, this config should be used after change Spark config spark.scheduler.mode=FAIR.|string|1.1.1 -kyuubi.operation.spark.listener.enabled|true|When set to true, Spark engine registers a SQLOperationListener before executing the statement, logs a few summary statistics when each stage completes.|boolean|1.6.0 -kyuubi.operation.status.polling.timeout|PT5S|Timeout(ms) for long polling asynchronous running sql query's status|duration|1.0.0 - +| Key | Default | Meaning | Type | Since | +|--------------------------------------------------|---------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.operation.getTables.ignoreTableProperties | false | Speed up the `GetTables` operation by returning table identities only. | boolean | 1.8.0 | +| kyuubi.operation.idle.timeout | PT3H | Operation will be closed when it's not accessed for this duration of time | duration | 1.0.0 | +| kyuubi.operation.interrupt.on.cancel | true | When true, all running tasks will be interrupted if one cancels a query. When false, all running tasks will remain until finished. | boolean | 1.2.0 | +| kyuubi.operation.language | SQL | Choose a programing language for the following inputs
      • SQL: (Default) Run all following statements as SQL queries.
      • SCALA: Run all following input as scala codes
      • PYTHON: (Experimental) Run all following input as Python codes with Spark engine
      | string | 1.5.0 | +| kyuubi.operation.log.dir.root | server_operation_logs | Root directory for query operation log at server-side. | string | 1.4.0 | +| kyuubi.operation.plan.only.excludes | ResetCommand,SetCommand,SetNamespaceCommand,UseStatement,SetCatalogAndNamespace | Comma-separated list of query plan names, in the form of simple class names, i.e, for `SET abc=xyz`, the value will be `SetCommand`. For those auxiliary plans, such as `switch databases`, `set properties`, or `create temporary view` etc., which are used for setup evaluating environments for analyzing actual queries, we can use this config to exclude them and let them take effect. See also kyuubi.operation.plan.only.mode. | seq | 1.5.0 | +| kyuubi.operation.plan.only.mode | none | Configures the statement performed mode, The value can be 'parse', 'analyze', 'optimize', 'optimize_with_stats', 'physical', 'execution', or 'none', when it is 'none', indicate to the statement will be fully executed, otherwise only way without executing the query. different engines currently support different modes, the Spark engine supports all modes, and the Flink engine supports 'parse', 'physical', and 'execution', other engines do not support planOnly currently. | string | 1.4.0 | +| kyuubi.operation.plan.only.output.style | plain | Configures the planOnly output style. The value can be 'plain' or 'json', and the default value is 'plain'. This configuration supports only the output styles of the Spark engine | string | 1.7.0 | +| kyuubi.operation.progress.enabled | false | Whether to enable the operation progress. When true, the operation progress will be returned in `GetOperationStatus`. | boolean | 1.6.0 | +| kyuubi.operation.query.timeout | <undefined> | Timeout for query executions at server-side, take effect with client-side timeout(`java.sql.Statement.setQueryTimeout`) together, a running query will be cancelled automatically if timeout. It's off by default, which means only client-side take full control of whether the query should timeout or not. If set, client-side timeout is capped at this point. To cancel the queries right away without waiting for task to finish, consider enabling kyuubi.operation.interrupt.on.cancel together. | duration | 1.2.0 | +| kyuubi.operation.result.arrow.timestampAsString | false | When true, arrow-based rowsets will convert columns of type timestamp to strings for transmission. | boolean | 1.7.0 | +| kyuubi.operation.result.format | thrift | Specify the result format, available configs are:
      • THRIFT: the result will convert to TRow at the engine driver side.
      • ARROW: the result will be encoded as Arrow at the executor side before collecting by the driver, and deserialized at the client side. note that it only takes effect for kyuubi-hive-jdbc clients now.
      | string | 1.7.0 | +| kyuubi.operation.result.max.rows | 0 | Max rows of Spark query results. Rows exceeding the limit would be ignored. By setting this value to 0 to disable the max rows limit. | int | 1.6.0 | +| kyuubi.operation.scheduler.pool | <undefined> | The scheduler pool of job. Note that, this config should be used after changing Spark config spark.scheduler.mode=FAIR. | string | 1.1.1 | +| kyuubi.operation.spark.listener.enabled | true | When set to true, Spark engine registers an SQLOperationListener before executing the statement, logging a few summary statistics when each stage completes. | boolean | 1.6.0 | +| kyuubi.operation.status.polling.timeout | PT5S | Timeout(ms) for long polling asynchronous running sql query's status | duration | 1.0.0 | ### Server -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.server.batch.limit.connections.per.ipaddress|<undefined>|Maximum kyuubi server batch connections per ipaddress. Any user exceeding this limit will not be allowed to connect.|int|1.7.0 -kyuubi.server.batch.limit.connections.per.user|<undefined>|Maximum kyuubi server batch connections per user. Any user exceeding this limit will not be allowed to connect.|int|1.7.0 -kyuubi.server.batch.limit.connections.per.user.ipaddress|<undefined>|Maximum kyuubi server batch connections per user:ipaddress combination. Any user-ipaddress exceeding this limit will not be allowed to connect.|int|1.7.0 -kyuubi.server.info.provider|ENGINE|The server information provider name, some clients may rely on this information to check the server compatibilities and functionalities.
    • SERVER: Return Kyuubi server information.
    • ENGINE: Return Kyuubi engine information.
    • |string|1.6.1 -kyuubi.server.limit.connections.per.ipaddress|<undefined>|Maximum kyuubi server connections per ipaddress. Any user exceeding this limit will not be allowed to connect.|int|1.6.0 -kyuubi.server.limit.connections.per.user|<undefined>|Maximum kyuubi server connections per user. Any user exceeding this limit will not be allowed to connect.|int|1.6.0 -kyuubi.server.limit.connections.per.user.ipaddress|<undefined>|Maximum kyuubi server connections per user:ipaddress combination. Any user-ipaddress exceeding this limit will not be allowed to connect.|int|1.6.0 -kyuubi.server.limit.connections.user.unlimited.list||The maximin connections of the user in the white list will not be limited.|seq|1.7.0 -kyuubi.server.name|<undefined>|The name of Kyuubi Server.|string|1.5.0 -kyuubi.server.redaction.regex|<undefined>|Regex to decide which Kyuubi contain sensitive information. When this regex matches a property key or value, the value is redacted from the various logs.||1.6.0 - +| Key | Default | Meaning | Type | Since | +|----------------------------------------------------------|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.server.administrators || Comma-separated list of Kyuubi service administrators. We use this config to grant admin permission to any service accounts. | seq | 1.8.0 | +| kyuubi.server.info.provider | ENGINE | The server information provider name, some clients may rely on this information to check the server compatibilities and functionalities.
    • SERVER: Return Kyuubi server information.
    • ENGINE: Return Kyuubi engine information.
    • | string | 1.6.1 | +| kyuubi.server.limit.batch.connections.per.ipaddress | <undefined> | Maximum kyuubi server batch connections per ipaddress. Any user exceeding this limit will not be allowed to connect. | int | 1.7.0 | +| kyuubi.server.limit.batch.connections.per.user | <undefined> | Maximum kyuubi server batch connections per user. Any user exceeding this limit will not be allowed to connect. | int | 1.7.0 | +| kyuubi.server.limit.batch.connections.per.user.ipaddress | <undefined> | Maximum kyuubi server batch connections per user:ipaddress combination. Any user-ipaddress exceeding this limit will not be allowed to connect. | int | 1.7.0 | +| kyuubi.server.limit.client.fetch.max.rows | <undefined> | Max rows limit for getting result row set operation. If the max rows specified by client-side is larger than the limit, request will fail directly. | int | 1.8.0 | +| kyuubi.server.limit.connections.per.ipaddress | <undefined> | Maximum kyuubi server connections per ipaddress. Any user exceeding this limit will not be allowed to connect. | int | 1.6.0 | +| kyuubi.server.limit.connections.per.user | <undefined> | Maximum kyuubi server connections per user. Any user exceeding this limit will not be allowed to connect. | int | 1.6.0 | +| kyuubi.server.limit.connections.per.user.ipaddress | <undefined> | Maximum kyuubi server connections per user:ipaddress combination. Any user-ipaddress exceeding this limit will not be allowed to connect. | int | 1.6.0 | +| kyuubi.server.limit.connections.user.unlimited.list || The maximum connections of the user in the white list will not be limited. | seq | 1.7.0 | +| kyuubi.server.name | <undefined> | The name of Kyuubi Server. | string | 1.5.0 | +| kyuubi.server.periodicGC.interval | PT30M | How often to trigger a garbage collection. | duration | 1.7.0 | +| kyuubi.server.redaction.regex | <undefined> | Regex to decide which Kyuubi contain sensitive information. When this regex matches a property key or value, the value is redacted from the various logs. || 1.6.0 | ### Session -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.session.check.interval|PT5M|The check interval for session timeout.|duration|1.0.0 -kyuubi.session.conf.advisor|<undefined>|A config advisor plugin for Kyuubi Server. This plugin can provide some custom configs for different user or session configs and overwrite the session configs before open a new session. This config value should be a class which is a child of 'org.apache.kyuubi.plugin.SessionConfAdvisor' which has zero-arg constructor.|string|1.5.0 -kyuubi.session.conf.file.reload.interval|PT10M|When `FileSessionConfAdvisor` is used, this configuration defines the expired time of `$KYUUBI_CONF_DIR/kyuubi-session-.conf` in the cache. After exceeding this value, the file will be reloaded.|duration|1.7.0 -kyuubi.session.conf.ignore.list||A comma separated list of ignored keys. If the client connection contains any of them, the key and the corresponding value will be removed silently during engine bootstrap and connection setup. Note that this rule is for server-side protection defined via administrators to prevent some essential configs from tampering but will not forbid users to set dynamic configurations via SET syntax.|seq|1.2.0 -kyuubi.session.conf.profile|<undefined>|Specify a profile to load session-level configurations from `$KYUUBI_CONF_DIR/kyuubi-session-.conf`. This configuration will be ignored if the file does not exist. This configuration only has effect when `kyuubi.session.conf.advisor` is set as `org.apache.kyuubi.session.FileSessionConfAdvisor`.|string|1.7.0 -kyuubi.session.conf.restrict.list||A comma separated list of restricted keys. If the client connection contains any of them, the connection will be rejected explicitly during engine bootstrap and connection setup. Note that this rule is for server-side protection defined via administrators to prevent some essential configs from tampering but will not forbid users to set dynamic configurations via SET syntax.|seq|1.2.0 -kyuubi.session.engine.alive.probe.enabled|false|Whether to enable the engine alive probe, it true, we will create a companion thrift client that sends simple request to check whether the engine is keep alive.|boolean|1.6.0 -kyuubi.session.engine.alive.probe.interval|PT10S|The interval for engine alive probe.|duration|1.6.0 -kyuubi.session.engine.alive.timeout|PT2M|The timeout for engine alive. If there is no alive probe success in the last timeout window, the engine will be marked as no-alive.|duration|1.6.0 -kyuubi.session.engine.check.interval|PT1M|The check interval for engine timeout|duration|1.0.0 -kyuubi.session.engine.flink.main.resource|<undefined>|The package used to create Flink SQL engine remote job. If it is undefined, Kyuubi will use the default|string|1.4.0 -kyuubi.session.engine.flink.max.rows|1000000|Max rows of Flink query results. For batch queries, rows that exceeds the limit would be ignored. For streaming queries, the query would be canceled if the limit is reached.|int|1.5.0 -kyuubi.session.engine.hive.main.resource|<undefined>|The package used to create Hive engine remote job. If it is undefined, Kyuubi will use the default|string|1.6.0 -kyuubi.session.engine.idle.timeout|PT30M|engine timeout, the engine will self-terminate when it's not accessed for this duration. 0 or negative means not to self-terminate.|duration|1.0.0 -kyuubi.session.engine.initialize.timeout|PT3M|Timeout for starting the background engine, e.g. SparkSQLEngine.|duration|1.0.0 -kyuubi.session.engine.launch.async|true|When opening kyuubi session, whether to launch backend engine asynchronously. When true, the Kyuubi server will set up the connection with the client without delay as the backend engine will be created asynchronously.|boolean|1.4.0 -kyuubi.session.engine.log.timeout|PT24H|If we use Spark as the engine then the session submit log is the console output of spark-submit. We will retain the session submit log until over the config value.|duration|1.1.0 -kyuubi.session.engine.login.timeout|PT15S|The timeout of creating the connection to remote sql query engine|duration|1.0.0 -kyuubi.session.engine.open.max.attempts|9|The number of times an open engine will retry when encountering a special error.|int|1.7.0 -kyuubi.session.engine.open.retry.wait|PT10S|How long to wait before retrying to open engine after a failure.|duration|1.7.0 -kyuubi.session.engine.share.level|USER|(deprecated) - Using kyuubi.engine.share.level instead|string|1.0.0 -kyuubi.session.engine.spark.main.resource|<undefined>|The package used to create Spark SQL engine remote application. If it is undefined, Kyuubi will use the default|string|1.0.0 -kyuubi.session.engine.spark.max.lifetime|PT0S|Max lifetime for spark engine, the engine will self-terminate when it reaches the end of life. 0 or negative means not to self-terminate.|duration|1.6.0 -kyuubi.session.engine.spark.progress.timeFormat|yyyy-MM-dd HH:mm:ss.SSS|The time format of the progress bar|string|1.6.0 -kyuubi.session.engine.spark.progress.update.interval|PT1S|Update period of progress bar.|duration|1.6.0 -kyuubi.session.engine.spark.showProgress|false|When true, show the progress bar in the spark engine log.|boolean|1.6.0 -kyuubi.session.engine.startup.error.max.size|8192|During engine bootstrapping, if error occurs, using this config to limit the length error message(characters).|int|1.1.0 -kyuubi.session.engine.startup.maxLogLines|10|The maximum number of engine log lines when errors occur during engine startup phase. Note that this max lines is for client-side to help track engine startup issue.|int|1.4.0 -kyuubi.session.engine.startup.waitCompletion|true|Whether to wait for completion after engine starts. If false, the startup process will be destroyed after the engine is started. Note that only use it when the driver is not running locally, such as yarn-cluster mode; Otherwise, the engine will be killed.|boolean|1.5.0 -kyuubi.session.engine.trino.connection.catalog|<undefined>|The default catalog that trino engine will connect to|string|1.5.0 -kyuubi.session.engine.trino.connection.url|<undefined>|The server url that trino engine will connect to|string|1.5.0 -kyuubi.session.engine.trino.main.resource|<undefined>|The package used to create Trino engine remote job. If it is undefined, Kyuubi will use the default|string|1.5.0 -kyuubi.session.engine.trino.showProgress|true|When true, show the progress bar and final info in the trino engine log.|boolean|1.6.0 -kyuubi.session.engine.trino.showProgress.debug|false|When true, show the progress debug info in the trino engine log.|boolean|1.6.0 -kyuubi.session.group.provider|hadoop|A group provider plugin for Kyuubi Server. This plugin can provide primary group and groups information for different user or session configs. This config value should be a class which is a child of 'org.apache.kyuubi.plugin.GroupProvider' which has zero-arg constructor. Kyuubi provides the following built-in implementations:
    • hadoop: delegate the user group mapping to hadoop UserGroupInformation.
    • |string|1.7.0 -kyuubi.session.idle.timeout|PT6H|session idle timeout, it will be closed when it's not accessed for this duration|duration|1.2.0 -kyuubi.session.local.dir.allow.list||The local dir list that are allowed to access by the kyuubi session application. User might set some parameters such as `spark.files` and it will upload some local files when launching the kyuubi engine, if the local dir allow list is defined, kyuubi will check whether the path to upload is in the allow list. Note that, if it is empty, there is no limitation for that and please use absolute path list.|seq|1.6.0 -kyuubi.session.name|<undefined>|A human readable name of session and we use empty string by default. This name will be recorded in event. Note that, we only apply this value from session conf.|string|1.4.0 -kyuubi.session.timeout|PT6H|(deprecated)session timeout, it will be closed when it's not accessed for this duration|duration|1.0.0 -kyuubi.session.user.sign.enabled|false|Whether to verify the integrity of session user name on engine side, e.g. Authz plugin in Spark.|boolean|1.7.0 - +| Key | Default | Meaning | Type | Since | +|------------------------------------------------------|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------| +| kyuubi.session.check.interval | PT5M | The check interval for session timeout. | duration | 1.0.0 | +| kyuubi.session.close.on.disconnect | true | Session will be closed when client disconnects from kyuubi gateway. Set this to false to have session outlive its parent connection. | boolean | 1.8.0 | +| kyuubi.session.conf.advisor | <undefined> | A config advisor plugin for Kyuubi Server. This plugin can provide some custom configs for different users or session configs and overwrite the session configs before opening a new session. This config value should be a subclass of `org.apache.kyuubi.plugin.SessionConfAdvisor` which has a zero-arg constructor. | string | 1.5.0 | +| kyuubi.session.conf.file.reload.interval | PT10M | When `FileSessionConfAdvisor` is used, this configuration defines the expired time of `$KYUUBI_CONF_DIR/kyuubi-session-.conf` in the cache. After exceeding this value, the file will be reloaded. | duration | 1.7.0 | +| kyuubi.session.conf.ignore.list || A comma-separated list of ignored keys. If the client connection contains any of them, the key and the corresponding value will be removed silently during engine bootstrap and connection setup. Note that this rule is for server-side protection defined via administrators to prevent some essential configs from tampering but will not forbid users to set dynamic configurations via SET syntax. | seq | 1.2.0 | +| kyuubi.session.conf.profile | <undefined> | Specify a profile to load session-level configurations from `$KYUUBI_CONF_DIR/kyuubi-session-.conf`. This configuration will be ignored if the file does not exist. This configuration only takes effect when `kyuubi.session.conf.advisor` is set as `org.apache.kyuubi.session.FileSessionConfAdvisor`. | string | 1.7.0 | +| kyuubi.session.conf.restrict.list || A comma-separated list of restricted keys. If the client connection contains any of them, the connection will be rejected explicitly during engine bootstrap and connection setup. Note that this rule is for server-side protection defined via administrators to prevent some essential configs from tampering but will not forbid users to set dynamic configurations via SET syntax. | seq | 1.2.0 | +| kyuubi.session.engine.alive.probe.enabled | false | Whether to enable the engine alive probe, it true, we will create a companion thrift client that keeps sending simple requests to check whether the engine is alive. | boolean | 1.6.0 | +| kyuubi.session.engine.alive.probe.interval | PT10S | The interval for engine alive probe. | duration | 1.6.0 | +| kyuubi.session.engine.alive.timeout | PT2M | The timeout for engine alive. If there is no alive probe success in the last timeout window, the engine will be marked as no-alive. | duration | 1.6.0 | +| kyuubi.session.engine.check.interval | PT1M | The check interval for engine timeout | duration | 1.0.0 | +| kyuubi.session.engine.flink.main.resource | <undefined> | The package used to create Flink SQL engine remote job. If it is undefined, Kyuubi will use the default | string | 1.4.0 | +| kyuubi.session.engine.flink.max.rows | 1000000 | Max rows of Flink query results. For batch queries, rows exceeding the limit would be ignored. For streaming queries, the query would be canceled if the limit is reached. | int | 1.5.0 | +| kyuubi.session.engine.hive.main.resource | <undefined> | The package used to create Hive engine remote job. If it is undefined, Kyuubi will use the default | string | 1.6.0 | +| kyuubi.session.engine.idle.timeout | PT30M | engine timeout, the engine will self-terminate when it's not accessed for this duration. 0 or negative means not to self-terminate. | duration | 1.0.0 | +| kyuubi.session.engine.initialize.timeout | PT3M | Timeout for starting the background engine, e.g. SparkSQLEngine. | duration | 1.0.0 | +| kyuubi.session.engine.launch.async | true | When opening kyuubi session, whether to launch the backend engine asynchronously. When true, the Kyuubi server will set up the connection with the client without delay as the backend engine will be created asynchronously. | boolean | 1.4.0 | +| kyuubi.session.engine.log.timeout | PT24H | If we use Spark as the engine then the session submit log is the console output of spark-submit. We will retain the session submit log until over the config value. | duration | 1.1.0 | +| kyuubi.session.engine.login.timeout | PT15S | The timeout of creating the connection to remote sql query engine | duration | 1.0.0 | +| kyuubi.session.engine.open.max.attempts | 9 | The number of times an open engine will retry when encountering a special error. | int | 1.7.0 | +| kyuubi.session.engine.open.retry.wait | PT10S | How long to wait before retrying to open the engine after failure. | duration | 1.7.0 | +| kyuubi.session.engine.share.level | USER | (deprecated) - Using kyuubi.engine.share.level instead | string | 1.0.0 | +| kyuubi.session.engine.spark.main.resource | <undefined> | The package used to create Spark SQL engine remote application. If it is undefined, Kyuubi will use the default | string | 1.0.0 | +| kyuubi.session.engine.spark.max.lifetime | PT0S | Max lifetime for Spark engine, the engine will self-terminate when it reaches the end of life. 0 or negative means not to self-terminate. | duration | 1.6.0 | +| kyuubi.session.engine.spark.progress.timeFormat | yyyy-MM-dd HH:mm:ss.SSS | The time format of the progress bar | string | 1.6.0 | +| kyuubi.session.engine.spark.progress.update.interval | PT1S | Update period of progress bar. | duration | 1.6.0 | +| kyuubi.session.engine.spark.showProgress | false | When true, show the progress bar in the Spark's engine log. | boolean | 1.6.0 | +| kyuubi.session.engine.startup.error.max.size | 8192 | During engine bootstrapping, if anderror occurs, using this config to limit the length of error message(characters). | int | 1.1.0 | +| kyuubi.session.engine.startup.maxLogLines | 10 | The maximum number of engine log lines when errors occur during the engine startup phase. Note that this config effects on client-side to help track engine startup issues. | int | 1.4.0 | +| kyuubi.session.engine.startup.waitCompletion | true | Whether to wait for completion after the engine starts. If false, the startup process will be destroyed after the engine is started. Note that only use it when the driver is not running locally, such as in yarn-cluster mode; Otherwise, the engine will be killed. | boolean | 1.5.0 | +| kyuubi.session.engine.trino.connection.catalog | <undefined> | The default catalog that Trino engine will connect to | string | 1.5.0 | +| kyuubi.session.engine.trino.connection.url | <undefined> | The server url that Trino engine will connect to | string | 1.5.0 | +| kyuubi.session.engine.trino.main.resource | <undefined> | The package used to create Trino engine remote job. If it is undefined, Kyuubi will use the default | string | 1.5.0 | +| kyuubi.session.engine.trino.showProgress | true | When true, show the progress bar and final info in the Trino engine log. | boolean | 1.6.0 | +| kyuubi.session.engine.trino.showProgress.debug | false | When true, show the progress debug info in the Trino engine log. | boolean | 1.6.0 | +| kyuubi.session.group.provider | hadoop | A group provider plugin for Kyuubi Server. This plugin can provide primary group and groups information for different users or session configs. This config value should be a subclass of `org.apache.kyuubi.plugin.GroupProvider` which has a zero-arg constructor. Kyuubi provides the following built-in implementations:
    • hadoop: delegate the user group mapping to hadoop UserGroupInformation.
    • | string | 1.7.0 | +| kyuubi.session.idle.timeout | PT6H | session idle timeout, it will be closed when it's not accessed for this duration | duration | 1.2.0 | +| kyuubi.session.local.dir.allow.list || The local dir list that are allowed to access by the kyuubi session application. End-users might set some parameters such as `spark.files` and it will upload some local files when launching the kyuubi engine, if the local dir allow list is defined, kyuubi will check whether the path to upload is in the allow list. Note that, if it is empty, there is no limitation for that. And please use absolute paths. | seq | 1.6.0 | +| kyuubi.session.name | <undefined> | A human readable name of the session and we use empty string by default. This name will be recorded in the event. Note that, we only apply this value from session conf. | string | 1.4.0 | +| kyuubi.session.timeout | PT6H | (deprecated)session timeout, it will be closed when it's not accessed for this duration | duration | 1.0.0 | +| kyuubi.session.user.sign.enabled | false | Whether to verify the integrity of session user name on the engine side, e.g. Authz plugin in Spark. | boolean | 1.7.0 | ### Spnego -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.spnego.keytab|<undefined>|Keytab file for SPNego principal|string|1.6.0 -kyuubi.spnego.principal|<undefined>|SPNego service principal, typical value would look like HTTP/_HOST@EXAMPLE.COM. SPNego service principal would be used when restful Kerberos security is enabled. This needs to be set only if SPNEGO is to be used in authentication.|string|1.6.0 - +| Key | Default | Meaning | Type | Since | +|-------------------------|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|-------| +| kyuubi.spnego.keytab | <undefined> | Keytab file for SPNego principal | string | 1.6.0 | +| kyuubi.spnego.principal | <undefined> | SPNego service principal, typical value would look like HTTP/_HOST@EXAMPLE.COM. SPNego service principal would be used when restful Kerberos security is enabled. This needs to be set only if SPNEGO is to be used in authentication. | string | 1.6.0 | ### Zookeeper -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.zookeeper.embedded.client.port|2181|clientPort for the embedded zookeeper server to listen for client connections, a client here could be Kyuubi server, engine and JDBC client|int|1.2.0 -kyuubi.zookeeper.embedded.client.port.address|<undefined>|clientPortAddress for the embedded zookeeper server to|string|1.2.0 -kyuubi.zookeeper.embedded.data.dir|embedded_zookeeper|dataDir for the embedded zookeeper server where stores the in-memory database snapshots and, unless specified otherwise, the transaction log of updates to the database.|string|1.2.0 -kyuubi.zookeeper.embedded.data.log.dir|embedded_zookeeper|dataLogDir for the embedded zookeeper server where writes the transaction log .|string|1.2.0 -kyuubi.zookeeper.embedded.directory|embedded_zookeeper|The temporary directory for the embedded zookeeper server|string|1.0.0 -kyuubi.zookeeper.embedded.max.client.connections|120|maxClientCnxns for the embedded zookeeper server to limits the number of concurrent connections of a single client identified by IP address|int|1.2.0 -kyuubi.zookeeper.embedded.max.session.timeout|60000|maxSessionTimeout in milliseconds for the embedded zookeeper server will allow the client to negotiate. Defaults to 20 times the tickTime|int|1.2.0 -kyuubi.zookeeper.embedded.min.session.timeout|6000|minSessionTimeout in milliseconds for the embedded zookeeper server will allow the client to negotiate. Defaults to 2 times the tickTime|int|1.2.0 -kyuubi.zookeeper.embedded.port|2181|The port of the embedded zookeeper server|int|1.0.0 -kyuubi.zookeeper.embedded.tick.time|3000|tickTime in milliseconds for the embedded zookeeper server|int|1.2.0 +| Key | Default | Meaning | Type | Since | +|--------------------------------------------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|-------| +| kyuubi.zookeeper.embedded.client.port | 2181 | clientPort for the embedded ZooKeeper server to listen for client connections, a client here could be Kyuubi server, engine, and JDBC client | int | 1.2.0 | +| kyuubi.zookeeper.embedded.client.port.address | <undefined> | clientPortAddress for the embedded ZooKeeper server to | string | 1.2.0 | +| kyuubi.zookeeper.embedded.data.dir | embedded_zookeeper | dataDir for the embedded zookeeper server where stores the in-memory database snapshots and, unless specified otherwise, the transaction log of updates to the database. | string | 1.2.0 | +| kyuubi.zookeeper.embedded.data.log.dir | embedded_zookeeper | dataLogDir for the embedded ZooKeeper server where writes the transaction log . | string | 1.2.0 | +| kyuubi.zookeeper.embedded.directory | embedded_zookeeper | The temporary directory for the embedded ZooKeeper server | string | 1.0.0 | +| kyuubi.zookeeper.embedded.max.client.connections | 120 | maxClientCnxns for the embedded ZooKeeper server to limit the number of concurrent connections of a single client identified by IP address | int | 1.2.0 | +| kyuubi.zookeeper.embedded.max.session.timeout | 60000 | maxSessionTimeout in milliseconds for the embedded ZooKeeper server will allow the client to negotiate. Defaults to 20 times the tickTime | int | 1.2.0 | +| kyuubi.zookeeper.embedded.min.session.timeout | 6000 | minSessionTimeout in milliseconds for the embedded ZooKeeper server will allow the client to negotiate. Defaults to 2 times the tickTime | int | 1.2.0 | +| kyuubi.zookeeper.embedded.port | 2181 | The port of the embedded ZooKeeper server | int | 1.0.0 | +| kyuubi.zookeeper.embedded.tick.time | 3000 | tickTime in milliseconds for the embedded ZooKeeper server | int | 1.2.0 | ## Spark Configurations ### Via spark-defaults.conf -Setting them in `$SPARK_HOME/conf/spark-defaults.conf` supplies with default values for SQL engine application. Available properties can be found at Spark official online documentation for [Spark Configurations](http://spark.apache.org/docs/latest/configuration.html) +Setting them in `$SPARK_HOME/conf/spark-defaults.conf` supplies with default values for SQL engine application. Available properties can be found at Spark official online documentation for [Spark Configurations](https://spark.apache.org/docs/latest/configuration.html) ### Via kyuubi-defaults.conf @@ -553,16 +460,13 @@ Setting them in `$KYUUBI_HOME/conf/kyuubi-defaults.conf` supplies with default v Setting them in the JDBC Connection URL supplies session-specific for each SQL engine. For example: ```jdbc:hive2://localhost:10009/default;#spark.sql.shuffle.partitions=2;spark.executor.memory=5g``` - **Runtime SQL Configuration** - - - For [Runtime SQL Configurations](http://spark.apache.org/docs/latest/configuration.html#runtime-sql-configuration), they will take affect every time - + - For [Runtime SQL Configurations](https://spark.apache.org/docs/latest/configuration.html#runtime-sql-configuration), they will take affect every time - **Static SQL and Spark Core Configuration** - - - For [Static SQL Configurations](http://spark.apache.org/docs/latest/configuration.html#static-sql-configuration) and other spark core configs, e.g. `spark.executor.memory`, they will take affect if there is no existing SQL engine application. Otherwise, they will just be ignored + - For [Static SQL Configurations](https://spark.apache.org/docs/latest/configuration.html#static-sql-configuration) and other spark core configs, e.g. `spark.executor.memory`, they will take effect if there is no existing SQL engine application. Otherwise, they will just be ignored ### Via SET Syntax -Please refer to the Spark official online documentation for [SET Command](http://spark.apache.org/docs/latest/sql-ref-syntax-aux-conf-mgmt-set.html) +Please refer to the Spark official online documentation for [SET Command](https://spark.apache.org/docs/latest/sql-ref-syntax-aux-conf-mgmt-set.html) ## Flink Configurations @@ -575,6 +479,7 @@ Setting them in `$FLINK_HOME/conf/flink-conf.yaml` supplies with default values Setting them in `$KYUUBI_HOME/conf/kyuubi-defaults.conf` supplies with default values for SQL engine application too. You can use properties with the additional prefix `flink.` to override settings in `$FLINK_HOME/conf/flink-conf.yaml`. For example: + ``` flink.parallelism.default 2 flink.taskmanager.memory.process.size 5g @@ -592,86 +497,23 @@ Please refer to the Flink official online documentation for [SET Statements](htt ## Logging -Kyuubi uses [log4j](https://logging.apache.org/log4j/2.x/) for logging. You can configure it using `$KYUUBI_HOME/conf/log4j2.xml`. -```bash - - - - - - - - rest-audit.log - rest-audit-%d{yyyy-MM-dd}-%i.log - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` +Kyuubi uses [log4j](https://logging.apache.org/log4j/2.x/) for logging. You can configure it using `$KYUUBI_HOME/conf/log4j2.xml`, see `$KYUUBI_HOME/conf/log4j2.xml.template` as an example. ## Other Configurations ### Hadoop Configurations -Specifying `HADOOP_CONF_DIR` to the directory contains hadoop configuration files or treating them as Spark properties with a `spark.hadoop.` prefix. Please refer to the Spark official online documentation for [Inheriting Hadoop Cluster Configuration](http://spark.apache.org/docs/latest/configuration.html#inheriting-hadoop-cluster-configuration). Also, please refer to the [Apache Hadoop](http://hadoop.apache.org)'s online documentation for an overview on how to configure Hadoop. +Specifying `HADOOP_CONF_DIR` to the directory containing Hadoop configuration files or treating them as Spark properties with a `spark.hadoop.` prefix. Please refer to the Spark official online documentation for [Inheriting Hadoop Cluster Configuration](https://spark.apache.org/docs/latest/configuration.html#inheriting-hadoop-cluster-configuration). Also, please refer to the [Apache Hadoop](https://hadoop.apache.org)'s online documentation for an overview on how to configure Hadoop. ### Hive Configurations -These configurations are used for SQL engine application to talk to Hive MetaStore and could be configured in a `hive-site.xml`. Placed it in `$SPARK_HOME/conf` directory, or treating them as Spark properties with a `spark.hadoop.` prefix. +These configurations are used for SQL engine application to talk to Hive MetaStore and could be configured in a `hive-site.xml`. Placed it in `$SPARK_HOME/conf` directory, or treat them as Spark properties with a `spark.hadoop.` prefix. ## User Defaults In Kyuubi, we can configure user default settings to meet separate needs. These user defaults override system defaults, but will be overridden by those from [JDBC Connection URL](#via-jdbc-connection-url) or [Set Command](#via-set-syntax) if could be. They will take effect when creating the SQL engine application ONLY. User default settings are in the form of `___{username}___.{config key}`. There are three continuous underscores(`_`) at both sides of the `username` and a dot(`.`) that separates the config key and the prefix. For example: + ```bash # For system defaults spark.master=local diff --git a/docs/deployment/spark/aqe.md b/docs/deployment/spark/aqe.md index f85fcbf2037..90cc5aff84c 100644 --- a/docs/deployment/spark/aqe.md +++ b/docs/deployment/spark/aqe.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # How To Use Spark Adaptive Query Execution (AQE) in Kyuubi @@ -197,7 +196,6 @@ By default, if there are only less than 20% partitions of the dataset contain da This optimization rule detects and converts a Join to an empty LocalRelation. - #### Disabling the Hidden Features We can exclude some of the AQE additional rules if performance regression or bug occurs. For example, @@ -210,7 +208,6 @@ SET spark.sql.adaptive.optimizer.excludedRules=org.apache.spark.sql.execution.ad Kyuubi is a long-running service to make it easier for end-users to use Spark SQL without having much of Spark's basic knowledge. It is essential to have a basic configuration that works for most scenarios on the server-side. - ### Setting Default Configurations [Configuring by `spark-defaults.conf`](settings.html#via-spark-defaults-conf) at the engine side is the best way to set up Kyuubi with AQE. All engines will be instantiated with AQE enabled. @@ -234,7 +231,9 @@ spark.sql.adaptive.nonEmptyPartitionRatioForBroadcastJoin=0.2 spark.sql.adaptive.optimizer.excludedRules spark.sql.autoBroadcastJoinThreshold=-1 ``` + #### Tips + Turn on AQE by default can significantly improve the user experience. Other sub-features are all enabled. `advisoryPartitionSizeInBytes` is targeting the HDFS block size @@ -246,7 +245,6 @@ Since AQE requires at least one shuffle, ideally, we need to set `autoBroadcastJ All AQE related configurations are runtime changeable, which means that it can still modify some specific configs by `SET` syntaxes for each SQL query with more precise control on the client-side. - ## Spark Known issues [SPARK-33933: Broadcast timeout happened unexpectedly in AQE](https://issues.apache.org/jira/browse/SPARK-33933) @@ -262,3 +260,4 @@ For other potential problems that may be found in the AQE features of Spark, you 3. [SPARK-31412: New Adaptive Query Execution in Spark SQL](https://issues.apache.org/jira/browse/SPARK-31412) 4. [SPARK-28560: Optimize shuffle reader to local shuffle reader when smj converted to bhj in adaptive execution](https://issues.apache.org/jira/browse/SPARK-28560) 5. [Coalesce and Repartition Hint for SQL Queries](https://issues.apache.org/jira/browse/SPARK-24940) + diff --git a/docs/deployment/spark/dynamic_allocation.md b/docs/deployment/spark/dynamic_allocation.md index 7b35e4bd998..b177b63c365 100644 --- a/docs/deployment/spark/dynamic_allocation.md +++ b/docs/deployment/spark/dynamic_allocation.md @@ -1,24 +1,22 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # How To Use Spark Dynamic Resource Allocation (DRA) in Kyuubi - When we adopt Kyuubi in a production environment, we always want to use the environment's computing resources more cost-effectively and efficiently. Cluster managers such as K8S and Yarn manage the cluster compute resources, @@ -42,7 +40,6 @@ On the one hand, we need to rely on the resource manager's capabilities for effi resource isolation, and sharing. On the other hand, we need to enable Spark's DRA feature for the engines' executors' elastic scaling. - ## The Basics of Dynamic Resource Allocation Spark provides a mechanism to dynamically adjust the application resources based on the workload, which means that an application may give resources back to the cluster if they are no longer used and request them again later when there is demand. @@ -58,7 +55,6 @@ When the engine has executors that become idle, the executors are released, and the occupied resources are given back to the cluster manager. Then other engines or other applications run in the same queue could acquire the resources. - ## How to Enable Dynamic Resource Allocation The prerequisite for enabling this feature is for downstream stages to have proper access to shuffle data, even if the executors that generated the data are recycled. @@ -78,7 +74,6 @@ spark.shuffle.service.enabled=true Another thing to be sure of is that ```spark.shuffle.service.port``` should be configured to point to the port on which the ESS is running. - ### Dynamic Allocation w/o External Shuffle Service Implementations of the ESS feature are cluster manager dependent. Yarn, for instance, where the ESS needs to be deployed cluster-widely and is actually running in the Yarn's `NodeManager` component. Nevertheless, if run Kyuubi's engines on Kubernetes, the ESS is not an option yet. @@ -104,7 +99,6 @@ On the other hand, the ```maxExecutors``` determines the upper bound executors o The following Spark configurations consist of sizing for the DRA. - ``` spark.dynamicAllocation.minExecutors=10 spark.dynamicAllocation.maxExecutors=500 @@ -132,12 +126,10 @@ By default, the dynamic allocation will request enough executors to maximize the - While this minimizes the latency of the job, but with small tasks, the default behavior can waste many resources due to executor allocation overhead, as some executors might not even do any work. In this case, we can adjust ```spark.dynamicAllocation.executorAllocationRatio``` a bit lower to reduce the number of executors w.r.t. full parallelism. For instance, 0.5 will divide the target number of executors by 2. -
      ![](../../imgs/spark/dra_executor_add_ratio.png) @@ -153,6 +145,7 @@ After finish one task, Spark Driver will schedule a new task for the executor w
      If one executor reached the maximum timeout, it will be removed. + ```properties spark.dynamicAllocation.executorIdleTimeout=60s spark.dynamicAllocation.cachedExecutorIdleTimeout=infinity @@ -164,7 +157,6 @@ spark.dynamicAllocation.cachedExecutorIdleTimeout=infinity - If the DRA finds there have been pending tasks backlogged for more than the timeouts, new executors will be requested, controlled by the following configs. ```properties @@ -176,7 +168,6 @@ spark.dynamicAllocation.sustainedSchedulerBacklogTimeout=1s Kyuubi is a long-running service to make it easier for end-users to use Spark SQL without having much of Spark's basic knowledge. It is essential to have a basic configuration for resource management that works for most scenarios on the server-side. - ### Setting Default Configurations [Configuring by `spark-defaults.conf`](settings.html#via-spark-defaults-conf) at the engine side is the best way to set up Kyuubi with DRA. All engines will be instantiated with DRA enabled. @@ -185,7 +176,7 @@ Here is a config setting that we use in our platform when deploying Kyuubi. ```properties spark.dynamicAllocation.enabled=true -##false if perfer shuffle tracking than ESS +##false if prefer shuffle tracking than ESS spark.shuffle.service.enabled=true spark.dynamicAllocation.initialExecutors=10 spark.dynamicAllocation.minExecutors=10 @@ -193,7 +184,7 @@ spark.dynamicAllocation.maxExecutors=500 spark.dynamicAllocation.executorAllocationRatio=0.5 spark.dynamicAllocation.executorIdleTimeout=60s spark.dynamicAllocation.cachedExecutorIdleTimeout=30min -# true if perfer shuffle tracking than ESS +# true if prefer shuffle tracking than ESS spark.dynamicAllocation.shuffleTracking.enabled=false spark.dynamicAllocation.shuffleTracking.timeout=30min spark.dynamicAllocation.schedulerBacklogTimeout=1s @@ -204,6 +195,7 @@ spark.cleaner.periodicGC.interval=5min Note that, ```spark.cleaner.periodicGC.interval=5min``` is useful here when ```spark.dynamicAllocation.shuffleTracking.enabled``` is enabled, as we can tell Spark to be more active for shuffle data GC. ### Setting User Default Settings + On the server-side, the workloads for different users might be different. Then we can set different defaults for them via the [User Defaults](../settings.html#user-defaults) in ```$KYUUBI_HOME/conf/kyuubi-defaults.conf``` @@ -214,11 +206,12 @@ ___kent___.spark.dynamicAllocation.maxExecutors=20 # For a user named bob ___bob___.spark.dynamicAllocation.maxExecutors=600 ``` + In this case, the user named `kent` can only use 20 executors for his engines, but `bob` can use 600 executors for better performance or handle heavy workloads. ### Dynamically Setting -All AQE related configurations are static of Spark core and unchangeable by `SET` syntaxes before each SQL query. For example, +All AQE related configurations are static of Spark core and unchangeable by `SET` commands before each SQL query. For example, ```sql SET spark.dynamicAllocation.maxExecutors=33; @@ -229,9 +222,9 @@ For the above case, the value - 33 will not affect as Spark does not support cha Instead, end-users can set them via [JDBC Connection URL](../settings.html#via-jdbc-connection-url) for some specific cases. - ## References 1. [Spark Official Online Document: Dynamic Resource Allocation](https://spark.apache.org/docs/latest/job-scheduling.html#dynamic-resource-allocation) 2. [Spark Official Online Document: Dynamic Resource Allocation Configurations](https://spark.apache.org/docs/latest/configuration.html#dynamic-allocation) 3. [SPARK-27963: Allow dynamic allocation without an external shuffle service](https://issues.apache.org/jira/browse/SPARK-27963) + diff --git a/docs/deployment/spark/incremental_collection.md b/docs/deployment/spark/incremental_collection.md index 6883cdd0027..28fd4aa7807 100644 --- a/docs/deployment/spark/incremental_collection.md +++ b/docs/deployment/spark/incremental_collection.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Solution for Big Result Sets diff --git a/docs/develop_tools/build_document.md b/docs/develop_tools/build_document.md index c3c310db309..0be5a180705 100644 --- a/docs/develop_tools/build_document.md +++ b/docs/develop_tools/build_document.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Building Kyuubi Documentation @@ -59,10 +58,13 @@ pip install -r requirements.txt Make sure you are in the `$KYUUBI_SOURCE_PATH/docs` directory. linux & macos + ```bash make html ``` + windows + ```bash make.bat html ``` diff --git a/docs/develop_tools/building.md b/docs/develop_tools/building.md index 99fdd47148e..d4582dc8dae 100644 --- a/docs/develop_tools/building.md +++ b/docs/develop_tools/building.md @@ -1,26 +1,25 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Building Kyuubi ## Building Kyuubi with Apache Maven -**Kyuubi** is built based on [Apache Maven](http://maven.apache.org), +**Kyuubi** is built based on [Apache Maven](https://maven.apache.org), ```bash ./build/mvn clean package -DskipTests @@ -62,12 +61,11 @@ mvn clean install -pl '!dev/kyuubi-codecov,!kyuubi-assembly' -DskipTests Since v1.1.0, Kyuubi support building with different Spark profiles, -Profile | Default | Since ---- | --- | --- --Pspark-3.1 | No | 1.1.0 --Pspark-3.2 | No | 1.4.0 --Pspark-3.3 | Yes | 1.6.0 - +| Profile | Default | Since | +|-------------|---------|-------| +| -Pspark-3.1 | No | 1.1.0 | +| -Pspark-3.2 | No | 1.4.0 | +| -Pspark-3.3 | Yes | 1.6.0 | ## Building with Apache dlcdn site @@ -81,6 +79,15 @@ For example, build/mvn clean package -Pmirror-cdn ``` -The profile migrates your download repo to the Apache offically suggested site - https://dlcdn.apache.org. +The profile migrates your download repo to the Apache officially suggested site - https://dlcdn.apache.org. Note that, this site only holds the latest versions of Apache releases. You may fail if the specific version defined by `spark.version` or `flink.version` is overdue. + +## Building with the `fast` profile + +The `fast` profile helps to significantly reduce build time, which is useful for development or compilation validation, by skipping running the tests, code style checks, building scaladoc, enforcer rules and downloading engine archives used for tests. + +```bash +build/mvn clean package -Pfast +``` + diff --git a/docs/develop_tools/debugging.md b/docs/develop_tools/debugging.md index 90ebd58f67a..faf7173e427 100644 --- a/docs/develop_tools/debugging.md +++ b/docs/develop_tools/debugging.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Debugging Kyuubi @@ -24,15 +23,16 @@ with your favorite IDE tool, e.g. IntelliJ IDEA. ## Debugging Server We can configure the JDWP agent in `KYUUBI_JAVA_OPTS` for debugging. - - + For example, + ```bash KYUUBI_JAVA_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 \ bin/kyuubi start ``` In the IDE, you set the corresponding parameters(host&port) in debug configurations, for example, +
      ![](../imgs/idea_debug.png) @@ -107,4 +107,5 @@ env.java.opts.historyserver -agentlib:jdwp=transport=dt_socket,server=y,suspen ```bash env.java.opts.client -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 -``` \ No newline at end of file +``` + diff --git a/docs/develop_tools/developer.md b/docs/develop_tools/developer.md index 5f69f4a1ba5..329e219de46 100644 --- a/docs/develop_tools/developer.md +++ b/docs/develop_tools/developer.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Developer Tools @@ -45,7 +44,6 @@ You can run `build/dependency.sh` locally first to detect the potential dependen If the changes look expected, run `build/dependency.sh --replace` to update `dev/dependencyList` in your Pull request. - ## Format All Code Kyuubi uses [Spotless](https://github.com/diffplug/spotless/tree/main/plugin-maven) @@ -54,10 +52,9 @@ to format the Java and Scala code. You can run `dev/reformat` to format all Java and Scala code. - ## Append descriptions of new configurations to settings.md Kyuubi uses settings.md to explain available configurations. -You can run `KYUUBI_UPDATE=1 build/mvn clean install -Pflink-provided,spark-provided,hive-provided -DwildcardSuites=org.apache.kyuubi.config.AllKyuubiConfiguration` -to append descriptions of new configurations to settings.md. \ No newline at end of file +You can run `KYUUBI_UPDATE=1 build/mvn clean test -pl kyuubi-server -am -Pflink-provided,spark-provided,hive-provided -DwildcardSuites=org.apache.kyuubi.config.AllKyuubiConfiguration` +to append descriptions of new configurations to settings.md. diff --git a/docs/develop_tools/distribution.md b/docs/develop_tools/distribution.md index 680f4e212a7..217f0a4178d 100644 --- a/docs/develop_tools/distribution.md +++ b/docs/develop_tools/distribution.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Building a Runnable Distribution @@ -27,15 +26,16 @@ For more information on usage, run `./build/dist --help` ./build/dist - Tool for making binary distributions of Kyuubi Usage: -+------------------------------------------------------------------------------------------------------+ -| ./build/dist [--name ] [--tgz] [--flink-provided] [--spark-provided] [--hive-provided] | -| [--mvn ] | -+------------------------------------------------------------------------------------------------------+ ++----------------------------------------------------------------------------------------------+ +| ./build/dist [--name ] [--tgz] [--web-ui] [--flink-provided] [--hive-provided] | +| [--spark-provided] [--mvn ] | ++----------------------------------------------------------------------------------------------+ name: - custom binary name, using project version if undefined tgz: - whether to make a whole bundled package +web-ui: - whether to include web ui flink-provided: - whether to make a package without Flink binary -spark-provided: - whether to make a package without Spark binary hive-provided: - whether to make a package without Hive binary +spark-provided: - whether to make a package without Spark binary mvn: - external maven executable location ``` @@ -47,7 +47,7 @@ For instance, This results in a Kyuubi distribution named `apache-kyuubi-{version}-bin-custom-name.tgz` for you. -If you are planing to deploy Kyuubi where `spark`/`flink`/`hive` is provided, in other word, it's not required to bundle spark/flink/hive binary, use +If you are planing to deploy Kyuubi where `spark`/`flink`/`hive` is provided, in other word, it's not required to bundle spark/flink/hive binary, use ```bash ./build/dist --tgz --spark-provided --flink-provided --hive-provided diff --git a/docs/develop_tools/idea_setup.md b/docs/develop_tools/idea_setup.md index bf5c44d54d9..96ba33bc434 100644 --- a/docs/develop_tools/idea_setup.md +++ b/docs/develop_tools/idea_setup.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # IntelliJ IDEA Setup Guide @@ -35,9 +34,9 @@ profile: to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - + http://www.apache.org/licenses/LICENSE-2.0 - + Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. diff --git a/docs/develop_tools/testing.md b/docs/develop_tools/testing.md index deb984f45d7..3e63aa1a22f 100644 --- a/docs/develop_tools/testing.md +++ b/docs/develop_tools/testing.md @@ -1,24 +1,24 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Running Tests -**Kyuubi** can be tested based on [Apache Maven](http://maven.apache.org) and the ScalaTest Maven Plugin, -please refer to the [ScalaTest documentation](http://www.scalatest.org/user_guide/using_the_scalatest_maven_plugin), +**Kyuubi** can be tested based on [Apache Maven](https://maven.apache.org) and the ScalaTest Maven Plugin, +please refer to the [ScalaTest documentation](https://www.scalatest.org/user_guide/using_the_scalatest_maven_plugin), ## Running Tests Fully @@ -52,3 +52,4 @@ You can leverage the ready-made tool for creating a binary distribution. ```bash ./build/dist ``` + diff --git a/docs/extensions/engines/spark/functions.md b/docs/extensions/engines/spark/functions.md index b467a3abbc3..66f22aea860 100644 --- a/docs/extensions/engines/spark/functions.md +++ b/docs/extensions/engines/spark/functions.md @@ -1,31 +1,30 @@ - - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> + # Auxiliary SQL Functions Kyuubi provides several auxiliary SQL functions as supplement to Spark's [Built-in Functions](https://spark.apache.org/docs/latest/api/sql/index.html#built-in-functions) -Name | Description | Return Type | Since ---- | --- | --- | --- -kyuubi_version | Return the version of Kyuubi Server | string | 1.3.0 -engine_name | Return the spark application name for the associated query engine | string | 1.3.0 -engine_id | Return the spark application id for the associated query engine | string | 1.4.0 -system_user | Return the system user name for the associated query engine | string | 1.3.0 -session_user | Return the session username for the associated query engine | string | 1.4.0 +| Name | Description | Return Type | Since | +|----------------|-------------------------------------------------------------------|-------------|-------| +| kyuubi_version | Return the version of Kyuubi Server | string | 1.3.0 | +| engine_name | Return the spark application name for the associated query engine | string | 1.3.0 | +| engine_id | Return the spark application id for the associated query engine | string | 1.4.0 | +| system_user | Return the system user name for the associated query engine | string | 1.3.0 | +| session_user | Return the session username for the associated query engine | string | 1.4.0 | diff --git a/docs/extensions/engines/spark/jdbc-dialect.md b/docs/extensions/engines/spark/jdbc-dialect.md index 82aa453f397..e22c3392669 100644 --- a/docs/extensions/engines/spark/jdbc-dialect.md +++ b/docs/extensions/engines/spark/jdbc-dialect.md @@ -1,28 +1,26 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Hive Dialect Support Hive Dialect plugin aims to provide Hive Dialect support to Spark's JDBC source. It will auto registered to Spark and applied to JDBC sources with url prefix of `jdbc:hive2://` or `jdbc:kyuubi://`. -Hive Dialect helps to solve failures access Kyuubi. It fails and unexpected results when querying data from Kyuubi as JDBC source with Hive JDBC Driver or Kyuubi Hive JDBC Driver in Spark, as Spark JDBC provides no Hive Dialect support out of box and quoting columns and other identifiers in ANSI as "table.column" rather than in HiveSQL style as \`table\`.\`column\`. - +Hive Dialect helps to solve failures access Kyuubi. It fails and unexpected results when querying data from Kyuubi as JDBC source with Hive JDBC Driver or Kyuubi Hive JDBC Driver in Spark, as Spark JDBC provides no Hive Dialect support out of box and quoting columns and other identifiers in ANSI as "table.column" rather than in HiveSQL style as \`table\`.\`column\`. ## Features @@ -33,10 +31,11 @@ Hive Dialect helps to solve failures access Kyuubi. It fails and unexpected resu ## Usage 1. Get the Kyuubi Hive Dialect Extension jar - 1. compile the extension by executing `build/mvn clean package -pl :kyuubi-extension-spark-jdbc-dialect_2.12 -DskipTests` - 2. get the extension jar under `extensions/spark/kyuubi-extension-spark-jdbc-dialect/target` - 3. If you like, you can compile the extension jar with the corresponding Maven's profile on you compile command, i.e. you can get extension jar for Spark 3.2 by compiling with `-Pspark-3.1` + 1. compile the extension by executing `build/mvn clean package -pl :kyuubi-extension-spark-jdbc-dialect_2.12 -DskipTests` + 2. get the extension jar under `extensions/spark/kyuubi-extension-spark-jdbc-dialect/target` + 3. If you like, you can compile the extension jar with the corresponding Maven's profile on you compile command, i.e. you can get extension jar for Spark 3.2 by compiling with `-Pspark-3.1` 2. Put the Kyuubi Hive Dialect Extension jar `kyuubi-extension-spark-jdbc-dialect_-*.jar` into `$SPARK_HOME/jars` -3. Enable `KyuubiSparkJdbcDialectExtension`, by setting `spark.sql.extensions=org.apache.spark.sql.dialect.KyuubiSparkJdbcDialectExtension`, i.e. +3. Enable `KyuubiSparkJdbcDialectExtension`, by setting `spark.sql.extensions=org.apache.spark.sql.dialect.KyuubiSparkJdbcDialectExtension`, i.e. - add a config into `$SPARK_HOME/conf/spark-defaults.conf` - or add setting config in SparkSession builder + diff --git a/docs/extensions/engines/spark/lineage.md b/docs/extensions/engines/spark/lineage.md index 59cb6187bd8..cd38be4ba12 100644 --- a/docs/extensions/engines/spark/lineage.md +++ b/docs/extensions/engines/spark/lineage.md @@ -1,19 +1,19 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # SQL Lineage Support @@ -25,13 +25,14 @@ The source table is related to the result set, which is the SQL Lineage. ## Introduction The current lineage parsing functionality is implemented as a plugin by extending Spark's `QueryExecutionListener`. -1. The `SparkListenerSQLExecutionEnd` event is triggered after the SQL execution is finished and captured by the `QueryExecuctionListener`, - where the SQL lineage parsing process is performed on the successfully executed SQL. +1. The `SparkListenerSQLExecutionEnd` event is triggered after the SQL execution is finished and captured by the `QueryExecuctionListener`, +where the SQL lineage parsing process is performed on the successfully executed SQL. 2. Will write the parsed lineage information to the log file in JSON format. ### Example When the following SQL is executed: + ```sql ## table create table test_table0(a string, b string) @@ -39,7 +40,9 @@ create table test_table0(a string, b string) ## query select a as col0, b as col1 from test_table0 ``` + The lineage of this SQL: + ```json { "inputTables": ["default.test_table0"], @@ -56,21 +59,22 @@ The lineage of this SQL: #### Lineage specific identification -- `__count__`. Means that the column is an `count(*)` aggregate expression +- `__count__`. Means that the column is an `count(*)` aggregate expression and cannot extract the specific column. Lineage of the column like `default.test_table0.__count__`. - `__local__`. Means that the lineage of the table is a `LocalRelation` and not the real table, like `__local__.a` - ### SQL type support Currently supported column lineage for spark's `Command` and `Query` type: #### Query + - `Select` #### Command + - `AlterViewAsCommand` - `AppendData` - `CreateDataSourceTableAsSelectCommand` @@ -89,12 +93,11 @@ Currently supported column lineage for spark's `Command` and `Query` type: - `ReplaceTableAsSelect` - `SaveIntoDataSourceCommand` - ## Building ### Build with Apache Maven -Kyuubi Spark Lineage Listener Extension is built using [Apache Maven](http://maven.apache.org). +Kyuubi Spark Lineage Listener Extension is built using [Apache Maven](https://maven.apache.org). To build it, `cd` to the root direct of kyuubi project and run: ```shell @@ -120,19 +123,19 @@ build/mvn clean package -pl :kyuubi-spark-lineage_2.12 -DskipTests -Dspark.versi The available `spark.version`s are shown in the following table. -| Spark Version | Supported | Remark | -|:-----------------:|:-----------:|:--------------------------------------------------------------------------------------------------------------------------------:| -| master | √ | - | -| 3.3.x | √ | - | -| 3.2.x | √ | - | -| 3.1.x | √ | - | -| 3.0.x | √ | - | -| 2.4.x | x | - | +| Spark Version | Supported | Remark | +|:-------------:|:---------:|:------:| +| master | √ | - | +| 3.3.x | √ | - | +| 3.2.x | √ | - | +| 3.1.x | √ | - | +| 3.0.x | √ | - | +| 2.4.x | x | - | Currently, Spark released with Scala 2.12 are supported. - ### Test with ScalaTest Maven plugin + If you omit `-DskipTests` option in the command above, you will also get all unit tests run. ```shell @@ -149,8 +152,6 @@ The tests will suspend at startup and wait for a remote debugger to attach to th We will appreciate if you can share the bug or the fix to the Kyuubi community. - - ## Installing With the `kyuubi-spark-lineage_*.jar` and its transitive dependencies available for spark runtime classpath, such as @@ -167,15 +168,43 @@ Add `org.apache.kyuubi.plugin.lineage.SparkOperationLineageQueryExecutionListene spark.sql.queryExecutionListeners=org.apache.kyuubi.plugin.lineage.SparkOperationLineageQueryExecutionListener ``` -### Settings for Lineage Logger and Path +### Optional configuration + +#### Whether to Skip Permanent View Resolution + +If enabled, lineage resolution will stop at permanent views and treats them as physical tables. We need +to add one configurations. + +```properties +spark.kyuubi.plugin.lineage.skip.parsing.permanent.view.enabled=true +``` + +### Get Lineage Events + +The lineage dispatchers are used to dispatch lineage events, configured via `spark.kyuubi.plugin.lineage.dispatchers`. -#### Lineage Logger Path -The location of all the engine operation lineage events go for the builtin JSON logger. -We first need set `kyuubi.engine.event.loggers` to `JSON`. -All operation lineage events will be written in the unified event json logger path, which be setting with -`kyuubi.engine.event.json.log.path`. We can get the lineage logger from the `operation_lineage` dir in the -`kyuubi.engine.event.json.log.path`. +
        +
      • SPARK_EVENT (by default): send lineage event to spark event bus
      • +
      • KYUUBI_EVENT: send lineage event to kyuubi event bus
      • +
      +#### Get Lineage Events from SparkListener +When using the `SPARK_EVENT` dispatcher, the lineage events will be sent to the `SparkListenerBus`. To handle lineage events, a new `SparkListener` needs to be added. +Example for Adding `SparkListener`: + +```scala +spark.sparkContext.addSparkListener(new SparkListener { + override def onOtherEvent(event: SparkListenerEvent): Unit = { + event match { + case lineageEvent: OperationLineageEvent => + // Your processing logic + case _ => + } + } + }) +``` +#### Get Lineage Events from Kyuubi EventHandler +When using the `KYUUBI_EVENT` dispatcher, the lineage events will be sent to the Kyuubi `EventBus`. Refer to [Kyuubi Event Handler](../../server/events) to handle kyuubi events. diff --git a/docs/extensions/engines/spark/rules.md b/docs/extensions/engines/spark/rules.md index 535804dc241..a4bda5d53ff 100644 --- a/docs/extensions/engines/spark/rules.md +++ b/docs/extensions/engines/spark/rules.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Auxiliary Optimization Rules @@ -24,19 +23,17 @@ And don't worry, Kyuubi will support the new Apache Spark version in the future. ## Features - merging small files automatically - + Small files is a long time issue with Apache Spark. Kyuubi can merge small files by adding an extra shuffle. Currently, Kyuubi supports handle small files with datasource table and hive table, and also Kyuubi support optimize dynamic partition insertion. For example, a common write query `INSERT INTO TABLE $table1 SELECT * FROM $table2`, Kyuubi will introduce an extra shuffle before write and then the small files will go away. - - insert shuffle node before Join to make AQE OptimizeSkewedJoin work In current implementation, Apache Spark can only optimize skewed join by the standard join which means a join must have two sort and shuffle node. However, in complex scenario this assuming will be broken easily. Kyuubi can guarantee the join is standard by adding an extra shuffle node before join. So that, OptimizeSkewedJoin can work better. - - stage level config isolation in AQE As we know, `spark.sql.adaptive.advisoryPartitionSizeInBytes` is a key config in Apache Spark AQE. @@ -44,7 +41,6 @@ And don't worry, Kyuubi will support the new Apache Spark version in the future. However, in general, we expect a file is big enough like 256MB or 512MB. Kyuubi can make the config isolation to solve the conflict so that we can make staging partition data size small and last partition data size big. - ## Usage | Kyuubi Spark SQL extension | Supported Spark version(s) | Available since | EOL | Bundled in Binary release tarball | Maven profile | @@ -63,28 +59,35 @@ And don't worry, Kyuubi will support the new Apache Spark version in the future. Now, you can enjoy the Kyuubi SQL Extension. - ## Additional Configurations Kyuubi provides some configs to make these feature easy to use. -| Name | Default Value | Description | Since | -|---------------------------------------------------------------------|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------| -| spark.sql.optimizer.insertRepartitionBeforeWrite.enabled | true | Add repartition node at the top of query plan. An approach of merging small files. | 1.2.0 | -| spark.sql.optimizer.insertRepartitionNum | none | The partition number if `spark.sql.optimizer.insertRepartitionBeforeWrite.enabled` is enabled. If AQE is disabled, the default value is `spark.sql.shuffle.partitions`. If AQE is enabled, the default value is none that means depend on AQE. | 1.2.0 | -| spark.sql.optimizer.dynamicPartitionInsertionRepartitionNum | 100 | The partition number of each dynamic partition if `spark.sql.optimizer.insertRepartitionBeforeWrite.enabled` is enabled. We will repartition by dynamic partition columns to reduce the small file but that can cause data skew. This config is to extend the partition of dynamic partition column to avoid skew but may generate some small files. | 1.2.0 | -| spark.sql.optimizer.forceShuffleBeforeJoin.enabled | false | Ensure shuffle node exists before shuffled join (shj and smj) to make AQE `OptimizeSkewedJoin` works (complex scenario join, multi table join). | 1.2.0 | -| spark.sql.optimizer.finalStageConfigIsolation.enabled | false | If true, the final stage support use different config with previous stage. The prefix of final stage config key should be `spark.sql.finalStage.`. For example, the raw spark config: `spark.sql.adaptive.advisoryPartitionSizeInBytes`, then the final stage config should be: `spark.sql.finalStage.adaptive.advisoryPartitionSizeInBytes`. | 1.2.0 | -| spark.sql.analyzer.classification.enabled | false | When true, allows Kyuubi engine to judge this SQL's classification and set `spark.sql.analyzer.classification` back into sessionConf. Through this configuration item, Spark can optimizing configuration dynamic. | 1.4.0 | -| spark.sql.optimizer.insertZorderBeforeWriting.enabled | true | When true, we will follow target table properties to insert zorder or not. The key properties are: 1) `kyuubi.zorder.enabled`: if this property is true, we will insert zorder before writing data. 2) `kyuubi.zorder.cols`: string split by comma, we will zorder by these cols. | 1.4.0 | -| spark.sql.optimizer.zorderGlobalSort.enabled | true | When true, we do a global sort using zorder. Note that, it can cause data skew issue if the zorder columns have less cardinality. When false, we only do local sort using zorder. | 1.4.0 | -| spark.sql.watchdog.maxPartitions | none | Set the max partition number when spark scans a data source. Enable MaxPartitionStrategy by specifying this configuration. Add maxPartitions Strategy to avoid scan excessive partitions on partitioned table, it's optional that works with defined | 1.4.0 | -| spark.sql.optimizer.dropIgnoreNonExistent | false | When true, do not report an error if DROP DATABASE/TABLE/VIEW/FUNCTION/PARTITION specifies a non-existent database/table/view/function/partition | 1.5.0 | -| spark.sql.optimizer.rebalanceBeforeZorder.enabled | false | When true, we do a rebalance before zorder in case data skew. Note that, if the insertion is dynamic partition we will use the partition columns to rebalance. Note that, this config only affects with Spark 3.3.x. | 1.6.0 | -| spark.sql.optimizer.rebalanceZorderColumns.enabled | false | When true and `spark.sql.optimizer.rebalanceBeforeZorder.enabled` is true, we do rebalance before Z-Order. If it's dynamic partition insert, the rebalance expression will include both partition columns and Z-Order columns. Note that, this config only affects with Spark 3.3.x. | 1.6.0 | -| spark.sql.optimizer.twoPhaseRebalanceBeforeZorder.enabled | false | When true and `spark.sql.optimizer.rebalanceBeforeZorder.enabled` is true, we do two phase rebalance before Z-Order for the dynamic partition write. The first phase rebalance using dynamic partition column; The second phase rebalance using dynamic partition column Z-Order columns. Note that, this config only affects with Spark 3.3.x. | 1.6.0 | -| spark.sql.optimizer.zorderUsingOriginalOrdering.enabled | false | When true and `spark.sql.optimizer.rebalanceBeforeZorder.enabled` is true, we do sort by the original ordering i.e. lexicographical order. Note that, this config only affects with Spark 3.3.x. | 1.6.0 | -| spark.sql.optimizer.inferRebalanceAndSortOrders.enabled | false | When ture, infer columns for rebalance and sort orders from original query, e.g. the join keys from join. It can avoid compression ratio regression. | 1.7.0 | -| spark.sql.optimizer.inferRebalanceAndSortOrdersMaxColumns | 3 | The max columns of inferred columns. | 1.7.0 | -| spark.sql.optimizer.insertRepartitionBeforeWriteIfNoShuffle.enabled | false | When true, add repartition even if the original plan does not have shuffle. | 1.7.0 | -| spark.sql.optimizer.finalStageConfigIsolationWriteOnly.enabled | true | When true, only enable final stage isolation for writing. | 1.7.0 | \ No newline at end of file +| Name | Default Value | Description | Since | +|---------------------------------------------------------------------|----------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------| +| spark.sql.optimizer.insertRepartitionBeforeWrite.enabled | true | Add repartition node at the top of query plan. An approach of merging small files. | 1.2.0 | +| spark.sql.optimizer.insertRepartitionNum | none | The partition number if `spark.sql.optimizer.insertRepartitionBeforeWrite.enabled` is enabled. If AQE is disabled, the default value is `spark.sql.shuffle.partitions`. If AQE is enabled, the default value is none that means depend on AQE. | 1.2.0 | +| spark.sql.optimizer.dynamicPartitionInsertionRepartitionNum | 100 | The partition number of each dynamic partition if `spark.sql.optimizer.insertRepartitionBeforeWrite.enabled` is enabled. We will repartition by dynamic partition columns to reduce the small file but that can cause data skew. This config is to extend the partition of dynamic partition column to avoid skew but may generate some small files. | 1.2.0 | +| spark.sql.optimizer.forceShuffleBeforeJoin.enabled | false | Ensure shuffle node exists before shuffled join (shj and smj) to make AQE `OptimizeSkewedJoin` works (complex scenario join, multi table join). | 1.2.0 | +| spark.sql.optimizer.finalStageConfigIsolation.enabled | false | If true, the final stage support use different config with previous stage. The prefix of final stage config key should be `spark.sql.finalStage.`. For example, the raw spark config: `spark.sql.adaptive.advisoryPartitionSizeInBytes`, then the final stage config should be: `spark.sql.finalStage.adaptive.advisoryPartitionSizeInBytes`. | 1.2.0 | +| spark.sql.analyzer.classification.enabled | false | When true, allows Kyuubi engine to judge this SQL's classification and set `spark.sql.analyzer.classification` back into sessionConf. Through this configuration item, Spark can optimizing configuration dynamic. | 1.4.0 | +| spark.sql.optimizer.insertZorderBeforeWriting.enabled | true | When true, we will follow target table properties to insert zorder or not. The key properties are: 1) `kyuubi.zorder.enabled`: if this property is true, we will insert zorder before writing data. 2) `kyuubi.zorder.cols`: string split by comma, we will zorder by these cols. | 1.4.0 | +| spark.sql.optimizer.zorderGlobalSort.enabled | true | When true, we do a global sort using zorder. Note that, it can cause data skew issue if the zorder columns have less cardinality. When false, we only do local sort using zorder. | 1.4.0 | +| spark.sql.watchdog.maxPartitions | none | Set the max partition number when spark scans a data source. Enable MaxPartitionStrategy by specifying this configuration. Add maxPartitions Strategy to avoid scan excessive partitions on partitioned table, it's optional that works with defined | 1.4.0 | +| spark.sql.optimizer.dropIgnoreNonExistent | false | When true, do not report an error if DROP DATABASE/TABLE/VIEW/FUNCTION/PARTITION specifies a non-existent database/table/view/function/partition | 1.5.0 | +| spark.sql.optimizer.rebalanceBeforeZorder.enabled | false | When true, we do a rebalance before zorder in case data skew. Note that, if the insertion is dynamic partition we will use the partition columns to rebalance. Note that, this config only affects with Spark 3.3.x. | 1.6.0 | +| spark.sql.optimizer.rebalanceZorderColumns.enabled | false | When true and `spark.sql.optimizer.rebalanceBeforeZorder.enabled` is true, we do rebalance before Z-Order. If it's dynamic partition insert, the rebalance expression will include both partition columns and Z-Order columns. Note that, this config only affects with Spark 3.3.x. | 1.6.0 | +| spark.sql.optimizer.twoPhaseRebalanceBeforeZorder.enabled | false | When true and `spark.sql.optimizer.rebalanceBeforeZorder.enabled` is true, we do two phase rebalance before Z-Order for the dynamic partition write. The first phase rebalance using dynamic partition column; The second phase rebalance using dynamic partition column Z-Order columns. Note that, this config only affects with Spark 3.3.x. | 1.6.0 | +| spark.sql.optimizer.zorderUsingOriginalOrdering.enabled | false | When true and `spark.sql.optimizer.rebalanceBeforeZorder.enabled` is true, we do sort by the original ordering i.e. lexicographical order. Note that, this config only affects with Spark 3.3.x. | 1.6.0 | +| spark.sql.optimizer.inferRebalanceAndSortOrders.enabled | false | When ture, infer columns for rebalance and sort orders from original query, e.g. the join keys from join. It can avoid compression ratio regression. | 1.7.0 | +| spark.sql.optimizer.inferRebalanceAndSortOrdersMaxColumns | 3 | The max columns of inferred columns. | 1.7.0 | +| spark.sql.optimizer.insertRepartitionBeforeWriteIfNoShuffle.enabled | false | When true, add repartition even if the original plan does not have shuffle. | 1.7.0 | +| spark.sql.optimizer.finalStageConfigIsolationWriteOnly.enabled | true | When true, only enable final stage isolation for writing. | 1.7.0 | +| spark.sql.finalWriteStage.eagerlyKillExecutors.enabled | false | When true, eagerly kill redundant executors before running final write stage. | 1.8.0 | +| spark.sql.finalWriteStage.retainExecutorsFactor | 1.2 | If the target executors * factor < active executors, and target executors * factor > min executors, then inject kill executors or inject custom resource profile. | 1.8.0 | +| spark.sql.finalWriteStage.resourceIsolation.enabled | false | When true, make final write stage resource isolation using custom RDD resource profile. | 1.2.0 | +| spark.sql.finalWriteStageExecutorCores | fallback spark.executor.cores | Specify the executor core request for final write stage. It would be passed to the RDD resource profile. | 1.8.0 | +| spark.sql.finalWriteStageExecutorMemory | fallback spark.executor.memory | Specify the executor on heap memory request for final write stage. It would be passed to the RDD resource profile. | 1.8.0 | +| spark.sql.finalWriteStageExecutorMemoryOverhead | fallback spark.executor.memoryOverhead | Specify the executor memory overhead request for final write stage. It would be passed to the RDD resource profile. | 1.8.0 | +| spark.sql.finalWriteStageExecutorOffHeapMemory | NONE | Specify the executor off heap memory request for final write stage. It would be passed to the RDD resource profile. | 1.8.0 | + diff --git a/docs/extensions/engines/spark/z-order-benchmark.md b/docs/extensions/engines/spark/z-order-benchmark.md index d820eee19fd..293042530da 100644 --- a/docs/extensions/engines/spark/z-order-benchmark.md +++ b/docs/extensions/engines/spark/z-order-benchmark.md @@ -1,20 +1,19 @@ - +- x to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Z-order Benchmark @@ -76,7 +75,6 @@ INSERT overwrite table conn_order select src_ip, src_port, dst_ip, dst_port from OPTIMIZE conn_zorder ZORDER BY src_ip, src_port, dst_ip, dst_port; ``` - The complete code is as follows: ```shell @@ -152,7 +150,6 @@ spark.stop() Z-order Optimize statement: ```sql - set spark.sql.hive.convertMetastoreParquet=false; OPTIMIZE conn_zorder_only_ip ZORDER BY src_ip, dst_ip; @@ -166,13 +163,11 @@ ORDER BY statement: INSERT overwrite table conn_order_only_ip select src_ip, src_port, dst_ip, dst_port from conn_random_parquet order by src_ip, dst_ip; INSERT overwrite table conn_order select src_ip, src_port, dst_ip, dst_port from conn_random_parquet order by src_ip, src_port, dst_ip, dst_port; - ``` Query statement: ```sql - set spark.sql.hive.convertMetastoreParquet=true; select count(*) from conn_random_parquet where src_ip like '157%' and dst_ip like '216.%'; @@ -182,10 +177,9 @@ select count(*) from conn_zorder_only_ip where src_ip like '157%' and dst_ip lik select count(*) from conn_zorder where src_ip like '157%' and dst_ip like '216.%'; ``` - ## Benchmark result -We have done two performance tests: one is to compare the efficiency of Z-order Optimize and Order by Sort, +We have done two performance tests: one is to compare the efficiency of Z-order Optimize and Order by Sort, and the other is to query based on the optimized Z-order by data and Random data. ### Efficiency of Z-order Optimize and Order-by Sort @@ -194,17 +188,17 @@ and the other is to query based on the optimized Z-order by data and Random data Z-order by or order by only ip: -| Table | row count | optimize time | -| ------------------- | -------------- | ------------------ | -| conn_order_only_ip | 10,000,000,000 | 1591.99 s | -| conn_zorder_only_ip | 10,000,000,000 | 8371.405 s | +| Table | row count | optimize time | +|---------------------|----------------|----------------| +| conn_order_only_ip | 10,000,000,000 | 1591.99 s | +| conn_zorder_only_ip | 10,000,000,000 | 8371.405 s | Z-order by or order by all columns: -| Table | row count | optimize time | -| ------------------- | -------------- | ------------------ | -| conn_order | 10,000,000,000 | 1515.298 s | -| conn_zorder | 10,000,000,000 | 11057.194 s | +| Table | row count | optimize time | +|-------------|----------------|----------------| +| conn_order | 10,000,000,000 | 1515.298 s | +| conn_zorder | 10,000,000,000 | 11057.194 s | ### Z-order by benchmark result @@ -212,28 +206,24 @@ By querying the tables before and after optimization, we find that: **10 billion data and 200 files and Query resource: 200 core 600G memory** -| Table | Average File Size | Scan row count | Average query time | row count Skipping ratio | -| ------------------- | ----------------- | -------------- | ------------------ | ------------------------ | +| Table | Average File Size | Scan row count | Average query time | row count Skipping ratio | +|---------------------|-------------------|----------------|--------------------|--------------------------| | conn_random_parquet | 1.2 G | 10,000,000,000 | 27.554 s | 0.0% | | conn_zorder_only_ip | 890 M | 43,170,600 | 2.459 s | 99.568% | | conn_zorder | 890 M | 54,841,302 | 3.185 s | 99.451% | - - **10 billion data and 1000 files and Query resource: 200 core 600G memory** -| Table | Average File Size | Scan row count | Average query time | row count Skipping ratio | -| ------------------- | ----------------- | -------------- | ------------------ | ------------------------ | +| Table | Average File Size | Scan row count | Average query time | row count Skipping ratio | +|---------------------|-------------------|----------------|--------------------|--------------------------| | conn_random_parquet | 234.8 M | 10,000,000,000 | 27.031 s | 0.0% | | conn_zorder_only_ip | 173.9 M | 53,499,068 | 3.120 s | 99.465% | | conn_zorder | 174.0 M | 35,910,500 | 3.103 s | 99.640% | - - **1 billion data and 10000 files and Query resource: 10 core 40G memory** -| Table | Average File Size | Scan row count | Average query time | row count Skipping ratio | -| ------------------- | ----------------- | -------------- | ------------------ | ------------------------ | +| Table | Average File Size | Scan row count | Average query time | row count Skipping ratio | +|---------------------|-------------------|----------------|--------------------|--------------------------| | conn_random_parquet | 2.7 M | 1,000,000,000 | 76.772 s | 0.0% | | conn_zorder_only_ip | 2.1 M | 406,572 | 3.963 s | 99.959% | | conn_zorder | 2.2 M | 387,942 | 3.621s | 99.961% | diff --git a/docs/extensions/engines/spark/z-order.md b/docs/extensions/engines/spark/z-order.md index 0ac41529cbe..d04ca3e0c29 100644 --- a/docs/extensions/engines/spark/z-order.md +++ b/docs/extensions/engines/spark/z-order.md @@ -1,19 +1,19 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Z-Ordering Support @@ -22,7 +22,6 @@ stored in all kind of storage with various data format. Please check our benchmark report [here](z-order-benchmark.md). - ## Introduction The following picture shows the workflow of z-order. @@ -32,7 +31,7 @@ The following picture shows the workflow of z-order. It contains three parties: - Upstream - Due to the extra sort, the upstream job will run a little slower than before +Due to the extra sort, the upstream job will run a little slower than before - Table @@ -46,31 +45,31 @@ It contains three parties: | Table Format | Supported | |--------------|-----------| -| parquet | Y | -| orc | Y | -| json | N | -| csv | N | -| text | N | +| parquet | Y | +| orc | Y | +| json | N | +| csv | N | +| text | N | ### Supported column data type | Column Data Type | Supported | |------------------|-----------| -| byte | Y | -| short | Y | -| int | Y | -| long | Y | -| float | Y | -| double | Y | -| boolean | Y | -| string | Y | -| decimal | Y | -| date | Y | -| timestamp | Y | -| array | N | -| map | N | -| struct | N | -| udt | N | +| byte | Y | +| short | Y | +| int | Y | +| long | Y | +| float | Y | +| double | Y | +| boolean | Y | +| string | Y | +| decimal | Y | +| date | Y | +| timestamp | Y | +| array | N | +| map | N | +| struct | N | +| udt | N | ## How to use @@ -86,6 +85,7 @@ Due to the extension, z-order only works with Spark-3.1 and higher version. If you want to optimize the history data of a table, the `OPTIMIZE ...` syntax is good to go. Due to Spark SQL doesn't support read and overwrite same datasource table, the syntax can only support to optimize Hive table. #### Syntax + ```sql OPTIMIZE table_name [WHERE predicate] ZORDER BY col_name1 [, ...] ``` @@ -93,6 +93,7 @@ OPTIMIZE table_name [WHERE predicate] ZORDER BY col_name1 [, ...] Note that, the `predicate` only supports partition spec. #### Examples + ```sql OPTIMIZE t1 ZORDER BY c3; @@ -104,9 +105,11 @@ OPTIMIZE t1 WHERE day = '2021-12-01' ZORDER BY c1,c2; ### Optimize incremental data Kyuubi supports optimize a table automatically for incremental data. e.g., time partitioned table. The only things you need to do is adding Kyuubi properties into the target table properties: + ```sql ALTER TABLE t1 SET TBLPROPERTIES('kyuubi.zorder.enabled'='true','kyuubi.zorder.cols'='c1,c2'); ``` + - the key `kyuubi.zorder.enabled` decide if the table allows Kyuubi to optimize by z-order. - the key `kyuubi.zorder.cols` decide which columns are used to optimize by z-order. @@ -119,3 +122,4 @@ INSERT OVERWRITE TABLE t1 PARTITION() ...; CREATE TABLE t1 AS SELECT ...; ``` + diff --git a/docs/imgs/kyuubi_ecosystem.drawio b/docs/imgs/kyuubi_ecosystem.drawio index 723b306e825..7171491efec 100644 --- a/docs/imgs/kyuubi_ecosystem.drawio +++ b/docs/imgs/kyuubi_ecosystem.drawio @@ -1 +1 @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/docs/imgs/kyuubi_ecosystem.drawio.png b/docs/imgs/kyuubi_ecosystem.drawio.png index 19de7adb52e..72d221d1040 100644 Binary files a/docs/imgs/kyuubi_ecosystem.drawio.png and b/docs/imgs/kyuubi_ecosystem.drawio.png differ diff --git a/docs/make.bat b/docs/make.bat index 1f441aefc55..b8c48a2dba0 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -38,7 +38,7 @@ if errorlevel 9009 ( echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ + echo.https://www.sphinx-doc.org/ exit /b 1 ) diff --git a/docs/monitor/events.md b/docs/monitor/events.md index 3358d5766f4..fd8d91ca026 100644 --- a/docs/monitor/events.md +++ b/docs/monitor/events.md @@ -1,19 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Monitoring Kyuubi - Events System + diff --git a/docs/monitor/logging.md b/docs/monitor/logging.md index 57c673c25f2..24a5a88d699 100644 --- a/docs/monitor/logging.md +++ b/docs/monitor/logging.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Monitoring Kyuubi - Logging System @@ -259,10 +258,12 @@ You will both get the final results and the corresponding operation logs telling +-------------------------------------------------+--------------------+ 1 row selected (0.341 seconds) ``` + ## Further Readings - [Monitoring Kyuubi - Events System](events.md) - [Monitoring Kyuubi - Server Metrics](metrics.md) - [Trouble Shooting](trouble_shooting.md) - Spark Online Documentation - - [Monitoring and Instrumentation](http://spark.apache.org/docs/latest/monitoring.html) + - [Monitoring and Instrumentation](https://spark.apache.org/docs/latest/monitoring.html) + diff --git a/docs/monitor/metrics.md b/docs/monitor/metrics.md index 0a27cf43a40..561014c370c 100644 --- a/docs/monitor/metrics.md +++ b/docs/monitor/metrics.md @@ -1,89 +1,95 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Monitoring Kyuubi - Server Metrics Kyuubi has a configurable metrics system based on the [Dropwizard Metrics Library](https://metrics.dropwizard.io/). -This allows users to report Kyuubi metrics to a variety of `kyuubi.metrics.reporters`. +This allows users to report Kyuubi metrics to a variety of `kyuubi.metrics.reporters`. The metrics provide instrumentation for specific activities and Kyuubi server. ## Configurations The metrics system is configured via `$KYUUBI_HOME/conf/kyuubi-defaults.conf`. -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -`kyuubi.metrics.enabled`|
      true
      |
      Set to true to enable kyuubi metrics system
      |
      boolean
      |
      1.2.0
      -`kyuubi.metrics.reporters`|
      JSON
      |
      A comma separated list for all metrics reporters
      • CONSOLE - ConsoleReporter which outputs measurements to CONSOLE periodically.
      • JMX - JmxReporter which listens for new metrics and exposes them as MBeans.
      • JSON - JsonReporter which outputs measurements to json file periodically.
      • PROMETHEUS - PrometheusReporter which exposes metrics in prometheus format.
      • SLF4J - Slf4jReporter which outputs measurements to system log periodically.
      |
      seq
      |
      1.2.0
      -`kyuubi.metrics.console.interval`|
      PT5S
      |
      How often should report metrics to console
      |
      duration
      |
      1.2.0
      -`kyuubi.metrics.json.interval`|
      PT5S
      |
      How often should report metrics to json file
      |
      duration
      |
      1.2.0
      -`kyuubi.metrics.json.location`|
      metrics
      |
      Where the json metrics file located
      |
      string
      |
      1.2.0
      -`kyuubi.metrics.prometheus.path`|
      /metrics
      |
      URI context path of prometheus metrics HTTP server
      |
      string
      |
      1.2.0
      -`kyuubi.metrics.prometheus.port`|
      10019
      |
      Prometheus metrics HTTP server port
      |
      int
      |
      1.2.0
      -`kyuubi.metrics.slf4j.interval`|
      PT5S
      |
      How often should report metrics to SLF4J logger
      |
      duration
      |
      1.2.0
      +| Key | Default | Meaning | Type | Since | +|-----------------------------------|-----------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------|--------------------------------------| +| `kyuubi.metrics.enabled` |
      true
      |
      Set to true to enable kyuubi metrics system
      |
      boolean
      |
      1.2.0
      | +| `kyuubi.metrics.reporters` |
      JSON
      |
      A comma-separated list for all metrics reporters
      • CONSOLE - ConsoleReporter which outputs measurements to CONSOLE periodically.
      • JMX - JmxReporter which listens for new metrics and exposes them as MBeans.
      • JSON - JsonReporter which outputs measurements to json file periodically.
      • PROMETHEUS - PrometheusReporter which exposes metrics in Prometheus format.
      • SLF4J - Slf4jReporter which outputs measurements to system log periodically.
      |
      seq
      |
      1.2.0
      | +| `kyuubi.metrics.console.interval` |
      PT5S
      |
      How often should report metrics to console
      |
      duration
      |
      1.2.0
      | +| `kyuubi.metrics.json.interval` |
      PT5S
      |
      How often should report metrics to JSON file
      |
      duration
      |
      1.2.0
      | +| `kyuubi.metrics.json.location` |
      metrics
      |
      Where the JSON metrics file located
      |
      string
      |
      1.2.0
      | +| `kyuubi.metrics.prometheus.path` |
      /metrics
      |
      URI context path of prometheus metrics HTTP server
      |
      string
      |
      1.2.0
      | +| `kyuubi.metrics.prometheus.port` |
      10019
      |
      Prometheus metrics HTTP server port
      |
      int
      |
      1.2.0
      | +| `kyuubi.metrics.slf4j.interval` |
      PT5S
      |
      How often should report metrics to SLF4J logger
      |
      duration
      |
      1.2.0
      | ## Metrics These metrics include: -Metrics Prefix | Metrics Suffix | Type | Since | Description ----|---|---|---|--- -`kyuubi.exec.pool.threads.alive` | | gauge | 1.2.0 |
      threads keepAlive in the backend executive thread pool
      -`kyuubi.exec.pool.threads.active` | | gauge | 1.2.0 |
      threads active in the backend executive thread pool
      -`kyuubi.connection.total` | | counter | 1.2.0 |
      cumulative connection count
      -`kyuubi.connection.opened` | | gauge | 1.2.0 |
      current active connection count
      -`kyuubi.connection.opened` | `${user}` | counter | 1.2.0 |
      current active connections count requested by a `${user}`
      -`kyuubi.connection.failed` | | counter | 1.2.0 |
      cumulative failed connection count
      -`kyuubi.connection.failed` | `${user}` | counter | 1.2.0 |
      cumulative failed connections for a `${user}`
      -`kyuubi.operation.total` | | counter | 1.5.0 |
      cumulative opened operation count
      -`kyuubi.operation.total` | `${operationType}` | counter | 1.5.0 |
      cumulative opened count for the operation `${operationType}`
      -`kyuubi.operation.opened` | | gauge | 1.5.0 |
      current opened operation count
      -`kyuubi.operation.opened` | `${operationType}` | counter | 1.5.0 |
      current opened count for the operation `${operationType}`
      -`kyuubi.operation.failed` | `${operationType}`
      `.${errorType}` | counter | 1.5.0 |
      cumulative failed count for the operation `${operationType}` with a particular `${errorType}`, e.g. `execute_statement.AnalysisException`
      -`kyuubi.operation.state` | `${operationState}` | meter | 1.5.0 |
      kyuubi operation state rate
      -`kyuubi.engine.total` | | counter | 1.2.0 |
      cumulative created engines
      -`kyuubi.engine.timeout` | | counter | 1.2.0 |
      cumulative timeout engines
      -`kyuubi.engine.failed` | `${user}` | counter | 1.2.0 |
      cumulative explicitly failed engine count for a `${user}`
      -`kyuubi.engine.failed` | `${errorType}` | counter | 1.2.0 |
      cumulative explicitly failed engine count for a particular `${errorType}`, e.g. `ClassNotFoundException`
      -`kyuubi.backend_service.open_session` | | timer | 1.5.0 |
      kyuubi backend service `openSession` method execution time and rate
      -`kyuubi.backend_service.close_session` | | timer | 1.5.0 |
      kyuubi backend service `closeSession` method execution time and rate
      -`kyuubi.backend_service.get_info` | | timer | 1.5.0 |
      kyuubi backend service `getInfo` method execution time and rate
      -`kyuubi.backend_service.execute_statement` | | timer | 1.5.0 |
      kyuubi backend service `executeStatement` method execution time and rate
      -`kyuubi.backend_service.get_type_info` | | timer | 1.5.0 |
      kyuubi backend service `getTypeInfo` method execution time and rate
      -`kyuubi.backend_service.get_catalogs` | | timer | 1.5.0 |
      kyuubi backend service `getCatalogs` method execution time and rate
      -`kyuubi.backend_service.get_schemas` | | timer | 1.5.0 |
      kyuubi backend service `getSchemas` method execution time and rate
      -`kyuubi.backend_service.get_tables` | | timer | 1.5.0 |
      kyuubi backend service `getTables` method execution time and rate
      -`kyuubi.backend_service.get_table_types` | | timer | 1.5.0 |
      kyuubi backend service `getTableTypes` method execution time and rate
      -`kyuubi.backend_service.get_columns` | | timer | 1.5.0 |
      kyuubi backend service `getColumns` method execution time and rate
      -`kyuubi.backend_service.get_functions` | | timer | 1.5.0 |
      kyuubi backend service `getFunctions` method execution time and rate
      -`kyuubi.backend_service.get_operation_status` | | timer | 1.5.0 |
      kyuubi backend service `getOperationStatus` method execution time and rate
      -`kyuubi.backend_service.cancel_operation` | | timer | 1.5.0 |
      kyuubi backend service `cancelOperation` method execution time and rate
      -`kyuubi.backend_service.close_operation` | | timer | 1.5.0 |
      kyuubi backend service `closeOperation` method execution time and rate
      -`kyuubi.backend_service.get_result_set_metadata` | | timer | 1.5.0 |
      kyuubi backend service `getResultSetMetadata` method execution time and rate
      -`kyuubi.backend_service.fetch_results` | | timer | 1.5.0 |
      kyuubi backend service `fetchResults` method execution time and rate
      -`kyuubi.backend_service.fetch_log_rows_rate` | | meter | 1.5.0 |
      kyuubi backend service `fetchResults` method that fetch log rows rate
      -`kyuubi.backend_service.fetch_result_rows_rate` | | meter | 1.5.0 |
      kyuubi backend service `fetchResults` method that fetch result rows rate
      -`kyuubi.backend_service.get_primary_keys` | | meter | 1.6.0 |
      kyuubi backend service `get_primary_keys` method execution time and rate
      -`kyuubi.backend_service.get_cross_reference` | | meter | 1.6.0 |
      kyuubi backend service `get_cross_reference` method execution time and rate
      -`kyuubi.operation.state` | `${operationType}`
      `.${state}` | meter | 1.6.0 |
      The `${operationType}` with a particular `${state}` rate, e.g. `BatchJobSubmission.pending`, `BatchJobSubmission.finished`. Note that, the terminal states are cumulative, but the intermediate ones are not.
      -`kyuubi.metadata.request.opened` | | counter | 1.6.1 |
      current opened count for the metadata requests
      -`kyuubi.metadata.request.total` | | meter | 1.6.0 |
      metadata requests time and rate
      -`kyuubi.metadata.request.failed` | | meter | 1.6.0 |
      metadata requests failure time and rate
      -`kyuubi.metadata.request.retrying` | | meter | 1.6.0 |
      retrying metadata requests time and rate, it is not cumulative
      +| Metrics Prefix | Metrics Suffix | Type | Since | Description | +|--------------------------------------------------|----------------------------------------|-----------|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `kyuubi.exec.pool.threads.alive` | | gauge | 1.2.0 |
      threads keepAlive in the backend executive thread pool
      | +| `kyuubi.exec.pool.threads.active` | | gauge | 1.2.0 |
      threads active in the backend executive thread pool
      | +| `kyuubi.exec.pool.work_queue.size` | | gauge | 1.7.0 |
      work queue size in the backend executive thread pool
      | +| `kyuubi.connection.total` | | counter | 1.2.0 |
      cumulative connection count
      | +| `kyuubi.connection.total` | `${sessionType}` | counter | 1.7.0 |
      cumulative connection count with session type `${sessionType}`
      | +| `kyuubi.connection.opened` | | gauge | 1.2.0 |
      current active connection count
      | +| `kyuubi.connection.opened` | `${user}` | counter | 1.2.0 |
      current active connections count requested by a `${user}`
      | +| `kyuubi.connection.opened` | `${user}`
      `${sessionType}` | counter | 1.7.0 |
      current active connections count requested by a `${user}` with session type `${sessionType}`
      | +| `kyuubi.connection.opened` | `${sessionType}` | counter | 1.7.0 |
      current active connections count with session type `${sessionType}`
      | +| `kyuubi.connection.failed` | | counter | 1.2.0 |
      cumulative failed connection count
      | +| `kyuubi.connection.failed` | `${user}` | counter | 1.2.0 |
      cumulative failed connections for a `${user}`
      | +| `kyuubi.connection.failed` | `${sessionType}` | counter | 1.7.0 |
      cumulative failed connection count with session type `${sessionType}`
      | +| `kyuubi.operation.total` | | counter | 1.5.0 |
      cumulative opened operation count
      | +| `kyuubi.operation.total` | `${operationType}` | counter | 1.5.0 |
      cumulative opened count for the operation `${operationType}`
      | +| `kyuubi.operation.opened` | | gauge | 1.5.0 |
      current opened operation count
      | +| `kyuubi.operation.opened` | `${operationType}` | counter | 1.5.0 |
      current opened count for the operation `${operationType}`
      | +| `kyuubi.operation.failed` | `${operationType}`
      `.${errorType}` | counter | 1.5.0 |
      cumulative failed count for the operation `${operationType}` with a particular `${errorType}`, e.g. `execute_statement.AnalysisException`
      | +| `kyuubi.operation.state` | `${operationState}` | meter | 1.5.0 |
      kyuubi operation state rate
      | +| `kyuubi.operation.exec_time` | `${operationType}` | histogram | 1.7.0 |
      execution time histogram for the operation `${operationType}`, now only `ExecuteStatement` is enabled.
      | +| `kyuubi.engine.total` | | counter | 1.2.0 |
      cumulative created engines
      | +| `kyuubi.engine.timeout` | | counter | 1.2.0 |
      cumulative timeout engines
      | +| `kyuubi.engine.failed` | `${user}` | counter | 1.2.0 |
      cumulative explicitly failed engine count for a `${user}`
      | +| `kyuubi.engine.failed` | `${errorType}` | counter | 1.2.0 |
      cumulative explicitly failed engine count for a particular `${errorType}`, e.g. `ClassNotFoundException`
      | +| `kyuubi.backend_service.open_session` | | timer | 1.5.0 |
      kyuubi backend service `openSession` method execution time and rate
      | +| `kyuubi.backend_service.close_session` | | timer | 1.5.0 |
      kyuubi backend service `closeSession` method execution time and rate
      | +| `kyuubi.backend_service.get_info` | | timer | 1.5.0 |
      kyuubi backend service `getInfo` method execution time and rate
      | +| `kyuubi.backend_service.execute_statement` | | timer | 1.5.0 |
      kyuubi backend service `executeStatement` method execution time and rate
      | +| `kyuubi.backend_service.get_type_info` | | timer | 1.5.0 |
      kyuubi backend service `getTypeInfo` method execution time and rate
      | +| `kyuubi.backend_service.get_catalogs` | | timer | 1.5.0 |
      kyuubi backend service `getCatalogs` method execution time and rate
      | +| `kyuubi.backend_service.get_schemas` | | timer | 1.5.0 |
      kyuubi backend service `getSchemas` method execution time and rate
      | +| `kyuubi.backend_service.get_tables` | | timer | 1.5.0 |
      kyuubi backend service `getTables` method execution time and rate
      | +| `kyuubi.backend_service.get_table_types` | | timer | 1.5.0 |
      kyuubi backend service `getTableTypes` method execution time and rate
      | +| `kyuubi.backend_service.get_columns` | | timer | 1.5.0 |
      kyuubi backend service `getColumns` method execution time and rate
      | +| `kyuubi.backend_service.get_functions` | | timer | 1.5.0 |
      kyuubi backend service `getFunctions` method execution time and rate
      | +| `kyuubi.backend_service.get_operation_status` | | timer | 1.5.0 |
      kyuubi backend service `getOperationStatus` method execution time and rate
      | +| `kyuubi.backend_service.cancel_operation` | | timer | 1.5.0 |
      kyuubi backend service `cancelOperation` method execution time and rate
      | +| `kyuubi.backend_service.close_operation` | | timer | 1.5.0 |
      kyuubi backend service `closeOperation` method execution time and rate
      | +| `kyuubi.backend_service.get_result_set_metadata` | | timer | 1.5.0 |
      kyuubi backend service `getResultSetMetadata` method execution time and rate
      | +| `kyuubi.backend_service.fetch_results` | | timer | 1.5.0 |
      kyuubi backend service `fetchResults` method execution time and rate
      | +| `kyuubi.backend_service.fetch_log_rows_rate` | | meter | 1.5.0 |
      kyuubi backend service `fetchResults` method that fetch log rows rate
      | +| `kyuubi.backend_service.fetch_result_rows_rate` | | meter | 1.5.0 |
      kyuubi backend service `fetchResults` method that fetch result rows rate
      | +| `kyuubi.backend_service.get_primary_keys` | | meter | 1.6.0 |
      kyuubi backend service `get_primary_keys` method execution time and rate
      | +| `kyuubi.backend_service.get_cross_reference` | | meter | 1.6.0 |
      kyuubi backend service `get_cross_reference` method execution time and rate
      | +| `kyuubi.operation.state` | `${operationType}`
      `.${state}` | meter | 1.6.0 |
      The `${operationType}` with a particular `${state}` rate, e.g. `BatchJobSubmission.pending`, `BatchJobSubmission.finished`. Note that, the terminal states are cumulative, but the intermediate ones are not.
      | +| `kyuubi.metadata.request.opened` | | counter | 1.6.1 |
      current opened count for the metadata requests
      | +| `kyuubi.metadata.request.total` | | meter | 1.6.0 |
      metadata requests time and rate
      | +| `kyuubi.metadata.request.failed` | | meter | 1.6.0 |
      metadata requests failure time and rate
      | +| `kyuubi.metadata.request.retrying` | | meter | 1.6.0 |
      retrying metadata requests time and rate, it is not cumulative
      | Before v1.5.0, if you use these metrics: - `kyuubi.statement.total` diff --git a/docs/monitor/trouble_shooting.md b/docs/monitor/trouble_shooting.md index b7abc30261d..e6ba5ea1a6b 100644 --- a/docs/monitor/trouble_shooting.md +++ b/docs/monitor/trouble_shooting.md @@ -1,25 +1,26 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Trouble Shooting ## Common Issues + ### java.lang.UnsupportedClassVersionError .. Unsupported major.minor version 52.0 + ``` Exception in thread "main" java.lang.UnsupportedClassVersionError: org/apache/kyuubi/server/KyuubiServer : Unsupported major.minor version 52.0 at java.lang.ClassLoader.defineClass1(Native Method) @@ -87,10 +88,8 @@ To fix this problem you should export `HADOOP_CONF_DIR` to the folder that conta echo "export HADOOP_CONF_DIR=/path/to/hadoop/conf" >> conf/kyuubi-env.sh ``` - ### javax.security.sasl.SaslException: GSS initiate failed [Caused by GSSException: No valid credentials provided (Mechanism level: Failed to find any Kerberos tgt)]; - ### org.apache.hadoop.security.AccessControlException: Permission denied: user=hzyanqin, access=WRITE, inode="/user":hdfs:hdfs:drwxr-xr-x ``` @@ -167,7 +166,6 @@ The user do not have permission to create to Hadoop home dir, which is `/user/hz To fix this problem you need to create this directory first and grant ACL permission for `hzyanqin`. - ### org.apache.thrift.TApplicationException: Invalid method name: 'get_table_req' ``` @@ -198,7 +196,6 @@ This error means that you are using incompatible version of Hive metastore clien To fix this problem you could use a compatible version of Hive client by configuring `spark.sql.hive.metastore.jars` and `spark.sql.hive.metastore.version` at Spark side. - ### hive.server2.thrift.max.worker.threads ``` @@ -209,6 +206,7 @@ Error: org.apache.thrift.transport.TTransportException (state=08S01,code=0) In Kyuubi, we should increase `kyuubi.frontend.min.worker.threads` instead of `hive.server2.thrift.max.worker.threads` ### Failed to create function using jar + `CREATE TEMPORARY FUNCTION TEST AS 'com.netease.UDFTest' using jar 'hdfs:///tmp/udf.jar'` ``` @@ -248,7 +246,9 @@ If you get this exception when creating a function, you can check your JDK versi You should update JDK to JDK1.8.0_121 and later, since JDK1.8.0_121 fix a security issue [Additional access restrictions for URLClassLoader.newInstance](https://www.oracle.com/java/technologies/javase/8u121-relnotes.html). ### Failed to start Spark 3.1 with error msg 'Cannot modify the value of a Spark config' + Here is the error message + ``` Caused by: org.apache.spark.sql.AnalysisException: Cannot modify the value of a Spark config: spark.yarn.queue at org.apache.spark.sql.RuntimeConfig.requireNonStaticConf(RuntimeConfig.scala:156) diff --git a/docs/overview/architecture.md b/docs/overview/architecture.md index d3dc7030a0a..4df5e24a4ab 100644 --- a/docs/overview/architecture.md +++ b/docs/overview/architecture.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kyuubi Architecture @@ -108,7 +107,7 @@ and these applications can be placed in different shared domains for other conne Kyuubi does not occupy any resources from the Cluster Manager(e.g. Yarn) during startup and will give all resources back if there is not any active session interacting with a `SparkContext`. -Spark also provides [Dynamic Resource Allocation](http://spark.apache.org/docs/latest/job-scheduling.html#dynamic-resource-allocation) to dynamically adjust the resources your application occupies based on the workload. It means +Spark also provides [Dynamic Resource Allocation](https://spark.apache.org/docs/latest/job-scheduling.html#dynamic-resource-allocation) to dynamically adjust the resources your application occupies based on the workload. It means that your application may give resources back to the cluster if they are no longer used and request them again later when there is demand. This feature is handy if multiple applications share resources in your Spark cluster. @@ -133,7 +132,6 @@ On the one hand, because tom enables Spark's dynamic resource request feature, Spark will efficiently request and recycle executors within the program based on the SQL operations scale and the available resources in the queue. On the other hand, when Kyuubi finds that the application has been idle for too long, it will also recycle its application. - ## High Availability & Load Balance For an enterprise service, the Service Level Agreement(SLA) commitment must be very high. @@ -174,5 +172,5 @@ We also create a [Submarine: Spark Security](https://mvnrepository.com/artifact/ ## Conclusions -Kyuubi is a unified multi-tenant JDBC interface for large-scale data processing and analytics, built on top of [Apache Spark™](http://spark.apache.org/). +Kyuubi is a unified multi-tenant JDBC interface for large-scale data processing and analytics, built on top of [Apache Spark™](https://spark.apache.org/). It extends the Spark Thrift Server's scenarios in enterprise applications, the most important of which is multi-tenancy support. diff --git a/docs/overview/kyuubi_vs_hive.md b/docs/overview/kyuubi_vs_hive.md index 40ee9136b66..80038c17864 100644 --- a/docs/overview/kyuubi_vs_hive.md +++ b/docs/overview/kyuubi_vs_hive.md @@ -1,23 +1,22 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kyuubi v.s. HiveServer2 - ## Introduction HiveServer2 is a service that enables clients to execute Hive QL queries on Hive supporting multi-client concurrency and authentication. @@ -25,29 +24,26 @@ Kyuubi enables clients to execute Spark SQL queries directly on Spark supporting They are both designed to provide better support for open API clients like JDBC and ODBC to manage and analyze BigData. - ## Hive on Spark The purpose of Hive on Spark is to add Spark as a third execution backend, parallel to MR and Tez. Comparing to Hive on MR, it's use the Spark DAG will help improve the performance of Hive queries, especially those have multiple reducer stages. - - - ## Differences Between Kyuubi and HiveServer2 -- | Kyuubi | HiveServer2 | ---- | --- | --- -** Language ** | Spark SQL | Hive QL -** Optimizer ** | Spark SQL Catalyst | Hive Optimizer -** Engine ** | up to Spark 3.x | MapReduce/[up to Spark 2.3](https://cwiki.apache.org/confluence/display/Hive/Hive+on+Spark%3A+Getting+Started#HiveonSpark:GettingStarted-VersionCompatibility)/Tez -** Performance ** | High | Low -** Compatibility with Spark ** | Good | Bad(need to rebuild on a specific version) -** Data Types ** | [Spark Data Types](http://spark.apache.org/docs/latest/sql-ref-datatypes.html) | [Hive Data Types](https://cwiki.apache.org/confluence/display/Hive/LanguageManual+Types) - +| | Kyuubi | HiveServer2 | +|------------------------------|---------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Language** | Spark SQL | Hive QL | +| **Optimizer** | Spark SQL Catalyst | Hive Optimizer | +| **Engine** | up to Spark 3.x | MapReduce/[up to Spark 2.3](https://cwiki.apache.org/confluence/display/Hive/Hive+on+Spark%3A+Getting+Started#HiveonSpark:GettingStarted-VersionCompatibility)/Tez | +| **Performance** | High | Low | +| **Compatibility with Spark** | Good | Bad(need to rebuild on a specific version) | +| **Data Types** | [Spark Data Types](https://spark.apache.org/docs/latest/sql-ref-datatypes.html) | [Hive Data Types](https://cwiki.apache.org/confluence/display/Hive/LanguageManual+Types) | ## Performance + ## References 1. [HiveServer2 Overview](https://cwiki.apache.org/confluence/display/Hive/HiveServer2+Overview) + diff --git a/docs/overview/kyuubi_vs_thriftserver.md b/docs/overview/kyuubi_vs_thriftserver.md index 9aeb5962b11..66f900c7441 100644 --- a/docs/overview/kyuubi_vs_thriftserver.md +++ b/docs/overview/kyuubi_vs_thriftserver.md @@ -1,28 +1,27 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kyuubi v.s. Spark Thrift JDBC/ODBC Server (STS) ## Introductions -The Apache Spark [Thrift JDBC/ODBC Server](http://spark.apache.org/docs/latest/sql-distributed-sql-engine.html) is a Thrift service implemented by the Apache Spark community based on HiveServer2. +The Apache Spark [Thrift JDBC/ODBC Server](https://spark.apache.org/docs/latest/sql-distributed-sql-engine.html) is a Thrift service implemented by the Apache Spark community based on HiveServer2. Designed to be seamlessly compatible with HiveServer2, it provides Spark SQL capabilities to end-users in a pure SQL way through a JDBC interface. -This "out-of-the-box" model minimizes the barriers and costs for users to use Spark. +This "out-of-the-box" model minimizes the barriers and costs for users to use Spark. Kyuubi and Spark are aligned in this goal. On top of that, Kyuubi has made enhancements in multi-tenant support, service availability, service concurrency capability, data security, and other aspects. @@ -48,7 +47,7 @@ If they use too many resources, will it affect other critical tasks? Otherwise, will the cluster's resources be idle and wasted? It is also hard for users to set up thousands of Spark configurations properly. Key features like [Dynamic Resource Allocation](../deployment/spark/dynamic_allocation.md), Speculation might be hard to benefit all with a one-time setup. -And new features like [Adaptive Query Execution](../deployment/spark/aqe.md) could come a long way from the first release involved of Spark to finally get applied to end-users. +And new features like [Adaptive Query Execution](../deployment/spark/aqe.md) could come a long way from the first release involved of Spark to finally get applied to end-users. #### Insecurity @@ -98,7 +97,6 @@ The server-side upgrade will not cause interface incompatibility. As for the potential SQL compatibility problem in Spark version upgrade, it also exists when not using Spark ThriftServer, and is more challenging to solve. Moreover, in Spark ThriftServer mode, the server-side can do the full amount of SQL collection in advance, and the verification can be done before the upgrade. - ## Limitations of Spark ThriftServer As we can see from the basic architecture of Spark ThriftServer above, it is essentially a single Spark application, and there are generally significant limitations to responding to thousands of client requests. @@ -119,7 +117,7 @@ With Fair Scheduler Pools, Spark ThriftServer has the ability of resource isolat It will send queries to a high-weight pool to get more executors for execution. In essence, resource isolation such as CPU/memory/IO should be something that resource managers like YARN and Kubernetes should do. Doing logical isolation at the computing layer is unlikely to work well, and this problem exists in the Apache Impala project as well, for example. -And it is difficult to avoid the problem of HMS, HDFS single point access, especially in the scenario of reading and writing dynamic partition tables or handling queries with numerous `Union`s. +And it is difficult to avoid the problem of HMS, HDFS single point access, especially in the scenario of reading and writing dynamic partition tables or handling queries with numerous `Union`s. ### Multi-tenancy limitations @@ -162,7 +160,6 @@ Besides, since UDFs are loaded directly into the Spark ThriftServer, if they con The HiveServer2 is also introduced here for a more comprehensive comparison. - || HiveServer2
      (Hive on Spark) | Spark ThriftServer | Kyuubi | |--|--|--|--| |**Interface** | HiveJDBC | HiveJDBC | HiveJDBC | @@ -183,16 +180,15 @@ The HiveServer2 is also introduced here for a more comprehensive comparison. |**Compute
      Resource
      Management** | YARN |pools| YARN, Kubernetes, etc. | |**Resource
      Occupancy
      Time** | within a query | Permanent | Using Kyuubi Engine to request and
      release resources
      1. For `CONNECTION` level isolation, an Engine terminates when a JDBC connection disconnects
      2. For other modes, an Engine timeouts after all connections disconnect.
      3. All isolation modes support [DRA](../deployment/spark/dynamic_allocation.md)
      | -### Consistent Interfaces +### Consistent Interfaces Kyuubi, Spark Thrift Server, and HiveServer2 are identical in terms of interfaces and protocols. Therefore, from the user's point of view, the way of use is unchanged. -Compared with HiveServer2, the most significant advantage of the first two should be the performance improvement. +Compared with HiveServer2, the most significant advantage of the first two should be the performance improvement. From the perspective of SQL syntax compatibility, Kyuubi and Spark Thrift Server are fully compatible with Spark SQL as they are completely delegated to the Spark SQL Catalyst layer. Spark SQL also fully supports Hive QL collections, with only a few enumerable SQL behaviors and syntax differences. - ### Multi-tenant Architecture `From wikipedia`: The term "software multitenancy" refers to a software architecture in which a single instance of the software runs on a server and serves multiple tenants. Systems designed in such a manner are often called shared (in contrast to dedicated or isolated). diff --git a/docs/quick_start/quick_start.rst b/docs/quick_start/quick_start.rst index ca73fba35f3..db564edb92c 100644 --- a/docs/quick_start/quick_start.rst +++ b/docs/quick_start/quick_start.rst @@ -143,7 +143,7 @@ To install Spark, you need to unpack the tarball. For example, .. code-block:: - $ tar zxf spark-3.3.1-bin-hadoop3.tgz + $ tar zxf spark-3.3.2-bin-hadoop3.tgz Configuration ~~~~~~~~~~~~~ diff --git a/docs/quick_start/quick_start_with_helm.md b/docs/quick_start/quick_start_with_helm.md index 4b3c85ba7d7..a2de5444560 100644 --- a/docs/quick_start/quick_start_with_helm.md +++ b/docs/quick_start/quick_start_with_helm.md @@ -1,106 +1,120 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> +# Getting Started With Kyuubi on Kubernetes -# Getting Started With Kyuubi on kubernetes +## Running Kyuubi with Helm -## Running kyuubi with helm +[Helm](https://helm.sh/) is the package manager for Kubernetes, it can be used to find, share, and use software built for Kubernetes. -[Helm](https://helm.sh/) is the package manager for Kubernetes,it can be used to find, share, and use software built for Kubernetes. +### Install Helm -### Get helm and Install - -Please go to [Install Helm](https://helm.sh/docs/intro/install/) page to get and install an appropriate release version for yourself. +Please go to [Installing Helm](https://helm.sh/docs/intro/install/) page to get and install an appropriate release version for yourself. ### Get Kyuubi Started -#### [Optional] Create namespace on kubernetes -```bash -create ns kyuubi -``` +#### Install the chart -#### Get kyuubi started -```bash -helm install kyuubi-helm ${KYUUBI_HOME}/charts/kyuubi -n ${namespace_name} +```shell +helm install kyuubi ${KYUUBI_HOME}/charts/kyuubi -n kyuubi --create-namespace ``` -It will print variables and the way to get kyuubi expose ip and port. -```bash -NAME: kyuubi-helm -LAST DEPLOYED: Wed Oct 20 15:22:47 2021 + +It will print release info with notes, including the ways to get Kyuubi accessed within Kubernetes cluster and exposed externally depending on the configuration provided. + +```shell +NAME: kyuubi +LAST DEPLOYED: Sat Feb 11 20:59:00 2023 NAMESPACE: kyuubi STATUS: deployed REVISION: 1 TEST SUITE: None NOTES: -Get kyuubi expose URL by running these commands: - export NODE_PORT=$(kubectl get --namespace kyuubi -o jsonpath="{.spec.ports[0].nodePort}" services kyuubi-svc) - export NODE_IP=$(kubectl get nodes --namespace kyuubi -o jsonpath="{.items[0].status.addresses[0].address}") - echo $NODE_IP:$NODE_PORT +The chart has been installed! + +In order to check the release status, use: + helm status kyuubi -n kyuubi + or for more detailed info + helm get all kyuubi -n kyuubi + +************************ +******* Services ******* +************************ +THRIFT_BINARY: +- To access kyuubi-thrift-binary service within the cluster, use the following URL: + kyuubi-thrift-binary.kyuubi.svc.cluster.local +- To access kyuubi-thrift-binary service from outside the cluster for debugging, run the following command: + kubectl port-forward svc/kyuubi-thrift-binary 10009:10009 -n kyuubi + and use 127.0.0.1:10009 ``` -#### Using hive beeline -[Using Hive Beeline](./quick_start.html#using-hive-beeline) to opening a connection. +#### Uninstall the chart -#### Remove kyuubi -```bash -helm uninstall kyuubi-helm -n ${namespace_name} +```shell +helm uninstall kyuubi -n kyuubi ``` -#### Edit server config +#### Configure chart release + +Specify configuration properties using `--set` flag. +For example, to install the chart with `replicaCount` set to `1`, use the following command: + +```shell +helm install kyuubi ${KYUUBI_HOME}/charts/kyuubi -n kyuubi --create-namespace --set replicaCount=1 +``` + +Also, custom values file can be used to override default property values. For example, create `myvalues.yaml` to specify `replicaCount` and `resources`: -Modify `values.yaml` under `${KYUUBI_HOME}/docker/helm`: ```yaml -# Kyuubi server numbers -replicaCount: 2 - -image: - repository: apache/kyuubi - pullPolicy: Always - # Overrides the image tag whose default is the chart appVersion. - tag: "master-snapshot" - -server: - bind: - host: 0.0.0.0 - port: 10009 - conf: - mountPath: /opt/kyuubi/conf - -service: - type: NodePort - # The default port limit of kubernetes is 30000-32767 - # to change: - # vim kube-apiserver.yaml (usually under path: /etc/kubernetes/manifests/) - # add or change line 'service-node-port-range=1-32767' under kube-apiserver - port: 30009 +replicaCount: 1 + +resources: + requests: + cpu: 2 + memory: 4Gi + limits: + cpu: 4 + memory: 10Gi +``` + +and use it to override default chart values with `-f` flag: + +```shell +helm install kyuubi ${KYUUBI_HOME}/charts/kyuubi -n kyuubi --create-namespace -f myvalues.yaml ``` -#### Get server log -List all server pods: -```bash -kubectl get po -n ${namespace_name} +#### Access logs + +List all pods in the release namespace: + +```shell +kubectl get pod -n kyuubi ``` -The server pods will print: -```text -NAME READY STATUS RESTARTS AGE -kyuubi-server-585d8944c5-m7j5s 1/1 Running 0 30m -kyuubi-server-32sdsa1245-2d2sj 1/1 Running 0 30m + +Find Kyuubi pods: + +```shell +NAME READY STATUS RESTARTS AGE +kyuubi-5b6d496c98-kbhws 1/1 Running 0 38m +kyuubi-5b6d496c98-lqldk 1/1 Running 0 38m ``` -then, use pod name to get logs: -```bash -kubectl -n ${namespace_name} logs kyuubi-server-585d8944c5-m7j5s + +Then, use pod name to get logs: + +```shell +kubectl logs kyuubi-5b6d496c98-kbhws -n kyuubi ``` + diff --git a/docs/quick_start/quick_start_with_jdbc.md b/docs/quick_start/quick_start_with_jdbc.md index e305530f1b9..c22cc1b65c1 100644 --- a/docs/quick_start/quick_start_with_jdbc.md +++ b/docs/quick_start/quick_start_with_jdbc.md @@ -1,24 +1,24 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Getting Started With Hive JDBC ## How to install JDBC driver + Kyuubi JDBC driver is fully compatible with the 2.3.* version of hive JDBC driver, so we reuse hive JDBC driver to connect to Kyuubi server. Add repository to your maven configuration file which may reside in `$MAVEN_HOME/conf/settings.xml`. @@ -32,6 +32,7 @@ Add repository to your maven configuration file which may reside in `$MAVEN_HOME ``` + You can add below dependency to your `pom.xml` file in your application. ```xml @@ -50,6 +51,7 @@ You can add below dependency to your `pom.xml` file in your application. ``` ## Use JDBC driver with kerberos + The below java code is using a keytab file to login and connect to Kyuubi server by JDBC. ```java @@ -91,3 +93,4 @@ public class JDBCTest { } } ``` + diff --git a/docs/quick_start/quick_start_with_jupyter.md b/docs/quick_start/quick_start_with_jupyter.md index 9a651d45b0a..44b3faa5786 100644 --- a/docs/quick_start/quick_start_with_jupyter.md +++ b/docs/quick_start/quick_start_with_jupyter.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Getting Started With Hive Jupyter Lap diff --git a/docs/requirements.txt b/docs/requirements.txt index 8a5ee7e128b..ecc8116e77d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -17,11 +17,10 @@ # under the License. # -# we shall bypass markdown-3.4.1, see details in KYUUBI-3126 -markdown==3.3.7 +markdown==3.4.1 recommonmark==0.7.1 sphinx==4.5.0 sphinx-book-theme==0.3.3 -sphinx-markdown-tables==0.0.15 -sphinx-notfound-page==0.8 +sphinx-markdown-tables==0.0.17 +sphinx-notfound-page==0.8.3 sphinx-togglebutton===0.3.2 diff --git a/docs/security/authorization/spark/build.md b/docs/security/authorization/spark/build.md index bef011867ad..3886f08dfa3 100644 --- a/docs/security/authorization/spark/build.md +++ b/docs/security/authorization/spark/build.md @@ -1,28 +1,25 @@ - - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Building Kyuubi Spark AuthZ Plugin - ## Build with Apache Maven -Kyuubi Spark AuthZ Plugin is built using [Apache Maven](http://maven.apache.org). +Kyuubi Spark AuthZ Plugin is built using [Apache Maven](https://maven.apache.org). To build it, `cd` to the root direct of kyuubi project and run: ```shell @@ -48,14 +45,14 @@ build/mvn clean package -pl :kyuubi-spark-authz_2.12 -DskipTests -Dspark.version The available `spark.version`s are shown in the following table. -| Spark Version | Supported | Remark | -|:-----------------:|:-----------:|:----------------------------------------------------------------------------------------------------------------------:| -| master | √ | - | -| 3.3.x | √ | - | -| 3.2.x | √ | - | -| 3.1.x | √ | - | -| 3.0.x | √ | - | -| 2.4.x and earlier | × | [PR 2367](https://github.com/apache/kyuubi/pull/2367) is used to track how we work with older releases with scala 2.11 | +| Spark Version | Supported | Remark | +|:-----------------:|:---------:|:----------------------------------------------------------------------------------------------------------------------:| +| master | √ | - | +| 3.3.x | √ | - | +| 3.2.x | √ | - | +| 3.1.x | √ | - | +| 3.0.x | √ | - | +| 2.4.x and earlier | × | [PR 2367](https://github.com/apache/kyuubi/pull/2367) is used to track how we work with older releases with scala 2.11 | Currently, Spark released with Scala 2.12 are supported. @@ -71,21 +68,22 @@ build/mvn clean package -pl :kyuubi-spark-authz_2.12 -DskipTests -Dranger.versio The available `ranger.version`s are shown in the following table. -| Ranger Version | Supported | Remark | -|:--------------:|:-----------:|:------:| -| 2.3.x | √ | - | -| 2.2.x | √ | - | -| 2.1.x | √ | - | -| 2.0.x | √ | - | -| 1.2.x | √ | - | -| 1.1.x | √ | - | -| 1.0.x | √ | - | -| 0.7.x | √ | - | -| 0.6.x | √ | - | +| Ranger Version | Supported | Remark | +|:--------------:|:---------:|:------:| +| 2.3.x | √ | - | +| 2.2.x | √ | - | +| 2.1.x | √ | - | +| 2.0.x | √ | - | +| 1.2.x | √ | - | +| 1.1.x | √ | - | +| 1.0.x | √ | - | +| 0.7.x | √ | - | +| 0.6.x | √ | - | Currently, all ranger releases are supported. ## Test with ScalaTest Maven plugin + If you omit `-DskipTests` option in the command above, you will also get all unit tests run. ```shell diff --git a/docs/security/authorization/spark/install.md b/docs/security/authorization/spark/install.md index 1d77d15b5eb..f820f53c4ec 100644 --- a/docs/security/authorization/spark/install.md +++ b/docs/security/authorization/spark/install.md @@ -1,21 +1,19 @@ - - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Installing and Configuring Kyuubi Spark AuthZ Plugin @@ -42,8 +40,9 @@ With the `kyuubi-spark-authz_*.jar` and its transitive dependencies available fo ### Settings for Connecting Ranger Admin #### ranger-spark-security.xml + - Create `ranger-spark-security.xml` in `$SPARK_HOME/conf` and add the following configurations -for pointing to the right Ranger admin server. + for pointing to the right Ranger admin server. ```xml @@ -74,10 +73,11 @@ for pointing to the right Ranger admin server. ``` + ##### Using Macros in Row Level Filters Macros are now supported for using user/group/tag in row filter expressions, introduced in [Ranger 2.3](https://cwiki.apache.org/confluence/display/RANGER/Apache+Ranger+2.3.0+-+Release+Notes). This feature helps significantly simplify row filter expressions by using user/group/tag's attributes instead of explicit conditions. Considering a user with an attribute `born_city` of value `Guangzhou `, the row filter condition as `city='${{USER.born_city}}'` will be transformed to `city='Guangzhou'` in execution plan. More supported macros and usage refer to [RANGER-3605](https://issues.apache.org/jira/browse/RANGER-3605) and [RANGER-3550](https://issues.apache.org/jira/browse/RANGER-3550). Add the following configs to `ranger-spark-security.xml` to enable UserStore Enricher required by macros. - + ```xml ranger.plugin.spark.enable.implicit.userstore.enricher @@ -93,13 +93,15 @@ Macros are now supported for using user/group/tag in row filter expressions, int ``` ##### Showing all disallowed privileges + By default, Authz plugin checks required privileges one by one and throw the first unsatisfied privilege in exception. By setting `ranger.plugin.spark.authorize.in.single.call` to `true`, Authz plugin executes access checks in single call and throws all disallowed privileges in exception message. + ```xml - - ranger.plugin.spark.authorize.in.single.call - true - Enable access checks in single call with all disallowed privileges thrown in exception. Default value is false. - + + ranger.plugin.spark.authorize.in.single.call + true + Enable access checks in single call with all disallowed privileges thrown in exception. Default value is false. + ``` #### ranger-spark-audit.xml @@ -150,3 +152,4 @@ Add `org.apache.kyuubi.plugin.spark.authz.ranger.RangerSparkExtension` to the sp ```properties spark.sql.extensions=org.apache.kyuubi.plugin.spark.authz.ranger.RangerSparkExtension ``` + diff --git a/docs/security/hadoop_credentials_manager.md b/docs/security/hadoop_credentials_manager.md index 087d2c68b0c..baed91c8e3a 100644 --- a/docs/security/hadoop_credentials_manager.md +++ b/docs/security/hadoop_credentials_manager.md @@ -1,85 +1,90 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Hadoop Credentials Manager -In order to pass the authentication of a kerberos secured hadoop cluster, kyuubi currently submits +In order to pass the authentication of a kerberos secured hadoop cluster, kyuubi currently submits engines in two ways: 1. Submits with current kerberos user and extra `SparkSubmit` argument `--proxy-user`. 2. Submits with `spark.kerberos.principal` and `spark.kerberos.keytab` specified. -If engine is submitted with `--proxy-user` specified, its delegation tokens of hadoop cluster +If engine is submitted with `--proxy-user` specified, its delegation tokens of hadoop cluster services are obtained by current kerberos user and can not be renewed by itself. Thus, engine's lifetime is limited by the lifetime of delegation tokens. To remove this limitation, kyuubi renews delegation tokens at server side in Hadoop Credentials Manager. -Engine submitted with principal and keytab can renew delegation tokens by itself. +Engine submitted with principal and keytab can renew delegation tokens by itself. But for implementation simplicity, kyuubi server will also renew delegation tokens for it. ## Configurations ### Cluster Services + Kyuubi currently supports renew delegation tokens of Hadoop filesystems and Hive metastore servers. #### Hadoop client configurations + Set `HADOOP_CONF_DIR` in `$KYUUBI_HOME/conf/kyuubi-env.sh` if it hasn't been set yet, e.g. ```bash $ echo "export HADOOP_CONF_DIR=/path/to/hadoop/conf" >> $KYUUBI_HOME/conf/kyuubi-env.sh ``` + Extra Hadoop filesystems can be specified in `$KYUUBI_HOME/conf/kyuubi-defaults.conf` by `kyuubi.credentials.hadoopfs.uris` in comma separated list. #### Hive metastore configurations ##### Via kyuubi-defaults.conf -Specify Hive metastore configurations In `$KYUUBI_HOME/conf/kyuubi-defaults.conf`. Hadoop Credentials + +Specify Hive metastore configurations In `$KYUUBI_HOME/conf/kyuubi-defaults.conf`. Hadoop Credentials Manager will load the configurations when initialized. ##### Via hive-site.xml -Place your copy of `hive-site.xml` into `$KYUUBI_HOME/conf`, Kyuubi will load this config file to + +Place your copy of `hive-site.xml` into `$KYUUBI_HOME/conf`, Kyuubi will load this config file to its classpath. This version of configuration has lower priority than those in `$KYUUBI_HOME/conf/kyuubi-defaults.conf`. ##### Via JDBC Connection URL + Hive configurations specified in JDBC connection URL are ignored by Hadoop Credentials Manager as Hadoop Credentials Manager is initialized when Kyuubi server starts. ### Credentials Renewal -Key | Default | Meaning | Type | Since ---- | --- | --- | --- | --- -kyuubi.credentials.hadoopfs.enabled|
      true
      |
      Whether to renew Hadoop filesystem delegation tokens
      |
      boolean
      |
      1.4.0
      -kyuubi.credentials.hadoopfs.uris|
      |
      Extra Hadoop filesystem URIs for which to request delegation tokens. The filesystem that hosts fs.defaultFS does not need to be listed here.
      |
      seq
      |
      1.4.0
      -kyuubi.credentials.hive.enabled|
      true
      |
      Whether to renew Hive metastore delegation token
      |
      boolean
      |
      1.4.0
      -kyuubi.credentials.renewal.interval|
      PT1H
      |
      How often Kyuubi renews one user's delegation tokens
      |
      duration
      |
      1.4.0
      -kyuubi.credentials.renewal.retry.wait|
      PT1M
      |
      How long to wait before retrying to fetch new credentials after a failure.
      |
      duration
      |
      1.4.0
      - +| Key | Default | Meaning | Type | Since | +|----------------------------------------------------|-------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------|--------------------------------------| +| kyuubi.credentials.hadoopfs.enabled |
      true
      |
      Whether to renew Hadoop filesystem delegation tokens
      |
      boolean
      |
      1.4.0
      | +| kyuubi.credentials.hadoopfs.uris |
      |
      Extra Hadoop filesystem URIs for which to request delegation tokens. The filesystem that hosts fs.defaultFS does not need to be listed here.
      |
      seq
      |
      1.4.0
      | +| kyuubi.credentials.hive.enabled |
      true
      |
      Whether to renew Hive metastore delegation token
      |
      boolean
      |
      1.4.0
      | +| kyuubi.credentials.renewal.interval |
      PT1H
      |
      How often Kyuubi renews one user's delegation tokens
      |
      duration
      |
      1.4.0
      | +| kyuubi.credentials.renewal.retry.wait |
      PT1M
      |
      How long to wait before retrying to fetch new credentials after a failure.
      |
      duration
      |
      1.4.0
      | ### Required Security Configs The necessary configurations for hdfs and hive to obtain delegation token are as follows: -Key | Meaning | value ---- | --- | --- -hadoop.security.authentication|
      Set the authentication for the cluster
      |
      kerberos
      -hive.metastore.uris|
      URI for client to contact metastore server
      |
      thrift://{metastoreHost}:{metastorePort}}
      -hive.metastore.sasl.enabled|
      If true, the metastore thrift interface will be secured with SASL.Clients must authenticate with Kerberos.
      |
      true
      -hive.metastore.kerberos.principal|
      The service principal for the metastore thrift server. The special string _HOST will be replaced automatically with the correct host name.
      |
      for example hive/_HOST@${realm}
      -hive.metastore.kerberos.keytab.file|
      The path to the Kerberos Keytab file containing the metastore thrift server's service principal.
      |
      for example /etc/security/keytabs/hive.service.keytab
      +| Key | Meaning | value | +|--------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| +| hadoop.security.authentication |
      Set the authentication for the cluster
      |
      kerberos
      | +| hive.metastore.uris |
      URI for client to contact metastore server
      |
      thrift://{metastoreHost}:{metastorePort}}
      | +| hive.metastore.sasl.enabled |
      If true, the metastore thrift interface will be secured with SASL.Clients must authenticate with Kerberos.
      |
      true
      | +| hive.metastore.kerberos.principal |
      The service principal for the metastore thrift server. The special string _HOST will be replaced automatically with the correct host name.
      |
      for example hive/_HOST@${realm}
      | +| hive.metastore.kerberos.keytab.file |
      The path to the Kerberos Keytab file containing the metastore thrift server's service principal.
      |
      for example /etc/security/keytabs/hive.service.keytab
      | + diff --git a/docs/security/jdbc.md b/docs/security/jdbc.md index 0da6634f7d8..48c1d082f9d 100644 --- a/docs/security/jdbc.md +++ b/docs/security/jdbc.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Configure Kyuubi to Use JDBC Authentication @@ -27,16 +26,17 @@ The SQL statement must start with the `SELECT` clause. Placeholders are supporte For example, `SELECT 1 FROM auth_db.auth_table WHERE user=${user} AND passwd=MD5(CONCAT(salt,${password}))` will be prepared as `SELECT 1 FROM auth_db.auth_table WHERE user=? AND passwd=MD5(CONCAT(salt,?))` with value replacement of `user` and `password` in string type. -## Enable JDBC Authentication +## Enable JDBC Authentication -To enable the jdbc authentication method, we need to +To enable the JDBC authentication method, we need to -- Put the jdbc driver jar file to `$KYUUBI_HOME/jars` directory to make it visible for +- Put the JDBC driver jar file to `$KYUUBI_HOME/jars` directory to make it visible for the classpath of the kyuubi server. - Configure the following properties to `$KYUUBI_HOME/conf/kyuubi-defaults.conf` on each node where kyuubi server is installed. ## Configure the authentication properties + Configure the following properties to `$KYUUBI_HOME/conf/kyuubi-defaults.conf` on each node where kyuubi server is installed. ```properties @@ -50,7 +50,7 @@ kyuubi.authentication.jdbc.query = SELECT 1 FROM auth_table WHERE user=${user} A ## Authentication with In-memory Database -Used with auto created in-memory database, JDBC authentication could be applied for token validation without starting up a dedicated database service or setting up a custom plugin. +Used with auto created in-memory database, JDBC authentication could be applied for token validation without starting up a dedicated database service or setting up a custom plugin. Consider authentication for a pair of a username and a token which contacted with an `expire_time` in 'yyyyMMddHHmm' format and a MD5 signature generated with sequence of `expire_time`, `username` and a secret key. With the following example, an H2 in-memory database will be auto crated with Kyuubi Server and used for authentication with its system function `HASH` and checking token expire time with `NOW()`. @@ -66,3 +66,4 @@ kyuubi.authentication.jdbc.query = SELECT 1 FROM ( \ ) WHERE signed = RAWTOHEX(HASH('MD5', CONCAT(secret_key, username, expire_time))) \ AND PARSEDATETIME(expire_time,'yyyyMMddHHmm') > NOW() ``` + diff --git a/docs/security/kerberos.rst b/docs/security/kerberos.rst index c4bca8e8219..2505fa30d8b 100644 --- a/docs/security/kerberos.rst +++ b/docs/security/kerberos.rst @@ -115,4 +115,5 @@ Refresh all the kyuubi server instances Restart all the kyuubi server instances or `Refresh Configurations`_ to activate the settings. .. _Hadoop Impersonation: https://hadoop.apache.org/docs/stable/hadoop-project-dist/hadoop-common/Superusers.html -.. _Refresh Configurations: ..tools/kyuubi-admin.html#refresh-config +.. _configurations: ../client/advanced/kerberos.html +.. _Refresh Configurations: ../tools/kyuubi-admin.html#refresh-config diff --git a/docs/security/kinit.md b/docs/security/kinit.md index d7089625872..0d613e0006e 100644 --- a/docs/security/kinit.md +++ b/docs/security/kinit.md @@ -1,23 +1,23 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kinit Auxiliary Service -Kinit auxiliary service is a critical service both for authentication between Kyuubi client/server +Kinit auxiliary service is a critical service both for authentication between Kyuubi client/server and for authentication between Kyuubi server/Hadoop cluster in a Kerberos environment. It will get a Kerberos Ticket Cache from KDC and periodically re-kinit to keep the Ticket Cache fresh. @@ -69,17 +69,16 @@ They are valid for relatively short period. So, we always need to refresh it for ## Configurations -Key | Default | Meaning | Since ---- | --- | --- | --- -kyuubi.kinit.principal|
      <undefined>
      |
      Name of the Kerberos principal.
      |
      1.0.0
      -kyuubi.kinit.keytab|
      <undefined>
      |
      Location of Kyuubi server's keytab.
      |
      1.0.0
      -kyuubi.kinit.interval|
      PT1H
      |
      How often will Kyuubi server run `kinit -kt [keytab] [principal]` to renew the local Kerberos credentials cache
      |
      1.0.0
      -kyuubi.kinit.max.attempts|
      10
      |
      How many times will `kinit` process retry
      |
      1.0.0
      - -When working with a Kerberos-enabled Hadoop cluster, we should ensure that `hadoop.security.authentication` -is set to `KERBEROS` in `$HADOOP_CONF_DIR/core-site.xml` or `$KYUUBI_HOME/conf/kyuubi-defaults.conf`. -Then we need to specify `kyuubi.kinit.principal` and `kyuubi.kinit.keytab` for authentication. +| Key | Default | Meaning | Since | +|----------------------------------------|--------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------| +| kyuubi.kinit.principal |
      <undefined>
      |
      Name of the Kerberos principal.
      |
      1.0.0
      | +| kyuubi.kinit.keytab |
      <undefined>
      |
      Location of Kyuubi server's keytab.
      |
      1.0.0
      | +| kyuubi.kinit.interval |
      PT1H
      |
      How often will Kyuubi server run `kinit -kt [keytab] [principal]` to renew the local Kerberos credentials cache
      |
      1.0.0
      | +| kyuubi.kinit.max.attempts |
      10
      |
      How many times will `kinit` process retry
      |
      1.0.0
      | +When working with a Kerberos-enabled Hadoop cluster, we should ensure that `hadoop.security.authentication` +is set to `KERBEROS` in `$HADOOP_CONF_DIR/core-site.xml` or `$KYUUBI_HOME/conf/kyuubi-defaults.conf`. +Then we need to specify `kyuubi.kinit.principal` and `kyuubi.kinit.keytab` for authentication. For example, @@ -89,7 +88,7 @@ kyuubi.kinit.keytab=/path/to/kyuuib.keytab ``` **Note**: -`kyuubi.kinit.principal` must be in the format: `/@`, and `` must +`kyuubi.kinit.principal` must be in the format: `/@`, and `` must be a FQDN of the host Kyuubi is running. Kyuubi will use this `principal` to impersonate client users, @@ -101,7 +100,9 @@ For example, hadoop.proxyuser..groups * hadoop.proxyuser..hosts * ``` + ## Further Readings - [Hadoop in Secure Mode](https://hadoop.apache.org/docs/current/hadoop-project-dist/hadoop-common/SecureMode.html) -- [Use Kerberos for authentication in Spark](http://spark.apache.org/docs/latest/security.html#kerberos) +- [Use Kerberos for authentication in Spark](https://spark.apache.org/docs/latest/security.html#kerberos) + diff --git a/docs/tools/kyuubi-admin.rst b/docs/tools/kyuubi-admin.rst index cf60f67b182..6063965938c 100644 --- a/docs/tools/kyuubi-admin.rst +++ b/docs/tools/kyuubi-admin.rst @@ -69,6 +69,10 @@ Usage: ``bin/kyuubi-admin refresh config [options] []`` - Description * - hadoopConf - The hadoop conf used for proxy user verification. + * - userDefaultsConf + - The user defaults configs with key in format in the form of `___{username}___.{config key}` from default property file. + * - unlimitedUsers + - The users without maximum connections limitation. .. _list_engine: diff --git a/docs/tools/kyuubi-ctl.md b/docs/tools/kyuubi-ctl.md deleted file mode 100644 index 34fb3a23200..00000000000 --- a/docs/tools/kyuubi-ctl.md +++ /dev/null @@ -1,162 +0,0 @@ - - - -# Managing kyuubi servers and engines Tool - -## Usage -```shell -bin/kyuubi-ctl --help -``` -Output -```shell -kyuubi 1.6.0-SNAPSHOT -Usage: kyuubi-ctl [create|get|delete|list] [options] - - -zk, --zk-quorum - The connection string for the zookeeper ensemble, using zk quorum manually. - -n, --namespace The namespace, using kyuubi-defaults/conf if absent. - -s, --host Hostname or IP address of a service. - -p, --port Listening port of a service. - -v, --version Using the compiled KYUUBI_VERSION default, change it if the active service is running in another. - -b, --verbose Print additional debug output. - -Command: create [server] - -Command: create server - Expose Kyuubi server instance to another domain. - -Command: get [server|engine] [options] - Get the service/engine node info, host and port needed. -Command: get server - Get Kyuubi server info of domain -Command: get engine - Get Kyuubi engine info belong to a user. - -u, --user The user name this engine belong to. - -et, --engine-type - The engine type this engine belong to. - -es, --engine-subdomain - The engine subdomain this engine belong to. - -esl, --engine-share-level - The engine share level this engine belong to. - -Command: delete [server|engine] [options] - Delete the specified service/engine node, host and port needed. -Command: delete server - Delete the specified service node for a domain -Command: delete engine - Delete the specified engine node for user. - -u, --user The user name this engine belong to. - -et, --engine-type - The engine type this engine belong to. - -es, --engine-subdomain - The engine subdomain this engine belong to. - -esl, --engine-share-level - The engine share level this engine belong to. - -Command: list [server|engine] [options] - List all the service/engine nodes for a particular domain. -Command: list server - List all the service nodes for a particular domain -Command: list engine - List all the engine nodes for a user - -u, --user The user name this engine belong to. - -et, --engine-type - The engine type this engine belong to. - -es, --engine-subdomain - The engine subdomain this engine belong to. - -esl, --engine-share-level - The engine share level this engine belong to. - - -h, --help Show help message and exit. -``` - -## Manage kyuubi servers -You can specify the zookeeper address(`--zk-quorum`) and namespace(`--namespace`), version(`--version`) parameters to query a specific kyuubi server cluster. - -### List server -List all the service nodes for a particular domain. -```shell -bin/kyuubi-ctl list server -``` - -### Create server -Expose Kyuubi server instance to another domain. - -First read `kyuubi.ha.zookeeper.namespace` in `conf/kyuubi-defaults.conf`, if there are server instances under this namespace, register them in the new namespace specified by the `--namespace` parameter. -```shell -bin/kyuubi-ctl create server --namespace XXX -``` - -### Get server -Get Kyuubi server info of domain. -```shell -bin/kyuubi-ctl get server --host XXX --port YYY -``` - -### Delete server -Delete the specified service node for a domain. - -After the server node is deleted, the kyuubi server stops opening new sessions and waits for all currently open sessions to be closed before the process exits. -```shell -bin/kyuubi-ctl delete server --host XXX --port YYY -``` - -## Manage kyuubi engines -You can also specify the engine type(`--engine-type`), engine share level subdomain(`--engine-subdomain`) and engine share level(`--engine-share-level`). - -If not specified, the configuration item `kyuubi.engine.type` of `kyuubi-defaults.conf` read, the default value is `SPARK_SQL`, `kyuubi.engine.share.level.subdomain`, the default value is `default`, `kyuubi.engine.share.level`, the default value is `USER`. - -If the engine pool mode is enabled through `kyuubi.engine.pool.size`, the subdomain consists of `kyuubi.engine.pool.name` and a number below size, e.g. `engine-pool-0` . - -`--engine-share-level` supports the following enum values. -* CONNECTION - - The engine Ref Id (UUID) must be specified via `--engine-subdomain`. -* USER: - - Default Value. -* GROUP: - - The `--user` parameter is the group name corresponding to the user. -* SERVER: - - The `--user` parameter is the user who started the kyuubi server. - -### List engine -List all the engine nodes for a user. -```shell -bin/kyuubi-ctl list engine --user AAA -``` -The management share level is SERVER, the user who starts the kyuubi server is A, the engine is TRINO, and the subdomain is adhoc. -```shell -bin/kyuubi-ctl list engine --user A --engine-type TRINO --engine-subdomain adhoc --engine-share-level SERVER -``` - -### Get engine -Get Kyuubi engine info belong to a user. -```shell -bin/kyuubi-ctl get engine --user AAA --host XXX --port YYY -``` - -### Delete engine -Delete the specified engine node for user. - -After the engine node is deleted, the kyuubi engine stops opening new sessions and waits for all currently open sessions to be closed before the process exits. -```shell -bin/kyuubi-ctl delete engine --user AAA --host XXX --port YYY -``` diff --git a/docs/tools/kyuubi-ctl.rst b/docs/tools/kyuubi-ctl.rst new file mode 100644 index 00000000000..4a9308fed0e --- /dev/null +++ b/docs/tools/kyuubi-ctl.rst @@ -0,0 +1,213 @@ +.. Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + +.. http://www.apache.org/licenses/LICENSE-2.0 + +.. Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Administrator CLI +================= + +.. _usage: + +Usage +----- +.. code-block:: bash + + bin/kyuubi-ctl --help + +Output + +.. parsed-literal:: + + kyuubi |release| + Usage: kyuubi-ctl [create|get|delete|list] [options] + + -zk, --zk-quorum + The connection string for the zookeeper ensemble, using zk quorum manually. + -n, --namespace The namespace, using kyuubi-defaults/conf if absent. + -s, --host Hostname or IP address of a service. + -p, --port Listening port of a service. + -v, --version Using the compiled KYUUBI_VERSION default, change it if the active service is running in another. + -b, --verbose Print additional debug output. + + Command: create [server] + + Command: create server + Expose Kyuubi server instance to another domain. + + Command: get [server|engine] [options] + Get the service/engine node info, host and port needed. + Command: get server + Get Kyuubi server info of domain + Command: get engine + Get Kyuubi engine info belong to a user. + -u, --user The user name this engine belong to. + -et, --engine-type + The engine type this engine belong to. + -es, --engine-subdomain + The engine subdomain this engine belong to. + -esl, --engine-share-level + The engine share level this engine belong to. + + Command: delete [server|engine] [options] + Delete the specified service/engine node, host and port needed. + Command: delete server + Delete the specified service node for a domain + Command: delete engine + Delete the specified engine node for user. + -u, --user The user name this engine belong to. + -et, --engine-type + The engine type this engine belong to. + -es, --engine-subdomain + The engine subdomain this engine belong to. + -esl, --engine-share-level + The engine share level this engine belong to. + + Command: list [server|engine] [options] + List all the service/engine nodes for a particular domain. + Command: list server + List all the service nodes for a particular domain + Command: list engine + List all the engine nodes for a user + -u, --user The user name this engine belong to. + -et, --engine-type + The engine type this engine belong to. + -es, --engine-subdomain + The engine subdomain this engine belong to. + -esl, --engine-share-level + The engine share level this engine belong to. + + -h, --help Show help message and exit. + +.. _manage_kyuubi_servers: + +Manage kyuubi servers +--------------------- + +You can specify the zookeeper address(``--zk-quorum``) and namespace(``--namespace``), version(``--version``) parameters to query a specific kyuubi server cluster. + +.. _list_servers: + +List server +*********** + +List all the service nodes for a particular domain. + +.. code-block:: bash + + bin/kyuubi-ctl list server + +.. _create_servers: + +Create server +*********** +Expose Kyuubi server instance to another domain. + +First read ``kyuubi.ha.zookeeper.namespace`` in ``conf/kyuubi-defaults.conf``, if there are server instances under this namespace, register them in the new namespace specified by the ``--namespace`` parameter. + +.. code-block:: bash + + bin/kyuubi-ctl create server --namespace XXX + +.. _get_servers: + +Get server +*********** + +Get Kyuubi server info of domain. + +.. code-block:: bash + + bin/kyuubi-ctl get server --host XXX --port YYY + +.. _delete_servers: + +Delete server +*********** + +Delete the specified service node for a domain. + +After the server node is deleted, the kyuubi server stops opening new sessions and waits for all currently open sessions to be closed before the process exits. + +.. code-block:: bash + + bin/kyuubi-ctl delete server --host XXX --port YYY + +.. _manage_kyuubi_engines: + +Manage kyuubi engines +--------------------- + +You can also specify the engine type(``--engine-type``), engine share level subdomain(``--engine-subdomain``) and engine share level(``--engine-share-level``). + +If not specified, the configuration item ``kyuubi.engine.type`` of ``kyuubi-defaults.conf`` read, the default value is ``SPARK_SQL``, ``kyuubi.engine.share.level.subdomain``, the default value is ``default``, ``kyuubi.engine.share.level``, the default value is ``USER``. + +If the engine pool mode is enabled through ``kyuubi.engine.pool.size``, the subdomain consists of ``kyuubi.engine.pool.name`` and a number below size, e.g. ``engine-pool-0`` . + +``--engine-share-level`` supports the following enum values. + +- CONNECTION + +The engine Ref Id (UUID) must be specified via ``--engine-subdomain``. + +- USER: + +Default Value. + +- GROUP: + +The ``--user`` parameter is the group name corresponding to the user. + +- SERVER: + +The ``--user`` parameter is the user who started the kyuubi server. + +.. _list_engines: + +List engine +*********** + +List all the engine nodes for a user. + +.. code-block:: bash + + bin/kyuubi-ctl list engine --user AAA + +The management share level is SERVER, the user who starts the kyuubi server is A, the engine is TRINO, and the subdomain is adhoc. + +.. code-block:: bash + + bin/kyuubi-ctl list engine --user A --engine-type TRINO --engine-subdomain adhoc --engine-share-level SERVER + +.. _get_engines: + +Get engine +*********** + +Get Kyuubi engine info belong to a user. + +.. code-block:: bash + + bin/kyuubi-ctl get engine --user AAA --host XXX --port YYY + +.. _delete_engines: + +Delete engine +************* + +Delete the specified engine node for user. + +After the engine node is deleted, the kyuubi engine stops opening new sessions and waits for all currently open sessions to be closed before the process exits. + +.. code-block:: bash + + bin/kyuubi-ctl delete engine --user AAA --host XXX --port YYY \ No newline at end of file diff --git a/docs/tools/spark_block_cleaner.md b/docs/tools/spark_block_cleaner.md index 94e87387577..4a1f20ff884 100644 --- a/docs/tools/spark_block_cleaner.md +++ b/docs/tools/spark_block_cleaner.md @@ -1,20 +1,19 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kubernetes Tools Spark Block Cleaner @@ -35,9 +34,9 @@ Therefore, we chose to use Spark Block Cleaner to clear the block files accumula ## Principle -When deploying Spark Block Cleaner, we will configure volumes for the destination folder. Spark Block Cleaner will perceive the folder by the parameter `CACHE_DIRS`. +When deploying Spark Block Cleaner, we will configure volumes for the destination folder. Spark Block Cleaner will perceive the folder by the parameter `CACHE_DIRS`. -Spark Block Cleaner will clear the perceived folder in a fixed loop(which can be configured by `SCHEDULE_INTERVAL`). And Spark Block Cleaner will select folder start with `blockmgr` and `spark` for deletion using the logic Spark uses to create those folders. +Spark Block Cleaner will clear the perceived folder in a fixed loop(which can be configured by `SCHEDULE_INTERVAL`). And Spark Block Cleaner will select folder start with `blockmgr` and `spark` for deletion using the logic Spark uses to create those folders. Before deleting those files, Spark Block Cleaner will determine whether it is a recently modified file(depending on whether the file has not been acted on within the specified time which configured by `FILE_EXPIRED_TIME`). Only delete files those beyond that time interval. @@ -50,6 +49,7 @@ Before you start using Spark Block Cleaner, you should build its docker images. ### Build Block Cleaner Docker Image In the `KYUUBI_HOME` directory, you can use the following cmd to build docker image. + ```shell docker build ./tools/spark-block-cleaner/kubernetes/docker ``` @@ -60,7 +60,8 @@ You need to modify the `${KYUUBI_HOME}/tools/spark-block-cleaner/kubernetes/spar In Kyuubi tools, we recommend using `DaemonSet` to start, and we offer default yaml file in daemonSet way. -Base file structure: +Base file structure: + ```yaml apiVersion kind @@ -80,6 +81,7 @@ spec ``` You can use affect the performance of Spark Block Cleaner through configure parameters in containers env part of `spark-block-cleaner.yml`. + ```yaml env: - name: CACHE_DIRS @@ -97,17 +99,20 @@ env: The most important thing, configure volumeMounts and volumes corresponding to Spark local-dirs. For example, Spark use /spark/shuffle1 as local-dir, you can configure like: + ```yaml volumes: - name: block-files-dir-1 hostPath: path: /spark/shuffle1 ``` + ```yaml volumeMounts: - name: block-files-dir-1 mountPath: /data/data1 ``` + ```yaml env: - name: CACHE_DIRS @@ -120,10 +125,11 @@ After you finishing modifying the above, you can use the following command `kube ## Related parameters -Name | Default | unit | Meaning ---- | --- | --- | --- -CACHE_DIRS | /data/data1,/data/data2| | The target dirs in container path which will clean block files. -FILE_EXPIRED_TIME | 604800 | seconds | Cleaner will clean the block files which current time - last modified time more than the fileExpiredTime. -DEEP_CLEAN_FILE_EXPIRED_TIME | 432000 | seconds | Deep clean will clean the block files which current time - last modified time more than the deepCleanFileExpiredTime. -FREE_SPACE_THRESHOLD | 60 | % | After first clean, if free Space low than threshold trigger deep clean. -SCHEDULE_INTERVAL | 3600 | seconds | Cleaner sleep between cleaning. +| Name | Default | unit | Meaning | +|------------------------------|-------------------------|---------|-----------------------------------------------------------------------------------------------------------------------| +| CACHE_DIRS | /data/data1,/data/data2 | | The target dirs in container path which will clean block files. | +| FILE_EXPIRED_TIME | 604800 | seconds | Cleaner will clean the block files which current time - last modified time more than the fileExpiredTime. | +| DEEP_CLEAN_FILE_EXPIRED_TIME | 432000 | seconds | Deep clean will clean the block files which current time - last modified time more than the deepCleanFileExpiredTime. | +| FREE_SPACE_THRESHOLD | 60 | % | After first clean, if free Space low than threshold trigger deep clean. | +| SCHEDULE_INTERVAL | 3600 | seconds | Cleaner sleep between cleaning. | + diff --git a/extensions/README.md b/extensions/README.md index 92eac9097d8..5725f0f9b08 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -1,25 +1,24 @@ - +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> + # For developers This folder contains plugins/extension for kyuubi server and different engine types. - - ext - kyuubi-server - spark @@ -27,4 +26,5 @@ This folder contains plugins/extension for kyuubi server and different engine ty - trino - hive - others - - ... \ No newline at end of file + - ... + diff --git a/extensions/server/kyuubi-server-plugin/pom.xml b/extensions/server/kyuubi-server-plugin/pom.xml index b7dfe0ae8dd..799f27c4632 100644 --- a/extensions/server/kyuubi-server-plugin/pom.xml +++ b/extensions/server/kyuubi-server-plugin/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml diff --git a/extensions/spark/kyuubi-extension-spark-3-1/pom.xml b/extensions/spark/kyuubi-extension-spark-3-1/pom.xml index 5bd4b2fd5d6..9f218f9d0fe 100644 --- a/extensions/spark/kyuubi-extension-spark-3-1/pom.xml +++ b/extensions/spark/kyuubi-extension-spark-3-1/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml diff --git a/extensions/spark/kyuubi-extension-spark-3-2/pom.xml b/extensions/spark/kyuubi-extension-spark-3-2/pom.xml index daab162b7b8..a80040aca65 100644 --- a/extensions/spark/kyuubi-extension-spark-3-2/pom.xml +++ b/extensions/spark/kyuubi-extension-spark-3-2/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml diff --git a/extensions/spark/kyuubi-extension-spark-3-3/pom.xml b/extensions/spark/kyuubi-extension-spark-3-3/pom.xml index cc82912133b..ca729a7819b 100644 --- a/extensions/spark/kyuubi-extension-spark-3-3/pom.xml +++ b/extensions/spark/kyuubi-extension-spark-3-3/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml diff --git a/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/kyuubi/sql/KyuubiSparkSQLExtension.scala b/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/kyuubi/sql/KyuubiSparkSQLExtension.scala index ef9da41be13..0db9b3ab88a 100644 --- a/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/kyuubi/sql/KyuubiSparkSQLExtension.scala +++ b/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/kyuubi/sql/KyuubiSparkSQLExtension.scala @@ -17,7 +17,7 @@ package org.apache.kyuubi.sql -import org.apache.spark.sql.SparkSessionExtensions +import org.apache.spark.sql.{FinalStageResourceManager, InjectCustomResourceProfile, SparkSessionExtensions} import org.apache.kyuubi.sql.watchdog.{ForcedMaxOutputRowsRule, MaxPartitionStrategy} @@ -39,5 +39,8 @@ class KyuubiSparkSQLExtension extends (SparkSessionExtensions => Unit) { // watchdog extension extensions.injectOptimizerRule(ForcedMaxOutputRowsRule) extensions.injectPlannerStrategy(MaxPartitionStrategy) + + extensions.injectQueryStagePrepRule(FinalStageResourceManager) + extensions.injectQueryStagePrepRule(InjectCustomResourceProfile) } } diff --git a/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/spark/sql/FinalStageResourceManager.scala b/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/spark/sql/FinalStageResourceManager.scala new file mode 100644 index 00000000000..2bf7ae6b75e --- /dev/null +++ b/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/spark/sql/FinalStageResourceManager.scala @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.spark.sql + +import scala.annotation.tailrec +import scala.collection.mutable +import scala.collection.mutable.ArrayBuffer + +import org.apache.spark.{ExecutorAllocationClient, MapOutputTrackerMaster, SparkContext, SparkEnv} +import org.apache.spark.scheduler.cluster.CoarseGrainedSchedulerBackend +import org.apache.spark.sql.catalyst.rules.Rule +import org.apache.spark.sql.execution.{FilterExec, ProjectExec, SortExec, SparkPlan} +import org.apache.spark.sql.execution.adaptive._ +import org.apache.spark.sql.execution.exchange.{ENSURE_REQUIREMENTS, ShuffleExchangeExec} + +import org.apache.kyuubi.sql.{KyuubiSQLConf, MarkNumOutputColumnsRule} + +/** + * This rule assumes the final write stage has less cores requirement than previous, otherwise + * this rule would take no effect. + * + * It provide a feature: + * 1. Kill redundant executors before running final write stage + */ +case class FinalStageResourceManager(session: SparkSession) + extends Rule[SparkPlan] with FinalRebalanceStageHelper { + override def apply(plan: SparkPlan): SparkPlan = { + if (!conf.getConf(KyuubiSQLConf.FINAL_WRITE_STAGE_EAGERLY_KILL_EXECUTORS_ENABLED)) { + return plan + } + + if (!MarkNumOutputColumnsRule.isWrite(session, plan)) { + return plan + } + + val sc = session.sparkContext + val dra = sc.getConf.getBoolean("spark.dynamicAllocation.enabled", false) + val coresPerExecutor = sc.getConf.getInt("spark.executor.cores", 1) + val minExecutors = sc.getConf.getInt("spark.dynamicAllocation.minExecutors", 0) + val maxExecutors = sc.getConf.getInt("spark.dynamicAllocation.maxExecutors", Int.MaxValue) + val factor = conf.getConf(KyuubiSQLConf.FINAL_WRITE_STAGE_PARTITION_FACTOR) + val hasImprovementRoom = maxExecutors - 1 > minExecutors * factor + // Fast fail if: + // 1. DRA off + // 2. only work with yarn and k8s + // 3. maxExecutors is not bigger than minExecutors * factor + if (!dra || !sc.schedulerBackend.isInstanceOf[CoarseGrainedSchedulerBackend] || + !hasImprovementRoom) { + return plan + } + + val stageOpt = findFinalRebalanceStage(plan) + if (stageOpt.isEmpty) { + return plan + } + + // Since we are in `prepareQueryStage`, the AQE shuffle read has not been applied. + // So we need to apply it by self. + val shuffleRead = queryStageOptimizerRules.foldLeft(stageOpt.get.asInstanceOf[SparkPlan]) { + case (latest, rule) => rule.apply(latest) + } + val (targetCores, stage) = shuffleRead match { + case AQEShuffleReadExec(stage: ShuffleQueryStageExec, partitionSpecs) => + (partitionSpecs.length, stage) + case stage: ShuffleQueryStageExec => + // we can still kill executors if no AQE shuffle read, e.g., `.repartition(2)` + (stage.shuffle.numPartitions, stage) + case _ => + // it should never happen in current Spark, but to be safe do nothing if happens + logWarning("BUG, Please report to Apache Kyuubi community") + return plan + } + // The condition whether inject custom resource profile: + // - target executors < active executors + // - active executors - target executors > min executors + val numActiveExecutors = sc.getExecutorIds().length + val targetExecutors = (math.ceil(targetCores.toFloat / coresPerExecutor) * factor).toInt + .max(1) + val hasBenefits = targetExecutors < numActiveExecutors && + (numActiveExecutors - targetExecutors) > minExecutors + logInfo(s"The snapshot of current executors view, " + + s"active executors: $numActiveExecutors, min executor: $minExecutors, " + + s"target executors: $targetExecutors, has benefits: $hasBenefits") + if (hasBenefits) { + val shuffleId = stage.plan.asInstanceOf[ShuffleExchangeExec].shuffleDependency.shuffleId + val numReduce = stage.plan.asInstanceOf[ShuffleExchangeExec].numPartitions + // Now, there is only a final rebalance stage waiting to execute and all tasks of previous + // stage are finished. Kill redundant existed executors eagerly so the tasks of final + // stage can be centralized scheduled. + killExecutors(sc, targetExecutors, shuffleId, numReduce) + } + + plan + } + + /** + * The priority of kill executors follow: + * 1. kill executor who is younger than other (The older the JIT works better) + * 2. kill executor who produces less shuffle data first + */ + private def findExecutorToKill( + sc: SparkContext, + targetExecutors: Int, + shuffleId: Int, + numReduce: Int): Seq[String] = { + val tracker = SparkEnv.get.mapOutputTracker.asInstanceOf[MapOutputTrackerMaster] + val shuffleStatus = tracker.shuffleStatuses(shuffleId) + val executorToBlockSize = new mutable.HashMap[String, Long] + shuffleStatus.withMapStatuses { mapStatus => + mapStatus.foreach { status => + var i = 0 + var sum = 0L + while (i < numReduce) { + sum += status.getSizeForBlock(i) + i += 1 + } + executorToBlockSize.getOrElseUpdate(status.location.executorId, sum) + } + } + + val backend = sc.schedulerBackend.asInstanceOf[CoarseGrainedSchedulerBackend] + val executorsWithRegistrationTs = backend.getExecutorsWithRegistrationTs() + val existedExecutors = executorsWithRegistrationTs.keys.toSet + val expectedNumExecutorToKill = existedExecutors.size - targetExecutors + if (expectedNumExecutorToKill < 1) { + return Seq.empty + } + + val executorIdsToKill = new ArrayBuffer[String]() + // We first kill executor who does not hold shuffle block. It would happen because + // the last stage is running fast and finished in a short time. The existed executors are + // from previous stages that have not been killed by DRA, so we can not find it by tracking + // shuffle status. + // We should evict executors by their alive time first and retain all of executors which + // have better locality for shuffle block. + executorsWithRegistrationTs.toSeq.sortBy(_._2).foreach { case (id, _) => + if (executorIdsToKill.length < expectedNumExecutorToKill && + !executorToBlockSize.contains(id)) { + executorIdsToKill.append(id) + } + } + + // Evict the rest executors according to the shuffle block size + executorToBlockSize.toSeq.sortBy(_._2).foreach { case (id, _) => + if (executorIdsToKill.length < expectedNumExecutorToKill) { + executorIdsToKill.append(id) + } + } + + executorIdsToKill.toSeq + } + + private def killExecutors( + sc: SparkContext, + targetExecutors: Int, + shuffleId: Int, + numReduce: Int): Unit = { + val executorAllocationClient = sc.schedulerBackend.asInstanceOf[ExecutorAllocationClient] + + val executorsToKill = findExecutorToKill(sc, targetExecutors, shuffleId, numReduce) + logInfo(s"Request to kill executors, total count ${executorsToKill.size}, " + + s"[${executorsToKill.mkString(", ")}].") + + // Note, `SparkContext#killExecutors` does not allow with DRA enabled, + // see `https://github.com/apache/spark/pull/20604`. + // It may cause the status in `ExecutorAllocationManager` inconsistent with + // `CoarseGrainedSchedulerBackend` for a while. But it should be synchronous finally. + executorAllocationClient.killExecutors( + executorIds = executorsToKill, + adjustTargetNumExecutors = false, + countFailures = false, + force = false) + } + + @transient private val queryStageOptimizerRules: Seq[Rule[SparkPlan]] = Seq( + OptimizeSkewInRebalancePartitions, + CoalesceShufflePartitions(session), + OptimizeShuffleWithLocalRead) +} + +trait FinalRebalanceStageHelper { + @tailrec + final protected def findFinalRebalanceStage(plan: SparkPlan): Option[ShuffleQueryStageExec] = { + plan match { + case p: ProjectExec => findFinalRebalanceStage(p.child) + case f: FilterExec => findFinalRebalanceStage(f.child) + case s: SortExec if !s.global => findFinalRebalanceStage(s.child) + case stage: ShuffleQueryStageExec + if stage.isMaterialized && + stage.plan.isInstanceOf[ShuffleExchangeExec] && + stage.plan.asInstanceOf[ShuffleExchangeExec].shuffleOrigin != ENSURE_REQUIREMENTS => + Some(stage) + case _ => None + } + } +} diff --git a/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/spark/sql/InjectCustomResourceProfile.scala b/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/spark/sql/InjectCustomResourceProfile.scala new file mode 100644 index 00000000000..30c042b2a2c --- /dev/null +++ b/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/spark/sql/InjectCustomResourceProfile.scala @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.spark.sql + +import org.apache.spark.sql.catalyst.rules.Rule +import org.apache.spark.sql.execution.{CustomResourceProfileExec, SparkPlan} +import org.apache.spark.sql.execution.adaptive._ + +import org.apache.kyuubi.sql.{KyuubiSQLConf, MarkNumOutputColumnsRule} + +/** + * Inject custom resource profile for final write stage, so we can specify custom + * executor resource configs. + */ +case class InjectCustomResourceProfile(session: SparkSession) + extends Rule[SparkPlan] with FinalRebalanceStageHelper { + override def apply(plan: SparkPlan): SparkPlan = { + if (!conf.getConf(KyuubiSQLConf.FINAL_WRITE_STAGE_RESOURCE_ISOLATION_ENABLED)) { + return plan + } + + if (!MarkNumOutputColumnsRule.isWrite(session, plan)) { + return plan + } + + val stage = findFinalRebalanceStage(plan) + if (stage.isEmpty) { + return plan + } + + // TODO: Ideally, We can call `CoarseGrainedSchedulerBackend.requestTotalExecutors` eagerly + // to reduce the task submit pending time, but it may lose task locality. + // + // By default, it would request executors when catch stage submit event. + injectCustomResourceProfile(plan, stage.get.id) + } + + private def injectCustomResourceProfile(plan: SparkPlan, id: Int): SparkPlan = { + plan match { + case stage: ShuffleQueryStageExec if stage.id == id => + CustomResourceProfileExec(stage) + case _ => plan.mapChildren(child => injectCustomResourceProfile(child, id)) + } + } +} diff --git a/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/spark/sql/execution/CustomResourceProfileExec.scala b/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/spark/sql/execution/CustomResourceProfileExec.scala new file mode 100644 index 00000000000..3698140fbd0 --- /dev/null +++ b/extensions/spark/kyuubi-extension-spark-3-3/src/main/scala/org/apache/spark/sql/execution/CustomResourceProfileExec.scala @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.spark.sql.execution + +import org.apache.spark.network.util.{ByteUnit, JavaUtils} +import org.apache.spark.rdd.RDD +import org.apache.spark.resource.{ExecutorResourceRequests, ResourceProfileBuilder} +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.{Attribute, SortOrder} +import org.apache.spark.sql.catalyst.plans.physical.Partitioning +import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics} +import org.apache.spark.sql.vectorized.ColumnarBatch +import org.apache.spark.util.Utils + +import org.apache.kyuubi.sql.KyuubiSQLConf._ + +/** + * This node wraps the final executed plan and inject custom resource profile to the RDD. + * It assumes that, the produced RDD would create the `ResultStage` in `DAGScheduler`, + * so it makes resource isolation between previous and final stage. + * + * Note that, Spark does not support config `minExecutors` for each resource profile. + * Which means, it would retain `minExecutors` for each resource profile. + * So, suggest set `spark.dynamicAllocation.minExecutors` to 0 if enable this feature. + */ +case class CustomResourceProfileExec(child: SparkPlan) extends UnaryExecNode { + override def output: Seq[Attribute] = child.output + override def outputPartitioning: Partitioning = child.outputPartitioning + override def outputOrdering: Seq[SortOrder] = child.outputOrdering + override def supportsColumnar: Boolean = child.supportsColumnar + override def supportsRowBased: Boolean = child.supportsRowBased + override protected def doCanonicalize(): SparkPlan = child.canonicalized + + private val executorCores = conf.getConf(FINAL_WRITE_STAGE_EXECUTOR_CORES).getOrElse( + sparkContext.getConf.getInt("spark.executor.cores", 1)) + private val executorMemory = conf.getConf(FINAL_WRITE_STAGE_EXECUTOR_MEMORY).getOrElse( + sparkContext.getConf.get("spark.executor.memory", "2G")) + private val executorMemoryOverhead = + conf.getConf(FINAL_WRITE_STAGE_EXECUTOR_MEMORY_OVERHEAD) + .getOrElse(sparkContext.getConf.get("spark.executor.memoryOverhead", "1G")) + private val executorOffHeapMemory = conf.getConf(FINAL_WRITE_STAGE_EXECUTOR_OFF_HEAP_MEMORY) + + override lazy val metrics: Map[String, SQLMetric] = { + val base = Map( + "executorCores" -> SQLMetrics.createMetric(sparkContext, "executor cores"), + "executorMemory" -> SQLMetrics.createMetric(sparkContext, "executor memory (MiB)"), + "executorMemoryOverhead" -> SQLMetrics.createMetric( + sparkContext, + "executor memory overhead (MiB)")) + val addition = executorOffHeapMemory.map(_ => + "executorOffHeapMemory" -> + SQLMetrics.createMetric(sparkContext, "executor off heap memory (MiB)")).toMap + base ++ addition + } + + private def wrapResourceProfile[T](rdd: RDD[T]): RDD[T] = { + if (Utils.isTesting) { + // do nothing for local testing + return rdd + } + + metrics("executorCores") += executorCores + metrics("executorMemory") += JavaUtils.byteStringAs(executorMemory, ByteUnit.MiB) + metrics("executorMemoryOverhead") += JavaUtils.byteStringAs( + executorMemoryOverhead, + ByteUnit.MiB) + executorOffHeapMemory.foreach(m => + metrics("executorOffHeapMemory") += JavaUtils.byteStringAs(m, ByteUnit.MiB)) + + val executionId = sparkContext.getLocalProperty(SQLExecution.EXECUTION_ID_KEY) + SQLMetrics.postDriverMetricUpdates(sparkContext, executionId, metrics.values.toSeq) + + val resourceProfileBuilder = new ResourceProfileBuilder() + val executorResourceRequests = new ExecutorResourceRequests() + executorResourceRequests.cores(executorCores) + executorResourceRequests.memory(executorMemory) + executorResourceRequests.memoryOverhead(executorMemoryOverhead) + executorOffHeapMemory.foreach(executorResourceRequests.offHeapMemory) + resourceProfileBuilder.require(executorResourceRequests) + rdd.withResources(resourceProfileBuilder.build()) + rdd + } + + override protected def doExecute(): RDD[InternalRow] = { + val rdd = child.execute() + wrapResourceProfile(rdd) + } + + override protected def doExecuteColumnar(): RDD[ColumnarBatch] = { + val rdd = child.executeColumnar() + wrapResourceProfile(rdd) + } + + override protected def withNewChildInternal(newChild: SparkPlan): SparkPlan = { + this.copy(child = newChild) + } +} diff --git a/extensions/spark/kyuubi-extension-spark-3-3/src/test/scala/org/apache/spark/sql/InjectResourceProfileSuite.scala b/extensions/spark/kyuubi-extension-spark-3-3/src/test/scala/org/apache/spark/sql/InjectResourceProfileSuite.scala new file mode 100644 index 00000000000..b0767b18708 --- /dev/null +++ b/extensions/spark/kyuubi-extension-spark-3-3/src/test/scala/org/apache/spark/sql/InjectResourceProfileSuite.scala @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.spark.sql + +import org.apache.spark.scheduler.{SparkListener, SparkListenerEvent} +import org.apache.spark.sql.execution.ui.SparkListenerSQLAdaptiveExecutionUpdate + +import org.apache.kyuubi.sql.KyuubiSQLConf + +class InjectResourceProfileSuite extends KyuubiSparkSQLExtensionTest { + private def checkCustomResourceProfile(sqlString: String, exists: Boolean): Unit = { + @volatile var lastEvent: SparkListenerSQLAdaptiveExecutionUpdate = null + val listener = new SparkListener { + override def onOtherEvent(event: SparkListenerEvent): Unit = { + event match { + case e: SparkListenerSQLAdaptiveExecutionUpdate => lastEvent = e + case _ => + } + } + } + + spark.sparkContext.addSparkListener(listener) + try { + sql(sqlString).collect() + spark.sparkContext.listenerBus.waitUntilEmpty() + assert(lastEvent != null) + var current = lastEvent.sparkPlanInfo + var shouldStop = false + while (!shouldStop) { + if (current.nodeName != "CustomResourceProfile") { + if (current.children.isEmpty) { + assert(!exists) + shouldStop = true + } else { + current = current.children.head + } + } else { + assert(exists) + shouldStop = true + } + } + } finally { + spark.sparkContext.removeSparkListener(listener) + } + } + + test("Inject resource profile") { + withTable("t") { + withSQLConf( + "spark.sql.adaptive.forceApply" -> "true", + KyuubiSQLConf.FINAL_STAGE_CONFIG_ISOLATION.key -> "true", + KyuubiSQLConf.FINAL_WRITE_STAGE_RESOURCE_ISOLATION_ENABLED.key -> "true") { + + sql("CREATE TABLE t (c1 int, c2 string) USING PARQUET") + + checkCustomResourceProfile("INSERT INTO TABLE t VALUES(1, 'a')", false) + checkCustomResourceProfile("SELECT 1", false) + checkCustomResourceProfile( + "INSERT INTO TABLE t SELECT /*+ rebalance */ * FROM VALUES(1, 'a')", + true) + } + } + } +} diff --git a/extensions/spark/kyuubi-extension-spark-common/pom.xml b/extensions/spark/kyuubi-extension-spark-common/pom.xml index 2c587fd788a..6d4bd144369 100644 --- a/extensions/spark/kyuubi-extension-spark-common/pom.xml +++ b/extensions/spark/kyuubi-extension-spark-common/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml diff --git a/extensions/spark/kyuubi-extension-spark-common/src/main/scala/org/apache/kyuubi/sql/KyuubiQueryStagePreparation.scala b/extensions/spark/kyuubi-extension-spark-common/src/main/scala/org/apache/kyuubi/sql/KyuubiQueryStagePreparation.scala index 360a2645e50..fee65b35082 100644 --- a/extensions/spark/kyuubi-extension-spark-common/src/main/scala/org/apache/kyuubi/sql/KyuubiQueryStagePreparation.scala +++ b/extensions/spark/kyuubi-extension-spark-common/src/main/scala/org/apache/kyuubi/sql/KyuubiQueryStagePreparation.scala @@ -133,7 +133,9 @@ case class FinalStageConfigIsolation(session: SparkSession) extends Rule[SparkPl reusedExchangeExec // query stage is leaf node so we need to transform it manually - case queryStage: QueryStageExec => + // compatible with Spark 3.5: + // SPARK-42101: table cache is a independent query stage, so do not need include it. + case queryStage: QueryStageExec if queryStage.nodeName != "TableCacheQueryStage" => queryStageNum += 1 collectNumber(queryStage.plan) queryStage diff --git a/extensions/spark/kyuubi-extension-spark-common/src/main/scala/org/apache/kyuubi/sql/KyuubiSQLConf.scala b/extensions/spark/kyuubi-extension-spark-common/src/main/scala/org/apache/kyuubi/sql/KyuubiSQLConf.scala index 0fe9f649eaa..4df924b519f 100644 --- a/extensions/spark/kyuubi-extension-spark-common/src/main/scala/org/apache/kyuubi/sql/KyuubiSQLConf.scala +++ b/extensions/spark/kyuubi-extension-spark-common/src/main/scala/org/apache/kyuubi/sql/KyuubiSQLConf.scala @@ -190,4 +190,60 @@ object KyuubiSQLConf { .version("1.7.0") .booleanConf .createWithDefault(true) + + val FINAL_WRITE_STAGE_EAGERLY_KILL_EXECUTORS_ENABLED = + buildConf("spark.sql.finalWriteStage.eagerlyKillExecutors.enabled") + .doc("When true, eagerly kill redundant executors before running final write stage.") + .version("1.8.0") + .booleanConf + .createWithDefault(false) + + val FINAL_WRITE_STAGE_PARTITION_FACTOR = + buildConf("spark.sql.finalWriteStage.retainExecutorsFactor") + .doc("If the target executors * factor < active executors, and " + + "target executors * factor > min executors, then kill redundant executors.") + .version("1.8.0") + .doubleConf + .checkValue(_ >= 1, "must be bigger than or equal to 1") + .createWithDefault(1.2) + + val FINAL_WRITE_STAGE_RESOURCE_ISOLATION_ENABLED = + buildConf("spark.sql.finalWriteStage.resourceIsolation.enabled") + .doc( + "When true, make final write stage resource isolation using custom RDD resource profile.") + .version("1.8.0") + .booleanConf + .createWithDefault(false) + + val FINAL_WRITE_STAGE_EXECUTOR_CORES = + buildConf("spark.sql.finalWriteStage.executorCores") + .doc("Specify the executor core request for final write stage. " + + "It would be passed to the RDD resource profile.") + .version("1.8.0") + .intConf + .createOptional + + val FINAL_WRITE_STAGE_EXECUTOR_MEMORY = + buildConf("spark.sql.finalWriteStage.executorMemory") + .doc("Specify the executor on heap memory request for final write stage. " + + "It would be passed to the RDD resource profile.") + .version("1.8.0") + .stringConf + .createOptional + + val FINAL_WRITE_STAGE_EXECUTOR_MEMORY_OVERHEAD = + buildConf("spark.sql.finalWriteStage.executorMemoryOverhead") + .doc("Specify the executor memory overhead request for final write stage. " + + "It would be passed to the RDD resource profile.") + .version("1.8.0") + .stringConf + .createOptional + + val FINAL_WRITE_STAGE_EXECUTOR_OFF_HEAP_MEMORY = + buildConf("spark.sql.finalWriteStage.executorOffHeapMemory") + .doc("Specify the executor off heap memory request for final write stage. " + + "It would be passed to the RDD resource profile.") + .version("1.8.0") + .stringConf + .createOptional } diff --git a/extensions/spark/kyuubi-extension-spark-common/src/test/scala/org/apache/spark/sql/KyuubiSparkSQLExtensionTest.scala b/extensions/spark/kyuubi-extension-spark-common/src/test/scala/org/apache/spark/sql/KyuubiSparkSQLExtensionTest.scala index fd81948c61a..e58ac726c13 100644 --- a/extensions/spark/kyuubi-extension-spark-common/src/test/scala/org/apache/spark/sql/KyuubiSparkSQLExtensionTest.scala +++ b/extensions/spark/kyuubi-extension-spark-common/src/test/scala/org/apache/spark/sql/KyuubiSparkSQLExtensionTest.scala @@ -29,6 +29,8 @@ import org.apache.kyuubi.sql.KyuubiSQLConf trait KyuubiSparkSQLExtensionTest extends QueryTest with SQLTestUtils with AdaptiveSparkPlanHelper { + sys.props.put("spark.testing", "1") + private var _spark: Option[SparkSession] = None protected def spark: SparkSession = _spark.getOrElse { throw new RuntimeException("test spark session don't initial before using it.") diff --git a/extensions/spark/kyuubi-extension-spark-jdbc-dialect/pom.xml b/extensions/spark/kyuubi-extension-spark-jdbc-dialect/pom.xml index 5588805e9f5..48c4c437923 100644 --- a/extensions/spark/kyuubi-extension-spark-jdbc-dialect/pom.xml +++ b/extensions/spark/kyuubi-extension-spark-jdbc-dialect/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml diff --git a/extensions/spark/kyuubi-spark-authz/README.md b/extensions/spark/kyuubi-spark-authz/README.md index c257e30e143..554797ee01d 100644 --- a/extensions/spark/kyuubi-spark-authz/README.md +++ b/extensions/spark/kyuubi-spark-authz/README.md @@ -1,19 +1,19 @@ +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kyuubi Spark AuthZ Extension @@ -29,7 +29,6 @@ build/mvn clean package -pl :kyuubi-spark-authz_2.12 -Dspark.version=3.2.1 -Dranger.version=2.3.0 ``` - ### Supported Apache Spark Versions `-Dspark.version=` @@ -54,3 +53,4 @@ build/mvn clean package -pl :kyuubi-spark-authz_2.12 -Dspark.version=3.2.1 -Dran - [x] 1.0.x - [x] 0.7.x - [x] 0.6.x + diff --git a/extensions/spark/kyuubi-spark-authz/pom.xml b/extensions/spark/kyuubi-spark-authz/pom.xml index 532564183a1..8df1b9465a9 100644 --- a/extensions/spark/kyuubi-spark-authz/pom.xml +++ b/extensions/spark/kyuubi-spark-authz/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml @@ -32,7 +32,9 @@ https://kyuubi.apache.org/ + 1.0.0 + 1.19.4 5.7.0 @@ -42,6 +44,10 @@ ranger-plugins-common ${ranger.version} + + com.sun.jersey + jersey-bundle + org.apache.ranger ranger-plugin-classloader @@ -101,6 +107,18 @@ + + com.sun.jersey + jersey-client + ${jersey.client.version} + + + javax.ws.rs + jsr311-api + + + + com.kstruct gethostname4j @@ -283,12 +301,6 @@ test - - com.google.code.gson - gson - test - - com.google.guava guava diff --git a/extensions/spark/kyuubi-spark-authz/src/main/resources/table_command_spec.json b/extensions/spark/kyuubi-spark-authz/src/main/resources/table_command_spec.json index 3b9b8f24e6b..f1c2297b38e 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/resources/table_command_spec.json +++ b/extensions/spark/kyuubi-spark-authz/src/main/resources/table_command_spec.json @@ -219,6 +219,20 @@ "fieldName" : "query", "fieldExtractor" : "LogicalPlanQueryExtractor" } ] +}, { + "classname" : "org.apache.spark.sql.catalyst.plans.logical.DescribeRelation", + "tableDescs" : [ { + "fieldName" : "relation", + "fieldExtractor" : "ResolvedTableTableExtractor", + "columnDesc" : null, + "actionTypeDesc" : null, + "tableTypeDesc" : null, + "catalogDesc" : null, + "isInput" : true, + "setCurrentDatabaseIfMissing" : true + } ], + "opType" : "DESCTABLE", + "queryDescs" : [ ] }, { "classname" : "org.apache.spark.sql.catalyst.plans.logical.DropColumns", "tableDescs" : [ { @@ -677,23 +691,6 @@ "fieldName" : "oldName", "fieldExtractor" : "TableIdentifierTableExtractor", "columnDesc" : null, - "actionTypeDesc" : { - "fieldName" : null, - "fieldExtractor" : null, - "actionType" : "DELETE" - }, - "tableTypeDesc" : { - "fieldName" : "oldName", - "fieldExtractor" : "TableIdentifierTableTypeExtractor", - "skipTypes" : [ "TEMP_VIEW" ] - }, - "catalogDesc" : null, - "isInput" : false, - "setCurrentDatabaseIfMissing" : false - }, { - "fieldName" : "newName", - "fieldExtractor" : "TableIdentifierTableExtractor", - "columnDesc" : null, "actionTypeDesc" : null, "tableTypeDesc" : { "fieldName" : "oldName", @@ -1179,6 +1176,23 @@ } ], "opType" : "TRUNCATETABLE", "queryDescs" : [ ] +}, { + "classname" : "org.apache.spark.sql.execution.datasources.CreateTable", + "tableDescs" : [ { + "fieldName" : "tableDesc", + "fieldExtractor" : "CatalogTableTableExtractor", + "columnDesc" : null, + "actionTypeDesc" : null, + "tableTypeDesc" : null, + "catalogDesc" : null, + "isInput" : false, + "setCurrentDatabaseIfMissing" : false + } ], + "opType" : "CREATETABLE", + "queryDescs" : [ { + "fieldName" : "query", + "fieldExtractor" : "LogicalPlanOptionQueryExtractor" + } ] }, { "classname" : "org.apache.spark.sql.execution.datasources.CreateTempViewUsing", "tableDescs" : [ ], diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/PrivilegesBuilder.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/PrivilegesBuilder.scala index cee79b87d7c..b8220ea2732 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/PrivilegesBuilder.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/PrivilegesBuilder.scala @@ -95,6 +95,12 @@ object PrivilegesBuilder { val cols = conditionList ++ sortCols buildQuery(s.child, privilegeObjects, projectionList, cols, spark) + case a: Aggregate => + val aggCols = + (a.aggregateExpressions ++ a.groupingExpressions).flatMap(e => collectLeaves(e)) + val cols = conditionList ++ aggCols + buildQuery(a.child, privilegeObjects, projectionList, cols, spark) + case scan if isKnownScan(scan) && scan.resolved => getScanSpec(scan).tables(scan, spark).foreach(mergeProjection(_, scan)) @@ -144,7 +150,7 @@ object PrivilegesBuilder { } } catch { case e: Exception => - LOG.warn(tableDesc.error(plan, e)) + LOG.debug(tableDesc.error(plan, e)) Nil } } @@ -162,7 +168,7 @@ object PrivilegesBuilder { } } catch { case e: Exception => - LOG.warn(databaseDesc.error(plan, e)) + LOG.debug(databaseDesc.error(plan, e)) } } desc.operationType @@ -193,7 +199,7 @@ object PrivilegesBuilder { } } catch { case e: Exception => - LOG.warn(fd.error(plan, e)) + LOG.debug(fd.error(plan, e)) } } spec.operationType diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/AccessType.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/AccessType.scala index 52e3c01768f..7d62229ee41 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/AccessType.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/AccessType.scala @@ -35,14 +35,13 @@ object AccessType extends Enumeration { case CREATETABLE | CREATEVIEW | CREATETABLE_AS_SELECT if obj.privilegeObjectType == TABLE_OR_VIEW => if (isInput) SELECT else CREATE - // new table new `CREATE` privilege here and the old table gets `DELETE` via actionType - case ALTERTABLE_RENAME => CREATE case ALTERDATABASE | ALTERDATABASE_LOCATION | ALTERTABLE_ADDCOLS | ALTERTABLE_ADDPARTS | ALTERTABLE_DROPPARTS | ALTERTABLE_LOCATION | + ALTERTABLE_RENAME | ALTERTABLE_PROPERTIES | ALTERTABLE_RENAMECOL | ALTERTABLE_RENAMEPART | diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/FilterDataSourceV2Strategy.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/FilterDataSourceV2Strategy.scala index 1109464ac0a..d39aacdcf91 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/FilterDataSourceV2Strategy.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/FilterDataSourceV2Strategy.scala @@ -25,10 +25,13 @@ import org.apache.kyuubi.plugin.spark.authz.util.ObjectFilterPlaceHolder class FilterDataSourceV2Strategy(spark: SparkSession) extends Strategy { override def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match { case ObjectFilterPlaceHolder(child) if child.nodeName == "ShowNamespaces" => - spark.sessionState.planner.plan(child).map(FilteredShowNamespaceExec).toSeq + spark.sessionState.planner.plan(child) + .map(FilteredShowNamespaceExec(_, spark.sparkContext)).toSeq case ObjectFilterPlaceHolder(child) if child.nodeName == "ShowTables" => - spark.sessionState.planner.plan(child).map(FilteredShowTablesExec).toSeq + spark.sessionState.planner.plan(child) + .map(FilteredShowTablesExec(_, spark.sparkContext)).toSeq + case _ => Nil } } diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/FilteredShowObjectsExec.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/FilteredShowObjectsExec.scala index 7cc777d9b89..67519118ecc 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/FilteredShowObjectsExec.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/FilteredShowObjectsExec.scala @@ -17,6 +17,7 @@ package org.apache.kyuubi.plugin.spark.authz.ranger import org.apache.hadoop.security.UserGroupInformation +import org.apache.spark.SparkContext import org.apache.spark.rdd.RDD import org.apache.spark.sql.catalyst.InternalRow import org.apache.spark.sql.catalyst.expressions.Attribute @@ -26,24 +27,29 @@ import org.apache.kyuubi.plugin.spark.authz.{ObjectType, OperationType} import org.apache.kyuubi.plugin.spark.authz.util.AuthZUtils trait FilteredShowObjectsExec extends LeafExecNode { - def delegated: SparkPlan + def result: Array[InternalRow] - final override def output: Seq[Attribute] = delegated.output - - final private lazy val result = { - delegated.executeCollect().filter(isAllowed(_, AuthZUtils.getAuthzUgi(sparkContext))) - } + override def output: Seq[Attribute] final override def doExecute(): RDD[InternalRow] = { sparkContext.parallelize(result, 1) } +} - protected def isAllowed(r: InternalRow, ugi: UserGroupInformation): Boolean +trait FilteredShowObjectsCheck { + def isAllowed(r: InternalRow, ugi: UserGroupInformation): Boolean } -case class FilteredShowNamespaceExec(delegated: SparkPlan) extends FilteredShowObjectsExec { +case class FilteredShowNamespaceExec(result: Array[InternalRow], output: Seq[Attribute]) + extends FilteredShowObjectsExec {} +object FilteredShowNamespaceExec extends FilteredShowObjectsCheck { + def apply(delegated: SparkPlan, sc: SparkContext): FilteredShowNamespaceExec = { + val result = delegated.executeCollect() + .filter(isAllowed(_, AuthZUtils.getAuthzUgi(sc))) + new FilteredShowNamespaceExec(result, delegated.output) + } - override protected def isAllowed(r: InternalRow, ugi: UserGroupInformation): Boolean = { + override def isAllowed(r: InternalRow, ugi: UserGroupInformation): Boolean = { val database = r.getString(0) val resource = AccessResource(ObjectType.DATABASE, database, null, null) val request = AccessRequest(resource, ugi, OperationType.SHOWDATABASES, AccessType.USE) @@ -52,8 +58,16 @@ case class FilteredShowNamespaceExec(delegated: SparkPlan) extends FilteredShowO } } -case class FilteredShowTablesExec(delegated: SparkPlan) extends FilteredShowObjectsExec { - override protected def isAllowed(r: InternalRow, ugi: UserGroupInformation): Boolean = { +case class FilteredShowTablesExec(result: Array[InternalRow], output: Seq[Attribute]) + extends FilteredShowObjectsExec {} +object FilteredShowTablesExec extends FilteredShowObjectsCheck { + def apply(delegated: SparkPlan, sc: SparkContext): FilteredShowNamespaceExec = { + val result = delegated.executeCollect() + .filter(isAllowed(_, AuthZUtils.getAuthzUgi(sc))) + new FilteredShowNamespaceExec(result, delegated.output) + } + + override def isAllowed(r: InternalRow, ugi: UserGroupInformation): Boolean = { val database = r.getString(0) val table = r.getString(1) val isTemp = r.getBoolean(2) diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerSparkExtension.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerSparkExtension.scala index f4dcb3f9fdf..f8e941d9def 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerSparkExtension.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerSparkExtension.scala @@ -19,6 +19,8 @@ package org.apache.kyuubi.plugin.spark.authz.ranger import org.apache.spark.sql.SparkSessionExtensions +import org.apache.kyuubi.plugin.spark.authz.ranger.datamasking.{RuleApplyDataMaskingStage0, RuleApplyDataMaskingStage1} +import org.apache.kyuubi.plugin.spark.authz.ranger.rowfilter.RuleApplyRowFilter import org.apache.kyuubi.plugin.spark.authz.util.{RuleEliminateMarker, RuleEliminateViewMarker} /** @@ -36,13 +38,15 @@ import org.apache.kyuubi.plugin.spark.authz.util.{RuleEliminateMarker, RuleElimi * @since 1.6.0 */ class RangerSparkExtension extends (SparkSessionExtensions => Unit) { - SparkRangerAdminPlugin.init() + SparkRangerAdminPlugin.initialize() override def apply(v1: SparkSessionExtensions): Unit = { v1.injectCheckRule(AuthzConfigurationChecker) v1.injectResolutionRule(_ => new RuleReplaceShowObjectCommands()) v1.injectResolutionRule(_ => new RuleApplyPermanentViewMarker()) - v1.injectResolutionRule(new RuleApplyRowFilterAndDataMasking(_)) + v1.injectResolutionRule(RuleApplyRowFilter) + v1.injectResolutionRule(RuleApplyDataMaskingStage0) + v1.injectResolutionRule(RuleApplyDataMaskingStage1) v1.injectOptimizerRule(_ => new RuleEliminateMarker()) v1.injectOptimizerRule(new RuleAuthorization(_)) v1.injectOptimizerRule(_ => new RuleEliminateViewMarker()) diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleApplyRowFilterAndDataMasking.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleApplyRowFilterAndDataMasking.scala deleted file mode 100644 index b6961c92459..00000000000 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleApplyRowFilterAndDataMasking.scala +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.kyuubi.plugin.spark.authz.ranger - -import org.apache.spark.sql.SparkSession -import org.apache.spark.sql.catalyst.expressions.Alias -import org.apache.spark.sql.catalyst.plans.logical.{Filter, LogicalPlan, Project} -import org.apache.spark.sql.catalyst.rules.Rule - -import org.apache.kyuubi.plugin.spark.authz.ObjectType -import org.apache.kyuubi.plugin.spark.authz.serde._ -import org.apache.kyuubi.plugin.spark.authz.util.{PermanentViewMarker, RowFilterAndDataMaskingMarker} -import org.apache.kyuubi.plugin.spark.authz.util.AuthZUtils._ - -class RuleApplyRowFilterAndDataMasking(spark: SparkSession) extends Rule[LogicalPlan] { - private def mapChildren(plan: LogicalPlan)(f: LogicalPlan => LogicalPlan): LogicalPlan = { - val newChildren = plan match { - case cmd if isKnownTableCommand(cmd) => - val tableCommandSpec = getTableCommandSpec(cmd) - val queries = tableCommandSpec.queries(cmd) - cmd.children.map { - case c if queries.contains(c) => f(c) - case other => other - } - case _ => - plan.children.map(f) - } - plan.withNewChildren(newChildren) - } - - override def apply(plan: LogicalPlan): LogicalPlan = { - mapChildren(plan) { - case p: RowFilterAndDataMaskingMarker => p - case scan if isKnownScan(scan) && scan.resolved => - val tables = getScanSpec(scan).tables(scan, spark) - tables.headOption.map(applyFilterAndMasking(scan, _)).getOrElse(scan) - case other => apply(other) - } - } - - private def applyFilterAndMasking( - plan: LogicalPlan, - table: Table): LogicalPlan = { - val ugi = getAuthzUgi(spark.sparkContext) - val opType = operationType(plan) - val parse = spark.sessionState.sqlParser.parseExpression _ - val are = AccessResource(ObjectType.TABLE, table.database.orNull, table.table, null) - val art = AccessRequest(are, ugi, opType, AccessType.SELECT) - val filterExprStr = SparkRangerAdminPlugin.getFilterExpr(art) - val newOutput = plan.output.map { attr => - val are = - AccessResource(ObjectType.COLUMN, table.database.orNull, table.table, attr.name) - val art = AccessRequest(are, ugi, opType, AccessType.SELECT) - val maskExprStr = SparkRangerAdminPlugin.getMaskingExpr(art) - if (maskExprStr.isEmpty) { - attr - } else { - val maskExpr = parse(maskExprStr.get) - plan match { - case _: PermanentViewMarker => - Alias(maskExpr, attr.name)(exprId = attr.exprId) - case _ => - Alias(maskExpr, attr.name)() - } - } - } - - if (filterExprStr.isEmpty) { - Project(newOutput, RowFilterAndDataMaskingMarker(plan)) - } else { - val filterExpr = parse(filterExprStr.get) - Project(newOutput, Filter(filterExpr, RowFilterAndDataMaskingMarker(plan))) - } - } -} diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleAuthorization.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleAuthorization.scala index 1c73acc492e..3d53174f3e6 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleAuthorization.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleAuthorization.scala @@ -27,16 +27,15 @@ import org.apache.spark.sql.catalyst.trees.TreeNodeTag import org.apache.kyuubi.plugin.spark.authz._ import org.apache.kyuubi.plugin.spark.authz.ObjectType._ -import org.apache.kyuubi.plugin.spark.authz.ranger.RuleAuthorization.KYUUBI_AUTHZ_TAG +import org.apache.kyuubi.plugin.spark.authz.ranger.RuleAuthorization._ import org.apache.kyuubi.plugin.spark.authz.ranger.SparkRangerAdminPlugin._ -import org.apache.kyuubi.plugin.spark.authz.util.AuthZUtils._; +import org.apache.kyuubi.plugin.spark.authz.util.AuthZUtils._ class RuleAuthorization(spark: SparkSession) extends Rule[LogicalPlan] { - override def apply(plan: LogicalPlan): LogicalPlan = plan match { - case p if !plan.getTagValue(KYUUBI_AUTHZ_TAG).contains(true) => - RuleAuthorization.checkPrivileges(spark, p) - p.setTagValue(KYUUBI_AUTHZ_TAG, true) - p - case p => p // do nothing if checked privileges already. + override def apply(plan: LogicalPlan): LogicalPlan = { + plan match { + case plan if isAuthChecked(plan) => plan // do nothing if checked privileges already. + case p => checkPrivileges(spark, p) + } } } @@ -44,7 +43,7 @@ object RuleAuthorization { val KYUUBI_AUTHZ_TAG = TreeNodeTag[Boolean]("__KYUUBI_AUTHZ_TAG") - def checkPrivileges(spark: SparkSession, plan: LogicalPlan): Unit = { + private def checkPrivileges(spark: SparkSession, plan: LogicalPlan): LogicalPlan = { val auditHandler = new SparkRangerAuditHandler val ugi = getAuthzUgi(spark.sparkContext) val (inputs, outputs, opType) = PrivilegesBuilder.build(plan, spark) @@ -94,5 +93,17 @@ object RuleAuthorization { verify(Seq(req), auditHandler) } } + markAuthChecked(plan) + } + + private def markAuthChecked(plan: LogicalPlan): LogicalPlan = { + plan.transformUp { case p => + p.setTagValue(KYUUBI_AUTHZ_TAG, true) + p + } + } + + private def isAuthChecked(plan: LogicalPlan): Boolean = { + plan.find(_.getTagValue(KYUUBI_AUTHZ_TAG).contains(true)).nonEmpty } } diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleHelper.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleHelper.scala new file mode 100644 index 00000000000..3cfe2b9406b --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RuleHelper.scala @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger + +import org.apache.hadoop.security.UserGroupInformation +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.rules.Rule + +import org.apache.kyuubi.plugin.spark.authz.serde.{getTableCommandSpec, isKnownTableCommand} +import org.apache.kyuubi.plugin.spark.authz.util.AuthZUtils + +trait RuleHelper extends Rule[LogicalPlan] { + + def spark: SparkSession + + final protected val parse: String => Expression = spark.sessionState.sqlParser.parseExpression _ + + protected def mapChildren(plan: LogicalPlan)(f: LogicalPlan => LogicalPlan): LogicalPlan = { + val newChildren = plan match { + case cmd if isKnownTableCommand(cmd) => + val tableCommandSpec = getTableCommandSpec(cmd) + val queries = tableCommandSpec.queries(cmd) + cmd.children.map { + case c if queries.contains(c) => f(c) + case other => other + } + case _ => + plan.children.map(f) + } + plan.withNewChildren(newChildren) + } + + def ugi: UserGroupInformation = AuthZUtils.getAuthzUgi(spark.sparkContext) + +} diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/SparkRangerAdminPlugin.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/SparkRangerAdminPlugin.scala index 7ece55fe535..78e59ff897f 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/SparkRangerAdminPlugin.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/SparkRangerAdminPlugin.scala @@ -18,11 +18,12 @@ package org.apache.kyuubi.plugin.spark.authz.ranger import scala.collection.JavaConverters._ -import scala.collection.mutable.ArrayBuffer -import scala.collection.mutable.LinkedHashMap +import scala.collection.mutable.{ArrayBuffer, LinkedHashMap} +import org.apache.hadoop.util.ShutdownHookManager import org.apache.ranger.plugin.policyengine.RangerAccessRequest import org.apache.ranger.plugin.service.RangerBasePlugin +import org.slf4j.LoggerFactory import org.apache.kyuubi.plugin.spark.authz.AccessControlException import org.apache.kyuubi.plugin.spark.authz.util.AuthZUtils._ @@ -30,6 +31,7 @@ import org.apache.kyuubi.plugin.spark.authz.util.RangerConfigProvider object SparkRangerAdminPlugin extends RangerBasePlugin("spark", "sparkSql") with RangerConfigProvider { + final private val LOG = LoggerFactory.getLogger(getClass) /** * For a Spark SQL query, it may contain 0 or more privilege objects to verify, e.g. a typical @@ -60,6 +62,29 @@ object SparkRangerAdminPlugin extends RangerBasePlugin("spark", "sparkSql") s"ranger.plugin.$getServiceType.use.usergroups.from.userstore.enabled", false) + /** + * plugin initialization + * with cleanup shutdown hook registered + */ + def initialize(): Unit = { + this.init() + registerCleanupShutdownHook(this) + } + + /** + * register shutdown hook for plugin cleanup + */ + private def registerCleanupShutdownHook(plugin: RangerBasePlugin): Unit = { + ShutdownHookManager.get().addShutdownHook( + () => { + if (plugin != null) { + LOG.info(s"clean up ranger plugin, appId: ${plugin.getAppId}") + this.cleanup() + } + }, + Integer.MAX_VALUE) + } + def getFilterExpr(req: AccessRequest): Option[String] = { val result = evalRowFilterPolicies(req, null) Option(result) diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingStage0Marker.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingStage0Marker.scala new file mode 100644 index 00000000000..b4314938324 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingStage0Marker.scala @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking + +import org.apache.spark.sql.catalyst.expressions.{Attribute, ExprId} +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, UnaryNode} + +import org.apache.kyuubi.plugin.spark.authz.util.WithInternalChild +case class DataMaskingStage0Marker(child: LogicalPlan, scan: LogicalPlan) + extends UnaryNode with WithInternalChild { + + def exprToMaskers(): Map[ExprId, Attribute] = { + scan.output.map(_.exprId).zip(child.output).flatMap { case (id, expr) => + if (id == expr.exprId) None else Some(id -> expr) + }.toMap + } + + override def output: Seq[Attribute] = child.output + + override def withNewChildInternal(newChild: LogicalPlan): LogicalPlan = copy(child = newChild) + +} diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingStage1Marker.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingStage1Marker.scala new file mode 100644 index 00000000000..aed0ac693b1 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingStage1Marker.scala @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking + +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, UnaryNode} + +import org.apache.kyuubi.plugin.spark.authz.util.WithInternalChild + +case class DataMaskingStage1Marker(child: LogicalPlan) extends UnaryNode with WithInternalChild { + + override def output: Seq[Attribute] = child.output + + override def withNewChildInternal(newChild: LogicalPlan): LogicalPlan = copy(child = newChild) + +} diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/RuleApplyDataMaskingStage0.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/RuleApplyDataMaskingStage0.scala new file mode 100644 index 00000000000..de125550ac9 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/RuleApplyDataMaskingStage0.scala @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking + +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.expressions.Alias +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project} + +import org.apache.kyuubi.plugin.spark.authz.ObjectType +import org.apache.kyuubi.plugin.spark.authz.OperationType.QUERY +import org.apache.kyuubi.plugin.spark.authz.ranger._ +import org.apache.kyuubi.plugin.spark.authz.serde._ + +/** + * The full data masking rule contains two separate stages. + * + * Step1: RuleApplyDataMaskingStage0 + * - lookup the full plan for supported scans + * - once found, get masker configuration from external column by column + * - use spark sql parser to generate an unresolved expression for each masker + * - add a projection with new output on the right top of the original scan if the output has + * changed + * - Add DataMaskingStage0Marker to track the original expression and its masker expression. + * + * Step2: Spark native rules will resolve our newly added maskers + * + * Step3: [[RuleApplyDataMaskingStage1]] + */ +case class RuleApplyDataMaskingStage0(spark: SparkSession) extends RuleHelper { + + override def apply(plan: LogicalPlan): LogicalPlan = { + val newPlan = mapChildren(plan) { + case p: DataMaskingStage0Marker => p + case p: DataMaskingStage1Marker => p + case scan if isKnownScan(scan) && scan.resolved => + val tables = getScanSpec(scan).tables(scan, spark) + tables.headOption.map(applyMasking(scan, _)).getOrElse(scan) + case other => apply(other) + } + newPlan + } + + private def applyMasking( + plan: LogicalPlan, + table: Table): LogicalPlan = { + val newOutput = plan.output.map { attr => + val are = + AccessResource(ObjectType.COLUMN, table.database.orNull, table.table, attr.name) + val art = AccessRequest(are, ugi, QUERY, AccessType.SELECT) + val maskExprStr = SparkRangerAdminPlugin.getMaskingExpr(art) + maskExprStr.map(parse).map(Alias(_, attr.name)()).getOrElse(attr) + } + if (newOutput == plan.output) { + plan + } else { + DataMaskingStage0Marker(Project(newOutput, plan), plan) + } + } +} diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/RuleApplyDataMaskingStage1.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/RuleApplyDataMaskingStage1.scala new file mode 100644 index 00000000000..9589be2e97b --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/RuleApplyDataMaskingStage1.scala @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking + +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.expressions.NamedExpression +import org.apache.spark.sql.catalyst.plans.logical.{Command, LogicalPlan} + +import org.apache.kyuubi.plugin.spark.authz.ranger.RuleHelper +import org.apache.kyuubi.plugin.spark.authz.serde._ + +/** + * See [[RuleApplyDataMaskingStage0]] also. + * + * This is the second step for data masking. It will fulfill the missing attributes that + * have a related masker expression buffered by DataMaskingStage0Marker. + */ +case class RuleApplyDataMaskingStage1(spark: SparkSession) extends RuleHelper { + + override def apply(plan: LogicalPlan): LogicalPlan = { + + plan match { + case marker0: DataMaskingStage0Marker => marker0 + case marker1: DataMaskingStage1Marker => marker1 + case cmd if isKnownTableCommand(cmd) => + val tableCommandSpec = getTableCommandSpec(cmd) + val queries = tableCommandSpec.queries(cmd) + cmd.mapChildren { + case marker0: DataMaskingStage0Marker => marker0 + case marker1: DataMaskingStage1Marker => marker1 + case query if queries.contains(query) && query.resolved => + applyDataMasking(query) + case o => o + } + case cmd: Command if cmd.childrenResolved => + cmd.mapChildren(applyDataMasking) + case cmd: Command => cmd + case other if other.resolved => applyDataMasking(other) + case other => other + } + } + + private def applyDataMasking(plan: LogicalPlan): LogicalPlan = { + assert(plan.resolved, "the current masking approach relies on a resolved plan") + def replaceOriginExprWithMasker(plan: LogicalPlan): LogicalPlan = plan match { + case m: DataMaskingStage0Marker => m + case m: DataMaskingStage1Marker => m + case p => + val maskerExprs = p.collect { + case marker: DataMaskingStage0Marker if marker.resolved => marker.exprToMaskers() + }.flatten.toMap + if (maskerExprs.isEmpty) { + p + } else { + val t = p.transformExpressionsUp { + case e: NamedExpression => maskerExprs.getOrElse(e.exprId, e) + } + t.withNewChildren(t.children.map(replaceOriginExprWithMasker)) + } + } + val newPlan = replaceOriginExprWithMasker(plan) + + if (newPlan == plan) { + plan + } else { + DataMaskingStage1Marker(newPlan) + } + } +} diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/util/RowFilterAndDataMaskingMarker.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfilter/RowFilterMarker.scala similarity index 80% rename from extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/util/RowFilterAndDataMaskingMarker.scala rename to extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfilter/RowFilterMarker.scala index 357e9bfc2a5..8817958b585 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/util/RowFilterAndDataMaskingMarker.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfilter/RowFilterMarker.scala @@ -15,17 +15,17 @@ * limitations under the License. */ -package org.apache.kyuubi.plugin.spark.authz.util +package org.apache.kyuubi.plugin.spark.authz.ranger.rowfilter import org.apache.spark.sql.catalyst.expressions.Attribute import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, UnaryNode} -case class RowFilterAndDataMaskingMarker(child: LogicalPlan) extends UnaryNode - with WithInternalChild { +import org.apache.kyuubi.plugin.spark.authz.util.WithInternalChild + +case class RowFilterMarker(child: LogicalPlan) extends UnaryNode with WithInternalChild { override def output: Seq[Attribute] = child.output - override def withNewChildInternal(newChild: LogicalPlan): LogicalPlan = - copy(child = newChild) + override def withNewChildInternal(newChild: LogicalPlan): LogicalPlan = copy(child = newChild) } diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfilter/RuleApplyRowFilter.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfilter/RuleApplyRowFilter.scala new file mode 100644 index 00000000000..22bcfae49d9 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfilter/RuleApplyRowFilter.scala @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.rowfilter + +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.plans.logical.{Filter, LogicalPlan} + +import org.apache.kyuubi.plugin.spark.authz.ObjectType +import org.apache.kyuubi.plugin.spark.authz.OperationType.QUERY +import org.apache.kyuubi.plugin.spark.authz.ranger._ +import org.apache.kyuubi.plugin.spark.authz.serde._ + +case class RuleApplyRowFilter(spark: SparkSession) extends RuleHelper { + + override def apply(plan: LogicalPlan): LogicalPlan = { + val newPlan = mapChildren(plan) { + case p: RowFilterMarker => p + case scan if isKnownScan(scan) && scan.resolved => + val tables = getScanSpec(scan).tables(scan, spark) + tables.headOption.map(applyFilter(scan, _)).getOrElse(scan) + case other => apply(other) + } + newPlan + } + + private def applyFilter( + plan: LogicalPlan, + table: Table): LogicalPlan = { + val are = AccessResource(ObjectType.TABLE, table.database.orNull, table.table, null) + val art = AccessRequest(are, ugi, QUERY, AccessType.SELECT) + val filterExpr = SparkRangerAdminPlugin.getFilterExpr(art).map(parse) + val filtered = filterExpr.foldLeft(plan)((p, expr) => Filter(expr, RowFilterMarker(p))) + filtered + } +} diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/serde/CommandSpec.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/serde/CommandSpec.scala index d72d789324e..e96ef8cbfd6 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/serde/CommandSpec.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/serde/CommandSpec.scala @@ -85,7 +85,7 @@ case class TableCommandSpec( qd.extract(plan) } catch { case e: Exception => - LOG.warn(qd.error(plan, e)) + LOG.debug(qd.error(plan, e)) None } } @@ -102,7 +102,7 @@ case class ScanSpec( td.extract(plan, spark) } catch { case e: Exception => - LOG.warn(td.error(plan, e)) + LOG.debug(td.error(plan, e)) None } } diff --git a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/util/RuleEliminateMarker.scala b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/util/RuleEliminateMarker.scala index d2da7257096..448439b8426 100644 --- a/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/util/RuleEliminateMarker.scala +++ b/extensions/spark/kyuubi-spark-authz/src/main/scala/org/apache/kyuubi/plugin/spark/authz/util/RuleEliminateMarker.scala @@ -17,11 +17,25 @@ package org.apache.kyuubi.plugin.spark.authz.util +import org.apache.spark.sql.catalyst.expressions.SubqueryExpression import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan import org.apache.spark.sql.catalyst.rules.Rule +import org.apache.kyuubi.plugin.spark.authz.ranger.datamasking.{DataMaskingStage0Marker, DataMaskingStage1Marker} +import org.apache.kyuubi.plugin.spark.authz.ranger.rowfilter.RowFilterMarker + class RuleEliminateMarker extends Rule[LogicalPlan] { override def apply(plan: LogicalPlan): LogicalPlan = { - plan.transformUp { case rf: RowFilterAndDataMaskingMarker => rf.child } + plan.transformUp { case p => + p.transformExpressionsUp { + case p: SubqueryExpression => + p.withNewPlan(apply(p.plan)) + } match { + case marker0: DataMaskingStage0Marker => marker0.child + case marker1: DataMaskingStage1Marker => marker1.child + case rf: RowFilterMarker => rf.child + case other => other + } + } } } diff --git a/extensions/spark/kyuubi-spark-authz/src/test/resources/sparkSql_hive_jenkins.json b/extensions/spark/kyuubi-spark-authz/src/test/resources/sparkSql_hive_jenkins.json index b5b069c463a..250df2ddc59 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/resources/sparkSql_hive_jenkins.json +++ b/extensions/spark/kyuubi-spark-authz/src/test/resources/sparkSql_hive_jenkins.json @@ -280,7 +280,8 @@ "values": [ "default", "spark_catalog", - "iceberg_ns" + "iceberg_ns", + "ns1" ], "isExcludes": false, "isRecursive": false @@ -900,7 +901,9 @@ "database": { "values": [ "default", - "spark_catalog" + "spark_catalog", + "iceberg_ns", + "ns1" ], "isExcludes": false, "isRecursive": false @@ -1148,6 +1151,67 @@ "guid": "b3f1f1e0-2bd6-4b20-8a32-a531006ae151", "isEnabled": true, "version": 1 + }, + { + "service": "hive_jenkins", + "name": "someone_access_perm_view", + "policyType": 0, + "policyPriority": 0, + "description": "", + "isAuditEnabled": true, + "resources": { + "database": { + "values": [ + "default" + ], + "isExcludes": false, + "isRecursive": false + }, + "column": { + "values": [ + "*" + ], + "isExcludes": false, + "isRecursive": false + }, + "table": { + "values": [ + "perm_view" + ], + "isExcludes": false, + "isRecursive": false + } + }, + "policyItems": [ + { + "accesses": [ + { + "type": "select", + "isAllowed": true + } + ], + "users": [ + "user_perm_view_only" + ], + "groups": [], + "conditions": [], + "delegateAdmin": false + } + ], + "denyPolicyItems": [], + "allowExceptions": [], + "denyExceptions": [], + "dataMaskPolicyItems": [], + "rowFilterPolicyItems": [], + "options": {}, + "validitySchedules": [], + "policyLabels": [ + "" + ], + "id": 123, + "guid": "2fb6099d-e421-41df-9d24-f2f47bed618e", + "isEnabled": true, + "version": 5 } ], "serviceDef": { diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/IcebergCatalogPrivilegesBuilderSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/IcebergCatalogPrivilegesBuilderSuite.scala index d89d0696feb..81397038920 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/IcebergCatalogPrivilegesBuilderSuite.scala +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/IcebergCatalogPrivilegesBuilderSuite.scala @@ -26,7 +26,7 @@ import org.apache.kyuubi.plugin.spark.authz.ranger.AccessType class IcebergCatalogPrivilegesBuilderSuite extends V2CommandsPrivilegesSuite { override protected val catalogImpl: String = "hive" override protected val sqlExtensions: String = - if (isSparkV32OrGreater) { + if (isSparkV31OrGreater) { "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions" } else "" override protected def format = "iceberg" @@ -38,7 +38,7 @@ class IcebergCatalogPrivilegesBuilderSuite extends V2CommandsPrivilegesSuite { override protected val supportsPartitionManagement = false override def beforeAll(): Unit = { - if (isSparkV32OrGreater) { + if (isSparkV31OrGreater) { spark.conf.set( s"spark.sql.catalog.$catalogV2", "org.apache.iceberg.spark.SparkCatalog") @@ -51,7 +51,7 @@ class IcebergCatalogPrivilegesBuilderSuite extends V2CommandsPrivilegesSuite { } override def withFixture(test: NoArgTest): Outcome = { - assume(isSparkV32OrGreater) + assume(isSparkV31OrGreater) test() } diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/PrivilegesBuilderSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/PrivilegesBuilderSuite.scala index 15f58deb309..43929091769 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/PrivilegesBuilderSuite.scala +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/PrivilegesBuilderSuite.scala @@ -143,7 +143,7 @@ abstract class PrivilegesBuilderSuite extends AnyFunSuite val (in, out, operationType) = PrivilegesBuilder.build(plan, spark) assert(operationType === ALTERTABLE_RENAME) assert(in.isEmpty) - assert(out.size === 2) + assert(out.size === 1) out.foreach { po => assert(po.privilegeObjectType === PrivilegeObjectType.TABLE_OR_VIEW) assert(po.catalog.isEmpty) @@ -151,10 +151,7 @@ abstract class PrivilegesBuilderSuite extends AnyFunSuite assert(Set(oldTableShort, "efg").contains(po.objectName)) assert(po.columns.isEmpty) val accessType = ranger.AccessType(po, operationType, isInput = false) - assert(Set(AccessType.CREATE, AccessType.DROP).contains(accessType)) - if (accessType == AccessType.DROP) { - checkTableOwner(po) - } + assert(accessType == AccessType.ALTER) } } } @@ -1648,6 +1645,48 @@ class HiveCatalogPrivilegeBuilderSuite extends PrivilegesBuilderSuite { val accessType = ranger.AccessType(po, operationType, isInput = false) assert(accessType === AccessType.CREATE) } + + test("KYUUBI #4532: Displays the columns involved in extracting the aggregation operator") { + // case1: There is no project operator involving all columns. + val plan1 = sql(s"SELECT COUNT(key), MAX(value) FROM $reusedPartTable GROUP BY pid") + .queryExecution.optimizedPlan + val (in1, out1, _) = PrivilegesBuilder.build(plan1, spark) + assert(in1.size === 1) + assert(out1.isEmpty) + val pi1 = in1.head + assert(pi1.columns.size === 3) + assert(pi1.columns === Seq("key", "value", "pid")) + + // case2: Some columns are involved, and the group column is not selected. + val plan2 = sql(s"SELECT COUNT(key) FROM $reusedPartTable GROUP BY pid") + .queryExecution.optimizedPlan + val (in2, out2, _) = PrivilegesBuilder.build(plan2, spark) + assert(in2.size === 1) + assert(out2.isEmpty) + val pi2 = in2.head + assert(pi2.columns.size === 2) + assert(pi2.columns === Seq("key", "pid")) + + // case3: Some columns are involved, and the group column is selected. + val plan3 = sql(s"SELECT COUNT(key), pid FROM $reusedPartTable GROUP BY pid") + .queryExecution.optimizedPlan + val (in3, out3, _) = PrivilegesBuilder.build(plan3, spark) + assert(in3.size === 1) + assert(out3.isEmpty) + val pi3 = in3.head + assert(pi3.columns.size === 2) + assert(pi3.columns === Seq("key", "pid")) + + // case4: HAVING & GROUP clause + val plan4 = sql(s"SELECT COUNT(key) FROM $reusedPartTable GROUP BY pid HAVING MAX(key) > 1000") + .queryExecution.optimizedPlan + val (in4, out4, _) = PrivilegesBuilder.build(plan4, spark) + assert(in4.size === 1) + assert(out4.isEmpty) + val pi4 = in4.head + assert(pi4.columns.size === 2) + assert(pi4.columns === Seq("key", "pid")) + } } case class SimpleInsert(userSpecifiedSchema: StructType)(@transient val sparkSession: SparkSession) diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/SparkSessionProvider.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/SparkSessionProvider.scala index 0ab88917b6d..ce8d6bc0ccf 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/SparkSessionProvider.scala +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/SparkSessionProvider.scala @@ -22,7 +22,8 @@ import java.security.PrivilegedExceptionAction import org.apache.hadoop.security.UserGroupInformation import org.apache.spark.SparkConf -import org.apache.spark.sql.{DataFrame, SparkSession, SparkSessionExtensions} +import org.apache.spark.sql.{DataFrame, Row, SparkSession, SparkSessionExtensions} +import org.scalatest.Assertions.convertToEqualizer import org.apache.kyuubi.Utils import org.apache.kyuubi.plugin.spark.authz.util.AuthZUtils._ @@ -71,4 +72,32 @@ trait SparkSessionProvider { protected val sql: String => DataFrame = spark.sql + protected def doAs[T](user: String, f: => T): T = { + UserGroupInformation.createRemoteUser(user).doAs[T]( + new PrivilegedExceptionAction[T] { + override def run(): T = f + }) + } + protected def withCleanTmpResources[T](res: Seq[(String, String)])(f: => T): T = { + try { + f + } finally { + res.foreach { + case (t, "table") => doAs("admin", sql(s"DROP TABLE IF EXISTS $t")) + case (db, "database") => doAs("admin", sql(s"DROP DATABASE IF EXISTS $db")) + case (fn, "function") => doAs("admin", sql(s"DROP FUNCTION IF EXISTS $fn")) + case (view, "view") => doAs("admin", sql(s"DROP VIEW IF EXISTS $view")) + case (cacheTable, "cache") => if (isSparkV32OrGreater) { + doAs("admin", sql(s"UNCACHE TABLE IF EXISTS $cacheTable")) + } + case (_, e) => + throw new RuntimeException(s"the resource whose resource type is $e cannot be cleared") + } + } + } + + protected def checkAnswer(user: String, query: String, result: Seq[Row]): Unit = { + doAs(user, assert(sql(query).collect() === result)) + } + } diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/V2CommandsPrivilegesSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/V2CommandsPrivilegesSuite.scala index 9d3e6d42df4..dede8142693 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/V2CommandsPrivilegesSuite.scala +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/V2CommandsPrivilegesSuite.scala @@ -515,6 +515,24 @@ abstract class V2CommandsPrivilegesSuite extends PrivilegesBuilderSuite { assert(accessType === AccessType.UPDATE) } + test("DescribeTable") { + val plan = executePlan(s"DESCRIBE TABLE $catalogTable").analyzed + val (inputs, outputs, operationType) = PrivilegesBuilder.build(plan, spark) + assert(operationType === DESCTABLE) + assert(inputs.size === 1) + val po = inputs.head + assert(po.actionType === PrivilegeObjectActionType.OTHER) + assert(po.privilegeObjectType === PrivilegeObjectType.TABLE_OR_VIEW) + assert(po.catalog === Some(catalogV2)) + assert(po.dbname === namespace) + assert(po.objectName === catalogTableShort) + assert(po.columns.isEmpty) + checkV2TableOwner(po) + val accessType = AccessType(po, operationType, isInput = true) + assert(accessType === AccessType.SELECT) + assert(outputs.size === 0) + } + // with V2AlterTableCommand test("AddColumns") { diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/gen/TableCommands.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/gen/TableCommands.scala index ef981515a47..a8b8121e2b0 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/gen/TableCommands.scala +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/gen/TableCommands.scala @@ -102,7 +102,6 @@ object TableCommands { val AlterTableRename = { val cmd = "org.apache.spark.sql.execution.command.AlterTableRenameCommand" - val actionTypeDesc = ActionTypeDesc(actionType = Some(DELETE)) val oldTableTableTypeDesc = TableTypeDesc( @@ -112,12 +111,9 @@ object TableCommands { val oldTableD = TableDesc( "oldName", tite, - tableTypeDesc = Some(oldTableTableTypeDesc), - actionTypeDesc = Some(actionTypeDesc)) + tableTypeDesc = Some(oldTableTableTypeDesc)) - val newTableD = - TableDesc("newName", tite, tableTypeDesc = Some(oldTableTableTypeDesc)) - TableCommandSpec(cmd, Seq(oldTableD, newTableD), ALTERTABLE_RENAME) + TableCommandSpec(cmd, Seq(oldTableD), ALTERTABLE_RENAME) } // this is for spark 3.1 or below @@ -350,6 +346,13 @@ object TableCommands { TableCommandSpec(cmd, Nil, CREATEVIEW) } + val CreateTable = { + val cmd = "org.apache.spark.sql.execution.datasources.CreateTable" + val tableDesc = TableDesc("tableDesc", classOf[CatalogTableTableExtractor]) + val queryDesc = QueryDesc("query", "LogicalPlanOptionQueryExtractor") + TableCommandSpec(cmd, Seq(tableDesc), CREATETABLE, queryDescs = Seq(queryDesc)) + } + val CreateDataSourceTable = { val cmd = "org.apache.spark.sql.execution.command.CreateDataSourceTableCommand" val tableDesc = TableDesc("table", classOf[CatalogTableTableExtractor]) @@ -410,6 +413,16 @@ object TableCommands { TableCommandSpec(cmd, Seq(tableDesc), DESCTABLE) } + val DescribeRelationTable = { + val cmd = "org.apache.spark.sql.catalyst.plans.logical.DescribeRelation" + val tableDesc = TableDesc( + "relation", + classOf[ResolvedTableTableExtractor], + isInput = true, + setCurrentDatabaseIfMissing = true) + TableCommandSpec(cmd, Seq(tableDesc), DESCTABLE) + } + val DropTable = { val cmd = "org.apache.spark.sql.execution.command.DropTableCommand" val tableTypeDesc = @@ -601,6 +614,7 @@ object TableCommands { CreateHiveTableAsSelect, CreateHiveTableAsSelect.copy(classname = "org.apache.spark.sql.hive.execution.OptimizedCreateHiveTableAsSelectCommand"), + CreateTable, CreateTableLike, CreateTableV2, CreateTableV2.copy(classname = @@ -614,6 +628,7 @@ object TableCommands { DeleteFromTable, DescribeColumn, DescribeTable, + DescribeRelationTable, DropTable, DropTableV2, InsertIntoDataSource, diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/IcebergCatalogRangerSparkExtensionSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/IcebergCatalogRangerSparkExtensionSuite.scala index a2634bb2672..6b1cedf786f 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/IcebergCatalogRangerSparkExtensionSuite.scala +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/IcebergCatalogRangerSparkExtensionSuite.scala @@ -19,6 +19,8 @@ package org.apache.kyuubi.plugin.spark.authz.ranger // scalastyle:off import scala.util.Try +import org.scalatest.Outcome + import org.apache.kyuubi.Utils import org.apache.kyuubi.plugin.spark.authz.AccessControlException @@ -29,7 +31,7 @@ import org.apache.kyuubi.plugin.spark.authz.AccessControlException class IcebergCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite { override protected val catalogImpl: String = "hive" override protected val sqlExtensions: String = - if (isSparkV32OrGreater) + if (isSparkV31OrGreater) "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions" else "" @@ -38,8 +40,13 @@ class IcebergCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite val table1 = "table1" val outputTable1 = "outputTable1" + override def withFixture(test: NoArgTest): Outcome = { + assume(isSparkV31OrGreater) + test() + } + override def beforeAll(): Unit = { - if (isSparkV32OrGreater) { + if (isSparkV31OrGreater) { spark.conf.set( s"spark.sql.catalog.$catalogV2", "org.apache.iceberg.spark.SparkCatalog") @@ -74,8 +81,6 @@ class IcebergCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite } test("[KYUUBI #3515] MERGE INTO") { - assume(isSparkV32OrGreater) - val mergeIntoSql = s""" |MERGE INTO $catalogV2.$namespace1.$outputTable1 AS target @@ -115,8 +120,6 @@ class IcebergCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite } test("[KYUUBI #3515] UPDATE TABLE") { - assume(isSparkV32OrGreater) - // UpdateTable val e1 = intercept[AccessControlException]( doAs( @@ -133,8 +136,6 @@ class IcebergCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite } test("[KYUUBI #3515] DELETE FROM TABLE") { - assume(isSparkV32OrGreater) - // DeleteFromTable val e6 = intercept[AccessControlException]( doAs("someone", sql(s"DELETE FROM $catalogV2.$namespace1.$table1 WHERE id=2"))) @@ -145,8 +146,6 @@ class IcebergCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite } test("[KYUUBI #3666] Support {OWNER} variable for queries run on CatalogV2") { - assume(isSparkV32OrGreater) - val table = "owner_variable" val select = s"SELECT key FROM $catalogV2.$namespace1.$table" @@ -222,4 +221,11 @@ class IcebergCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite }) } } + + test("[KYUUBI #4255] DESCRIBE TABLE") { + val e1 = intercept[AccessControlException]( + doAs("someone", sql(s"DESCRIBE TABLE $catalogV2.$namespace1.$table1").explain())) + assert(e1.getMessage.contains(s"does not have [select] privilege" + + s" on [$namespace1/$table1]")) + } } diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerLocalClient.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerLocalClient.scala index 323bd524a95..d7473a58065 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerLocalClient.scala +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerLocalClient.scala @@ -17,22 +17,25 @@ package org.apache.kyuubi.plugin.spark.authz.ranger -import java.io.InputStreamReader +import java.text.SimpleDateFormat -import com.google.gson.GsonBuilder +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper import org.apache.ranger.admin.client.RangerAdminRESTClient import org.apache.ranger.plugin.util.ServicePolicies class RangerLocalClient extends RangerAdminRESTClient with RangerClientHelper { - private val g = - new GsonBuilder().setDateFormat("yyyyMMdd-HH:mm:ss.SSS-Z").setPrettyPrinting().create + private val mapper = new JsonMapper() + .setDateFormat(new SimpleDateFormat("yyyyMMdd-HH:mm:ss.SSS-Z")) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + private val policies: ServicePolicies = { val loader = Thread.currentThread().getContextClassLoader val inputStream = { loader.getResourceAsStream("sparkSql_hive_jenkins.json") } - g.fromJson(new InputStreamReader(inputStream), classOf[ServicePolicies]) + mapper.readValue(inputStream, classOf[ServicePolicies]) } override def getServicePoliciesIfUpdated( diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerSparkExtensionSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerSparkExtensionSuite.scala index 8f95a3f9f3a..4ccf15cba98 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerSparkExtensionSuite.scala +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/RangerSparkExtensionSuite.scala @@ -17,14 +17,10 @@ package org.apache.kyuubi.plugin.spark.authz.ranger -import java.security.PrivilegedExceptionAction -import java.sql.Timestamp - import scala.util.Try -import org.apache.commons.codec.digest.DigestUtils import org.apache.hadoop.security.UserGroupInformation -import org.apache.spark.sql.{Row, SparkSessionExtensions} +import org.apache.spark.sql.SparkSessionExtensions import org.apache.spark.sql.catalyst.analysis.NoSuchTableException import org.apache.spark.sql.catalyst.catalog.HiveTableRelation import org.apache.spark.sql.catalyst.plans.logical.Statistics @@ -43,13 +39,6 @@ abstract class RangerSparkExtensionSuite extends AnyFunSuite // scalastyle:on override protected val extension: SparkSessionExtensions => Unit = new RangerSparkExtension - protected def doAs[T](user: String, f: => T): T = { - UserGroupInformation.createRemoteUser(user).doAs[T]( - new PrivilegedExceptionAction[T] { - override def run(): T = f - }) - } - override def afterAll(): Unit = { spark.stop() super.afterAll() @@ -62,24 +51,6 @@ abstract class RangerSparkExtensionSuite extends AnyFunSuite s"Permission denied: user [$user] does not have [$privilege] privilege on [$resource]" } - protected def withCleanTmpResources[T](res: Seq[(String, String)])(f: => T): T = { - try { - f - } finally { - res.foreach { - case (t, "table") => doAs("admin", sql(s"DROP TABLE IF EXISTS $t")) - case (db, "database") => doAs("admin", sql(s"DROP DATABASE IF EXISTS $db")) - case (fn, "function") => doAs("admin", sql(s"DROP FUNCTION IF EXISTS $fn")) - case (view, "view") => doAs("admin", sql(s"DROP VIEW IF EXISTS $view")) - case (cacheTable, "cache") => if (isSparkV32OrGreater) { - doAs("admin", sql(s"UNCACHE TABLE IF EXISTS $cacheTable")) - } - case (_, e) => - throw new RuntimeException(s"the resource whose resource type is $e cannot be cleared") - } - } - } - /** * Drops temporary view `viewNames` after calling `f`. */ @@ -247,212 +218,6 @@ abstract class RangerSparkExtensionSuite extends AnyFunSuite doAs("admin", assert(Try(sql(create0)).isSuccess)) } - test("row level filter") { - val db = "default" - val table = "src" - val col = "key" - val create = s"CREATE TABLE IF NOT EXISTS $db.$table ($col int, value int) USING $format" - - withCleanTmpResources(Seq((s"$db.${table}2", "table"), (s"$db.$table", "table"))) { - doAs("admin", assert(Try { sql(create) }.isSuccess)) - doAs("admin", sql(s"INSERT INTO $db.$table SELECT 1, 1")) - doAs("admin", sql(s"INSERT INTO $db.$table SELECT 20, 2")) - doAs("admin", sql(s"INSERT INTO $db.$table SELECT 30, 3")) - - doAs( - "kent", - assert(sql(s"SELECT key FROM $db.$table order by key").collect() === - Seq(Row(1), Row(20), Row(30)))) - - Seq( - s"SELECT value FROM $db.$table", - s"SELECT value as key FROM $db.$table", - s"SELECT max(value) FROM $db.$table", - s"SELECT coalesce(max(value), 1) FROM $db.$table", - s"SELECT value FROM $db.$table WHERE value in (SELECT value as key FROM $db.$table)") - .foreach { q => - doAs( - "bob", { - withClue(q) { - assert(sql(q).collect() === Seq(Row(1))) - } - }) - } - doAs( - "bob", { - sql(s"CREATE TABLE $db.src2 using $format AS SELECT value FROM $db.$table") - assert(sql(s"SELECT value FROM $db.${table}2").collect() === Seq(Row(1))) - }) - } - } - - test("[KYUUBI #3581]: row level filter on permanent view") { - assume(isSparkV31OrGreater) - - val db = "default" - val table = "src" - val permView = "perm_view" - val col = "key" - val create = s"CREATE TABLE IF NOT EXISTS $db.$table ($col int, value int) USING $format" - val createView = - s"CREATE OR REPLACE VIEW $db.$permView" + - s" AS SELECT * FROM $db.$table" - - withCleanTmpResources(Seq( - (s"$db.$table", "table"), - (s"$db.$permView", "view"))) { - doAs("admin", assert(Try { sql(create) }.isSuccess)) - doAs("admin", assert(Try { sql(createView) }.isSuccess)) - doAs("admin", sql(s"INSERT INTO $db.$table SELECT 1, 1")) - doAs("admin", sql(s"INSERT INTO $db.$table SELECT 20, 2")) - doAs("admin", sql(s"INSERT INTO $db.$table SELECT 30, 3")) - - Seq( - s"SELECT value FROM $db.$permView", - s"SELECT value as key FROM $db.$permView", - s"SELECT max(value) FROM $db.$permView", - s"SELECT coalesce(max(value), 1) FROM $db.$permView", - s"SELECT value FROM $db.$permView WHERE value in (SELECT value as key FROM $db.$permView)") - .foreach { q => - doAs( - "perm_view_user", { - withClue(q) { - assert(sql(q).collect() === Seq(Row(1))) - } - }) - } - } - } - - test("data masking") { - val db = "default" - val table = "src" - val col = "key" - val create = - s"CREATE TABLE IF NOT EXISTS $db.$table" + - s" ($col int, value1 int, value2 string, value3 string, value4 timestamp, value5 string)" + - s" USING $format" - - withCleanTmpResources(Seq( - (s"$db.${table}2", "table"), - (s"$db.$table", "table"))) { - doAs("admin", assert(Try { sql(create) }.isSuccess)) - doAs( - "admin", - sql( - s"INSERT INTO $db.$table SELECT 1, 1, 'hello', 'world', " + - s"timestamp'2018-11-17 12:34:56', 'World'")) - doAs( - "admin", - sql( - s"INSERT INTO $db.$table SELECT 20, 2, 'kyuubi', 'y', " + - s"timestamp'2018-11-17 12:34:56', 'world'")) - doAs( - "admin", - sql( - s"INSERT INTO $db.$table SELECT 30, 3, 'spark', 'a'," + - s" timestamp'2018-11-17 12:34:56', 'world'")) - - doAs( - "kent", - assert(sql(s"SELECT key FROM $db.$table order by key").collect() === - Seq(Row(1), Row(20), Row(30)))) - - Seq( - s"SELECT value1, value2, value3, value4, value5 FROM $db.$table", - s"SELECT value1 as key, value2, value3, value4, value5 FROM $db.$table", - s"SELECT max(value1), max(value2), max(value3), max(value4), max(value5) FROM $db.$table", - s"SELECT coalesce(max(value1), 1), coalesce(max(value2), 1), coalesce(max(value3), 1), " + - s"coalesce(max(value4), timestamp '2018-01-01 22:33:44'), coalesce(max(value5), 1) " + - s"FROM $db.$table", - s"SELECT value1, value2, value3, value4, value5 FROM $db.$table WHERE value2 in" + - s" (SELECT value2 as key FROM $db.$table)") - .foreach { q => - doAs( - "bob", { - withClue(q) { - assert(sql(q).collect() === - Seq( - Row( - DigestUtils.md5Hex("1"), - "xxxxx", - "worlx", - Timestamp.valueOf("2018-01-01 00:00:00"), - "Xorld"))) - } - }) - } - doAs( - "bob", { - sql(s"CREATE TABLE $db.src2 using $format AS SELECT value1 FROM $db.$table") - assert(sql(s"SELECT value1 FROM $db.${table}2").collect() === - Seq(Row(DigestUtils.md5Hex("1")))) - }) - } - } - - test("[KYUUBI #3581]: data masking on permanent view") { - assume(isSparkV31OrGreater) - - val db = "default" - val table = "src" - val permView = "perm_view" - val col = "key" - val create = - s"CREATE TABLE IF NOT EXISTS $db.$table" + - s" ($col int, value1 int, value2 string)" + - s" USING $format" - - val createView = - s"CREATE OR REPLACE VIEW $db.$permView" + - s" AS SELECT * FROM $db.$table" - - withCleanTmpResources(Seq( - (s"$db.$table", "table"), - (s"$db.$permView", "view"))) { - doAs("admin", assert(Try { sql(create) }.isSuccess)) - doAs("admin", assert(Try { sql(createView) }.isSuccess)) - doAs( - "admin", - sql( - s"INSERT INTO $db.$table SELECT 1, 1, 'hello'")) - - Seq( - s"SELECT value1, value2 FROM $db.$permView") - .foreach { q => - doAs( - "perm_view_user", { - withClue(q) { - assert(sql(q).collect() === - Seq( - Row( - DigestUtils.md5Hex("1"), - "hello"))) - } - }) - } - } - } - - test("KYUUBI #2390: RuleEliminateMarker stays in analyze phase for data masking") { - val db = "default" - val table = "src" - val create = - s"CREATE TABLE IF NOT EXISTS $db.$table (key int, value1 int) USING $format" - - withCleanTmpResources(Seq((s"$db.$table", "table"))) { - doAs("admin", sql(create)) - doAs("admin", sql(s"INSERT INTO $db.$table SELECT 1, 1")) - // scalastyle: off - doAs( - "bob", { - assert(sql(s"select * from $db.$table").collect() === - Seq(Row(1, DigestUtils.md5Hex("1")))) - assert(Try(sql(s"select * from $db.$table").show(1)).isSuccess) - }) - } - } - test("show tables") { val db = "default2" val table = "src" @@ -468,6 +233,7 @@ abstract class RangerSparkExtensionSuite extends AnyFunSuite doAs("admin", assert(sql(s"show tables from $db").collect().length === 2)) doAs("bob", assert(sql(s"show tables from $db").collect().length === 0)) doAs("i_am_invisible", assert(sql(s"show tables from $db").collect().length === 0)) + doAs("i_am_invisible", assert(sql(s"show tables from $db").limit(1).isEmpty)) } } @@ -482,6 +248,7 @@ abstract class RangerSparkExtensionSuite extends AnyFunSuite doAs("bob", assert(sql(s"SHOW DATABASES").collect().length == 1)) doAs("bob", assert(sql(s"SHOW DATABASES").collectAsList().get(0).getString(0) == "default")) + doAs("i_am_invisible", assert(sql(s"SHOW DATABASES").limit(1).isEmpty)) } } @@ -680,7 +447,6 @@ class InMemoryCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite class HiveCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite { override protected val catalogImpl: String = "hive" - test("table stats must be specified") { val table = "hive_src" withCleanTmpResources(Seq((table, "table"))) { @@ -757,30 +523,64 @@ class HiveCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSuite { } test("[KYUUBI #3326] check persisted view and skip shadowed table") { + val db1 = "default" val table = "hive_src" val permView = "perm_view" - val db1 = "default" - val db2 = "db2" withCleanTmpResources(Seq( (s"$db1.$table", "table"), - (s"$db2.$permView", "view"), - (db2, "database"))) { - doAs("admin", sql(s"CREATE TABLE IF NOT EXISTS $db1.$table (id int)")) - - doAs("admin", sql(s"CREATE DATABASE IF NOT EXISTS $db2")) - doAs("admin", sql(s"CREATE VIEW $db2.$permView AS SELECT * FROM $table")) + (s"$db1.$permView", "view"))) { + doAs("admin", sql(s"CREATE TABLE IF NOT EXISTS $db1.$table (id int, name string)")) + doAs("admin", sql(s"CREATE VIEW $db1.$permView AS SELECT * FROM $db1.$table")) + // KYUUBI #3326: with no privileges to the permanent view or the source table val e1 = intercept[AccessControlException]( - doAs("someone", sql(s"select * from $db2.$permView")).show(0)) + doAs( + "someone", { + sql(s"select * from $db1.$permView").collect() + })) if (isSparkV31OrGreater) { - assert(e1.getMessage.contains(s"does not have [select] privilege on [$db2/$permView/id]")) + assert(e1.getMessage.contains(s"does not have [select] privilege on [$db1/$permView/id]")) } else { assert(e1.getMessage.contains(s"does not have [select] privilege on [$db1/$table/id]")) } } } + test("KYUUBI #4504: query permanent view with privilege to permanent view only") { + val db1 = "default" + val table = "hive_src" + val permView = "perm_view" + val userPermViewOnly = "user_perm_view_only" + + withCleanTmpResources(Seq( + (s"$db1.$table", "table"), + (s"$db1.$permView", "view"))) { + doAs("admin", sql(s"CREATE TABLE IF NOT EXISTS $db1.$table (id int, name string)")) + doAs("admin", sql(s"CREATE VIEW $db1.$permView AS SELECT * FROM $db1.$table")) + + // query all columns of the permanent view + // with access privileges to the permanent view but no privilege to the source table + val sql1 = s"SELECT * FROM $db1.$permView" + if (isSparkV31OrGreater) { + doAs(userPermViewOnly, { sql(sql1).collect() }) + } else { + val e1 = intercept[AccessControlException](doAs(userPermViewOnly, { sql(sql1).collect() })) + assert(e1.getMessage.contains(s"does not have [select] privilege on [$db1/$table/id]")) + } + + // query the second column of permanent view with multiple columns + // with access privileges to the permanent view but no privilege to the source table + val sql2 = s"SELECT name FROM $db1.$permView" + if (isSparkV31OrGreater) { + doAs(userPermViewOnly, { sql(sql2).collect() }) + } else { + val e2 = intercept[AccessControlException](doAs(userPermViewOnly, { sql(sql2).collect() })) + assert(e2.getMessage.contains(s"does not have [select] privilege on [$db1/$table/name]")) + } + } + } + test("[KYUUBI #3371] support throws all disallowed privileges in exception") { val db1 = "default" val srcTable1 = "hive_src1" diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/V2JdbcTableCatalogRangerSparkExtensionSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/V2JdbcTableCatalogRangerSparkExtensionSuite.scala index 6bdab9d9d7b..73a13bc1c3c 100644 --- a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/V2JdbcTableCatalogRangerSparkExtensionSuite.scala +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/V2JdbcTableCatalogRangerSparkExtensionSuite.scala @@ -104,7 +104,15 @@ class V2JdbcTableCatalogRangerSparkExtensionSuite extends RangerSparkExtensionSu val e1 = intercept[AccessControlException]( doAs("someone", sql(s"select city, id from $catalogV2.$namespace1.$table1").explain())) assert(e1.getMessage.contains(s"does not have [select] privilege" + - s" on [$namespace1/$table1/id]")) + s" on [$namespace1/$table1/city]")) + } + + test("[KYUUBI #4255] DESCRIBE TABLE") { + assume(isSparkV31OrGreater) + val e1 = intercept[AccessControlException]( + doAs("someone", sql(s"DESCRIBE TABLE $catalogV2.$namespace1.$table1").explain())) + assert(e1.getMessage.contains(s"does not have [select] privilege" + + s" on [$namespace1/$table1]")) } test("[KYUUBI #3424] CREATE TABLE") { diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForHiveHiveParquetSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForHiveHiveParquetSuite.scala new file mode 100644 index 00000000000..ccc694f9b13 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForHiveHiveParquetSuite.scala @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking + +class DataMaskingForHiveHiveParquetSuite extends DataMaskingTestBase { + override protected val catalogImpl: String = "hive" + override protected def format: String = "USING hive OPTIONS(fileFormat='parquet')" +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForHiveParquetSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForHiveParquetSuite.scala new file mode 100644 index 00000000000..ba254abbd3d --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForHiveParquetSuite.scala @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking + +class DataMaskingForHiveParquetSuite extends DataMaskingTestBase { + override protected val catalogImpl: String = "hive" + override protected def format: String = "USING parquet" +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForIcebergSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForIcebergSuite.scala new file mode 100644 index 00000000000..99b7eb97300 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForIcebergSuite.scala @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking + +import org.apache.spark.SparkConf +import org.scalatest.Outcome + +import org.apache.kyuubi.Utils + +class DataMaskingForIcebergSuite extends DataMaskingTestBase { + override protected val extraSparkConf: SparkConf = { + val conf = new SparkConf() + + if (isSparkV31OrGreater) { + conf + .set("spark.sql.defaultCatalog", "testcat") + .set( + "spark.sql.catalog.testcat", + "org.apache.iceberg.spark.SparkCatalog") + .set(s"spark.sql.catalog.testcat.type", "hadoop") + .set( + "spark.sql.catalog.testcat.warehouse", + Utils.createTempDir("iceberg-hadoop").toString) + } + conf + + } + + override protected val catalogImpl: String = "in-memory" + + override protected def format: String = "USING iceberg" + + override def beforeAll(): Unit = { + if (isSparkV31OrGreater) { + super.beforeAll() + } + } + + override def afterAll(): Unit = { + if (isSparkV31OrGreater) { + super.afterAll() + } + } + + override def withFixture(test: NoArgTest): Outcome = { + assume(isSparkV31OrGreater) + test() + } +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForInMemoryParquetSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForInMemoryParquetSuite.scala new file mode 100644 index 00000000000..1bfb71e79ba --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForInMemoryParquetSuite.scala @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking + +class DataMaskingForInMemoryParquetSuite extends DataMaskingTestBase { + + override protected val catalogImpl: String = "in-memory" + override protected def format: String = "USING parquet" +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForJDBCV2Suite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForJDBCV2Suite.scala new file mode 100644 index 00000000000..894daeaf711 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingForJDBCV2Suite.scala @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking +import java.sql.DriverManager + +import scala.util.Try + +import org.apache.spark.SparkConf +import org.scalatest.Outcome + +class DataMaskingForJDBCV2Suite extends DataMaskingTestBase { + override protected val extraSparkConf: SparkConf = { + val conf = new SparkConf() + if (isSparkV31OrGreater) { + conf + .set("spark.sql.defaultCatalog", "testcat") + .set( + "spark.sql.catalog.testcat", + "org.apache.spark.sql.execution.datasources.v2.jdbc.JDBCTableCatalog") + .set(s"spark.sql.catalog.testcat.url", "jdbc:derby:memory:testcat;create=true") + .set( + s"spark.sql.catalog.testcat.driver", + "org.apache.derby.jdbc.AutoloadedDriver") + } + conf + } + + override protected val catalogImpl: String = "in-memory" + + override protected def format: String = "" + + override def beforeAll(): Unit = { + if (isSparkV31OrGreater) super.beforeAll() + } + + override def afterAll(): Unit = { + if (isSparkV31OrGreater) { + super.afterAll() + // cleanup db + Try { + DriverManager.getConnection(s"jdbc:derby:memory:testcat;shutdown=true") + } + } + } + + override def withFixture(test: NoArgTest): Outcome = { + assume(isSparkV31OrGreater) + test() + } +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingTestBase.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingTestBase.scala new file mode 100644 index 00000000000..3585397c6fa --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/datamasking/DataMaskingTestBase.scala @@ -0,0 +1,263 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.datamasking + +// scalastyle:off +import java.sql.Timestamp + +import scala.util.Try + +import org.apache.commons.codec.digest.DigestUtils.md5Hex +import org.apache.spark.sql.{Row, SparkSessionExtensions} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuite + +import org.apache.kyuubi.plugin.spark.authz.SparkSessionProvider +import org.apache.kyuubi.plugin.spark.authz.ranger.RangerSparkExtension + +/** + * Base trait for data masking tests, derivative classes shall name themselves following: + * DataMaskingFor CatalogImpl? FileFormat? Additions? Suite + */ +trait DataMaskingTestBase extends AnyFunSuite with SparkSessionProvider with BeforeAndAfterAll { +// scalastyle:on + override protected val extension: SparkSessionExtensions => Unit = new RangerSparkExtension + + private def setup(): Unit = { + sql(s"CREATE TABLE IF NOT EXISTS default.src" + + "(key int," + + " value1 int," + + " value2 string," + + " value3 string," + + " value4 timestamp," + + " value5 string)" + + s" $format") + + // NOTICE: `bob` has a row filter `key < 20` + sql("INSERT INTO default.src " + + "SELECT 1, 1, 'hello', 'world', timestamp'2018-11-17 12:34:56', 'World'") + sql("INSERT INTO default.src " + + "SELECT 20, 2, 'kyuubi', 'y', timestamp'2018-11-17 12:34:56', 'world'") + sql("INSERT INTO default.src " + + "SELECT 30, 3, 'spark', 'a', timestamp'2018-11-17 12:34:56', 'world'") + sql(s"CREATE TABLE default.unmasked $format AS SELECT * FROM default.src") + } + + private def cleanup(): Unit = { + sql("DROP TABLE IF EXISTS default.src") + sql("DROP TABLE IF EXISTS default.unmasked") + } + + override def beforeAll(): Unit = { + doAs("admin", setup()) + super.beforeAll() + } + override def afterAll(): Unit = { + doAs("admin", cleanup()) + spark.stop + super.afterAll() + } + + test("simple query with a user doesn't have mask rules") { + checkAnswer("kent", "SELECT key FROM default.src order by key", Seq(Row(1), Row(20), Row(30))) + } + + test("simple query with a user has mask rules") { + val result = + Seq(Row(md5Hex("1"), "xxxxx", "worlx", Timestamp.valueOf("2018-01-01 00:00:00"), "Xorld")) + checkAnswer("bob", "SELECT value1, value2, value3, value4, value5 FROM default.src", result) + checkAnswer( + "bob", + "SELECT value1 as key, value2, value3, value4, value5 FROM default.src", + result) + } + + test("star") { + val result = + Seq(Row(1, md5Hex("1"), "xxxxx", "worlx", Timestamp.valueOf("2018-01-01 00:00:00"), "Xorld")) + checkAnswer("bob", "SELECT * FROM default.src", result) + } + + test("simple udf") { + val result = + Seq(Row(md5Hex("1"), "xxxxx", "worlx", Timestamp.valueOf("2018-01-01 00:00:00"), "Xorld")) + checkAnswer( + "bob", + "SELECT max(value1), max(value2), max(value3), max(value4), max(value5) FROM default.src", + result) + } + + test("complex udf") { + val result = + Seq(Row(md5Hex("1"), "xxxxx", "worlx", Timestamp.valueOf("2018-01-01 00:00:00"), "Xorld")) + checkAnswer( + "bob", + "SELECT coalesce(max(value1), 1), coalesce(max(value2), 1), coalesce(max(value3), 1), " + + "coalesce(max(value4), timestamp '2018-01-01 22:33:44'), coalesce(max(value5), 1) " + + "FROM default.src", + result) + } + + test("in subquery") { + val result = + Seq(Row(md5Hex("1"), "xxxxx", "worlx", Timestamp.valueOf("2018-01-01 00:00:00"), "Xorld")) + checkAnswer( + "bob", + "SELECT value1, value2, value3, value4, value5 FROM default.src WHERE value2 in " + + "(SELECT value2 as key FROM default.src)", + result) + } + + test("create a unmasked table as select from a masked one") { + withCleanTmpResources(Seq(("default.src2", "table"))) { + doAs("bob", sql(s"CREATE TABLE default.src2 $format AS SELECT value1 FROM default.src")) + checkAnswer("bob", "SELECT value1 FROM default.src2", Seq(Row(md5Hex("1")))) + } + } + + test("insert into a unmasked table from a masked one") { + withCleanTmpResources(Seq(("default.src2", "table"), ("default.src3", "table"))) { + doAs("bob", sql(s"CREATE TABLE default.src2 (value1 string) $format")) + doAs("bob", sql(s"INSERT INTO default.src2 SELECT value1 from default.src")) + doAs("bob", sql(s"INSERT INTO default.src2 SELECT value1 as v from default.src")) + checkAnswer("bob", "SELECT value1 FROM default.src2", Seq(Row(md5Hex("1")), Row(md5Hex("1")))) + doAs("bob", sql(s"CREATE TABLE default.src3 (k int, value string) $format")) + doAs("bob", sql(s"INSERT INTO default.src3 SELECT key, value1 from default.src")) + doAs("bob", sql(s"INSERT INTO default.src3 SELECT key, value1 as v from default.src")) + checkAnswer("bob", "SELECT value FROM default.src3", Seq(Row(md5Hex("1")), Row(md5Hex("1")))) + } + } + + test("join on an unmasked table") { + val s = "SELECT a.value1, b.value1 FROM default.src a" + + " join default.unmasked b on a.value1=b.value1" + checkAnswer("bob", s, Nil) + checkAnswer("bob", s, Nil) // just for testing query multiple times, don't delete it + } + + test("self join on a masked table") { + val s = "SELECT a.value1, b.value1 FROM default.src a" + + " join default.src b on a.value1=b.value1" + checkAnswer("bob", s, Seq(Row(md5Hex("1"), md5Hex("1")))) + // just for testing query multiple times, don't delete it + checkAnswer("bob", s, Seq(Row(md5Hex("1"), md5Hex("1")))) + } + + test("self join on a masked table and filter the masked column with original value") { + val s = "SELECT a.value1, b.value1 FROM default.src a" + + " join default.src b on a.value1=b.value1" + + " where a.value1='1' and b.value1='1'" + checkAnswer("bob", s, Nil) + checkAnswer("bob", s, Nil) // just for testing query multiple times, don't delete it + } + + test("self join on a masked table and filter the masked column with masked value") { + // scalastyle:off + val s = "SELECT a.value1, b.value1 FROM default.src a" + + " join default.src b on a.value1=b.value1" + + s" where a.value1='${md5Hex("1")}' and b.value1='${md5Hex("1")}'" + // TODO: The v1 an v2 relations generate different implicit type cast rules for filters + // so the bellow test failed in derivative classes that us v2 data source, e.g., DataMaskingForIcebergSuite + // For the issue itself, we might need check the spark logic first + // DataMaskingStage1Marker Project [value1#178, value1#183] + // +- Project [value1#178, value1#183] + // +- Filter ((cast(value1#178 as int) = cast(c4ca4238a0b923820dcc509a6f75849b as int)) AND (cast(value1#183 as int) = cast(c4ca4238a0b923820dcc509a6f75849b as int))) + // +- Join Inner, (value1#178 = value1#183) + // :- SubqueryAlias a + // : +- SubqueryAlias testcat.default.src + // : +- Filter (key#166 < 20) + // : +- RowFilterMarker + // : +- DataMaskingStage0Marker RelationV2[key#166, value1#167, value2#168, value3#169, value4#170, value5#171] default.src + // : +- Project [key#166, md5(cast(cast(value1#167 as string) as binary)) AS value1#178, regexp_replace(regexp_replace(regexp_replace(value2#168, [A-Z], X, 1), [a-z], x, 1), [0-9], n, 1) AS value2#179, regexp_replace(regexp_replace(regexp_replace(value3#169, [A-Z], X, 5), [a-z], x, 5), [0-9], n, 5) AS value3#180, date_trunc(YEAR, value4#170, Some(Asia/Shanghai)) AS value4#181, concat(regexp_replace(regexp_replace(regexp_replace(left(value5#171, (length(value5#171) - 4)), [A-Z], X, 1), [a-z], x, 1), [0-9], n, 1), right(value5#171, 4)) AS value5#182] + // : +- RelationV2[key#166, value1#167, value2#168, value3#169, value4#170, value5#171] default.src + // +- SubqueryAlias b + // +- SubqueryAlias testcat.default.src + // +- Filter (key#172 < 20) + // +- RowFilterMarker + // +- DataMaskingStage0Marker RelationV2[key#172, value1#173, value2#174, value3#175, value4#176, value5#177] default.src + // +- Project [key#172, md5(cast(cast(value1#173 as string) as binary)) AS value1#183, regexp_replace(regexp_replace(regexp_replace(value2#174, [A-Z], X, 1), [a-z], x, 1), [0-9], n, 1) AS value2#184, regexp_replace(regexp_replace(regexp_replace(value3#175, [A-Z], X, 5), [a-z], x, 5), [0-9], n, 5) AS value3#185, date_trunc(YEAR, value4#176, Some(Asia/Shanghai)) AS value4#186, concat(regexp_replace(regexp_replace(regexp_replace(left(value5#177, (length(value5#177) - 4)), [A-Z], X, 1), [a-z], x, 1), [0-9], n, 1), right(value5#177, 4)) AS value5#187] + // +- RelationV2[key#172, value1#173, value2#174, value3#175, value4#176, value5#177] default.src + // + // + // Project [value1#143, value1#148] + // +- Filter ((value1#143 = c4ca4238a0b923820dcc509a6f75849b) AND (value1#148 = c4ca4238a0b923820dcc509a6f75849b)) + // +- Join Inner, (value1#143 = value1#148) + // :- SubqueryAlias a + // : +- SubqueryAlias spark_catalog.default.src + // : +- Filter (key#60 < 20) + // : +- RowFilterMarker + // : +- DataMaskingStage0Marker Relation default.src[key#60,value1#61,value2#62,value3#63,value4#64,value5#65] parquet + // : +- Project [key#60, md5(cast(cast(value1#61 as string) as binary)) AS value1#143, regexp_replace(regexp_replace(regexp_replace(value2#62, [A-Z], X, 1), [a-z], x, 1), [0-9], n, 1) AS value2#144, regexp_replace(regexp_replace(regexp_replace(value3#63, [A-Z], X, 5), [a-z], x, 5), [0-9], n, 5) AS value3#145, date_trunc(YEAR, value4#64, Some(Asia/Shanghai)) AS value4#146, concat(regexp_replace(regexp_replace(regexp_replace(left(value5#65, (length(value5#65) - 4)), [A-Z], X, 1), [a-z], x, 1), [0-9], n, 1), right(value5#65, 4)) AS value5#147] + // : +- Relation default.src[key#60,value1#61,value2#62,value3#63,value4#64,value5#65] parquet + // +- SubqueryAlias b + // +- SubqueryAlias spark_catalog.default.src + // +- Filter (key#153 < 20) + // +- RowFilterMarker + // +- DataMaskingStage0Marker Relation default.src[key#60,value1#61,value2#62,value3#63,value4#64,value5#65] parquet + // +- Project [key#153, md5(cast(cast(value1#154 as string) as binary)) AS value1#148, regexp_replace(regexp_replace(regexp_replace(value2#155, [A-Z], X, 1), [a-z], x, 1), [0-9], n, 1) AS value2#149, regexp_replace(regexp_replace(regexp_replace(value3#156, [A-Z], X, 5), [a-z], x, 5), [0-9], n, 5) AS value3#150, date_trunc(YEAR, value4#157, Some(Asia/Shanghai)) AS value4#151, concat(regexp_replace(regexp_replace(regexp_replace(left(value5#158, (length(value5#158) - 4)), [A-Z], X, 1), [a-z], x, 1), [0-9], n, 1), right(value5#158, 4)) AS value5#152] + // +- Relation default.src[key#153,value1#154,value2#155,value3#156,value4#157,value5#158] parquet + // checkAnswer("bob", s, Seq(Row(md5Hex("1"), md5Hex("1")))) + // + // + // scalastyle:on + + // So here we use value2 to avoid type casting + val s2 = "SELECT a.value1, b.value1 FROM default.src a" + + " join default.src b on a.value1=b.value1" + + s" where a.value2='xxxxx' and b.value2='xxxxx'" + checkAnswer("bob", s2, Seq(Row(md5Hex("1"), md5Hex("1")))) + // just for testing query multiple times, don't delete it + checkAnswer("bob", s2, Seq(Row(md5Hex("1"), md5Hex("1")))) + } + + test("union an unmasked table") { + val s = """ + SELECT value1 from ( + SELECT a.value1 FROM default.src a + union + (SELECT b.value1 FROM default.unmasked b) + ) c order by value1 + """ + checkAnswer("bob", s, Seq(Row("1"), Row("2"), Row("3"), Row(md5Hex("1")))) + } + + test("union a masked table") { + val s = "SELECT a.value1 FROM default.src a union" + + " (SELECT b.value1 FROM default.src b)" + checkAnswer("bob", s, Seq(Row(md5Hex("1")))) + } + + test("KYUUBI #3581: permanent view should lookup rule on itself not the raw table") { + assume(isSparkV31OrGreater) + val supported = doAs( + "perm_view_user", + Try(sql("CREATE OR REPLACE VIEW default.perm_view AS SELECT * FROM default.src")).isSuccess) + assume(supported, s"view support for '$format' has not been implemented yet") + + withCleanTmpResources(Seq(("default.perm_view", "view"))) { + checkAnswer( + "perm_view_user", + "SELECT value1, value2 FROM default.src where key < 20", + Seq(Row(1, "hello"))) + checkAnswer( + "perm_view_user", + "SELECT value1, value2 FROM default.perm_view where key < 20", + Seq(Row(md5Hex("1"), "hello"))) + } + } +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForHiveHiveParquetSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForHiveHiveParquetSuite.scala new file mode 100644 index 00000000000..142a2f82508 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForHiveHiveParquetSuite.scala @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.rowfiltering + +class RowFilteringForHiveHiveParquetSuite extends RowFilteringTestBase { + override protected val catalogImpl: String = "hive" + override protected def format: String = "USING hive OPTIONS(fileFormat='parquet')" +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForHiveParquetSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForHiveParquetSuite.scala new file mode 100644 index 00000000000..9727643cf93 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForHiveParquetSuite.scala @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.rowfiltering + +class RowFilteringForHiveParquetSuite extends RowFilteringTestBase { + override protected val catalogImpl: String = "hive" + override protected def format: String = "USING parquet" +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForIcebergSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForIcebergSuite.scala new file mode 100644 index 00000000000..2120b195221 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForIcebergSuite.scala @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.rowfiltering + +import org.apache.spark.SparkConf +import org.scalatest.Outcome + +import org.apache.kyuubi.Utils +class RowFilteringForIcebergSuite extends RowFilteringTestBase { + override protected val extraSparkConf: SparkConf = { + val conf = new SparkConf() + + if (isSparkV31OrGreater) { + conf + .set("spark.sql.defaultCatalog", "testcat") + .set( + "spark.sql.catalog.testcat", + "org.apache.iceberg.spark.SparkCatalog") + .set(s"spark.sql.catalog.testcat.type", "hadoop") + .set( + "spark.sql.catalog.testcat.warehouse", + Utils.createTempDir("iceberg-hadoop").toString) + } + conf + + } + + override protected val catalogImpl: String = "in-memory" + + override protected def format: String = "USING iceberg" + + override def beforeAll(): Unit = { + if (isSparkV31OrGreater) { + super.beforeAll() + } + } + + override def afterAll(): Unit = { + if (isSparkV31OrGreater) { + super.afterAll() + } + } + + override def withFixture(test: NoArgTest): Outcome = { + assume(isSparkV31OrGreater) + test() + } +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForInMemoryParquetSuite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForInMemoryParquetSuite.scala new file mode 100644 index 00000000000..9baaa2a3166 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForInMemoryParquetSuite.scala @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.rowfiltering + +class RowFilteringForInMemoryParquetSuite extends RowFilteringTestBase { + + override protected val catalogImpl: String = "in-memory" + override protected def format: String = "USING parquet" +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForJDBCV2Suite.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForJDBCV2Suite.scala new file mode 100644 index 00000000000..cfdb7dadc46 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringForJDBCV2Suite.scala @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.rowfiltering + +import java.sql.DriverManager + +import scala.util.Try + +import org.apache.spark.SparkConf +import org.scalatest.Outcome + +class RowFilteringForJDBCV2Suite extends RowFilteringTestBase { + override protected val extraSparkConf: SparkConf = { + val conf = new SparkConf() + if (isSparkV31OrGreater) { + conf + .set("spark.sql.defaultCatalog", "testcat") + .set( + "spark.sql.catalog.testcat", + "org.apache.spark.sql.execution.datasources.v2.jdbc.JDBCTableCatalog") + .set(s"spark.sql.catalog.testcat.url", "jdbc:derby:memory:testcat;create=true") + .set( + s"spark.sql.catalog.testcat.driver", + "org.apache.derby.jdbc.AutoloadedDriver") + } + conf + } + + override protected val catalogImpl: String = "in-memory" + + override protected def format: String = "" + + override def beforeAll(): Unit = { + if (isSparkV31OrGreater) super.beforeAll() + } + + override def afterAll(): Unit = { + if (isSparkV31OrGreater) { + super.afterAll() + // cleanup db + Try { + DriverManager.getConnection(s"jdbc:derby:memory:testcat;shutdown=true") + } + } + } + + override def withFixture(test: NoArgTest): Outcome = { + assume(isSparkV31OrGreater) + test() + } +} diff --git a/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringTestBase.scala b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringTestBase.scala new file mode 100644 index 00000000000..a73690724e4 --- /dev/null +++ b/extensions/spark/kyuubi-spark-authz/src/test/scala/org/apache/kyuubi/plugin/spark/authz/ranger/rowfiltering/RowFilteringTestBase.scala @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.spark.authz.ranger.rowfiltering + +// scalastyle:off +import scala.util.Try + +import org.apache.spark.sql.{Row, SparkSessionExtensions} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuite + +import org.apache.kyuubi.plugin.spark.authz.SparkSessionProvider +import org.apache.kyuubi.plugin.spark.authz.ranger.RangerSparkExtension + +/** + * Base trait for row filtering tests, derivative classes shall name themselves following: + * RowFilteringFor CatalogImpl? FileFormat? Additions? Suite + */ +trait RowFilteringTestBase extends AnyFunSuite with SparkSessionProvider with BeforeAndAfterAll { +// scalastyle:on + override protected val extension: SparkSessionExtensions => Unit = new RangerSparkExtension + + private def setup(): Unit = { + sql(s"CREATE TABLE IF NOT EXISTS default.src(key int, value int) $format") + sql("INSERT INTO default.src SELECT 1, 1") + sql("INSERT INTO default.src SELECT 20, 2") + sql("INSERT INTO default.src SELECT 30, 3") + } + + private def cleanup(): Unit = { + sql("DROP TABLE IF EXISTS default.src") + } + + override def beforeAll(): Unit = { + doAs("admin", setup()) + super.beforeAll() + } + override def afterAll(): Unit = { + doAs("admin", cleanup()) + spark.stop + super.afterAll() + } + + test("user without row filtering rule") { + checkAnswer( + "kent", + "SELECT key FROM default.src order order by key", + Seq(Row(1), Row(20), Row(30))) + } + + test("simple query projecting filtering column") { + checkAnswer("bob", "SELECT key FROM default.src", Seq(Row(1))) + } + + test("simple query projecting non filtering column") { + checkAnswer("bob", "SELECT value FROM default.src", Seq(Row(1))) + } + + test("simple query projecting non filtering column with udf max") { + checkAnswer("bob", "SELECT max(value) FROM default.src", Seq(Row(1))) + } + + test("simple query projecting non filtering column with udf coalesce") { + checkAnswer("bob", "SELECT coalesce(max(value), 1) FROM default.src", Seq(Row(1))) + } + + test("in subquery") { + checkAnswer( + "bob", + "SELECT value FROM default.src WHERE value in (SELECT value as key FROM default.src)", + Seq(Row(1))) + } + + test("ctas") { + withCleanTmpResources(Seq(("default.src2", "table"))) { + doAs("bob", sql(s"CREATE TABLE default.src2 $format AS SELECT value FROM default.src")) + val query = "select value from default.src2" + checkAnswer("admin", query, Seq(Row(1))) + checkAnswer("bob", query, Seq(Row(1))) + } + } + + test("[KYUUBI #3581]: row level filter on permanent view") { + assume(isSparkV31OrGreater) + val supported = doAs( + "perm_view_user", + Try(sql("CREATE OR REPLACE VIEW default.perm_view AS SELECT * FROM default.src")).isSuccess) + assume(supported, s"view support for '$format' has not been implemented yet") + + withCleanTmpResources(Seq((s"default.perm_view", "view"))) { + checkAnswer( + "admin", + "SELECT key FROM default.perm_view order order by key", + Seq(Row(1), Row(20), Row(30))) + checkAnswer("bob", "SELECT key FROM default.perm_view", Seq(Row(1))) + checkAnswer("bob", "SELECT value FROM default.perm_view", Seq(Row(1))) + checkAnswer("bob", "SELECT max(value) FROM default.perm_view", Seq(Row(1))) + checkAnswer("bob", "SELECT coalesce(max(value), 1) FROM default.perm_view", Seq(Row(1))) + checkAnswer( + "bob", + "SELECT value FROM default.perm_view WHERE value in " + + "(SELECT value as key FROM default.perm_view)", + Seq(Row(1))) + } + } +} diff --git a/extensions/spark/kyuubi-spark-connector-common/pom.xml b/extensions/spark/kyuubi-spark-connector-common/pom.xml index e9fa8fcb42a..1cba0ccdd4b 100644 --- a/extensions/spark/kyuubi-spark-connector-common/pom.xml +++ b/extensions/spark/kyuubi-spark-connector-common/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml diff --git a/extensions/spark/kyuubi-spark-connector-hive/pom.xml b/extensions/spark/kyuubi-spark-connector-hive/pom.xml index a97dfa053d0..b75db929d50 100644 --- a/extensions/spark/kyuubi-spark-connector-hive/pom.xml +++ b/extensions/spark/kyuubi-spark-connector-hive/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml @@ -32,7 +32,6 @@ https://kyuubi.apache.org/ - org.apache.kyuubi kyuubi-spark-connector-common_${scala.binary.version} @@ -40,34 +39,34 @@ - org.apache.kyuubi - kyuubi-spark-connector-common_${scala.binary.version} - ${project.version} - test-jar - test + com.google.guava + guava - org.scala-lang - scala-library + org.apache.spark + spark-hive_${scala.binary.version} provided - org.slf4j - slf4j-api + org.apache.hadoop + hadoop-client-api provided - org.apache.spark - spark-sql_${scala.binary.version} - provided + org.apache.kyuubi + kyuubi-spark-connector-common_${scala.binary.version} + ${project.version} + test-jar + test - com.google.guava - guava + org.scalatestplus + scalacheck-1-17_${scala.binary.version} + test @@ -84,17 +83,6 @@ test - - org.apache.spark - spark-hive_${scala.binary.version} - - - - org.scalatestplus - scalacheck-1-17_${scala.binary.version} - test - - org.apache.spark spark-sql_${scala.binary.version} @@ -117,15 +105,10 @@ test - - org.apache.hadoop - hadoop-client-api - - org.apache.hadoop hadoop-client-runtime - runtime + test +- Licensed to the Apache Software Foundation (ASF) under one or more +- contributor license agreements. See the NOTICE file distributed with +- this work for additional information regarding copyright ownership. +- The ASF licenses this file to You under the Apache License, Version 2.0 +- (the "License"); you may not use this file except in compliance with +- the License. You may obtain a copy of the License at +- +- http://www.apache.org/licenses/LICENSE-2.0 +- +- Unless required by applicable law or agreed to in writing, software +- distributed under the License is distributed on an "AS IS" BASIS, +- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +- See the License for the specific language governing permissions and +- limitations under the License. +--> # Kyuubi Spark Listener Extension ## Functions - [x] All `listener` extensions can be implemented in this module, like `QueryExecutionListener` and `ExtraListener` -- [x] Add `SparkOperationLineageQueryExecutionListener` to extends spark `QueryExecutionListener` +- [x] Add `SparkOperationLineageQueryExecutionListener` to extends spark `QueryExecutionListener` - [x] SQL lineage parsing will be triggered after SQL execution and will be written to the json logger file ## Build @@ -37,3 +37,4 @@ build/mvn clean package -pl :kyuubi-spark-lineage_2.12 -Dspark.version=3.2.1 - [x] 3.3.x (default) - [x] 3.2.x - [x] 3.1.x + diff --git a/extensions/spark/kyuubi-spark-lineage/pom.xml b/extensions/spark/kyuubi-spark-lineage/pom.xml index 74c05299dc9..bc13480d77c 100644 --- a/extensions/spark/kyuubi-spark-lineage/pom.xml +++ b/extensions/spark/kyuubi-spark-lineage/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../../pom.xml @@ -38,15 +38,22 @@ - commons-collections - commons-collections - test + org.apache.kyuubi + kyuubi-common_${scala.binary.version} + ${project.version} + provided org.apache.kyuubi - kyuubi-common_${scala.binary.version} + kyuubi-events_${scala.binary.version} ${project.version} + provided + + + + commons-collections + commons-collections test diff --git a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/events/OperationLineageEvent.scala b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/Lineage.scala similarity index 88% rename from extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/events/OperationLineageEvent.scala rename to extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/Lineage.scala index c69b45709b3..4bd0bd0b168 100644 --- a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/events/OperationLineageEvent.scala +++ b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/Lineage.scala @@ -15,9 +15,7 @@ * limitations under the License. */ -package org.apache.kyuubi.plugin.lineage.events - -import org.apache.spark.scheduler.SparkListenerEvent +package org.apache.kyuubi.plugin.lineage case class ColumnLineage(column: String, originalColumns: Set[String]) @@ -60,9 +58,3 @@ object Lineage { new Lineage(inputTables, outputTables, newColumnLineage) } } - -case class OperationLineageEvent( - executionId: Long, - eventTime: Long, - lineage: Option[Lineage], - exception: Option[Throwable]) extends SparkListenerEvent diff --git a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/LineageDispatcher.scala b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/LineageDispatcher.scala new file mode 100644 index 00000000000..8f5dc0d9e61 --- /dev/null +++ b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/LineageDispatcher.scala @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.lineage + +import org.apache.spark.sql.execution.QueryExecution + +import org.apache.kyuubi.plugin.lineage.dispatcher.{KyuubiEventDispatcher, SparkEventDispatcher} + +trait LineageDispatcher { + + def send(qe: QueryExecution, lineage: Option[Lineage]): Unit + + def onFailure(qe: QueryExecution, exception: Exception): Unit = {} + +} + +object LineageDispatcher { + + def apply(dispatcherType: String): LineageDispatcher = { + LineageDispatcherType.withName(dispatcherType) match { + case LineageDispatcherType.SPARK_EVENT => new SparkEventDispatcher() + case LineageDispatcherType.KYUUBI_EVENT => new KyuubiEventDispatcher() + case _ => throw new UnsupportedOperationException( + s"Unsupported lineage dispatcher: $dispatcherType.") + } + } + +} diff --git a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/LineageDispatcherType.scala b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/LineageDispatcherType.scala new file mode 100644 index 00000000000..d6afea15233 --- /dev/null +++ b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/LineageDispatcherType.scala @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.lineage + +object LineageDispatcherType extends Enumeration { + type LineageDispatcherType = Value + + val SPARK_EVENT, KYUUBI_EVENT = Value +} diff --git a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/SparkOperationLineageQueryExecutionListener.scala b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/SparkOperationLineageQueryExecutionListener.scala index c27d2eb8b4a..b83117cde29 100644 --- a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/SparkOperationLineageQueryExecutionListener.scala +++ b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/SparkOperationLineageQueryExecutionListener.scala @@ -17,24 +17,25 @@ package org.apache.kyuubi.plugin.lineage -import org.apache.spark.kyuubi.lineage.SparkContextHelper +import org.apache.spark.kyuubi.lineage.{LineageConf, SparkContextHelper} import org.apache.spark.sql.execution.QueryExecution import org.apache.spark.sql.util.QueryExecutionListener -import org.apache.kyuubi.plugin.lineage.events.OperationLineageEvent import org.apache.kyuubi.plugin.lineage.helper.SparkSQLLineageParseHelper class SparkOperationLineageQueryExecutionListener extends QueryExecutionListener { + private lazy val dispatchers: Seq[LineageDispatcher] = { + SparkContextHelper.getConf(LineageConf.DISPATCHERS).map(LineageDispatcher(_)) + } + override def onSuccess(funcName: String, qe: QueryExecution, durationNs: Long): Unit = { val lineage = - SparkSQLLineageParseHelper(qe.sparkSession).transformToLineage(qe.id, qe.optimizedPlan) - val event = OperationLineageEvent(qe.id, System.currentTimeMillis(), lineage, None) - SparkContextHelper.postEventToSparkListenerBus(event) + SparkSQLLineageParseHelper(qe.sparkSession).transformToLineage(qe.id, qe.analyzed) + dispatchers.foreach(_.send(qe, lineage)) } override def onFailure(funcName: String, qe: QueryExecution, exception: Exception): Unit = { - val event = OperationLineageEvent(qe.id, System.currentTimeMillis(), None, Some(exception)) - SparkContextHelper.postEventToSparkListenerBus(event) + dispatchers.foreach(_.onFailure(qe, exception)) } } diff --git a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/dispatcher/KyuubiEventDispatcher.scala b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/dispatcher/KyuubiEventDispatcher.scala new file mode 100644 index 00000000000..6a9e65948a6 --- /dev/null +++ b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/dispatcher/KyuubiEventDispatcher.scala @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.lineage.dispatcher + +import org.apache.spark.sql.execution.QueryExecution + +import org.apache.kyuubi.Utils +import org.apache.kyuubi.events.{EventBus, KyuubiEvent} +import org.apache.kyuubi.plugin.lineage.{Lineage, LineageDispatcher} + +class KyuubiEventDispatcher extends LineageDispatcher { + + override def send(qe: QueryExecution, lineage: Option[Lineage]): Unit = { + val event = OperationLineageKyuubiEvent(qe.id, System.currentTimeMillis(), lineage, None) + EventBus.post(event) + } + + override def onFailure(qe: QueryExecution, exception: Exception): Unit = { + val event = + OperationLineageKyuubiEvent(qe.id, System.currentTimeMillis(), None, Some(exception)) + EventBus.post(event) + } + +} + +case class OperationLineageKyuubiEvent( + executionId: Long, + eventTime: Long, + lineage: Option[Lineage], + exception: Option[Throwable]) extends KyuubiEvent { + override def partitions: Seq[(String, String)] = + ("day", Utils.getDateFromTimestamp(eventTime)) :: Nil +} diff --git a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/dispatcher/SparkEventDispatcher.scala b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/dispatcher/SparkEventDispatcher.scala new file mode 100644 index 00000000000..36fbbb7d4a0 --- /dev/null +++ b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/dispatcher/SparkEventDispatcher.scala @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.plugin.lineage.dispatcher + +import org.apache.spark.kyuubi.lineage.SparkContextHelper +import org.apache.spark.scheduler.SparkListenerEvent +import org.apache.spark.sql.execution.QueryExecution + +import org.apache.kyuubi.plugin.lineage.{Lineage, LineageDispatcher} + +class SparkEventDispatcher extends LineageDispatcher { + + override def send(qe: QueryExecution, lineage: Option[Lineage]): Unit = { + val event = OperationLineageSparkEvent(qe.id, System.currentTimeMillis(), lineage, None) + SparkContextHelper.postEventToSparkListenerBus(event) + } + + override def onFailure(qe: QueryExecution, exception: Exception): Unit = { + val event = OperationLineageSparkEvent(qe.id, System.currentTimeMillis(), None, Some(exception)) + SparkContextHelper.postEventToSparkListenerBus(event) + } +} + +case class OperationLineageSparkEvent( + executionId: Long, + eventTime: Long, + lineage: Option[Lineage], + exception: Option[Throwable]) extends SparkListenerEvent diff --git a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/helper/SparkSQLLineageParseHelper.scala b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/helper/SparkSQLLineageParseHelper.scala index f70e09126cb..f060cc99422 100644 --- a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/helper/SparkSQLLineageParseHelper.scala +++ b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/kyuubi/plugin/lineage/helper/SparkSQLLineageParseHelper.scala @@ -21,12 +21,12 @@ import scala.collection.immutable.ListMap import scala.util.{Failure, Success, Try} import org.apache.spark.internal.Logging +import org.apache.spark.kyuubi.lineage.{LineageConf, SparkContextHelper} import org.apache.spark.sql.SparkSession import org.apache.spark.sql.catalyst.TableIdentifier import org.apache.spark.sql.catalyst.analysis.{NamedRelation, PersistedView, ViewType} import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat, CatalogTable, HiveTableRelation} -import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, AttributeSet, Expression, NamedExpression} -import org.apache.spark.sql.catalyst.expressions.ScalarSubquery +import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute, AttributeSet, Expression, NamedExpression, ScalarSubquery} import org.apache.spark.sql.catalyst.expressions.aggregate.Count import org.apache.spark.sql.catalyst.plans.{LeftAnti, LeftSemi} import org.apache.spark.sql.catalyst.plans.logical._ @@ -36,7 +36,7 @@ import org.apache.spark.sql.execution.columnar.InMemoryRelation import org.apache.spark.sql.execution.datasources.LogicalRelation import org.apache.spark.sql.execution.datasources.v2.{DataSourceV2Relation, DataSourceV2ScanRelation} -import org.apache.kyuubi.plugin.lineage.events.Lineage +import org.apache.kyuubi.plugin.lineage.Lineage import org.apache.kyuubi.plugin.lineage.helper.SparkListenerHelper.isSparkVersionAtMost trait LineageParser { @@ -128,7 +128,7 @@ trait LineageParser { exp.toAttribute, if (!containsCountAll(exp.child)) references else references + exp.toAttribute.withName(AGGREGATE_COUNT_COLUMN_IDENTIFIER)) - case a: Attribute => a -> a.references + case a: Attribute => a -> AttributeSet(a) } ListMap(exps: _*) } @@ -149,6 +149,9 @@ trait LineageParser { attr.withQualifier(attr.qualifier.init) case attr if attr.name.equalsIgnoreCase(AGGREGATE_COUNT_COLUMN_IDENTIFIER) => attr.withQualifier(qualifier) + case attr if isNameWithQualifier(attr, qualifier) => + val newName = attr.name.split('.').last.stripPrefix("`").stripSuffix("`") + attr.withName(newName).withQualifier(qualifier) }) } } else { @@ -160,6 +163,12 @@ trait LineageParser { } } + private def isNameWithQualifier(attr: Attribute, qualifier: Seq[String]): Boolean = { + val nameTokens = attr.name.split('.') + val namespace = nameTokens.init.mkString(".") + nameTokens.length > 1 && namespace.endsWith(qualifier.mkString(".")) + } + private def mergeRelationColumnLineage( parentColumnsLineage: AttributeMap[AttributeSet], relationOutput: Seq[Attribute], @@ -303,7 +312,7 @@ trait LineageParser { val nextColumnsLlineage = ListMap(allAssignments.map { assignment => ( assignment.key.asInstanceOf[Attribute], - AttributeSet(assignment.value.asInstanceOf[Attribute])) + assignment.value.references) }: _*) val targetTable = getPlanField[LogicalPlan]("targetTable", plan) val sourceTable = getPlanField[LogicalPlan]("sourceTable", plan) @@ -316,6 +325,10 @@ trait LineageParser { } ListMap(targetColumnsWithTargetTable.zip(sourceColumnsLineage.values).toSeq: _*) + case p if p.nodeName == "WithCTE" => + val optimized = sparkSession.sessionState.optimizer.execute(p) + extractColumnsLineage(optimized, parentColumnsLineage) + // For query case p: Project => val nextColumnsLineage = @@ -327,6 +340,45 @@ trait LineageParser { joinColumnsLineage(parentColumnsLineage, getSelectColumnLineage(p.aggregateExpressions)) p.children.map(extractColumnsLineage(_, nextColumnsLineage)).reduce(mergeColumnsLineage) + case p: Expand => + val references = + p.projections.transpose.map(_.flatMap(x => x.references)).map(AttributeSet(_)) + + val childColumnsLineage = ListMap(p.output.zip(references): _*) + val nextColumnsLineage = + joinColumnsLineage(parentColumnsLineage, childColumnsLineage) + p.children.map(extractColumnsLineage(_, nextColumnsLineage)).reduce(mergeColumnsLineage) + + case p: Generate => + val generateColumnsLineageWithId = + ListMap(p.generatorOutput.map(attrRef => (attrRef.toAttribute.exprId, p.references)): _*) + + val nextColumnsLineage = parentColumnsLineage.map { + case (key, attrRefs) => + key -> AttributeSet(attrRefs.flatMap(attr => + generateColumnsLineageWithId.getOrElse( + attr.exprId, + AttributeSet(attr)))) + } + p.children.map(extractColumnsLineage(_, nextColumnsLineage)).reduce(mergeColumnsLineage) + + case p: Window => + val windowColumnsLineage = + ListMap(p.windowExpressions.map(exp => (exp.toAttribute, exp.references)): _*) + + val nextColumnsLineage = if (parentColumnsLineage.isEmpty) { + ListMap(p.child.output.map(attr => (attr, attr.references)): _*) ++ windowColumnsLineage + } else { + parentColumnsLineage.map { + case (k, _) if windowColumnsLineage.contains(k) => + k -> windowColumnsLineage(k) + case (k, attrs) => + k -> AttributeSet(attrs.flatten(attr => + windowColumnsLineage.getOrElse(attr, AttributeSet(attr)))) + } + } + p.children.map(extractColumnsLineage(_, nextColumnsLineage)).reduce(mergeColumnsLineage) + case p: Join => p.joinType match { case LeftSemi | LeftAnti => @@ -337,14 +389,22 @@ trait LineageParser { } case p: Union => - // merge all children in to one derivedColumns - val childrenUnion = - p.children.map(extractColumnsLineage(_, ListMap[Attribute, AttributeSet]())).map( - _.values).reduce { - (left, right) => - left.zip(right).map(attr => attr._1 ++ attr._2) + val childrenColumnsLineage = + // support for the multi-insert statement + if (p.output.isEmpty) { + p.children + .map(extractColumnsLineage(_, ListMap[Attribute, AttributeSet]())) + .reduce(mergeColumnsLineage) + } else { + // merge all children in to one derivedColumns + val childrenUnion = + p.children.map(extractColumnsLineage(_, ListMap[Attribute, AttributeSet]())).map( + _.values).reduce { + (left, right) => + left.zip(right).map(attr => attr._1 ++ attr._2) + } + ListMap(p.output.zip(childrenUnion): _*) } - val childrenColumnsLineage = ListMap(p.output.zip(childrenUnion): _*) joinColumnsLineage(parentColumnsLineage, childrenColumnsLineage) case p: LogicalRelation if p.catalogTable.nonEmpty => @@ -369,6 +429,29 @@ trait LineageParser { case p: LocalRelation => joinRelationColumnLineage(parentColumnsLineage, p.output, Seq(LOCAL_TABLE_IDENTIFIER)) + case _: OneRowRelation => + parentColumnsLineage.map { + case (k, attrs) => + k -> AttributeSet(attrs.map { + case attr + if attr.qualifier.nonEmpty && attr.qualifier.last.equalsIgnoreCase( + SUBQUERY_COLUMN_IDENTIFIER) => + attr.withQualifier(attr.qualifier.init) + case attr => attr + }) + } + + case p: View => + if (!p.isTempView && SparkContextHelper.getConf( + LineageConf.SKIP_PARSING_PERMANENT_VIEW_ENABLED)) { + val viewName = p.desc.qualifiedName + joinRelationColumnLineage(parentColumnsLineage, p.output, Seq(viewName)) + } else { + val viewColumnsLineage = + extractColumnsLineage(p.child, ListMap[Attribute, AttributeSet]()) + mergeRelationColumnLineage(parentColumnsLineage, p.output, viewColumnsLineage) + } + case p: InMemoryRelation => // get logical plan from cachedPlan val cachedTableLogical = findSparkPlanLogicalLink(Seq(p.cacheBuilder.cachedPlan)) diff --git a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/spark/kyuubi/lineage/LineageConf.scala b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/spark/kyuubi/lineage/LineageConf.scala new file mode 100644 index 00000000000..6fb5399c059 --- /dev/null +++ b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/spark/kyuubi/lineage/LineageConf.scala @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.spark.kyuubi.lineage + +import org.apache.spark.internal.config.ConfigBuilder + +import org.apache.kyuubi.plugin.lineage.LineageDispatcherType + +object LineageConf { + + val SKIP_PARSING_PERMANENT_VIEW_ENABLED = + ConfigBuilder("spark.kyuubi.plugin.lineage.skip.parsing.permanent.view.enabled") + .doc("Whether to skip the lineage parsing of permanent views") + .version("1.8.0") + .booleanConf + .createWithDefault(false) + + val DISPATCHERS = ConfigBuilder("spark.kyuubi.plugin.lineage.dispatchers") + .doc("The lineage dispatchers are implementations of " + + "`org.apache.kyuubi.plugin.lineage.LineageDispatcher` for dispatching lineage events.
        " + + "
      • SPARK_EVENT: send lineage event to spark event bus
      • " + + "
      • KYUUBI_EVENT: send lineage event to kyuubi event bus
      • " + + "
      ") + .version("1.8.0") + .stringConf + .toSequence + .checkValue( + _.toSet.subsetOf(LineageDispatcherType.values.map(_.toString)), + "Unsupported lineage dispatchers") + .createWithDefault(Seq(LineageDispatcherType.SPARK_EVENT.toString)) + +} diff --git a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/spark/kyuubi/lineage/SparkContextHelper.scala b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/spark/kyuubi/lineage/SparkContextHelper.scala index e6272364f80..6e0f0e5c846 100644 --- a/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/spark/kyuubi/lineage/SparkContextHelper.scala +++ b/extensions/spark/kyuubi-spark-lineage/src/main/scala/org/apache/spark/kyuubi/lineage/SparkContextHelper.scala @@ -18,6 +18,7 @@ package org.apache.spark.kyuubi.lineage import org.apache.spark.SparkContext +import org.apache.spark.internal.config.ConfigEntry import org.apache.spark.scheduler.SparkListenerEvent import org.apache.spark.sql.SparkSession @@ -31,4 +32,11 @@ object SparkContextHelper { sc.listenerBus.post(event) } + def getConf[T](entry: ConfigEntry[T]): T = { + globalSparkContext.getConf.get(entry) + } + + def setConf[T](entry: ConfigEntry[T], value: T): Unit = { + globalSparkContext.conf.set(entry, value) + } } diff --git a/extensions/spark/kyuubi-spark-lineage/src/test/scala/org/apache/kyuubi/plugin/lineage/events/OperationLineageEventSuite.scala b/extensions/spark/kyuubi-spark-lineage/src/test/scala/org/apache/kyuubi/plugin/lineage/events/OperationLineageEventSuite.scala index 6eeebbd3c50..67e94ad0b79 100644 --- a/extensions/spark/kyuubi-spark-lineage/src/test/scala/org/apache/kyuubi/plugin/lineage/events/OperationLineageEventSuite.scala +++ b/extensions/spark/kyuubi-spark-lineage/src/test/scala/org/apache/kyuubi/plugin/lineage/events/OperationLineageEventSuite.scala @@ -19,11 +19,17 @@ package org.apache.kyuubi.plugin.lineage.events import java.util.concurrent.{CountDownLatch, TimeUnit} +import scala.collection.immutable.List + import org.apache.spark.SparkConf +import org.apache.spark.kyuubi.lineage.LineageConf._ import org.apache.spark.scheduler.{SparkListener, SparkListenerEvent} import org.apache.spark.sql.SparkListenerExtensionTest import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.events.EventBus +import org.apache.kyuubi.plugin.lineage.Lineage +import org.apache.kyuubi.plugin.lineage.dispatcher.{OperationLineageKyuubiEvent, OperationLineageSparkEvent} import org.apache.kyuubi.plugin.lineage.helper.SparkListenerHelper.isSparkVersionAtMost class OperationLineageEventSuite extends KyuubiFunSuite with SparkListenerExtensionTest { @@ -40,18 +46,21 @@ class OperationLineageEventSuite extends KyuubiFunSuite with SparkListenerExtens .set( "spark.sql.queryExecutionListeners", "org.apache.kyuubi.plugin.lineage.SparkOperationLineageQueryExecutionListener") + .set(DISPATCHERS.key, "SPARK_EVENT,KYUUBI_EVENT") + .set(SKIP_PARSING_PERMANENT_VIEW_ENABLED.key, "true") } test("operation lineage event capture: for execute sql") { - val countDownLatch = new CountDownLatch(1) - var actual: Lineage = null + val countDownLatch = new CountDownLatch(2) + // get lineage from spark event + var actualSparkEventLineage: Lineage = null spark.sparkContext.addSparkListener(new SparkListener { override def onOtherEvent(event: SparkListenerEvent): Unit = { event match { - case lineageEvent: OperationLineageEvent => + case lineageEvent: OperationLineageSparkEvent => lineageEvent.lineage.foreach { case lineage if lineage.inputTables.nonEmpty => - actual = lineage + actualSparkEventLineage = lineage countDownLatch.countDown() } case _ => @@ -59,6 +68,16 @@ class OperationLineageEventSuite extends KyuubiFunSuite with SparkListenerExtens } }) + // get lineage from kyuubi event + var actualKyuubiEventLineage: Lineage = null + EventBus.register[OperationLineageKyuubiEvent] { lineageEvent: OperationLineageKyuubiEvent => + lineageEvent.lineage.foreach { + case lineage if lineage.inputTables.nonEmpty => + actualKyuubiEventLineage = lineage + countDownLatch.countDown() + } + } + withTable("test_table0") { _ => spark.sql("create table test_table0(a string, b string)") spark.sql("select a as col0, b as col1 from test_table0").collect() @@ -69,7 +88,8 @@ class OperationLineageEventSuite extends KyuubiFunSuite with SparkListenerExtens ("col0", Set("default.test_table0.a")), ("col1", Set("default.test_table0.b")))) countDownLatch.await(20, TimeUnit.SECONDS) - assert(actual == expected) + assert(actualSparkEventLineage == expected) + assert(actualKyuubiEventLineage == expected) } } @@ -86,7 +106,8 @@ class OperationLineageEventSuite extends KyuubiFunSuite with SparkListenerExtens spark.sparkContext.addSparkListener(new SparkListener { override def onOtherEvent(event: SparkListenerEvent): Unit = { event match { - case lineageEvent: OperationLineageEvent if executionId == lineageEvent.executionId => + case lineageEvent: OperationLineageSparkEvent + if executionId == lineageEvent.executionId => lineageEvent.lineage.foreach { lineage => assert(lineage == expected) countDownLatch.countDown() @@ -116,4 +137,40 @@ class OperationLineageEventSuite extends KyuubiFunSuite with SparkListenerExtens } } + test("test for skip parsing permanent view") { + val countDownLatch = new CountDownLatch(1) + var actual: Lineage = null + spark.sparkContext.addSparkListener(new SparkListener { + override def onOtherEvent(event: SparkListenerEvent): Unit = { + event match { + case lineageEvent: OperationLineageSparkEvent => + lineageEvent.lineage.foreach { + case lineage if lineage.inputTables.nonEmpty && lineage.outputTables.isEmpty => + actual = lineage + countDownLatch.countDown() + } + case _ => + } + } + }) + + withTable("t1") { _ => + spark.sql("CREATE TABLE t1 (a string, b string, c string) USING hive") + spark.sql("CREATE VIEW t2 as select * from t1") + spark.sql( + s"select a as k, b" + + s" from t2" + + s" where a in ('HELLO') and c = 'HELLO'").collect() + + val expected = Lineage( + List("default.t2"), + List(), + List( + ("k", Set("default.t2.a")), + ("b", Set("default.t2.b")))) + countDownLatch.await(20, TimeUnit.SECONDS) + assert(actual == expected) + } + } + } diff --git a/extensions/spark/kyuubi-spark-lineage/src/test/scala/org/apache/kyuubi/plugin/lineage/helper/SparkSQLLineageParserHelperSuite.scala b/extensions/spark/kyuubi-spark-lineage/src/test/scala/org/apache/kyuubi/plugin/lineage/helper/SparkSQLLineageParserHelperSuite.scala index 6652be9ea15..96003f051f5 100644 --- a/extensions/spark/kyuubi-spark-lineage/src/test/scala/org/apache/kyuubi/plugin/lineage/helper/SparkSQLLineageParserHelperSuite.scala +++ b/extensions/spark/kyuubi-spark-lineage/src/test/scala/org/apache/kyuubi/plugin/lineage/helper/SparkSQLLineageParserHelperSuite.scala @@ -21,6 +21,7 @@ import scala.collection.immutable.List import scala.reflect.io.File import org.apache.spark.SparkConf +import org.apache.spark.kyuubi.lineage.{LineageConf, SparkContextHelper} import org.apache.spark.sql.{DataFrame, SparkListenerExtensionTest, SparkSession, SQLContext} import org.apache.spark.sql.catalyst.TableIdentifier import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat, CatalogTable, CatalogTableType} @@ -28,7 +29,7 @@ import org.apache.spark.sql.sources.{BaseRelation, InsertableRelation, SchemaRel import org.apache.spark.sql.types.{IntegerType, StringType, StructType} import org.apache.kyuubi.KyuubiFunSuite -import org.apache.kyuubi.plugin.lineage.events.Lineage +import org.apache.kyuubi.plugin.lineage.Lineage import org.apache.kyuubi.plugin.lineage.helper.SparkListenerHelper.isSparkVersionAtMost class SparkSQLLineageParserHelperSuite extends KyuubiFunSuite @@ -171,7 +172,7 @@ class SparkSQLLineageParserHelperSuite extends KyuubiFunSuite "WHEN MATCHED THEN " + " UPDATE SET target.name = source.name, target.price = source.price " + "WHEN NOT MATCHED THEN " + - " INSERT (id, name, price) VALUES (source.id, source.name, source.price)") + " INSERT (id, name, price) VALUES (cast(source.id as int), source.name, source.price)") assert(ret0 == Lineage( List("v2_catalog.db.source_t"), List("v2_catalog.db.target_t"), @@ -932,8 +933,8 @@ class SparkSQLLineageParserHelperSuite extends KyuubiFunSuite df0.cache() val df1 = spark.sql("select a, b from table1") val df = df0.join(df1).select(df0("a0").alias("aa"), df1("b").alias("bb")) - val optimized = df.queryExecution.optimizedPlan - val ret1 = SparkSQLLineageParseHelper(spark).transformToLineage(0, optimized).get + val analyzed = df.queryExecution.analyzed + val ret1 = SparkSQLLineageParseHelper(spark).transformToLineage(0, analyzed).get assert(ret1 == Lineage( List("default.table0", "default.table1"), List(), @@ -1091,6 +1092,259 @@ class SparkSQLLineageParserHelperSuite extends KyuubiFunSuite List( ("aa", Set("default.table1.a", "default.table0.a")), ("bb", Set("default.table1.b"))))) + + val sql11 = + """ + |select tmp.a, b from (select * from table1) tmp; + |""".stripMargin + + val ret11 = exectractLineage(sql11) + assert(ret11 == Lineage( + List("default.table1"), + List(), + List( + ("a", Set("default.table1.a")), + ("b", Set("default.table1.b"))))) + } + } + + test("test group by") { + withTable("t1", "t2", "v2_catalog.db.t1", "v2_catalog.db.t2") { _ => + spark.sql("CREATE TABLE t1 (a string, b string, c string) USING hive") + spark.sql("CREATE TABLE t2 (a string, b string, c string) USING hive") + spark.sql("CREATE TABLE v2_catalog.db.t1 (a string, b string, c string)") + spark.sql("CREATE TABLE v2_catalog.db.t2 (a string, b string, c string)") + val ret0 = + exectractLineage( + s"insert into table t1 select a," + + s"concat_ws('/', collect_set(b))," + + s"count(distinct(b)) * count(distinct(c))" + + s"from t2 group by a") + assert(ret0 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set("default.t2.a")), + ("default.t1.b", Set("default.t2.b")), + ("default.t1.c", Set("default.t2.b", "default.t2.c"))))) + + val ret1 = + exectractLineage( + s"insert into table v2_catalog.db.t1 select a," + + s"concat_ws('/', collect_set(b))," + + s"count(distinct(b)) * count(distinct(c))" + + s"from v2_catalog.db.t2 group by a") + assert(ret1 == Lineage( + List("v2_catalog.db.t2"), + List("v2_catalog.db.t1"), + List( + ("v2_catalog.db.t1.a", Set("v2_catalog.db.t2.a")), + ("v2_catalog.db.t1.b", Set("v2_catalog.db.t2.b")), + ("v2_catalog.db.t1.c", Set("v2_catalog.db.t2.b", "v2_catalog.db.t2.c"))))) + + val ret2 = + exectractLineage( + s"insert into table v2_catalog.db.t1 select a," + + s"count(distinct(b+c))," + + s"count(distinct(b)) * count(distinct(c))" + + s"from v2_catalog.db.t2 group by a") + assert(ret2 == Lineage( + List("v2_catalog.db.t2"), + List("v2_catalog.db.t1"), + List( + ("v2_catalog.db.t1.a", Set("v2_catalog.db.t2.a")), + ("v2_catalog.db.t1.b", Set("v2_catalog.db.t2.b", "v2_catalog.db.t2.c")), + ("v2_catalog.db.t1.c", Set("v2_catalog.db.t2.b", "v2_catalog.db.t2.c"))))) + } + } + + test("test grouping sets") { + withTable("t1", "t2") { _ => + spark.sql("CREATE TABLE t1 (a string, b string, c string) USING hive") + spark.sql("CREATE TABLE t2 (a string, b string, c string, d string) USING hive") + val ret0 = + exectractLineage( + s"insert into table t1 select a,b,GROUPING__ID " + + s"from t2 group by a,b,c,d grouping sets ((a,b,c), (a,b,d))") + assert(ret0 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set("default.t2.a")), + ("default.t1.b", Set("default.t2.b")), + ("default.t1.c", Set())))) + } + } + + test("test cache table with window function") { + withTable("t1", "t2") { _ => + spark.sql("CREATE TABLE t1 (a string, b string) USING hive") + spark.sql("CREATE TABLE t2 (a string, b string) USING hive") + + spark.sql( + s"cache table c1 select * from (" + + s"select a, b, row_number() over (partition by a order by b asc ) rank from t2)" + + s" where rank=1") + val ret0 = exectractLineage("insert overwrite table t1 select a, b from c1") + assert(ret0 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set("default.t2.a")), + ("default.t1.b", Set("default.t2.b"))))) + + val ret1 = exectractLineage("insert overwrite table t1 select a, rank from c1") + assert(ret1 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set("default.t2.a")), + ("default.t1.b", Set("default.t2.a", "default.t2.b"))))) + + spark.sql( + s"cache table c2 select * from (" + + s"select b, a, row_number() over (partition by a order by b asc ) rank from t2)" + + s" where rank=1") + val ret2 = exectractLineage("insert overwrite table t1 select a, b from c2") + assert(ret2 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set("default.t2.a")), + ("default.t1.b", Set("default.t2.b"))))) + + spark.sql( + s"cache table c3 select * from (" + + s"select a as aa, b as bb, row_number() over (partition by a order by b asc ) rank" + + s" from t2) where rank=1") + val ret3 = exectractLineage("insert overwrite table t1 select aa, bb from c3") + assert(ret3 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set("default.t2.a")), + ("default.t1.b", Set("default.t2.b"))))) + } + } + + test("test count()") { + withTable("t1", "t2") { _ => + spark.sql("CREATE TABLE t1 (a string, b string, c string) USING hive") + spark.sql("CREATE TABLE t2 (a string, b string, c string) USING hive") + val ret0 = exectractLineage("insert into t1 select 1,2,(select count(distinct" + + " ifnull(get_json_object(a, '$.b.imei'), get_json_object(a, '$.b.android_id'))) from t2)") + + assert(ret0 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set()), + ("default.t1.b", Set()), + ("default.t1.c", Set("default.t2.a"))))) + } + } + + test("test create view from view") { + withTable("t1") { _ => + spark.sql("CREATE TABLE t1 (a string, b string, c string) USING hive") + withView("t2") { _ => + spark.sql("CREATE VIEW t2 as select * from t1") + val ret0 = + exectractLineage( + s"create or replace view view_tst comment 'view'" + + s" as select a as k,b" + + s" from t2" + + s" where a in ('HELLO') and c = 'HELLO'") + assert(ret0 == Lineage( + List("default.t1"), + List("default.view_tst"), + List( + ("default.view_tst.k", Set("default.t1.a")), + ("default.view_tst.b", Set("default.t1.b"))))) + } + } + } + + test("test for skip parsing permanent view") { + withTable("t1") { _ => + SparkContextHelper.setConf(LineageConf.SKIP_PARSING_PERMANENT_VIEW_ENABLED, true) + spark.sql("CREATE TABLE t1 (a string, b string, c string) USING hive") + withView("t2") { _ => + spark.sql("CREATE VIEW t2 as select * from t1") + val ret0 = + exectractLineage( + s"select a as k, b" + + s" from t2" + + s" where a in ('HELLO') and c = 'HELLO'") + assert(ret0 == Lineage( + List("default.t2"), + List(), + List( + ("k", Set("default.t2.a")), + ("b", Set("default.t2.b"))))) + } + } + } + + test("test the statement with FROM xxx INSERT xxx") { + withTable("t1", "t2", "t3") { _ => + spark.sql("CREATE TABLE t1 (a string, b string) USING hive") + spark.sql("CREATE TABLE t2 (a string, b string) USING hive") + spark.sql("CREATE TABLE t3 (a string, b string) USING hive") + val ret0 = exectractLineage("from (select a,b from t1)" + + " insert overwrite table t2 select a,b where a=1" + + " insert overwrite table t3 select a,b where b=1") + assert(ret0 == Lineage( + List("default.t1"), + List("default.t2", "default.t3"), + List( + ("default.t2.a", Set("default.t1.a")), + ("default.t2.b", Set("default.t1.b")), + ("default.t3.a", Set("default.t1.a")), + ("default.t3.b", Set("default.t1.b"))))) + } + } + + test("test lateral view explode") { + withTable("t1", "t2") { _ => + spark.sql("CREATE TABLE t1 (a string, b string, c string, d string) USING hive") + spark.sql("CREATE TABLE t2 (a string, b string, c string, d string) USING hive") + + val ret0 = exectractLineage("insert into t1 select 1, t2.b, cc.action, t2.d " + + "from t2 lateral view explode(split(c,'\\},\\{')) cc as action") + assert(ret0 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set()), + ("default.t1.b", Set("default.t2.b")), + ("default.t1.c", Set("default.t2.c")), + ("default.t1.d", Set("default.t2.d"))))) + + val ret1 = exectractLineage("insert into t1 select 1, t2.b, cc.action0, dd.action1 " + + "from t2 " + + "lateral view explode(split(c,'\\},\\{')) cc as action0 " + + "lateral view explode(split(d,'\\},\\{')) dd as action1") + assert(ret1 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set()), + ("default.t1.b", Set("default.t2.b")), + ("default.t1.c", Set("default.t2.c")), + ("default.t1.d", Set("default.t2.d"))))) + + val ret2 = exectractLineage("insert into t1 select 1, t2.b, dd.pos, dd.action1 " + + "from t2 " + + "lateral view posexplode(split(d,'\\},\\{')) dd as pos, action1") + assert(ret2 == Lineage( + List("default.t2"), + List("default.t1"), + List( + ("default.t1.a", Set()), + ("default.t1.b", Set("default.t2.b")), + ("default.t1.c", Set("default.t2.d")), + ("default.t1.d", Set("default.t2.d"))))) } } @@ -1098,15 +1352,14 @@ class SparkSQLLineageParserHelperSuite extends KyuubiFunSuite val parsed = spark.sessionState.sqlParser.parsePlan(sql) val analyzed = spark.sessionState.analyzer.execute(parsed) spark.sessionState.analyzer.checkAnalysis(analyzed) - val optimized = spark.sessionState.optimizer.execute(analyzed) - SparkSQLLineageParseHelper(spark).transformToLineage(0, optimized).get + SparkSQLLineageParseHelper(spark).transformToLineage(0, analyzed).get } private def exectractLineage(sql: String): Lineage = { val parsed = spark.sessionState.sqlParser.parsePlan(sql) val qe = spark.sessionState.executePlan(parsed) - val optimized = qe.optimizedPlan - SparkSQLLineageParseHelper(spark).transformToLineage(0, optimized).get + val analyzed = qe.analyzed + SparkSQLLineageParseHelper(spark).transformToLineage(0, analyzed).get } } diff --git a/externals/kyuubi-chat-engine/pom.xml b/externals/kyuubi-chat-engine/pom.xml new file mode 100644 index 00000000000..28779f4504f --- /dev/null +++ b/externals/kyuubi-chat-engine/pom.xml @@ -0,0 +1,90 @@ + + + + 4.0.0 + + org.apache.kyuubi + kyuubi-parent + 1.8.0-SNAPSHOT + ../../pom.xml + + + kyuubi-chat-engine_2.12 + jar + Kyuubi Project Engine Chat + https://kyuubi.apache.org/ + + + + + org.apache.kyuubi + kyuubi-common_${scala.binary.version} + ${project.version} + + + + org.apache.kyuubi + kyuubi-ha_${scala.binary.version} + ${project.version} + + + + com.theokanning.openai-gpt3-java + service + ${openai.java.version} + + + + org.apache.kyuubi + kyuubi-common_${scala.binary.version} + ${project.version} + test-jar + test + + + + org.apache.kyuubi + ${hive.jdbc.artifact} + ${project.version} + test + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + prepare-test-jar + + test-jar + + test-compile + + + + + target/scala-${scala.binary.version}/classes + target/scala-${scala.binary.version}/test-classes + + + diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/ChatBackendService.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/ChatBackendService.scala new file mode 100644 index 00000000000..fdc710e2ccd --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/ChatBackendService.scala @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kyuubi.engine.chat + +import org.apache.kyuubi.engine.chat.session.ChatSessionManager +import org.apache.kyuubi.service.AbstractBackendService +import org.apache.kyuubi.session.SessionManager + +class ChatBackendService + extends AbstractBackendService("ChatBackendService") { + + override val sessionManager: SessionManager = new ChatSessionManager() + +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/ChatEngine.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/ChatEngine.scala new file mode 100644 index 00000000000..c1fdea9538c --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/ChatEngine.scala @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kyuubi.engine.chat + +import ChatEngine.currentEngine + +import org.apache.kyuubi.{Logging, Utils} +import org.apache.kyuubi.Utils.{addShutdownHook, JDBC_ENGINE_SHUTDOWN_PRIORITY} +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.ha.HighAvailabilityConf.HA_ZK_CONN_RETRY_POLICY +import org.apache.kyuubi.ha.client.RetryPolicies +import org.apache.kyuubi.service.Serverable +import org.apache.kyuubi.util.SignalRegister + +class ChatEngine extends Serverable("ChatEngine") { + + override val backendService = new ChatBackendService() + override val frontendServices = Seq(new ChatTBinaryFrontendService(this)) + + override def start(): Unit = { + super.start() + // Start engine self-terminating checker after all services are ready and it can be reached by + // all servers in engine spaces. + backendService.sessionManager.startTerminatingChecker(() => { + currentEngine.foreach(_.stop()) + }) + } + + override protected def stopServer(): Unit = {} +} + +object ChatEngine extends Logging { + + val kyuubiConf: KyuubiConf = KyuubiConf() + + var currentEngine: Option[ChatEngine] = None + + def startEngine(): Unit = { + currentEngine = Some(new ChatEngine()) + currentEngine.foreach { engine => + engine.initialize(kyuubiConf) + engine.start() + addShutdownHook( + () => { + engine.stop() + }, + JDBC_ENGINE_SHUTDOWN_PRIORITY + 1) + } + } + + def main(args: Array[String]): Unit = { + SignalRegister.registerLogger(logger) + + try { + Utils.fromCommandLineArgs(args, kyuubiConf) + kyuubiConf.setIfMissing(KyuubiConf.FRONTEND_THRIFT_BINARY_BIND_PORT, 0) + kyuubiConf.setIfMissing(HA_ZK_CONN_RETRY_POLICY, RetryPolicies.N_TIME.toString) + + startEngine() + } catch { + case t: Throwable if currentEngine.isDefined => + currentEngine.foreach { engine => + engine.stop() + } + error("Failed to create Chat Engine", t) + throw t + case t: Throwable => + error("Failed to create Chat Engine.", t) + throw t + } + } +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/ChatTBinaryFrontendService.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/ChatTBinaryFrontendService.scala new file mode 100644 index 00000000000..80702c97c3c --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/ChatTBinaryFrontendService.scala @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kyuubi.engine.chat + +import org.apache.kyuubi.ha.client.{EngineServiceDiscovery, ServiceDiscovery} +import org.apache.kyuubi.service.{Serverable, Service, TBinaryFrontendService} + +class ChatTBinaryFrontendService(override val serverable: Serverable) + extends TBinaryFrontendService("ChatTBinaryFrontend") { + + /** + * An optional `ServiceDiscovery` for [[FrontendService]] to expose itself + */ + override lazy val discoveryService: Option[Service] = + if (ServiceDiscovery.supportServiceDiscovery(conf)) { + Some(new EngineServiceDiscovery(this)) + } else { + None + } +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/operation/ChatOperation.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/operation/ChatOperation.scala new file mode 100644 index 00000000000..38527cbf1f8 --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/operation/ChatOperation.scala @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kyuubi.engine.chat.operation + +import org.apache.hive.service.rpc.thrift._ + +import org.apache.kyuubi.{KyuubiSQLException, Utils} +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.engine.chat.schema.{RowSet, SchemaHelper} +import org.apache.kyuubi.operation.{AbstractOperation, FetchIterator, OperationState} +import org.apache.kyuubi.operation.FetchOrientation.{FETCH_FIRST, FETCH_NEXT, FETCH_PRIOR, FetchOrientation} +import org.apache.kyuubi.session.Session + +abstract class ChatOperation(session: Session) extends AbstractOperation(session) { + + protected var iter: FetchIterator[Array[String]] = _ + + protected lazy val conf: KyuubiConf = session.sessionManager.getConf + + override def getNextRowSet(order: FetchOrientation, rowSetSize: Int): TRowSet = { + validateDefaultFetchOrientation(order) + assertState(OperationState.FINISHED) + setHasResultSet(true) + order match { + case FETCH_NEXT => + iter.fetchNext() + case FETCH_PRIOR => + iter.fetchPrior(rowSetSize) + case FETCH_FIRST => + iter.fetchAbsolute(0) + } + + val taken = iter.take(rowSetSize) + val resultRowSet = RowSet.toTRowSet(taken.toSeq, 1, getProtocolVersion) + resultRowSet.setStartRowOffset(iter.getPosition) + resultRowSet + } + + override def cancel(): Unit = { + cleanup(OperationState.CANCELED) + } + + override def close(): Unit = { + cleanup(OperationState.CLOSED) + } + + protected def onError(cancel: Boolean = false): PartialFunction[Throwable, Unit] = { + // We should use Throwable instead of Exception since `java.lang.NoClassDefFoundError` + // could be thrown. + case e: Throwable => + state.synchronized { + val errMsg = Utils.stringifyException(e) + if (state == OperationState.TIMEOUT) { + val ke = KyuubiSQLException(s"Timeout operating $opType: $errMsg") + setOperationException(ke) + throw ke + } else if (isTerminalState(state)) { + setOperationException(KyuubiSQLException(errMsg)) + warn(s"Ignore exception in terminal state with $statementId: $errMsg") + } else { + error(s"Error operating $opType: $errMsg", e) + val ke = KyuubiSQLException(s"Error operating $opType: $errMsg", e) + setOperationException(ke) + setState(OperationState.ERROR) + throw ke + } + } + } + + override protected def beforeRun(): Unit = { + setState(OperationState.PENDING) + setHasResultSet(true) + } + + override protected def afterRun(): Unit = {} + + override def getResultSetMetadata: TGetResultSetMetadataResp = { + val tTableSchema = SchemaHelper.stringTTableSchema("reply") + val resp = new TGetResultSetMetadataResp + resp.setSchema(tTableSchema) + resp.setStatus(OK_STATUS) + resp + } + + override def shouldRunAsync: Boolean = false +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/operation/ChatOperationManager.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/operation/ChatOperationManager.scala new file mode 100644 index 00000000000..1e89165176e --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/operation/ChatOperationManager.scala @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kyuubi.engine.chat.operation + +import java.util + +import org.apache.kyuubi.KyuubiSQLException +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.engine.chat.provider.ChatProvider +import org.apache.kyuubi.operation.{Operation, OperationManager} +import org.apache.kyuubi.session.Session + +class ChatOperationManager( + conf: KyuubiConf, + chatProvider: ChatProvider) extends OperationManager("ChatOperationManager") { + + override def newExecuteStatementOperation( + session: Session, + statement: String, + confOverlay: Map[String, String], + runAsync: Boolean, + queryTimeout: Long): Operation = { + val executeStatement = + new ExecuteStatement( + session, + statement, + runAsync, + queryTimeout, + chatProvider) + addOperation(executeStatement) + } + + override def newGetTypeInfoOperation(session: Session): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetCatalogsOperation(session: Session): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetSchemasOperation( + session: Session, + catalog: String, + schema: String): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetTablesOperation( + session: Session, + catalogName: String, + schemaName: String, + tableName: String, + tableTypes: util.List[String]): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetTableTypesOperation(session: Session): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetColumnsOperation( + session: Session, + catalogName: String, + schemaName: String, + tableName: String, + columnName: String): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetFunctionsOperation( + session: Session, + catalogName: String, + schemaName: String, + functionName: String): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetPrimaryKeysOperation( + session: Session, + catalogName: String, + schemaName: String, + tableName: String): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetCrossReferenceOperation( + session: Session, + primaryCatalog: String, + primarySchema: String, + primaryTable: String, + foreignCatalog: String, + foreignSchema: String, + foreignTable: String): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def getQueryId(operation: Operation): String = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newSetCurrentCatalogOperation(session: Session, catalog: String): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetCurrentCatalogOperation(session: Session): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newSetCurrentDatabaseOperation(session: Session, database: String): Operation = { + throw KyuubiSQLException.featureNotSupported() + } + + override def newGetCurrentDatabaseOperation(session: Session): Operation = { + throw KyuubiSQLException.featureNotSupported() + } +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/operation/ExecuteStatement.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/operation/ExecuteStatement.scala new file mode 100644 index 00000000000..754a519324f --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/operation/ExecuteStatement.scala @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kyuubi.engine.chat.operation + +import org.apache.kyuubi.Logging +import org.apache.kyuubi.engine.chat.provider.ChatProvider +import org.apache.kyuubi.operation.{ArrayFetchIterator, OperationState} +import org.apache.kyuubi.operation.log.OperationLog +import org.apache.kyuubi.session.Session + +class ExecuteStatement( + session: Session, + override val statement: String, + override val shouldRunAsync: Boolean, + queryTimeout: Long, + chatProvider: ChatProvider) + extends ChatOperation(session) with Logging { + + private val operationLog: OperationLog = OperationLog.createOperationLog(session, getHandle) + override def getOperationLog: Option[OperationLog] = Option(operationLog) + + override protected def runInternal(): Unit = { + addTimeoutMonitor(queryTimeout) + if (shouldRunAsync) { + val asyncOperation = new Runnable { + override def run(): Unit = { + executeStatement() + } + } + val chatSessionManager = session.sessionManager + val backgroundHandle = chatSessionManager.submitBackgroundOperation(asyncOperation) + setBackgroundHandle(backgroundHandle) + } else { + executeStatement() + } + } + + private def executeStatement(): Unit = { + setState(OperationState.RUNNING) + + try { + val reply = chatProvider.ask(session.handle.identifier.toString, statement) + iter = new ArrayFetchIterator(Array(Array(reply))) + + setState(OperationState.FINISHED) + } catch { + onError(true) + } finally { + shutdownTimeoutMonitor() + } + } +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/ChatGPTProvider.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/ChatGPTProvider.scala new file mode 100644 index 00000000000..cdea89d2aad --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/ChatGPTProvider.scala @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.chat.provider + +import java.net.{InetSocketAddress, Proxy, URL} +import java.time.Duration +import java.util +import java.util.concurrent.TimeUnit + +import scala.collection.JavaConverters._ + +import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache} +import com.theokanning.openai.OpenAiApi +import com.theokanning.openai.completion.chat.{ChatCompletionRequest, ChatMessage} +import com.theokanning.openai.service.OpenAiService +import com.theokanning.openai.service.OpenAiService.{defaultClient, defaultObjectMapper, defaultRetrofit} + +import org.apache.kyuubi.config.KyuubiConf + +class ChatGPTProvider(conf: KyuubiConf) extends ChatProvider { + + private val gptApiKey = conf.get(KyuubiConf.ENGINE_CHAT_GPT_API_KEY).getOrElse { + throw new IllegalArgumentException( + s"'${KyuubiConf.ENGINE_CHAT_GPT_API_KEY.key}' must be configured, " + + s"which could be got at https://platform.openai.com/account/api-keys") + } + + private val openAiService: OpenAiService = { + val builder = defaultClient( + gptApiKey, + Duration.ofMillis(conf.get(KyuubiConf.ENGINE_CHAT_GPT_HTTP_SOCKET_TIMEOUT))) + .newBuilder + .connectTimeout(Duration.ofMillis(conf.get(KyuubiConf.ENGINE_CHAT_GPT_HTTP_CONNECT_TIMEOUT))) + + conf.get(KyuubiConf.ENGINE_CHAT_GPT_HTTP_PROXY) match { + case Some(httpProxyUrl) => + val url = new URL(httpProxyUrl) + val proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(url.getHost, url.getPort)) + builder.proxy(proxy) + case _ => + } + + val retrofit = defaultRetrofit(builder.build(), defaultObjectMapper) + val api = retrofit.create(classOf[OpenAiApi]) + new OpenAiService(api) + } + + private val chatHistory: LoadingCache[String, util.ArrayDeque[ChatMessage]] = + CacheBuilder.newBuilder() + .expireAfterWrite(10, TimeUnit.MINUTES) + .build(new CacheLoader[String, util.ArrayDeque[ChatMessage]] { + override def load(sessionId: String): util.ArrayDeque[ChatMessage] = + new util.ArrayDeque[ChatMessage] + }) + + override def open(sessionId: String): Unit = { + chatHistory.getIfPresent(sessionId) + } + + override def ask(sessionId: String, q: String): String = { + val messages = chatHistory.get(sessionId) + try { + messages.addLast(new ChatMessage("user", q)) + val completionRequest = ChatCompletionRequest.builder() + .model(conf.get(KyuubiConf.ENGINE_CHAT_GPT_MODEL)) + .messages(messages.asScala.toList.asJava) + .build() + val responseText = openAiService.createChatCompletion(completionRequest).getChoices.asScala + .map(c => c.getMessage.getContent).mkString + responseText + } catch { + case e: Throwable => + messages.removeLast() + s"Chat failed. Error: ${e.getMessage}" + } + } + + override def close(sessionId: String): Unit = { + chatHistory.invalidate(sessionId) + } +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/ChatProvider.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/ChatProvider.scala new file mode 100644 index 00000000000..af1ba434bea --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/ChatProvider.scala @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.chat.provider + +import scala.util.control.NonFatal + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.{ClassTagExtensions, DefaultScalaModule} + +import org.apache.kyuubi.{KyuubiException, Logging} +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.reflection.DynConstructors + +trait ChatProvider { + + def open(sessionId: String): Unit + + def ask(sessionId: String, q: String): String + + def close(sessionId: String): Unit +} + +object ChatProvider extends Logging { + + val mapper: ObjectMapper with ClassTagExtensions = + new ObjectMapper().registerModule(DefaultScalaModule) :: ClassTagExtensions + + def load(conf: KyuubiConf): ChatProvider = { + val groupProviderClass = conf.get(KyuubiConf.ENGINE_CHAT_PROVIDER) + try { + DynConstructors.builder(classOf[ChatProvider]) + .impl(groupProviderClass, classOf[KyuubiConf]) + .impl(groupProviderClass) + .buildChecked + .newInstanceChecked(conf) + } catch { + case _: ClassCastException => + throw new KyuubiException( + s"Class $groupProviderClass is not a child of '${classOf[ChatProvider].getName}'.") + case NonFatal(e) => + throw new IllegalArgumentException(s"Error while instantiating '$groupProviderClass': ", e) + } + } +} diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/KyuubiScalaObjectMapper.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/EchoProvider.scala similarity index 66% rename from kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/KyuubiScalaObjectMapper.scala rename to externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/EchoProvider.scala index 915b109b7b9..31ad3b8e390 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/KyuubiScalaObjectMapper.scala +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/EchoProvider.scala @@ -15,15 +15,14 @@ * limitations under the License. */ -package org.apache.kyuubi.server.trino.api +package org.apache.kyuubi.engine.chat.provider -import javax.ws.rs.ext.ContextResolver +class EchoProvider extends ChatProvider { -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.scala.DefaultScalaModule + override def open(sessionId: String): Unit = {} -class KyuubiScalaObjectMapper extends ContextResolver[ObjectMapper] { - private val mapper = new ObjectMapper().registerModule(DefaultScalaModule) + override def ask(sessionId: String, q: String): String = + "This is ChatKyuubi, nice to meet you!" - override def getContext(aClass: Class[_]): ObjectMapper = mapper + override def close(sessionId: String): Unit = {} } diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/Message.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/Message.scala new file mode 100644 index 00000000000..e2162be9f1a --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/provider/Message.scala @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.chat.provider + +case class Message(role: String, content: String) diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/schema/RowSet.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/schema/RowSet.scala new file mode 100644 index 00000000000..3bb4ba7dfa9 --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/schema/RowSet.scala @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.chat.schema + +import java.util + +import org.apache.hive.service.rpc.thrift._ + +import org.apache.kyuubi.util.RowSetUtils._ + +object RowSet { + + def emptyTRowSet(): TRowSet = { + new TRowSet(0, new java.util.ArrayList[TRow](0)) + } + + def toTRowSet( + rows: Seq[Array[String]], + columnSize: Int, + protocolVersion: TProtocolVersion): TRowSet = { + if (protocolVersion.getValue < TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V6.getValue) { + toRowBasedSet(rows, columnSize) + } else { + toColumnBasedSet(rows, columnSize) + } + } + + def toRowBasedSet(rows: Seq[Array[String]], columnSize: Int): TRowSet = { + val rowSize = rows.length + val tRows = new java.util.ArrayList[TRow](rowSize) + var i = 0 + while (i < rowSize) { + val row = rows(i) + val tRow = new TRow() + var j = 0 + val columnSize = row.length + while (j < columnSize) { + val columnValue = stringTColumnValue(j, row) + tRow.addToColVals(columnValue) + j += 1 + } + i += 1 + tRows.add(tRow) + } + new TRowSet(0, tRows) + } + + def toColumnBasedSet(rows: Seq[Array[String]], columnSize: Int): TRowSet = { + val rowSize = rows.length + val tRowSet = new TRowSet(0, new util.ArrayList[TRow](rowSize)) + var i = 0 + while (i < columnSize) { + val tColumn = toTColumn(rows, i) + tRowSet.addToColumns(tColumn) + i += 1 + } + tRowSet + } + + private def toTColumn(rows: Seq[Array[String]], ordinal: Int): TColumn = { + val nulls = new java.util.BitSet() + val values = getOrSetAsNull[String](rows, ordinal, nulls, "") + TColumn.stringVal(new TStringColumn(values, nulls)) + } + + private def getOrSetAsNull[String]( + rows: Seq[Array[String]], + ordinal: Int, + nulls: util.BitSet, + defaultVal: String): util.List[String] = { + val size = rows.length + val ret = new util.ArrayList[String](size) + var idx = 0 + while (idx < size) { + val row = rows(idx) + val isNull = row(ordinal) == null + if (isNull) { + nulls.set(idx, true) + ret.add(idx, defaultVal) + } else { + ret.add(idx, row(ordinal)) + } + idx += 1 + } + ret + } + + private def stringTColumnValue(ordinal: Int, row: Array[String]): TColumnValue = { + val tStringValue = new TStringValue + if (row(ordinal) != null) tStringValue.setValue(row(ordinal)) + TColumnValue.stringVal(tStringValue) + } +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/schema/SchemaHelper.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/schema/SchemaHelper.scala new file mode 100644 index 00000000000..8ccfdda2fe9 --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/schema/SchemaHelper.scala @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.chat.schema + +import java.util.Collections + +import org.apache.hive.service.rpc.thrift._ + +object SchemaHelper { + + def stringTTypeQualifiers: TTypeQualifiers = { + val ret = new TTypeQualifiers() + val qualifiers = Collections.emptyMap[String, TTypeQualifierValue]() + ret.setQualifiers(qualifiers) + ret + } + + def stringTTypeDesc: TTypeDesc = { + val typeEntry = new TPrimitiveTypeEntry(TTypeId.STRING_TYPE) + typeEntry.setTypeQualifiers(stringTTypeQualifiers) + val tTypeDesc = new TTypeDesc() + tTypeDesc.addToTypes(TTypeEntry.primitiveEntry(typeEntry)) + tTypeDesc + } + + def stringTColumnDesc(fieldName: String, pos: Int): TColumnDesc = { + val tColumnDesc = new TColumnDesc() + tColumnDesc.setColumnName(fieldName) + tColumnDesc.setTypeDesc(stringTTypeDesc) + tColumnDesc.setPosition(pos) + tColumnDesc + } + + def stringTTableSchema(fieldsName: String*): TTableSchema = { + val tTableSchema = new TTableSchema() + fieldsName.zipWithIndex.foreach { case (f, i) => + tTableSchema.addToColumns(stringTColumnDesc(f, i)) + } + tTableSchema + } +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/session/ChatSessionImpl.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/session/ChatSessionImpl.scala new file mode 100644 index 00000000000..29f42076822 --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/session/ChatSessionImpl.scala @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kyuubi.engine.chat.session + +import org.apache.hive.service.rpc.thrift.{TGetInfoType, TGetInfoValue, TProtocolVersion} + +import org.apache.kyuubi.{KYUUBI_VERSION, KyuubiSQLException} +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY +import org.apache.kyuubi.session.{AbstractSession, SessionHandle, SessionManager} + +class ChatSessionImpl( + protocol: TProtocolVersion, + user: String, + password: String, + ipAddress: String, + conf: Map[String, String], + sessionManager: SessionManager) + extends AbstractSession(protocol, user, password, ipAddress, conf, sessionManager) { + + override val handle: SessionHandle = + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).getOrElse(SessionHandle()) + + private val chatProvider = sessionManager.asInstanceOf[ChatSessionManager].chatProvider + + override def open(): Unit = { + info(s"Starting to open chat session.") + chatProvider.open(handle.identifier.toString) + super.open() + info(s"The chat session is started.") + } + + override def getInfo(infoType: TGetInfoType): TGetInfoValue = withAcquireRelease() { + infoType match { + case TGetInfoType.CLI_SERVER_NAME | TGetInfoType.CLI_DBMS_NAME => + TGetInfoValue.stringValue("Kyuubi Chat Engine") + case TGetInfoType.CLI_DBMS_VER => + TGetInfoValue.stringValue(KYUUBI_VERSION) + case TGetInfoType.CLI_ODBC_KEYWORDS => TGetInfoValue.stringValue("Unimplemented") + case TGetInfoType.CLI_MAX_COLUMN_NAME_LEN => + TGetInfoValue.lenValue(128) + case TGetInfoType.CLI_MAX_SCHEMA_NAME_LEN => + TGetInfoValue.lenValue(128) + case TGetInfoType.CLI_MAX_TABLE_NAME_LEN => + TGetInfoValue.lenValue(128) + case _ => throw KyuubiSQLException(s"Unrecognized GetInfoType value: $infoType") + } + } + + override def close(): Unit = { + chatProvider.close(handle.identifier.toString) + super.close() + } + +} diff --git a/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/session/ChatSessionManager.scala b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/session/ChatSessionManager.scala new file mode 100644 index 00000000000..33a9dd45066 --- /dev/null +++ b/externals/kyuubi-chat-engine/src/main/scala/org/apache/kyuubi/engine/chat/session/ChatSessionManager.scala @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kyuubi.engine.chat.session + +import org.apache.hive.service.rpc.thrift.TProtocolVersion + +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.config.KyuubiConf.ENGINE_SHARE_LEVEL +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY +import org.apache.kyuubi.engine.ShareLevel +import org.apache.kyuubi.engine.chat.ChatEngine +import org.apache.kyuubi.engine.chat.operation.ChatOperationManager +import org.apache.kyuubi.engine.chat.provider.ChatProvider +import org.apache.kyuubi.operation.OperationManager +import org.apache.kyuubi.session.{Session, SessionHandle, SessionManager} + +class ChatSessionManager(name: String) + extends SessionManager(name) { + + def this() = this(classOf[ChatSessionManager].getSimpleName) + + override protected def isServer: Boolean = false + + lazy val chatProvider: ChatProvider = ChatProvider.load(conf) + + override lazy val operationManager: OperationManager = + new ChatOperationManager(conf, chatProvider) + + override def initialize(conf: KyuubiConf): Unit = { + this.conf = conf + super.initialize(conf) + } + + override protected def createSession( + protocol: TProtocolVersion, + user: String, + password: String, + ipAddress: String, + conf: Map[String, String]): Session = { + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID) + .flatMap(getSessionOption).getOrElse { + new ChatSessionImpl(protocol, user, password, ipAddress, conf, this) + } + } + + override def closeSession(sessionHandle: SessionHandle): Unit = { + super.closeSession(sessionHandle) + if (conf.get(ENGINE_SHARE_LEVEL) == ShareLevel.CONNECTION.toString) { + info("Session stopped due to shared level is Connection.") + stopSession() + } + } + + private def stopSession(): Unit = { + ChatEngine.currentEngine.foreach(_.stop()) + } +} diff --git a/externals/kyuubi-chat-engine/src/test/resources/log4j2-test.xml b/externals/kyuubi-chat-engine/src/test/resources/log4j2-test.xml new file mode 100644 index 00000000000..585a12c6f99 --- /dev/null +++ b/externals/kyuubi-chat-engine/src/test/resources/log4j2-test.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/externals/kyuubi-chat-engine/src/test/scala/org/apache/kyuubi/engine/chat/WithChatEngine.scala b/externals/kyuubi-chat-engine/src/test/scala/org/apache/kyuubi/engine/chat/WithChatEngine.scala new file mode 100644 index 00000000000..287fdde2fb5 --- /dev/null +++ b/externals/kyuubi-chat-engine/src/test/scala/org/apache/kyuubi/engine/chat/WithChatEngine.scala @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kyuubi.engine.chat + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.config.KyuubiConf + +trait WithChatEngine extends KyuubiFunSuite { + + protected var engine: ChatEngine = _ + protected var connectionUrl: String = _ + + protected val kyuubiConf: KyuubiConf = ChatEngine.kyuubiConf + + def withKyuubiConf: Map[String, String] + + override def beforeAll(): Unit = { + super.beforeAll() + startChatEngine() + } + + override def afterAll(): Unit = { + stopChatEngine() + super.afterAll() + } + + def stopChatEngine(): Unit = { + if (engine != null) { + engine.stop() + engine = null + } + } + + def startChatEngine(): Unit = { + withKyuubiConf.foreach { case (k, v) => + System.setProperty(k, v) + kyuubiConf.set(k, v) + } + ChatEngine.startEngine() + engine = ChatEngine.currentEngine.get + connectionUrl = engine.frontendServices.head.connectionUrl + } + + protected def jdbcConnectionUrl: String = s"jdbc:hive2://$connectionUrl/;" + +} diff --git a/externals/kyuubi-chat-engine/src/test/scala/org/apache/kyuubi/engine/chat/operation/ChatOperationSuite.scala b/externals/kyuubi-chat-engine/src/test/scala/org/apache/kyuubi/engine/chat/operation/ChatOperationSuite.scala new file mode 100644 index 00000000000..b14407a267b --- /dev/null +++ b/externals/kyuubi-chat-engine/src/test/scala/org/apache/kyuubi/engine/chat/operation/ChatOperationSuite.scala @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.chat.operation + +import org.apache.kyuubi.config.KyuubiConf._ +import org.apache.kyuubi.engine.chat.WithChatEngine +import org.apache.kyuubi.operation.HiveJDBCTestHelper + +class ChatOperationSuite extends HiveJDBCTestHelper with WithChatEngine { + + override def withKyuubiConf: Map[String, String] = Map( + ENGINE_CHAT_PROVIDER.key -> "echo") + + override protected def jdbcUrl: String = jdbcConnectionUrl + + test("test echo chat provider") { + withJdbcStatement() { stmt => + val result = stmt.executeQuery("Hello, Kyuubi") + assert(result.next()) + val expected = "This is ChatKyuubi, nice to meet you!" + assert(result.getString("reply") === expected) + assert(!result.next()) + } + } +} diff --git a/externals/kyuubi-download/pom.xml b/externals/kyuubi-download/pom.xml index b0479f7edc8..d7f0c601322 100644 --- a/externals/kyuubi-download/pom.xml +++ b/externals/kyuubi-download/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../pom.xml @@ -36,6 +36,7 @@ com.googlecode.maven-download-plugin download-maven-plugin + ${maven.plugin.download.cache.path} ${project.build.directory} 60000 3 diff --git a/externals/kyuubi-flink-sql-engine/pom.xml b/externals/kyuubi-flink-sql-engine/pom.xml index c939936070b..f3633b904f5 100644 --- a/externals/kyuubi-flink-sql-engine/pom.xml +++ b/externals/kyuubi-flink-sql-engine/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../pom.xml @@ -59,19 +59,19 @@ org.apache.flink - flink-streaming-java${flink.module.scala.suffix} + flink-streaming-java provided org.apache.flink - flink-clients${flink.module.scala.suffix} + flink-clients provided org.apache.flink - flink-sql-client${flink.module.scala.suffix} + flink-sql-client provided @@ -89,7 +89,7 @@ org.apache.flink - flink-table-api-java-bridge${flink.module.scala.suffix} + flink-table-api-java-bridge provided @@ -101,7 +101,7 @@ org.apache.flink - flink-table-runtime${flink.module.scala.suffix} + flink-table-runtime provided @@ -128,7 +128,7 @@ org.apache.flink - flink-test-utils${flink.module.scala.suffix} + flink-test-utils test diff --git a/externals/kyuubi-flink-sql-engine/src/main/java/org/apache/kyuubi/engine/flink/result/Constants.java b/externals/kyuubi-flink-sql-engine/src/main/java/org/apache/kyuubi/engine/flink/result/Constants.java deleted file mode 100644 index b683eb76afa..00000000000 --- a/externals/kyuubi-flink-sql-engine/src/main/java/org/apache/kyuubi/engine/flink/result/Constants.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.kyuubi.engine.flink.result; - -/** Constant column names. */ -public class Constants { - - public static final String TABLE_TYPE = "TABLE"; - public static final String VIEW_TYPE = "VIEW"; - - public static final String[] SUPPORTED_TABLE_TYPES = new String[] {TABLE_TYPE, VIEW_TYPE}; -} diff --git a/externals/kyuubi-flink-sql-engine/src/main/java/org/apache/kyuubi/engine/flink/result/ResultSet.java b/externals/kyuubi-flink-sql-engine/src/main/java/org/apache/kyuubi/engine/flink/result/ResultSet.java deleted file mode 100644 index 66f03a159b9..00000000000 --- a/externals/kyuubi-flink-sql-engine/src/main/java/org/apache/kyuubi/engine/flink/result/ResultSet.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.kyuubi.engine.flink.result; - -import com.google.common.collect.Iterators; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; -import java.util.List; -import java.util.Objects; -import javax.annotation.Nullable; -import org.apache.flink.table.api.ResultKind; -import org.apache.flink.table.api.TableResult; -import org.apache.flink.table.catalog.Column; -import org.apache.flink.table.catalog.ResolvedSchema; -import org.apache.flink.types.Row; -import org.apache.flink.util.Preconditions; -import org.apache.kyuubi.operation.ArrayFetchIterator; -import org.apache.kyuubi.operation.FetchIterator; - -/** - * A set of one statement execution result containing result kind, columns, rows of data and change - * flags for streaming mode. - */ -public class ResultSet { - - private final ResultKind resultKind; - private final List columns; - private final FetchIterator data; - - // null in batch mode - // - // list of boolean in streaming mode, - // true if the corresponding row is an append row, false if its a retract row - private final List changeFlags; - - private ResultSet( - ResultKind resultKind, - List columns, - FetchIterator data, - @Nullable List changeFlags) { - this.resultKind = Preconditions.checkNotNull(resultKind, "resultKind must not be null"); - this.columns = Preconditions.checkNotNull(columns, "columns must not be null"); - this.data = Preconditions.checkNotNull(data, "data must not be null"); - this.changeFlags = changeFlags; - if (changeFlags != null) { - Preconditions.checkArgument( - Iterators.size((Iterator) data) == changeFlags.size(), - "the size of data and the size of changeFlags should be equal"); - } - } - - public List getColumns() { - return columns; - } - - public FetchIterator getData() { - return data; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - ResultSet resultSet = (ResultSet) o; - return resultKind.equals(resultSet.resultKind) - && columns.equals(resultSet.columns) - && data.equals(resultSet.data) - && Objects.equals(changeFlags, resultSet.changeFlags); - } - - @Override - public int hashCode() { - return Objects.hash(resultKind, columns, data, changeFlags); - } - - @Override - public String toString() { - return "ResultSet{" - + "resultKind=" - + resultKind - + ", columns=" - + columns - + ", data=" - + data - + ", changeFlags=" - + changeFlags - + '}'; - } - - public static ResultSet fromTableResult(TableResult tableResult) { - ResolvedSchema schema = tableResult.getResolvedSchema(); - // collect all rows from table result as list - // this is ok as TableResult contains limited rows - List rows = new ArrayList<>(); - tableResult.collect().forEachRemaining(rows::add); - return builder() - .resultKind(tableResult.getResultKind()) - .columns(schema.getColumns()) - .data(rows.toArray(new Row[0])) - .build(); - } - - public static Builder builder() { - return new Builder(); - } - - /** Builder for {@link ResultSet}. */ - public static class Builder { - private ResultKind resultKind = null; - private List columns = null; - private FetchIterator data = null; - private List changeFlags = null; - - private Builder() {} - - /** Set {@link ResultKind}. */ - public Builder resultKind(ResultKind resultKind) { - this.resultKind = resultKind; - return this; - } - - /** Set columns. */ - public Builder columns(Column... columns) { - this.columns = Arrays.asList(columns); - return this; - } - - /** Set columns. */ - public Builder columns(List columns) { - this.columns = columns; - return this; - } - - /** Set data. */ - public Builder data(FetchIterator data) { - this.data = data; - return this; - } - - /** Set data. */ - public Builder data(Row[] data) { - this.data = new ArrayFetchIterator<>(data); - return this; - } - - /** Set change flags. */ - public Builder changeFlags(List changeFlags) { - this.changeFlags = changeFlags; - return this; - } - - /** Returns a {@link ResultSet} instance. */ - public ResultSet build() { - return new ResultSet(resultKind, columns, data, changeFlags); - } - } -} diff --git a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/FlinkEngineUtils.scala b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/FlinkEngineUtils.scala index e271944a7c0..69fc8c69573 100644 --- a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/FlinkEngineUtils.scala +++ b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/FlinkEngineUtils.scala @@ -40,7 +40,7 @@ object FlinkEngineUtils extends Logging { val EMBEDDED_MODE_CLIENT_OPTIONS: Options = getEmbeddedModeClientOptions(new Options); val SUPPORTED_FLINK_VERSIONS: Array[SemanticVersion] = - Array("1.14", "1.15", "1.16").map(SemanticVersion.apply) + Array("1.15", "1.16").map(SemanticVersion.apply) def checkFlinkVersion(): Unit = { val flinkVersion = EnvironmentInformation.getVersion diff --git a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/operation/ExecuteStatement.scala b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/operation/ExecuteStatement.scala index 93d013556e1..0438b98d1ad 100644 --- a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/operation/ExecuteStatement.scala +++ b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/operation/ExecuteStatement.scala @@ -17,14 +17,14 @@ package org.apache.kyuubi.engine.flink.operation -import java.time.LocalDate +import java.time.{LocalDate, LocalTime} import java.util import scala.collection.JavaConverters._ import scala.collection.mutable.ArrayBuffer import org.apache.flink.api.common.JobID -import org.apache.flink.table.api.{ResultKind, TableResult} +import org.apache.flink.table.api.ResultKind import org.apache.flink.table.client.gateway.TypedResult import org.apache.flink.table.data.{GenericArrayData, GenericMapData, RowData} import org.apache.flink.table.data.binary.{BinaryArrayData, BinaryMapData} @@ -120,18 +120,8 @@ class ExecuteStatement( case TypedResult.ResultType.PAYLOAD => (1 to result.getPayload).foreach { page => if (rows.size < resultMaxRows) { - // FLINK-24461 retrieveResultPage method changes the return type from Row to RowData - val retrieveResultPage = DynMethods.builder("retrieveResultPage") - .impl(executor.getClass, classOf[String], classOf[Int]) - .build(executor) - val _page = Integer.valueOf(page) - if (isFlinkVersionEqualTo("1.14")) { - val result = retrieveResultPage.invoke[util.List[Row]](resultId, _page) - rows ++= result.asScala - } else if (isFlinkVersionAtLeast("1.15")) { - val result = retrieveResultPage.invoke[util.List[RowData]](resultId, _page) - rows ++= result.asScala.map(r => convertToRow(r, dataTypes)) - } + val result = executor.retrieveResultPage(resultId, page) + rows ++= result.asScala.map(r => convertToRow(r, dataTypes)) } else { loop = false } @@ -154,14 +144,10 @@ class ExecuteStatement( } private def runOperation(operation: Operation): Unit = { - // FLINK-24461 executeOperation method changes the return type - // from TableResult to TableResultInternal - val executeOperation = DynMethods.builder("executeOperation") - .impl(executor.getClass, classOf[String], classOf[Operation]) - .build(executor) - val result = executeOperation.invoke[TableResult](sessionId, operation) + val result = executor.executeOperation(sessionId, operation) jobId = result.getJobClient.asScala.map(_.getJobID) - result.await() + // after FLINK-24461, TableResult#await() would block insert statements + // until the job finishes, instead of returning row affected immediately resultSet = ResultSet.fromTableResult(result) } @@ -204,6 +190,9 @@ class ExecuteStatement( case _: DateType => val date = RowSetUtils.formatLocalDate(LocalDate.ofEpochDay(r.getInt(i))) row.setField(i, date) + case _: TimeType => + val time = RowSetUtils.formatLocalTime(LocalTime.ofNanoOfDay(r.getLong(i) * 1000 * 1000)) + row.setField(i, time) case t: TimestampType => val ts = RowSetUtils .formatLocalDateTime(r.getTimestamp(i, t.getPrecision) diff --git a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/result/Constants.scala b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/result/Constants.scala new file mode 100644 index 00000000000..ca582b2e3f3 --- /dev/null +++ b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/result/Constants.scala @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.flink.result + +object Constants { + val TABLE_TYPE: String = "TABLE" + val VIEW_TYPE: String = "VIEW" + val SUPPORTED_TABLE_TYPES: Array[String] = Array[String](TABLE_TYPE, VIEW_TYPE) +} diff --git a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/result/ResultSet.scala b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/result/ResultSet.scala new file mode 100644 index 00000000000..13673381258 --- /dev/null +++ b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/result/ResultSet.scala @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.flink.result + +import java.util + +import scala.collection.JavaConverters._ + +import com.google.common.collect.Iterators +import org.apache.flink.table.api.{ResultKind, TableResult} +import org.apache.flink.table.catalog.Column +import org.apache.flink.types.Row + +import org.apache.kyuubi.operation.{ArrayFetchIterator, FetchIterator} + +case class ResultSet( + resultKind: ResultKind, + columns: util.List[Column], + data: FetchIterator[Row], + // null in batch mode + // list of boolean in streaming mode, + // true if the corresponding row is an append row, false if its a retract row + changeFlags: Option[util.List[Boolean]]) { + + require(resultKind != null, "resultKind must not be null") + require(columns != null, "columns must not be null") + require(data != null, "data must not be null") + changeFlags.foreach { flags => + require( + Iterators.size(data.asInstanceOf[util.Iterator[_]]) == flags.size, + "the size of data and the size of changeFlags should be equal") + } + + def getColumns: util.List[Column] = columns + + def getData: FetchIterator[Row] = data +} + +/** + * A set of one statement execution result containing result kind, columns, rows of data and change + * flags for streaming mode. + */ +object ResultSet { + + def fromTableResult(tableResult: TableResult): ResultSet = { + val schema = tableResult.getResolvedSchema + // collect all rows from table result as list + // this is ok as TableResult contains limited rows + val rows = tableResult.collect.asScala.toArray + builder.resultKind(tableResult.getResultKind) + .columns(schema.getColumns) + .data(rows) + .build + } + + def builder: Builder = new ResultSet.Builder + + class Builder { + private var resultKind: ResultKind = _ + private var columns: util.List[Column] = _ + private var data: FetchIterator[Row] = _ + private var changeFlags: Option[util.List[Boolean]] = None + + def resultKind(resultKind: ResultKind): ResultSet.Builder = { + this.resultKind = resultKind + this + } + + def columns(columns: Column*): ResultSet.Builder = { + this.columns = columns.asJava + this + } + + def columns(columns: util.List[Column]): ResultSet.Builder = { + this.columns = columns + this + } + + def data(data: FetchIterator[Row]): ResultSet.Builder = { + this.data = data + this + } + + def data(data: Array[Row]): ResultSet.Builder = { + this.data = new ArrayFetchIterator[Row](data) + this + } + + def changeFlags(changeFlags: util.List[Boolean]): ResultSet.Builder = { + this.changeFlags = Some(changeFlags) + this + } + + def build: ResultSet = new ResultSet(resultKind, columns, data, changeFlags) + } +} diff --git a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/schema/RowSet.scala b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/schema/RowSet.scala index 2b3ae50b76e..ad83f9c2ba2 100644 --- a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/schema/RowSet.scala +++ b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/schema/RowSet.scala @@ -307,6 +307,7 @@ object RowSet { case _: MapType => TTypeId.MAP_TYPE case _: RowType => TTypeId.STRUCT_TYPE case _: BinaryType => TTypeId.BINARY_TYPE + case _: TimeType => TTypeId.STRING_TYPE case t @ (_: ZonedTimestampType | _: LocalZonedTimestampType | _: MultisetType | _: YearMonthIntervalType | _: DayTimeIntervalType) => throw new IllegalArgumentException( diff --git a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/session/FlinkSQLSessionManager.scala b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/session/FlinkSQLSessionManager.scala index 8a3fc7446cf..07971e39fae 100644 --- a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/session/FlinkSQLSessionManager.scala +++ b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/session/FlinkSQLSessionManager.scala @@ -21,6 +21,7 @@ import org.apache.flink.table.client.gateway.context.DefaultContext import org.apache.flink.table.client.gateway.local.LocalExecutor import org.apache.hive.service.rpc.thrift.TProtocolVersion +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY import org.apache.kyuubi.engine.flink.operation.FlinkSQLOperationManager import org.apache.kyuubi.session.{Session, SessionHandle, SessionManager} @@ -43,14 +44,17 @@ class FlinkSQLSessionManager(engineContext: DefaultContext) password: String, ipAddress: String, conf: Map[String, String]): Session = { - new FlinkSessionImpl( - protocol, - user, - password, - ipAddress, - conf, - this, - executor) + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).flatMap( + getSessionOption).getOrElse { + new FlinkSessionImpl( + protocol, + user, + password, + ipAddress, + conf, + this, + executor) + } } override def closeSession(sessionHandle: SessionHandle): Unit = { diff --git a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/session/FlinkSessionImpl.scala b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/session/FlinkSessionImpl.scala index 03d9ce42e7f..75087b48ca2 100644 --- a/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/session/FlinkSessionImpl.scala +++ b/externals/kyuubi-flink-sql-engine/src/main/scala/org/apache/kyuubi/engine/flink/session/FlinkSessionImpl.scala @@ -26,8 +26,9 @@ import org.apache.flink.table.client.gateway.local.LocalExecutor import org.apache.hive.service.rpc.thrift.{TGetInfoType, TGetInfoValue, TProtocolVersion} import org.apache.kyuubi.KyuubiSQLException +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY import org.apache.kyuubi.engine.flink.FlinkEngineUtils -import org.apache.kyuubi.session.{AbstractSession, SessionManager} +import org.apache.kyuubi.session.{AbstractSession, SessionHandle, SessionManager} class FlinkSessionImpl( protocol: TProtocolVersion, @@ -39,6 +40,9 @@ class FlinkSessionImpl( val executor: LocalExecutor) extends AbstractSession(protocol, user, password, ipAddress, conf, sessionManager) { + override val handle: SessionHandle = + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).getOrElse(SessionHandle()) + lazy val sessionContext: SessionContext = { FlinkEngineUtils.getSessionContext(executor, handle.identifier.toString) } diff --git a/externals/kyuubi-flink-sql-engine/src/test/scala/org/apache/kyuubi/engine/flink/operation/FlinkOperationSuite.scala b/externals/kyuubi-flink-sql-engine/src/test/scala/org/apache/kyuubi/engine/flink/operation/FlinkOperationSuite.scala index c75124c3947..5026fd41175 100644 --- a/externals/kyuubi-flink-sql-engine/src/test/scala/org/apache/kyuubi/engine/flink/operation/FlinkOperationSuite.scala +++ b/externals/kyuubi-flink-sql-engine/src/test/scala/org/apache/kyuubi/engine/flink/operation/FlinkOperationSuite.scala @@ -30,7 +30,6 @@ import org.scalatest.time.SpanSugar._ import org.apache.kyuubi.Utils import org.apache.kyuubi.config.KyuubiConf._ -import org.apache.kyuubi.engine.flink.FlinkEngineUtils._ import org.apache.kyuubi.engine.flink.WithFlinkSQLEngine import org.apache.kyuubi.engine.flink.result.Constants import org.apache.kyuubi.engine.flink.util.TestUserClassLoaderJar @@ -756,28 +755,34 @@ class FlinkOperationSuite extends WithFlinkSQLEngine with HiveJDBCTestHelper { } } - test("execute statement - select array") { + test("execute statement - select time") { withJdbcStatement() { statement => val resultSet = - statement.executeQuery("select array ['v1', 'v2', 'v3']") + statement.executeQuery( + "select time '00:00:03', time '00:00:05.123456789'") + val metaData = resultSet.getMetaData + assert(metaData.getColumnType(1) === java.sql.Types.VARCHAR) + assert(metaData.getColumnType(2) === java.sql.Types.VARCHAR) + assert(resultSet.next()) + assert(resultSet.getString(1) == "00:00:03") + assert(resultSet.getString(2) == "00:00:05.123") + } + } + + test("execute statement - select array") { + withJdbcStatement() { statement => + val resultSet = statement.executeQuery("select array ['v1', 'v2', 'v3']") val metaData = resultSet.getMetaData assert(metaData.getColumnType(1) === java.sql.Types.ARRAY) assert(resultSet.next()) - if (isFlinkVersionEqualTo("1.14")) { - val expected = """["v1","v2","v3"]""" - assert(resultSet.getObject(1).toString == expected) - } - if (isFlinkVersionAtLeast("1.15")) { - val expected = "[v1,v2,v3]" - assert(resultSet.getObject(1).toString == expected) - } + val expected = "[v1,v2,v3]" + assert(resultSet.getObject(1).toString == expected) } } test("execute statement - select map") { withJdbcStatement() { statement => - val resultSet = - statement.executeQuery("select map ['k1', 'v1', 'k2', 'v2']") + val resultSet = statement.executeQuery("select map ['k1', 'v1', 'k2', 'v2']") assert(resultSet.next()) assert(resultSet.getString(1) == "{k1=v1, k2=v2}") val metaData = resultSet.getMetaData @@ -787,17 +792,10 @@ class FlinkOperationSuite extends WithFlinkSQLEngine with HiveJDBCTestHelper { test("execute statement - select row") { withJdbcStatement() { statement => - val resultSet = - statement.executeQuery("select (1, '2', true)") + val resultSet = statement.executeQuery("select (1, '2', true)") assert(resultSet.next()) - if (isFlinkVersionEqualTo("1.14")) { - val expected = """{INT NOT NULL:1,CHAR(1) NOT NULL:"2",BOOLEAN NOT NULL:true}""" - assert(resultSet.getString(1) == expected) - } - if (isFlinkVersionAtLeast("1.15")) { - val expected = """{INT NOT NULL:1,CHAR(1) NOT NULL:2,BOOLEAN NOT NULL:true}""" - assert(resultSet.getString(1) == expected) - } + val expected = """{INT NOT NULL:1,CHAR(1) NOT NULL:2,BOOLEAN NOT NULL:true}""" + assert(resultSet.getString(1) == expected) val metaData = resultSet.getMetaData assert(metaData.getColumnType(1) === java.sql.Types.STRUCT) } @@ -807,25 +805,20 @@ class FlinkOperationSuite extends WithFlinkSQLEngine with HiveJDBCTestHelper { withJdbcStatement() { statement => val resultSet = statement.executeQuery("select encode('kyuubi', 'UTF-8')") assert(resultSet.next()) - if (isFlinkVersionEqualTo("1.14")) { - assert(resultSet.getString(1) == "kyuubi") - } - if (isFlinkVersionAtLeast("1.15")) { - // TODO: validate table results after FLINK-28882 is resolved - assert(resultSet.getString(1) == "k") - } + // TODO: validate table results after FLINK-28882 is resolved + assert(resultSet.getString(1) == "k") val metaData = resultSet.getMetaData assert(metaData.getColumnType(1) === java.sql.Types.BINARY) } } test("execute statement - select float") { - withJdbcStatement()({ statement => + withJdbcStatement() { statement => val resultSet = statement.executeQuery("SELECT cast(0.1 as float)") assert(resultSet.next()) assert(resultSet.getString(1) == "0.1") assert(resultSet.getFloat(1) == 0.1f) - }) + } } test("execute statement - select count") { @@ -876,20 +869,15 @@ class FlinkOperationSuite extends WithFlinkSQLEngine with HiveJDBCTestHelper { } test("execute statement - create/drop catalog") { - withJdbcStatement()({ statement => - val createResult = { + withJdbcStatement() { statement => + val createResult = statement.executeQuery("create catalog cat_a with ('type'='generic_in_memory')") - } - if (isFlinkVersionAtLeast("1.15")) { - assert(createResult.next()) - assert(createResult.getString(1) === "OK") - } + assert(createResult.next()) + assert(createResult.getString(1) === "OK") val dropResult = statement.executeQuery("drop catalog cat_a") - if (isFlinkVersionAtLeast("1.15")) { - assert(dropResult.next()) - assert(dropResult.getString(1) === "OK") - } - }) + assert(dropResult.next()) + assert(dropResult.getString(1) === "OK") + } } test("execute statement - set/get catalog") { @@ -903,36 +891,31 @@ class FlinkOperationSuite extends WithFlinkSQLEngine with HiveJDBCTestHelper { statement.getConnection.setCatalog("cat_a") val changedCatalog = statement.getConnection.getCatalog assert(changedCatalog == "cat_a") + statement.getConnection.setCatalog("default_catalog") assert(statement.execute("drop catalog cat_a")) } } } test("execute statement - create/alter/drop database") { - withJdbcStatement()({ statement => + withJdbcStatement() { statement => val createResult = statement.executeQuery("create database db_a") - if (isFlinkVersionAtLeast("1.15")) { - assert(createResult.next()) - assert(createResult.getString(1) === "OK") - } + assert(createResult.next()) + assert(createResult.getString(1) === "OK") val alterResult = statement.executeQuery("alter database db_a set ('k1' = 'v1')") - if (isFlinkVersionAtLeast("1.15")) { - assert(alterResult.next()) - assert(alterResult.getString(1) === "OK") - } + assert(alterResult.next()) + assert(alterResult.getString(1) === "OK") val dropResult = statement.executeQuery("drop database db_a") - if (isFlinkVersionAtLeast("1.15")) { - assert(dropResult.next()) - assert(dropResult.getString(1) === "OK") - } - }) + assert(dropResult.next()) + assert(dropResult.getString(1) === "OK") + } } test("execute statement - set/get database") { withSessionConf()( Map(ENGINE_OPERATION_CONVERT_CATALOG_DATABASE_ENABLED.key -> "true"))( Map.empty) { - withJdbcStatement()({ statement => + withJdbcStatement() { statement => statement.executeQuery("create database db_a") val schema = statement.getConnection.getSchema assert(schema == "default_database") @@ -940,54 +923,41 @@ class FlinkOperationSuite extends WithFlinkSQLEngine with HiveJDBCTestHelper { val changedSchema = statement.getConnection.getSchema assert(changedSchema == "db_a") assert(statement.execute("drop database db_a")) - }) + } } } test("execute statement - create/alter/drop table") { - withJdbcStatement()({ statement => - val createResult = { + withJdbcStatement() { statement => + val createResult = statement.executeQuery("create table tbl_a (a string) with ('connector' = 'blackhole')") - } - if (isFlinkVersionAtLeast("1.15")) { - assert(createResult.next()) - assert(createResult.getString(1) === "OK") - } + assert(createResult.next()) + assert(createResult.getString(1) === "OK") val alterResult = statement.executeQuery("alter table tbl_a rename to tbl_b") - if (isFlinkVersionAtLeast("1.15")) { - assert(alterResult.next()) - assert(alterResult.getString(1) === "OK") - } + assert(alterResult.next()) + assert(alterResult.getString(1) === "OK") val dropResult = statement.executeQuery("drop table tbl_b") - if (isFlinkVersionAtLeast("1.15")) { - assert(dropResult.next()) - assert(dropResult.getString(1) === "OK") - } - }) + assert(dropResult.next()) + assert(dropResult.getString(1) === "OK") + } } test("execute statement - create/alter/drop view") { - withMultipleConnectionJdbcStatement()({ statement => + withMultipleConnectionJdbcStatement() { statement => val createResult = statement.executeQuery("create view view_a as select 1") - if (isFlinkVersionAtLeast("1.15")) { - assert(createResult.next()) - assert(createResult.getString(1) === "OK") - } + assert(createResult.next()) + assert(createResult.getString(1) === "OK") val alterResult = statement.executeQuery("alter view view_a rename to view_b") - if (isFlinkVersionAtLeast("1.15")) { - assert(alterResult.next()) - assert(alterResult.getString(1) === "OK") - } + assert(alterResult.next()) + assert(alterResult.getString(1) === "OK") val dropResult = statement.executeQuery("drop view view_b") - if (isFlinkVersionAtLeast("1.15")) { - assert(dropResult.next()) - assert(dropResult.getString(1) === "OK") - } - }) + assert(dropResult.next()) + assert(dropResult.getString(1) === "OK") + } } test("execute statement - insert into") { - withMultipleConnectionJdbcStatement()({ statement => + withMultipleConnectionJdbcStatement() { statement => statement.executeQuery("create table tbl_a (a int) with ('connector' = 'blackhole')") val resultSet = statement.executeQuery("insert into tbl_a select 1") val metadata = resultSet.getMetaData @@ -995,11 +965,11 @@ class FlinkOperationSuite extends WithFlinkSQLEngine with HiveJDBCTestHelper { assert(metadata.getColumnType(1) == java.sql.Types.BIGINT) assert(resultSet.next()) assert(resultSet.getLong(1) == -1L) - }) + } } test("execute statement - set properties") { - withMultipleConnectionJdbcStatement()({ statement => + withMultipleConnectionJdbcStatement() { statement => val resultSet = statement.executeQuery("set table.dynamic-table-options.enabled = true") val metadata = resultSet.getMetaData assert(metadata.getColumnName(1) == "key") @@ -1007,21 +977,21 @@ class FlinkOperationSuite extends WithFlinkSQLEngine with HiveJDBCTestHelper { assert(resultSet.next()) assert(resultSet.getString(1) == "table.dynamic-table-options.enabled") assert(resultSet.getString(2) == "true") - }) + } } test("execute statement - show properties") { - withMultipleConnectionJdbcStatement()({ statement => + withMultipleConnectionJdbcStatement() { statement => val resultSet = statement.executeQuery("set") val metadata = resultSet.getMetaData assert(metadata.getColumnName(1) == "key") assert(metadata.getColumnName(2) == "value") assert(resultSet.next()) - }) + } } test("execute statement - reset property") { - withMultipleConnectionJdbcStatement()({ statement => + withMultipleConnectionJdbcStatement() { statement => statement.executeQuery("set pipeline.jars = my.jar") statement.executeQuery("reset pipeline.jars") val resultSet = statement.executeQuery("set") @@ -1035,7 +1005,7 @@ class FlinkOperationSuite extends WithFlinkSQLEngine with HiveJDBCTestHelper { } } assert(success) - }) + } } test("execute statement - select udf") { diff --git a/externals/kyuubi-hive-sql-engine/pom.xml b/externals/kyuubi-hive-sql-engine/pom.xml index 1dbc319471a..0319d3dd2f3 100644 --- a/externals/kyuubi-hive-sql-engine/pom.xml +++ b/externals/kyuubi-hive-sql-engine/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../pom.xml diff --git a/externals/kyuubi-hive-sql-engine/src/main/scala/org/apache/kyuubi/engine/hive/session/HiveSessionManager.scala b/externals/kyuubi-hive-sql-engine/src/main/scala/org/apache/kyuubi/engine/hive/session/HiveSessionManager.scala index dc807429c51..d09912770cc 100644 --- a/externals/kyuubi-hive-sql-engine/src/main/scala/org/apache/kyuubi/engine/hive/session/HiveSessionManager.scala +++ b/externals/kyuubi-hive-sql-engine/src/main/scala/org/apache/kyuubi/engine/hive/session/HiveSessionManager.scala @@ -28,6 +28,7 @@ import org.apache.hive.service.cli.session.{HiveSessionImplwithUGI => ImportedHi import org.apache.hive.service.rpc.thrift.TProtocolVersion import org.apache.kyuubi.config.KyuubiConf.ENGINE_SHARE_LEVEL +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY import org.apache.kyuubi.engine.ShareLevel import org.apache.kyuubi.engine.hive.HiveSQLEngine import org.apache.kyuubi.engine.hive.operation.HiveOperationManager @@ -72,33 +73,38 @@ class HiveSessionManager(engine: HiveSQLEngine) extends SessionManager("HiveSess password: String, ipAddress: String, conf: Map[String, String]): Session = { - val sessionHandle = SessionHandle() - val hive = { - val sessionWithUGI = new ImportedHiveSessionImpl( - new ImportedSessionHandle(sessionHandle.toTSessionHandle, protocol), + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).flatMap( + getSessionOption).getOrElse { + val sessionHandle = + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).getOrElse(SessionHandle()) + val hive = { + val sessionWithUGI = new ImportedHiveSessionImpl( + new ImportedSessionHandle(sessionHandle.toTSessionHandle, protocol), + protocol, + user, + password, + HiveSQLEngine.hiveConf, + ipAddress, + null, + Seq(ipAddress).asJava) + val proxy = HiveSessionProxy.getProxy(sessionWithUGI, sessionWithUGI.getSessionUgi) + sessionWithUGI.setProxySession(proxy) + proxy + } + hive.setSessionManager(internalSessionManager) + hive.setOperationManager(internalSessionManager.getOperationManager) + operationLogRoot.foreach(dir => hive.setOperationLogSessionDir(new File(dir))) + new HiveSessionImpl( protocol, user, password, - HiveSQLEngine.hiveConf, ipAddress, - null, - Seq(ipAddress).asJava) - val proxy = HiveSessionProxy.getProxy(sessionWithUGI, sessionWithUGI.getSessionUgi) - sessionWithUGI.setProxySession(proxy) - proxy + conf, + this, + sessionHandle, + hive) } - hive.setSessionManager(internalSessionManager) - hive.setOperationManager(internalSessionManager.getOperationManager) - operationLogRoot.foreach(dir => hive.setOperationLogSessionDir(new File(dir))) - new HiveSessionImpl( - protocol, - user, - password, - ipAddress, - conf, - this, - sessionHandle, - hive) + } override def closeSession(sessionHandle: SessionHandle): Unit = { diff --git a/externals/kyuubi-jdbc-engine/pom.xml b/externals/kyuubi-jdbc-engine/pom.xml index 8853cec6421..4bcc4fb601f 100644 --- a/externals/kyuubi-jdbc-engine/pom.xml +++ b/externals/kyuubi-jdbc-engine/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../pom.xml diff --git a/externals/kyuubi-jdbc-engine/src/main/scala/org/apache/kyuubi/engine/jdbc/session/JdbcSessionImpl.scala b/externals/kyuubi-jdbc-engine/src/main/scala/org/apache/kyuubi/engine/jdbc/session/JdbcSessionImpl.scala index 63fb2dd0739..f8cd40412f0 100644 --- a/externals/kyuubi-jdbc-engine/src/main/scala/org/apache/kyuubi/engine/jdbc/session/JdbcSessionImpl.scala +++ b/externals/kyuubi-jdbc-engine/src/main/scala/org/apache/kyuubi/engine/jdbc/session/JdbcSessionImpl.scala @@ -23,8 +23,9 @@ import scala.util.{Failure, Success, Try} import org.apache.hive.service.rpc.thrift.{TGetInfoType, TGetInfoValue, TProtocolVersion} import org.apache.kyuubi.KyuubiSQLException +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY import org.apache.kyuubi.engine.jdbc.connection.ConnectionProvider -import org.apache.kyuubi.session.{AbstractSession, SessionManager} +import org.apache.kyuubi.session.{AbstractSession, SessionHandle, SessionManager} class JdbcSessionImpl( protocol: TProtocolVersion, @@ -35,6 +36,9 @@ class JdbcSessionImpl( sessionManager: SessionManager) extends AbstractSession(protocol, user, password, ipAddress, conf, sessionManager) { + override val handle: SessionHandle = + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).getOrElse(SessionHandle()) + private[jdbc] var sessionConnection: Connection = _ private var databaseMetaData: DatabaseMetaData = _ diff --git a/externals/kyuubi-jdbc-engine/src/main/scala/org/apache/kyuubi/engine/jdbc/session/JdbcSessionManager.scala b/externals/kyuubi-jdbc-engine/src/main/scala/org/apache/kyuubi/engine/jdbc/session/JdbcSessionManager.scala index db8f60c3cae..09958e0507f 100644 --- a/externals/kyuubi-jdbc-engine/src/main/scala/org/apache/kyuubi/engine/jdbc/session/JdbcSessionManager.scala +++ b/externals/kyuubi-jdbc-engine/src/main/scala/org/apache/kyuubi/engine/jdbc/session/JdbcSessionManager.scala @@ -20,6 +20,7 @@ import org.apache.hive.service.rpc.thrift.TProtocolVersion import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf.ENGINE_SHARE_LEVEL +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY import org.apache.kyuubi.engine.ShareLevel import org.apache.kyuubi.engine.jdbc.JdbcSQLEngine import org.apache.kyuubi.engine.jdbc.operation.JdbcOperationManager @@ -46,7 +47,10 @@ class JdbcSessionManager(name: String) password: String, ipAddress: String, conf: Map[String, String]): Session = { - new JdbcSessionImpl(protocol, user, password, ipAddress, conf, this) + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).flatMap( + getSessionOption).getOrElse { + new JdbcSessionImpl(protocol, user, password, ipAddress, conf, this) + } } override def closeSession(sessionHandle: SessionHandle): Unit = { diff --git a/externals/kyuubi-spark-sql-engine/pom.xml b/externals/kyuubi-spark-sql-engine/pom.xml index 0ea3aaaba1c..5b227cb5e29 100644 --- a/externals/kyuubi-spark-sql-engine/pom.xml +++ b/externals/kyuubi-spark-sql-engine/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../pom.xml @@ -193,6 +193,12 @@ jetcd-launcher test + + + com.vladsch.flexmark + flexmark-all + test + diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/SparkTBinaryFrontendService.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/SparkTBinaryFrontendService.scala index d4eaf3454a4..854a28e85a1 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/SparkTBinaryFrontendService.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/SparkTBinaryFrontendService.scala @@ -27,6 +27,7 @@ import org.apache.spark.SparkContext import org.apache.spark.kyuubi.SparkContextHelper import org.apache.kyuubi.{KyuubiSQLException, Logging} +import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiReservedKeys._ import org.apache.kyuubi.ha.client.{EngineServiceDiscovery, ServiceDiscovery} import org.apache.kyuubi.service.{Serverable, Service, TBinaryFrontendService} @@ -94,7 +95,15 @@ class SparkTBinaryFrontendService( } override def attributes: Map[String, String] = { - Map(KYUUBI_ENGINE_ID -> KyuubiSparkUtil.engineId) + val extraAttributes = conf.get(KyuubiConf.ENGINE_SPARK_REGISTER_ATTRIBUTES).map { attr => + attr -> KyuubiSparkUtil.globalSparkContext.getConf.get(attr, "") + }.toMap + val attributes = extraAttributes ++ Map(KYUUBI_ENGINE_ID -> KyuubiSparkUtil.engineId) + // TODO Support Spark Web UI Enabled SSL + sc.uiWebUrl match { + case Some(url) => attributes ++ Map(KYUUBI_ENGINE_URL -> url.split("//").last) + case None => attributes + } } } diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecutePython.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecutePython.scala index e48ff6e5b06..d2627fd99fd 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecutePython.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecutePython.scala @@ -88,9 +88,9 @@ class ExecutePython( val output = response.map(_.content.getOutput()).getOrElse("") val ename = response.map(_.content.getEname()).getOrElse("") val evalue = response.map(_.content.getEvalue()).getOrElse("") - val traceback = response.map(_.content.getTraceback()).getOrElse(Array.empty) + val traceback = response.map(_.content.getTraceback()).getOrElse(Seq.empty) iter = - new ArrayFetchIterator[Row](Array(Row(output, status, ename, evalue, Row(traceback: _*)))) + new ArrayFetchIterator[Row](Array(Row(output, status, ename, evalue, traceback))) setState(OperationState.FINISHED) } else { throw KyuubiSQLException(s"Interpret error:\n$statement\n $response") @@ -210,7 +210,7 @@ case class SessionPythonWorker( stdin.flush() val pythonResponse = Option(stdout.readLine()).map(ExecutePython.fromJson[PythonResponse](_)) // throw exception if internal python code fail - if (internal && pythonResponse.map(_.content.status) != Some(PythonResponse.OK_STATUS)) { + if (internal && !pythonResponse.map(_.content.status).contains(PythonResponse.OK_STATUS)) { throw KyuubiSQLException(s"Internal python code $code failure: $pythonResponse") } pythonResponse @@ -328,7 +328,7 @@ object ExecutePython extends Logging { } // for test - def defaultSparkHome(): String = { + def defaultSparkHome: String = { val homeDirFilter: FilenameFilter = (dir: File, name: String) => dir.isDirectory && name.contains("spark-") && !name.contains("-engine") // get from kyuubi-server/../externals/kyuubi-download/target @@ -418,7 +418,7 @@ case class PythonResponseContent( data: Map[String, String], ename: String, evalue: String, - traceback: Array[String], + traceback: Seq[String], status: String) { def getOutput(): String = { Option(data) @@ -431,7 +431,7 @@ case class PythonResponseContent( def getEvalue(): String = { Option(evalue).getOrElse("") } - def getTraceback(): Array[String] = { - Option(traceback).getOrElse(Array.empty) + def getTraceback(): Seq[String] = { + Option(traceback).getOrElse(Seq.empty) } } diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecuteScala.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecuteScala.scala index 0f63dcc067f..ff686cca0d0 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecuteScala.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecuteScala.scala @@ -112,7 +112,7 @@ class ExecuteScala( new ArrayFetchIterator[Row](result.collect()) } else { val output = repl.getOutput - info("scala repl output:\n" + output) + debug("scala repl output:\n" + output) new ArrayFetchIterator[Row](Array(Row(output))) } } diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecuteStatement.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecuteStatement.scala index 2cdc2b50083..b29d2ca9a7e 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecuteStatement.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/ExecuteStatement.scala @@ -21,14 +21,16 @@ import java.util.concurrent.RejectedExecutionException import scala.collection.JavaConverters._ -import org.apache.spark.sql.{DataFrame, Row} +import org.apache.spark.rdd.RDD +import org.apache.spark.sql.DataFrame +import org.apache.spark.sql.execution.SQLExecution import org.apache.spark.sql.kyuubi.SparkDatasetHelper import org.apache.spark.sql.types._ import org.apache.kyuubi.{KyuubiSQLException, Logging} import org.apache.kyuubi.config.KyuubiConf.OPERATION_RESULT_MAX_ROWS import org.apache.kyuubi.engine.spark.KyuubiSparkUtil._ -import org.apache.kyuubi.operation.{ArrayFetchIterator, IterableFetchIterator, OperationState} +import org.apache.kyuubi.operation.{ArrayFetchIterator, FetchIterator, IterableFetchIterator, OperationHandle, OperationState} import org.apache.kyuubi.operation.log.OperationLog import org.apache.kyuubi.session.Session @@ -37,7 +39,8 @@ class ExecuteStatement( override val statement: String, override val shouldRunAsync: Boolean, queryTimeout: Long, - incrementalCollect: Boolean) + incrementalCollect: Boolean, + override protected val handle: OperationHandle) extends SparkOperation(session) with Logging { private val operationLog: OperationLog = OperationLog.createOperationLog(session, getHandle) @@ -62,51 +65,26 @@ class ExecuteStatement( OperationLog.removeCurrentOperationLog() } - private def executeStatement(): Unit = withLocalProperties { + protected def incrementalCollectResult(resultDF: DataFrame): Iterator[Any] = { + resultDF.toLocalIterator().asScala + } + + protected def fullCollectResult(resultDF: DataFrame): Array[_] = { + resultDF.collect() + } + + protected def takeResult(resultDF: DataFrame, maxRows: Int): Array[_] = { + resultDF.take(maxRows) + } + + protected def executeStatement(): Unit = withLocalProperties { try { setState(OperationState.RUNNING) info(diagnostics) Thread.currentThread().setContextClassLoader(spark.sharedState.jarClassLoader) addOperationListener() result = spark.sql(statement) - - iter = - if (incrementalCollect) { - info("Execute in incremental collect mode") - if (arrowEnabled) { - new IterableFetchIterator[Array[Byte]](new Iterable[Array[Byte]] { - override def iterator: Iterator[Array[Byte]] = SparkDatasetHelper.toArrowBatchRdd( - convertComplexType(result)).toLocalIterator - }) - } else { - new IterableFetchIterator[Row](new Iterable[Row] { - override def iterator: Iterator[Row] = result.toLocalIterator().asScala - }) - } - } else { - val resultMaxRows = spark.conf.getOption(OPERATION_RESULT_MAX_ROWS.key).map(_.toInt) - .getOrElse(session.sessionManager.getConf.get(OPERATION_RESULT_MAX_ROWS)) - if (resultMaxRows <= 0) { - info("Execute in full collect mode") - if (arrowEnabled) { - new ArrayFetchIterator( - SparkDatasetHelper.toArrowBatchRdd( - convertComplexType(result)).collect()) - } else { - new ArrayFetchIterator(result.collect()) - } - } else { - info(s"Execute with max result rows[$resultMaxRows]") - if (arrowEnabled) { - // this will introduce shuffle and hurt performance - new ArrayFetchIterator( - SparkDatasetHelper.toArrowBatchRdd( - convertComplexType(result.limit(resultMaxRows))).collect()) - } else { - new ArrayFetchIterator(result.take(resultMaxRows)) - } - } - } + iter = collectAsIterator(result) setCompiledStateIfNeeded() setState(OperationState.FINISHED) } catch { @@ -164,17 +142,87 @@ class ExecuteStatement( } } - // TODO:(fchen) make this configurable - val kyuubiBeelineConvertToString = true - - def convertComplexType(df: DataFrame): DataFrame = { - if (kyuubiBeelineConvertToString) { - SparkDatasetHelper.convertTopLevelComplexTypeToHiveString(df) + override def getResultSetMetadataHints(): Seq[String] = + Seq( + s"__kyuubi_operation_result_format__=$resultFormat", + s"__kyuubi_operation_result_arrow_timestampAsString__=$timestampAsString") + + private def collectAsIterator(resultDF: DataFrame): FetchIterator[_] = { + val resultMaxRows = spark.conf.getOption(OPERATION_RESULT_MAX_ROWS.key).map(_.toInt) + .getOrElse(session.sessionManager.getConf.get(OPERATION_RESULT_MAX_ROWS)) + if (incrementalCollect) { + if (resultMaxRows > 0) { + warn(s"Ignore ${OPERATION_RESULT_MAX_ROWS.key} on incremental collect mode.") + } + info("Execute in incremental collect mode") + new IterableFetchIterator[Any](new Iterable[Any] { + override def iterator: Iterator[Any] = incrementalCollectResult(resultDF) + }) } else { - df + val internalArray = if (resultMaxRows <= 0) { + info("Execute in full collect mode") + fullCollectResult(resultDF) + } else { + info(s"Execute with max result rows[$resultMaxRows]") + takeResult(resultDF, resultMaxRows) + } + new ArrayFetchIterator(internalArray) } } +} + +class ArrowBasedExecuteStatement( + session: Session, + override val statement: String, + override val shouldRunAsync: Boolean, + queryTimeout: Long, + incrementalCollect: Boolean, + override protected val handle: OperationHandle) + extends ExecuteStatement( + session, + statement, + shouldRunAsync, + queryTimeout, + incrementalCollect, + handle) { + + override protected def incrementalCollectResult(resultDF: DataFrame): Iterator[Any] = { + collectAsArrow(convertComplexType(resultDF)) { rdd => + rdd.toLocalIterator + } + } + + override protected def fullCollectResult(resultDF: DataFrame): Array[_] = { + collectAsArrow(convertComplexType(resultDF)) { rdd => + rdd.collect() + } + } + + override protected def takeResult(resultDF: DataFrame, maxRows: Int): Array[_] = { + // this will introduce shuffle and hurt performance + val limitedResult = resultDF.limit(maxRows) + collectAsArrow(convertComplexType(limitedResult)) { rdd => + rdd.collect() + } + } + + /** + * refer to org.apache.spark.sql.Dataset#withAction(), assign a new execution id for arrow-based + * operation, so that we can track the arrow-based queries on the UI tab. + */ + private def collectAsArrow[T](df: DataFrame)(action: RDD[Array[Byte]] => T): T = { + SQLExecution.withNewExecutionId(df.queryExecution, Some("collectAsArrow")) { + df.queryExecution.executedPlan.resetMetrics() + action(SparkDatasetHelper.toArrowBatchRdd(df)) + } + } + + override protected def isArrowBasedOperation: Boolean = true + + override val resultFormat = "arrow" + + private def convertComplexType(df: DataFrame): DataFrame = { + SparkDatasetHelper.convertTopLevelComplexTypeToHiveString(df, timestampAsString) + } - override def getResultSetMetadataHints(): Seq[String] = - Seq(s"__kyuubi_operation_result_format__=$resultFormat") } diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/GetTables.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/GetTables.scala index 4093c61c100..40642b825b9 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/GetTables.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/GetTables.scala @@ -19,6 +19,7 @@ package org.apache.kyuubi.engine.spark.operation import org.apache.spark.sql.types.StructType +import org.apache.kyuubi.config.KyuubiConf.OPERATION_GET_TABLES_IGNORE_TABLE_PROPERTIES import org.apache.kyuubi.engine.spark.shim.SparkCatalogShim import org.apache.kyuubi.operation.IterableFetchIterator import org.apache.kyuubi.operation.meta.ResultSetSchemaConstant._ @@ -32,6 +33,12 @@ class GetTables( tableTypes: Set[String]) extends SparkOperation(session) { + protected val ignoreTableProperties = + spark.conf.getOption(OPERATION_GET_TABLES_IGNORE_TABLE_PROPERTIES.key) match { + case Some(s) => s.toBoolean + case _ => session.sessionManager.getConf.get(OPERATION_GET_TABLES_IGNORE_TABLE_PROPERTIES) + } + override def statement: String = { super.statement + s" [catalog: $catalog," + @@ -68,7 +75,13 @@ class GetTables( val tablePattern = toJavaRegex(tableName) val sparkShim = SparkCatalogShim() val catalogTablesAndViews = - sparkShim.getCatalogTablesOrViews(spark, catalog, schemaPattern, tablePattern, tableTypes) + sparkShim.getCatalogTablesOrViews( + spark, + catalog, + schemaPattern, + tablePattern, + tableTypes, + ignoreTableProperties) val allTableAndViews = if (tableTypes.exists("VIEW".equalsIgnoreCase)) { diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/SparkOperation.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/SparkOperation.scala index 842ff944f34..eb58407d47c 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/SparkOperation.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/SparkOperation.scala @@ -24,6 +24,7 @@ import org.apache.hive.service.rpc.thrift.{TGetResultSetMetadataResp, TProgressU import org.apache.spark.kyuubi.{SparkProgressMonitor, SQLOperationListener} import org.apache.spark.kyuubi.SparkUtilsHelper.redact import org.apache.spark.sql.{DataFrame, Row, SparkSession} +import org.apache.spark.sql.execution.SQLExecution import org.apache.spark.sql.types.StructType import org.apache.kyuubi.{KyuubiSQLException, Utils} @@ -135,27 +136,35 @@ abstract class SparkOperation(session: Session) spark.sparkContext.setLocalProperty protected def withLocalProperties[T](f: => T): T = { - try { - spark.sparkContext.setJobGroup(statementId, redactedStatement, forceCancel) - spark.sparkContext.setLocalProperty(KYUUBI_SESSION_USER_KEY, session.user) - spark.sparkContext.setLocalProperty(KYUUBI_STATEMENT_ID_KEY, statementId) - schedulerPool match { - case Some(pool) => - spark.sparkContext.setLocalProperty(SPARK_SCHEDULER_POOL_KEY, pool) - case None => - } - if (isSessionUserSignEnabled) { - setSessionUserSign() - } + SQLExecution.withSQLConfPropagated(spark) { + val originalSession = SparkSession.getActiveSession + try { + SparkSession.setActiveSession(spark) + spark.sparkContext.setJobGroup(statementId, redactedStatement, forceCancel) + spark.sparkContext.setLocalProperty(KYUUBI_SESSION_USER_KEY, session.user) + spark.sparkContext.setLocalProperty(KYUUBI_STATEMENT_ID_KEY, statementId) + schedulerPool match { + case Some(pool) => + spark.sparkContext.setLocalProperty(SPARK_SCHEDULER_POOL_KEY, pool) + case None => + } + if (isSessionUserSignEnabled) { + setSessionUserSign() + } - f - } finally { - spark.sparkContext.setLocalProperty(SPARK_SCHEDULER_POOL_KEY, null) - spark.sparkContext.setLocalProperty(KYUUBI_SESSION_USER_KEY, null) - spark.sparkContext.setLocalProperty(KYUUBI_STATEMENT_ID_KEY, null) - spark.sparkContext.clearJobGroup() - if (isSessionUserSignEnabled) { - clearSessionUserSign() + f + } finally { + spark.sparkContext.setLocalProperty(SPARK_SCHEDULER_POOL_KEY, null) + spark.sparkContext.setLocalProperty(KYUUBI_SESSION_USER_KEY, null) + spark.sparkContext.setLocalProperty(KYUUBI_STATEMENT_ID_KEY, null) + spark.sparkContext.clearJobGroup() + if (isSessionUserSignEnabled) { + clearSessionUserSign() + } + originalSession match { + case Some(session) => SparkSession.setActiveSession(session) + case None => SparkSession.clearActiveSession() + } } } } @@ -236,7 +245,7 @@ abstract class SparkOperation(session: Session) case FETCH_FIRST => iter.fetchAbsolute(0); } resultRowSet = - if (arrowEnabled) { + if (isArrowBasedOperation) { if (iter.hasNext) { val taken = iter.next().asInstanceOf[Array[Byte]] RowSet.toTRowSet(taken, getProtocolVersion) @@ -246,10 +255,9 @@ abstract class SparkOperation(session: Session) } else { val taken = iter.take(rowSetSize) RowSet.toTRowSet( - taken.toList.asInstanceOf[List[Row]], + taken.toSeq.asInstanceOf[Seq[Row]], resultSchema, - getProtocolVersion, - timeZone) + getProtocolVersion) } resultRowSet.setStartRowOffset(iter.getPosition) } catch onError(cancel = true) @@ -259,15 +267,12 @@ abstract class SparkOperation(session: Session) override def shouldRunAsync: Boolean = false - protected def arrowEnabled(): Boolean = { - resultFormat().equalsIgnoreCase("arrow") && - // TODO: (fchen) make all operation support arrow - getClass.getCanonicalName == classOf[ExecuteStatement].getCanonicalName - } + protected def isArrowBasedOperation: Boolean = false + + protected def resultFormat: String = "thrift" - protected def resultFormat(): String = { - // TODO: respect the config of the operation ExecuteStatement, if it was set. - spark.conf.get("kyuubi.operation.result.format", "thrift") + protected def timestampAsString: Boolean = { + spark.conf.get("kyuubi.operation.result.arrow.timestampAsString", "false").toBoolean } protected def setSessionUserSign(): Unit = { diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/SparkSQLOperationManager.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/SparkSQLOperationManager.scala index 5c5ed0f9868..8fd58b33875 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/SparkSQLOperationManager.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/operation/SparkSQLOperationManager.scala @@ -23,10 +23,11 @@ import scala.collection.JavaConverters._ import org.apache.kyuubi.KyuubiSQLException import org.apache.kyuubi.config.KyuubiConf._ +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_OPERATION_HANDLE_KEY import org.apache.kyuubi.engine.spark.repl.KyuubiSparkILoop import org.apache.kyuubi.engine.spark.session.SparkSessionImpl import org.apache.kyuubi.engine.spark.shim.SparkCatalogShim -import org.apache.kyuubi.operation.{NoneMode, Operation, OperationManager, PlanOnlyMode} +import org.apache.kyuubi.operation.{NoneMode, Operation, OperationHandle, OperationManager, PlanOnlyMode} import org.apache.kyuubi.session.{Session, SessionHandle} class SparkSQLOperationManager private (name: String) extends OperationManager(name) { @@ -70,6 +71,8 @@ class SparkSQLOperationManager private (name: String) extends OperationManager(n val lang = OperationLanguages(confOverlay.getOrElse( OPERATION_LANGUAGE.key, spark.conf.get(OPERATION_LANGUAGE.key, operationLanguageDefault))) + val opHandle = confOverlay.get(KYUUBI_OPERATION_HANDLE_KEY).map( + OperationHandle.apply).getOrElse(OperationHandle()) val operation = lang match { case OperationLanguages.SQL => @@ -82,7 +85,26 @@ class SparkSQLOperationManager private (name: String) extends OperationManager(n case NoneMode => val incrementalCollect = spark.conf.getOption(OPERATION_INCREMENTAL_COLLECT.key) .map(_.toBoolean).getOrElse(operationIncrementalCollectDefault) - new ExecuteStatement(session, statement, runAsync, queryTimeout, incrementalCollect) + // TODO: respect the config of the operation ExecuteStatement, if it was set. + val resultFormat = spark.conf.get("kyuubi.operation.result.format", "thrift") + resultFormat.toLowerCase match { + case "arrow" => + new ArrowBasedExecuteStatement( + session, + statement, + runAsync, + queryTimeout, + incrementalCollect, + opHandle) + case _ => + new ExecuteStatement( + session, + statement, + runAsync, + queryTimeout, + incrementalCollect, + opHandle) + } case mode => new PlanOnlyStatement(session, statement, mode) } diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/schema/RowSet.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/schema/RowSet.scala index 8cc88156ba5..4f935ce49f0 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/schema/RowSet.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/schema/RowSet.scala @@ -18,22 +18,24 @@ package org.apache.kyuubi.engine.spark.schema import java.nio.ByteBuffer -import java.nio.charset.StandardCharsets -import java.sql.Timestamp -import java.time._ -import java.util.Date import scala.collection.JavaConverters._ import org.apache.hive.service.rpc.thrift._ import org.apache.spark.sql.Row +import org.apache.spark.sql.execution.HiveResult import org.apache.spark.sql.types._ -import org.apache.kyuubi.engine.spark.schema.SchemaHelper.TIMESTAMP_NTZ import org.apache.kyuubi.util.RowSetUtils._ object RowSet { + def toHiveString(valueAndType: (Any, DataType), nested: Boolean = false): String = { + // compatible w/ Spark 3.1 and above + val timeFormatters = HiveResult.getTimeFormatters + HiveResult.toHiveString(valueAndType, nested, timeFormatters) + } + def toTRowSet( bytes: Array[Byte], protocolVersion: TProtocolVersion): TRowSet = { @@ -58,26 +60,25 @@ object RowSet { def toTRowSet( rows: Seq[Row], schema: StructType, - protocolVersion: TProtocolVersion, - timeZone: ZoneId): TRowSet = { + protocolVersion: TProtocolVersion): TRowSet = { if (protocolVersion.getValue < TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V6.getValue) { - toRowBasedSet(rows, schema, timeZone) + toRowBasedSet(rows, schema) } else { - toColumnBasedSet(rows, schema, timeZone) + toColumnBasedSet(rows, schema) } } - def toRowBasedSet(rows: Seq[Row], schema: StructType, timeZone: ZoneId): TRowSet = { - var i = 0 + def toRowBasedSet(rows: Seq[Row], schema: StructType): TRowSet = { val rowSize = rows.length val tRows = new java.util.ArrayList[TRow](rowSize) + var i = 0 while (i < rowSize) { val row = rows(i) val tRow = new TRow() var j = 0 val columnSize = row.length while (j < columnSize) { - val columnValue = toTColumnValue(j, row, schema, timeZone) + val columnValue = toTColumnValue(j, row, schema) tRow.addToColVals(columnValue) j += 1 } @@ -87,21 +88,21 @@ object RowSet { new TRowSet(0, tRows) } - def toColumnBasedSet(rows: Seq[Row], schema: StructType, timeZone: ZoneId): TRowSet = { + def toColumnBasedSet(rows: Seq[Row], schema: StructType): TRowSet = { val rowSize = rows.length val tRowSet = new TRowSet(0, new java.util.ArrayList[TRow](rowSize)) var i = 0 val columnSize = schema.length while (i < columnSize) { val field = schema(i) - val tColumn = toTColumn(rows, i, field.dataType, timeZone) + val tColumn = toTColumn(rows, i, field.dataType) tRowSet.addToColumns(tColumn) i += 1 } tRowSet } - private def toTColumn(rows: Seq[Row], ordinal: Int, typ: DataType, timeZone: ZoneId): TColumn = { + private def toTColumn(rows: Seq[Row], ordinal: Int, typ: DataType): TColumn = { val nulls = new java.util.BitSet() typ match { case BooleanType => @@ -151,13 +152,7 @@ object RowSet { while (i < rowSize) { val row = rows(i) nulls.set(i, row.isNullAt(ordinal)) - val value = - if (row.isNullAt(ordinal)) { - "" - } else { - toHiveString((row.get(ordinal), typ), timeZone) - } - values.add(value) + values.add(toHiveString(row.get(ordinal) -> typ)) i += 1 } TColumn.stringVal(new TStringColumn(values, nulls)) @@ -189,8 +184,7 @@ object RowSet { private def toTColumnValue( ordinal: Int, row: Row, - types: StructType, - timeZone: ZoneId): TColumnValue = { + types: StructType): TColumnValue = { types(ordinal).dataType match { case BooleanType => val boolValue = new TBoolValue @@ -238,69 +232,12 @@ object RowSet { case _ => val tStrValue = new TStringValue if (!row.isNullAt(ordinal)) { - tStrValue.setValue( - toHiveString((row.get(ordinal), types(ordinal).dataType), timeZone)) + tStrValue.setValue(toHiveString(row.get(ordinal) -> types(ordinal).dataType)) } TColumnValue.stringVal(tStrValue) } } - /** - * A simpler impl of Spark's toHiveString - */ - def toHiveString(dataWithType: (Any, DataType), timeZone: ZoneId): String = { - dataWithType match { - case (null, _) => - // Only match nulls in nested type values - "null" - - case (d: Date, DateType) => - formatDate(d) - - case (ld: LocalDate, DateType) => - formatLocalDate(ld) - - case (t: Timestamp, TimestampType) => - formatTimestamp(t, Option(timeZone)) - - case (t: LocalDateTime, ntz) if ntz.getClass.getSimpleName.equals(TIMESTAMP_NTZ) => - formatLocalDateTime(t) - - case (i: Instant, TimestampType) => - formatInstant(i, Option(timeZone)) - - case (bin: Array[Byte], BinaryType) => - new String(bin, StandardCharsets.UTF_8) - - case (decimal: java.math.BigDecimal, DecimalType()) => - decimal.toPlainString - - case (s: String, StringType) => - // Only match string in nested type values - "\"" + s + "\"" - - case (d: Duration, _) => toDayTimeIntervalString(d) - - case (p: Period, _) => toYearMonthIntervalString(p) - - case (seq: scala.collection.Seq[_], ArrayType(typ, _)) => - seq.map(v => (v, typ)).map(e => toHiveString(e, timeZone)).mkString("[", ",", "]") - - case (m: Map[_, _], MapType(kType, vType, _)) => - m.map { case (key, value) => - toHiveString((key, kType), timeZone) + ":" + toHiveString((value, vType), timeZone) - }.toSeq.sorted.mkString("{", ",", "}") - - case (struct: Row, StructType(fields)) => - struct.toSeq.zip(fields).map { case (v, t) => - s""""${t.name}":${toHiveString((v, t.dataType), timeZone)}""" - }.mkString("{", ",", "}") - - case (other, _) => - other.toString - } - } - private def toTColumn(data: Array[Byte]): TColumn = { val values = new java.util.ArrayList[ByteBuffer](1) values.add(ByteBuffer.wrap(data)) diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/session/SparkSQLSessionManager.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/session/SparkSQLSessionManager.scala index 76c6a65050d..79f38ce35a4 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/session/SparkSQLSessionManager.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/session/SparkSQLSessionManager.scala @@ -20,10 +20,12 @@ package org.apache.kyuubi.engine.spark.session import java.util.concurrent.{ScheduledExecutorService, TimeUnit} import org.apache.hive.service.rpc.thrift.TProtocolVersion +import org.apache.spark.api.python.KyuubiPythonGatewayServer import org.apache.spark.sql.SparkSession import org.apache.kyuubi.KyuubiSQLException import org.apache.kyuubi.config.KyuubiConf._ +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY import org.apache.kyuubi.engine.ShareLevel import org.apache.kyuubi.engine.ShareLevel._ import org.apache.kyuubi.engine.spark.{KyuubiSparkUtil, SparkSQLEngine} @@ -93,6 +95,7 @@ class SparkSQLSessionManager private (name: String, spark: SparkSession) override def stop(): Unit = { super.stop() + KyuubiPythonGatewayServer.shutdown() userIsolatedSparkSessionThread.foreach(_.shutdown()) } @@ -135,21 +138,24 @@ class SparkSQLSessionManager private (name: String, spark: SparkSession) password: String, ipAddress: String, conf: Map[String, String]): Session = { - val sparkSession = - try { - getOrNewSparkSession(user) - } catch { - case e: Exception => throw KyuubiSQLException(e) - } + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).flatMap( + getSessionOption).getOrElse { + val sparkSession = + try { + getOrNewSparkSession(user) + } catch { + case e: Exception => throw KyuubiSQLException(e) + } - new SparkSessionImpl( - protocol, - user, - password, - ipAddress, - conf, - this, - sparkSession) + new SparkSessionImpl( + protocol, + user, + password, + ipAddress, + conf, + this, + sparkSession) + } } override def closeSession(sessionHandle: SessionHandle): Unit = { @@ -164,7 +170,12 @@ class SparkSQLSessionManager private (name: String, spark: SparkSession) } } } - super.closeSession(sessionHandle) + try { + super.closeSession(sessionHandle) + } catch { + case e: KyuubiSQLException => + warn(s"Error closing session ${sessionHandle}", e) + } if (shareLevel == ShareLevel.CONNECTION) { info("Session stopped due to shared level is Connection.") stopSession() diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/session/SparkSessionImpl.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/session/SparkSessionImpl.scala index 5bf1ec08472..78164ff5fab 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/session/SparkSessionImpl.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/session/SparkSessionImpl.scala @@ -21,13 +21,14 @@ import org.apache.hive.service.rpc.thrift.{TGetInfoType, TGetInfoValue, TProtoco import org.apache.spark.sql.{AnalysisException, SparkSession} import org.apache.kyuubi.KyuubiSQLException +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY import org.apache.kyuubi.engine.spark.events.SessionEvent import org.apache.kyuubi.engine.spark.operation.SparkSQLOperationManager import org.apache.kyuubi.engine.spark.shim.SparkCatalogShim import org.apache.kyuubi.engine.spark.udf.KDFRegistry import org.apache.kyuubi.events.EventBus import org.apache.kyuubi.operation.{Operation, OperationHandle} -import org.apache.kyuubi.session.{AbstractSession, SessionManager} +import org.apache.kyuubi.session.{AbstractSession, SessionHandle, SessionManager} class SparkSessionImpl( protocol: TProtocolVersion, @@ -39,6 +40,9 @@ class SparkSessionImpl( val spark: SparkSession) extends AbstractSession(protocol, user, password, ipAddress, conf, sessionManager) { + override val handle: SessionHandle = + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).getOrElse(SessionHandle()) + private def setModifiableConfig(key: String, value: String): Unit = { try { spark.conf.set(key, value) diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/CatalogShim_v2_4.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/CatalogShim_v2_4.scala index 5977cd415b0..ea72dd1563c 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/CatalogShim_v2_4.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/CatalogShim_v2_4.scala @@ -41,7 +41,7 @@ class CatalogShim_v2_4 extends SparkCatalogShim { catalogName: String, schemaPattern: String): Seq[Row] = { (spark.sessionState.catalog.listDatabases(schemaPattern) ++ - getGlobalTempViewManager(spark, schemaPattern)).map(Row(_, "")) + getGlobalTempViewManager(spark, schemaPattern)).map(Row(_, SparkCatalogShim.SESSION_CATALOG)) } def setCurrentDatabase(spark: SparkSession, databaseName: String): Unit = { @@ -64,7 +64,8 @@ class CatalogShim_v2_4 extends SparkCatalogShim { catalogName: String, schemaPattern: String, tablePattern: String, - tableTypes: Set[String]): Seq[Row] = { + tableTypes: Set[String], + ignoreTableProperties: Boolean): Seq[Row] = { val catalog = spark.sessionState.catalog val databases = catalog.listDatabases(schemaPattern) @@ -139,13 +140,7 @@ class CatalogShim_v2_4 extends SparkCatalogShim { databases.flatMap { db => val identifiers = catalog.listTables(db, tablePattern, includeLocalTempViews = true) catalog.getTablesByName(identifiers).flatMap { t => - val tableSchema = - if (t.provider.getOrElse("").equalsIgnoreCase("delta")) { - spark.table(t.identifier.table).schema - } else { - t.schema - } - tableSchema.zipWithIndex.filter(f => columnPattern.matcher(f._1.name).matches()) + t.schema.zipWithIndex.filter(f => columnPattern.matcher(f._1.name).matches()) .map { case (f, i) => toColumnResult(catalogName, t.database, t.identifier.table, f, i) } } } diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/CatalogShim_v3_0.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/CatalogShim_v3_0.scala index d60f94ac755..27c524f3032 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/CatalogShim_v3_0.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/CatalogShim_v3_0.scala @@ -129,13 +129,12 @@ class CatalogShim_v3_0 extends CatalogShim_v2_4 { spark: SparkSession, catalogName: String, schemaPattern: String): Seq[Row] = { - val catalog = getCatalog(spark, catalogName) - var schemas = getSchemasWithPattern(catalog, schemaPattern) if (catalogName == SparkCatalogShim.SESSION_CATALOG) { - val viewMgr = getGlobalTempViewManager(spark, schemaPattern) - schemas = schemas ++ viewMgr + super.getSchemas(spark, catalogName, schemaPattern) + } else { + val catalog = getCatalog(spark, catalogName) + getSchemasWithPattern(catalog, schemaPattern).map(Row(_, catalog.name)) } - schemas.map(Row(_, catalog.name)) } override def setCurrentDatabase(spark: SparkSession, databaseName: String): Unit = { @@ -151,7 +150,8 @@ class CatalogShim_v3_0 extends CatalogShim_v2_4 { catalogName: String, schemaPattern: String, tablePattern: String, - tableTypes: Set[String]): Seq[Row] = { + tableTypes: Set[String], + ignoreTableProperties: Boolean = false): Seq[Row] = { val catalog = getCatalog(spark, catalogName) val namespaces = listNamespacesWithPattern(catalog, schemaPattern) catalog match { @@ -161,16 +161,17 @@ class CatalogShim_v3_0 extends CatalogShim_v2_4 { SESSION_CATALOG, schemaPattern, tablePattern, - tableTypes) + tableTypes, + ignoreTableProperties) case tc: TableCatalog => val tp = tablePattern.r.pattern val identifiers = namespaces.flatMap { ns => tc.listTables(ns).filter(i => tp.matcher(quoteIfNeeded(i.name())).matches()) } identifiers.map { ident => - val table = tc.loadTable(ident) // TODO: restore view type for session catalog - val comment = table.properties().getOrDefault(TableCatalog.PROP_COMMENT, "") + val comment = if (ignoreTableProperties) "" + else tc.loadTable(ident).properties().getOrDefault(TableCatalog.PROP_COMMENT, "") val schema = ident.namespace().map(quoteIfNeeded).mkString(".") val tableName = quoteIfNeeded(ident.name()) Row(catalog.name(), schema, tableName, "TABLE", comment, null, null, null, null, null) @@ -188,14 +189,6 @@ class CatalogShim_v3_0 extends CatalogShim_v2_4 { val catalog = getCatalog(spark, catalogName) catalog match { - case builtin if builtin.name() == SESSION_CATALOG => - super.getColumnsByCatalog( - spark, - SESSION_CATALOG, - schemaPattern, - tablePattern, - columnPattern) - case tc: TableCatalog => val namespaces = listNamespacesWithPattern(catalog, schemaPattern) val tp = tablePattern.r.pattern @@ -210,6 +203,14 @@ class CatalogShim_v3_0 extends CatalogShim_v2_4 { table.schema.zipWithIndex.filter(f => columnPattern.matcher(f._1.name).matches()) .map { case (f, i) => toColumnResult(tc.name(), namespace, tableName, f, i) } } + + case builtin if builtin.name() == SESSION_CATALOG => + super.getColumnsByCatalog( + spark, + SESSION_CATALOG, + schemaPattern, + tablePattern, + columnPattern) } } } diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/SparkCatalogShim.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/SparkCatalogShim.scala index bc5792823f7..83c80652380 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/SparkCatalogShim.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/kyuubi/engine/spark/shim/SparkCatalogShim.scala @@ -69,7 +69,8 @@ trait SparkCatalogShim extends Logging { catalogName: String, schemaPattern: String, tablePattern: String, - tableTypes: Set[String]): Seq[Row] + tableTypes: Set[String], + ignoreTableProperties: Boolean): Seq[Row] def getTempViews( spark: SparkSession, diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/api/python/KyuubiPythonGatewayServer.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/api/python/KyuubiPythonGatewayServer.scala index 7e15ffe05a6..8cf8d685c86 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/api/python/KyuubiPythonGatewayServer.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/api/python/KyuubiPythonGatewayServer.scala @@ -30,10 +30,12 @@ object KyuubiPythonGatewayServer extends Logging { val CONNECTION_FILE_PATH = Utils.createTempDir() + "/connection.info" - def start(): Unit = { + private var gatewayServer: Py4JServer = _ + + def start(): Unit = synchronized { val sparkConf = new SparkConf() - val gatewayServer: Py4JServer = new Py4JServer(sparkConf) + gatewayServer = new Py4JServer(sparkConf) gatewayServer.start() val boundPort: Int = gatewayServer.getListeningPort @@ -65,4 +67,11 @@ object KyuubiPythonGatewayServer extends Logging { System.exit(1) } } + + def shutdown(): Unit = synchronized { + if (gatewayServer != null) { + logInfo("shutting down the python gateway server.") + gatewayServer.shutdown() + } + } } diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/sql/kyuubi/SparkDatasetHelper.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/sql/kyuubi/SparkDatasetHelper.scala index 23f7df21310..1a542937338 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/sql/kyuubi/SparkDatasetHelper.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/sql/kyuubi/SparkDatasetHelper.scala @@ -17,12 +17,10 @@ package org.apache.spark.sql.kyuubi -import java.time.ZoneId - import org.apache.spark.rdd.RDD import org.apache.spark.sql.{DataFrame, Dataset, Row} import org.apache.spark.sql.functions._ -import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StructField, StructType} +import org.apache.spark.sql.types._ import org.apache.kyuubi.engine.spark.schema.RowSet @@ -31,21 +29,24 @@ object SparkDatasetHelper { ds.toArrowBatchRdd } - def convertTopLevelComplexTypeToHiveString(df: DataFrame): DataFrame = { - val timeZone = ZoneId.of(df.sparkSession.sessionState.conf.sessionLocalTimeZone) + def convertTopLevelComplexTypeToHiveString( + df: DataFrame, + timestampAsString: Boolean): DataFrame = { val quotedCol = (name: String) => col(quoteIfNeeded(name)) - // an udf to call `RowSet.toHiveString` on complex types(struct/array/map). + // an udf to call `RowSet.toHiveString` on complex types(struct/array/map) and timestamp type. val toHiveStringUDF = udf[String, Row, String]((row, schemaDDL) => { val dt = DataType.fromDDL(schemaDDL) dt match { case StructType(Array(StructField(_, st: StructType, _, _))) => - RowSet.toHiveString((row, st), timeZone) + RowSet.toHiveString((row, st), nested = true) case StructType(Array(StructField(_, at: ArrayType, _, _))) => - RowSet.toHiveString((row.toSeq.head, at), timeZone) + RowSet.toHiveString((row.toSeq.head, at), nested = true) case StructType(Array(StructField(_, mt: MapType, _, _))) => - RowSet.toHiveString((row.toSeq.head, mt), timeZone) + RowSet.toHiveString((row.toSeq.head, mt), nested = true) + case StructType(Array(StructField(_, tt: TimestampType, _, _))) => + RowSet.toHiveString((row.toSeq.head, tt), nested = true) case _ => throw new UnsupportedOperationException } @@ -54,7 +55,9 @@ object SparkDatasetHelper { val cols = df.schema.map { case sf @ StructField(name, _: StructType, _, _) => toHiveStringUDF(quotedCol(name), lit(sf.toDDL)).as(name) - case sf @ StructField(name, (_: MapType | _: ArrayType), _, _) => + case sf @ StructField(name, _: MapType | _: ArrayType, _, _) => + toHiveStringUDF(struct(quotedCol(name)), lit(sf.toDDL)).as(name) + case sf @ StructField(name, _: TimestampType, _, _) if timestampAsString => toHiveStringUDF(struct(quotedCol(name)), lit(sf.toDDL)).as(name) case StructField(name, _, _, _) => quotedCol(name) } diff --git a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/ui/EnginePage.scala b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/ui/EnginePage.scala index 0aba0c7c588..a2a2931f411 100644 --- a/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/ui/EnginePage.scala +++ b/externals/kyuubi-spark-sql-engine/src/main/scala/org/apache/spark/ui/EnginePage.scala @@ -84,6 +84,10 @@ case class EnginePage(parent: EngineTab) extends WebUIPage("") { Background execution pool threads active: {engine.backendService.sessionManager.getActiveCount} +
    • + Background execution pool work queue size: + {engine.backendService.sessionManager.getWorkQueueSize} +
    • }.getOrElse(Seq.empty) }
    diff --git a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/SparkEngineRegisterSuite.scala b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/SparkEngineRegisterSuite.scala new file mode 100644 index 00000000000..8c636af7612 --- /dev/null +++ b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/SparkEngineRegisterSuite.scala @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.spark + +import java.util.UUID + +import org.apache.kyuubi.config.KyuubiReservedKeys.{KYUUBI_ENGINE_ID, KYUUBI_ENGINE_URL} + +trait SparkEngineRegisterSuite extends WithDiscoverySparkSQLEngine { + + override def withKyuubiConf: Map[String, String] = + super.withKyuubiConf ++ Map("spark.ui.enabled" -> "true") + + override val namespace: String = s"/kyuubi/deregister_test/${UUID.randomUUID.toString}" + + test("Spark Engine Register Zookeeper with spark ui info") { + withDiscoveryClient(client => { + val info = client.getChildren(namespace).head.split(";") + assert(info.exists(_.startsWith(KYUUBI_ENGINE_ID))) + assert(info.exists(_.startsWith(KYUUBI_ENGINE_URL))) + }) + } +} + +class ZookeeperSparkEngineRegisterSuite extends SparkEngineRegisterSuite + with WithEmbeddedZookeeper { + + override def withKyuubiConf: Map[String, String] = + super.withKyuubiConf ++ zookeeperConf +} + +class EtcdSparkEngineRegisterSuite extends SparkEngineRegisterSuite + with WithEtcdCluster { + override def withKyuubiConf: Map[String, String] = super.withKyuubiConf ++ etcdConf +} diff --git a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/SparkArrowbasedOperationSuite.scala b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/SparkArrowbasedOperationSuite.scala index e464569147c..ae6237bb59c 100644 --- a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/SparkArrowbasedOperationSuite.scala +++ b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/SparkArrowbasedOperationSuite.scala @@ -19,8 +19,14 @@ package org.apache.kyuubi.engine.spark.operation import java.sql.Statement +import org.apache.spark.KyuubiSparkContextHelper +import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project} +import org.apache.spark.sql.execution.QueryExecution +import org.apache.spark.sql.util.QueryExecutionListener + import org.apache.kyuubi.config.KyuubiConf -import org.apache.kyuubi.engine.spark.WithSparkSQLEngine +import org.apache.kyuubi.engine.spark.{SparkSQLEngine, WithSparkSQLEngine} +import org.apache.kyuubi.engine.spark.session.SparkSessionImpl import org.apache.kyuubi.operation.SparkDataTypeTests class SparkArrowbasedOperationSuite extends WithSparkSQLEngine with SparkDataTypeTests { @@ -35,6 +41,13 @@ class SparkArrowbasedOperationSuite extends WithSparkSQLEngine with SparkDataTyp override def resultFormat: String = "arrow" + override def beforeEach(): Unit = { + super.beforeEach() + withJdbcStatement() { statement => + checkResultSetFormat(statement, "arrow") + } + } + test("detect resultSet format") { withJdbcStatement() { statement => checkResultSetFormat(statement, "arrow") @@ -43,7 +56,89 @@ class SparkArrowbasedOperationSuite extends WithSparkSQLEngine with SparkDataTyp } } - def checkResultSetFormat(statement: Statement, expectFormat: String): Unit = { + test("Spark session timezone format") { + withJdbcStatement() { statement => + def check(expect: String): Unit = { + val query = + """ + |SELECT + | from_utc_timestamp( + | from_unixtime( + | 1670404535000 / 1000, 'yyyy-MM-dd HH:mm:ss' + | ), + | 'GMT+08:00' + | ) + |""".stripMargin + val resultSet = statement.executeQuery(query) + assert(resultSet.next()) + assert(resultSet.getString(1) == expect) + } + + def setTimeZone(timeZone: String): Unit = { + val rs = statement.executeQuery(s"set spark.sql.session.timeZone=$timeZone") + assert(rs.next()) + } + + Seq("true", "false").foreach { timestampAsString => + statement.executeQuery( + s"set ${KyuubiConf.ARROW_BASED_ROWSET_TIMESTAMP_AS_STRING.key}=$timestampAsString") + checkArrowBasedRowSetTimestampAsString(statement, timestampAsString) + setTimeZone("UTC") + check("2022-12-07 17:15:35.0") + setTimeZone("GMT+8") + check("2022-12-08 01:15:35.0") + } + } + } + + test("assign a new execution id for arrow-based result") { + var plan: LogicalPlan = null + + val listener = new QueryExecutionListener { + override def onSuccess(funcName: String, qe: QueryExecution, durationNs: Long): Unit = { + plan = qe.analyzed + } + override def onFailure(funcName: String, qe: QueryExecution, exception: Exception): Unit = {} + } + withJdbcStatement() { statement => + // since all the new sessions have their owner listener bus, we should register the listener + // in the current session. + registerListener(listener) + + val result = statement.executeQuery("select 1 as c1") + assert(result.next()) + assert(result.getInt("c1") == 1) + } + KyuubiSparkContextHelper.waitListenerBus(spark) + unregisterListener(listener) + assert(plan.isInstanceOf[Project]) + } + + test("arrow-based query metrics") { + var queryExecution: QueryExecution = null + + val listener = new QueryExecutionListener { + override def onSuccess(funcName: String, qe: QueryExecution, durationNs: Long): Unit = { + queryExecution = qe + } + override def onFailure(funcName: String, qe: QueryExecution, exception: Exception): Unit = {} + } + withJdbcStatement() { statement => + registerListener(listener) + val result = statement.executeQuery("select 1 as c1") + assert(result.next()) + assert(result.getInt("c1") == 1) + } + + KyuubiSparkContextHelper.waitListenerBus(spark) + unregisterListener(listener) + + val metrics = queryExecution.executedPlan.collectLeaves().head.metrics + assert(metrics.contains("numOutputRows")) + assert(metrics("numOutputRows").value === 1) + } + + private def checkResultSetFormat(statement: Statement, expectFormat: String): Unit = { val query = s""" |SELECT '$${hivevar:${KyuubiConf.OPERATION_RESULT_FORMAT.key}}' AS col @@ -52,4 +147,34 @@ class SparkArrowbasedOperationSuite extends WithSparkSQLEngine with SparkDataTyp assert(resultSet.next()) assert(resultSet.getString("col") === expectFormat) } + + private def checkArrowBasedRowSetTimestampAsString( + statement: Statement, + expect: String): Unit = { + val query = + s""" + |SELECT '$${hivevar:${KyuubiConf.ARROW_BASED_ROWSET_TIMESTAMP_AS_STRING.key}}' AS col + |""".stripMargin + val resultSet = statement.executeQuery(query) + assert(resultSet.next()) + assert(resultSet.getString("col") === expect) + } + + private def registerListener(listener: QueryExecutionListener): Unit = { + // since all the new sessions have their owner listener bus, we should register the listener + // in the current session. + SparkSQLEngine.currentEngine.get + .backendService + .sessionManager + .allSessions() + .foreach(_.asInstanceOf[SparkSessionImpl].spark.listenerManager.register(listener)) + } + + private def unregisterListener(listener: QueryExecutionListener): Unit = { + SparkSQLEngine.currentEngine.get + .backendService + .sessionManager + .allSessions() + .foreach(_.asInstanceOf[SparkSessionImpl].spark.listenerManager.unregister(listener)) + } } diff --git a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/SparkOperationSuite.scala b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/SparkOperationSuite.scala index 30bbf8b77b4..af514ceb3c0 100644 --- a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/SparkOperationSuite.scala +++ b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/SparkOperationSuite.scala @@ -39,7 +39,6 @@ import org.apache.kyuubi.engine.spark.shim.SparkCatalogShim import org.apache.kyuubi.operation.{HiveMetadataTests, SparkQueryTests} import org.apache.kyuubi.operation.meta.ResultSetSchemaConstant._ import org.apache.kyuubi.util.KyuubiHadoopUtils -import org.apache.kyuubi.util.SparkVersionUtil.isSparkVersionAtLeast class SparkOperationSuite extends WithSparkSQLEngine with HiveMetadataTests with SparkQueryTests { @@ -93,12 +92,12 @@ class SparkOperationSuite extends WithSparkSQLEngine with HiveMetadataTests with .add("c17", "struct", nullable = true, "17") // since spark3.3.0 - if (SPARK_ENGINE_VERSION >= "3.3") { + if (SPARK_ENGINE_RUNTIME_VERSION >= "3.3") { schema = schema.add("c18", "interval day", nullable = true, "18") .add("c19", "interval year", nullable = true, "19") } // since spark3.4.0 - if (SPARK_ENGINE_VERSION >= "3.4") { + if (SPARK_ENGINE_RUNTIME_VERSION >= "3.4") { schema = schema.add("c20", "timestamp_ntz", nullable = true, "20") } @@ -511,7 +510,7 @@ class SparkOperationSuite extends WithSparkSQLEngine with HiveMetadataTests with val status = tOpenSessionResp.getStatus val errorMessage = status.getErrorMessage assert(status.getStatusCode === TStatusCode.ERROR_STATUS) - if (isSparkVersionAtLeast("3.4")) { + if (SPARK_ENGINE_RUNTIME_VERSION >= "3.4") { assert(errorMessage.contains("[SCHEMA_NOT_FOUND]")) assert(errorMessage.contains(s"The schema `$dbName` cannot be found.")) } else { diff --git a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/schema/RowSetSuite.scala b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/schema/RowSetSuite.scala index 803eea3e6cd..5d2ba4a0d11 100644 --- a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/schema/RowSetSuite.scala +++ b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/schema/RowSetSuite.scala @@ -20,7 +20,7 @@ package org.apache.kyuubi.engine.spark.schema import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import java.sql.{Date, Timestamp} -import java.time.{Instant, LocalDate, ZoneId} +import java.time.{Instant, LocalDate} import scala.collection.JavaConverters._ @@ -30,7 +30,6 @@ import org.apache.spark.sql.types._ import org.apache.spark.unsafe.types.CalendarInterval import org.apache.kyuubi.KyuubiFunSuite -import org.apache.kyuubi.engine.spark.schema.RowSet.toHiveString class RowSetSuite extends KyuubiFunSuite { @@ -97,10 +96,9 @@ class RowSetSuite extends KyuubiFunSuite { .add("q", "timestamp") private val rows: Seq[Row] = (0 to 10).map(genRow) ++ Seq(Row.fromSeq(Seq.fill(17)(null))) - private val zoneId: ZoneId = ZoneId.systemDefault() test("column based set") { - val tRowSet = RowSet.toColumnBasedSet(rows, schema, zoneId) + val tRowSet = RowSet.toColumnBasedSet(rows, schema) assert(tRowSet.getColumns.size() === schema.size) assert(tRowSet.getRowsSize === 0) @@ -159,22 +157,22 @@ class RowSetSuite extends KyuubiFunSuite { val decCol = cols.next().getStringVal decCol.getValues.asScala.zipWithIndex.foreach { - case (b, 11) => assert(b.isEmpty) + case (b, 11) => assert(b === "NULL") case (b, i) => assert(b === s"$i.$i") } val dateCol = cols.next().getStringVal dateCol.getValues.asScala.zipWithIndex.foreach { - case (b, 11) => assert(b.isEmpty) + case (b, 11) => assert(b === "NULL") case (b, i) => - assert(b === toHiveString((Date.valueOf(s"2018-11-${i + 1}"), DateType), zoneId)) + assert(b === RowSet.toHiveString(Date.valueOf(s"2018-11-${i + 1}") -> DateType)) } val tsCol = cols.next().getStringVal tsCol.getValues.asScala.zipWithIndex.foreach { - case (b, 11) => assert(b.isEmpty) + case (b, 11) => assert(b === "NULL") case (b, i) => assert(b === - toHiveString((Timestamp.valueOf(s"2018-11-17 13:33:33.$i"), TimestampType), zoneId)) + RowSet.toHiveString(Timestamp.valueOf(s"2018-11-17 13:33:33.$i") -> TimestampType)) } val binCol = cols.next().getBinaryVal @@ -185,29 +183,27 @@ class RowSetSuite extends KyuubiFunSuite { val arrCol = cols.next().getStringVal arrCol.getValues.asScala.zipWithIndex.foreach { - case (b, 11) => assert(b === "") - case (b, i) => assert(b === toHiveString( - (Array.fill(i)(java.lang.Double.valueOf(s"$i.$i")).toSeq, ArrayType(DoubleType)), - zoneId)) + case (b, 11) => assert(b === "NULL") + case (b, i) => assert(b === RowSet.toHiveString( + Array.fill(i)(java.lang.Double.valueOf(s"$i.$i")).toSeq -> ArrayType(DoubleType))) } val mapCol = cols.next().getStringVal mapCol.getValues.asScala.zipWithIndex.foreach { - case (b, 11) => assert(b === "") - case (b, i) => assert(b === toHiveString( - (Map(i -> java.lang.Double.valueOf(s"$i.$i")), MapType(IntegerType, DoubleType)), - zoneId)) + case (b, 11) => assert(b === "NULL") + case (b, i) => assert(b === RowSet.toHiveString( + Map(i -> java.lang.Double.valueOf(s"$i.$i")) -> MapType(IntegerType, DoubleType))) } val intervalCol = cols.next().getStringVal intervalCol.getValues.asScala.zipWithIndex.foreach { - case (b, 11) => assert(b === "") + case (b, 11) => assert(b === "NULL") case (b, i) => assert(b === new CalendarInterval(i, i, i).toString) } } test("row based set") { - val tRowSet = RowSet.toRowBasedSet(rows, schema, zoneId) + val tRowSet = RowSet.toRowBasedSet(rows, schema) assert(tRowSet.getColumnCount === 0) assert(tRowSet.getRowsSize === rows.size) val iter = tRowSet.getRowsIterator @@ -237,7 +233,7 @@ class RowSetSuite extends KyuubiFunSuite { assert(r6.get(9).getStringVal.getValue === "2018-11-06") val r7 = iter.next().getColVals - assert(r7.get(10).getStringVal.getValue === "2018-11-17 13:33:33.600") + assert(r7.get(10).getStringVal.getValue === "2018-11-17 13:33:33.6") assert(r7.get(11).getStringVal.getValue === new String( Array.fill[Byte](6)(6.toByte), StandardCharsets.UTF_8)) @@ -245,7 +241,7 @@ class RowSetSuite extends KyuubiFunSuite { val r8 = iter.next().getColVals assert(r8.get(12).getStringVal.getValue === Array.fill(7)(7.7d).mkString("[", ",", "]")) assert(r8.get(13).getStringVal.getValue === - toHiveString((Map(7 -> 7.7d), MapType(IntegerType, DoubleType)), zoneId)) + RowSet.toHiveString(Map(7 -> 7.7d) -> MapType(IntegerType, DoubleType))) val r9 = iter.next().getColVals assert(r9.get(14).getStringVal.getValue === new CalendarInterval(8, 8, 8).toString) @@ -253,7 +249,7 @@ class RowSetSuite extends KyuubiFunSuite { test("to row set") { TProtocolVersion.values().foreach { proto => - val set = RowSet.toTRowSet(rows, schema, proto, zoneId) + val set = RowSet.toTRowSet(rows, schema, proto) if (proto.getValue < TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V6.getValue) { assert(!set.isSetColumns, proto.toString) assert(set.isSetRows, proto.toString) diff --git a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/udf/KyuubiDefinedFunctionSuite.scala b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/udf/KyuubiDefinedFunctionSuite.scala index 4d38bc363b3..f355e1e6b51 100644 --- a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/udf/KyuubiDefinedFunctionSuite.scala +++ b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/udf/KyuubiDefinedFunctionSuite.scala @@ -19,9 +19,7 @@ package org.apache.kyuubi.engine.spark.udf import java.nio.file.Paths -import scala.collection.mutable.ArrayBuffer - -import org.apache.kyuubi.{KyuubiFunSuite, TestUtils, Utils} +import org.apache.kyuubi.{KyuubiFunSuite, MarkdownBuilder, MarkdownUtils, Utils} // scalastyle:off line.size.limit /** @@ -30,12 +28,12 @@ import org.apache.kyuubi.{KyuubiFunSuite, TestUtils, Utils} * * To run the entire test suite: * {{{ - * build/mvn clean install -Pflink-provided,spark-provided,hive-provided -DwildcardSuites=org.apache.kyuubi.engine.spark.udf.KyuubiDefinedFunctionSuite + * build/mvn clean test -pl externals/kyuubi-spark-sql-engine -am -Pflink-provided,spark-provided,hive-provided -DwildcardSuites=org.apache.kyuubi.engine.spark.udf.KyuubiDefinedFunctionSuite * }}} * * To re-generate golden files for entire suite, run: * {{{ - * KYUUBI_UPDATE=1 build/mvn clean install -Pflink-provided,spark-provided,hive-provided -DwildcardSuites=org.apache.kyuubi.engine.spark.udf.KyuubiDefinedFunctionSuite + * KYUUBI_UPDATE=1 build/mvn clean test -pl externals/kyuubi-spark-sql-engine -am -Pflink-provided,spark-provided,hive-provided -DwildcardSuites=org.apache.kyuubi.engine.spark.udf.KyuubiDefinedFunctionSuite * }}} */ // scalastyle:on line.size.limit @@ -48,40 +46,26 @@ class KyuubiDefinedFunctionSuite extends KyuubiFunSuite { .toAbsolutePath test("verify or update kyuubi spark sql functions") { - val newOutput = new ArrayBuffer[String]() - newOutput += "" - newOutput += "" - newOutput += "" - newOutput += "" - newOutput += "# Auxiliary SQL Functions" - newOutput += "" - newOutput += "Kyuubi provides several auxiliary SQL functions as supplement to Spark's " + - "[Built-in Functions](https://spark.apache.org/docs/latest/api/sql/index.html#" + - "built-in-functions)" - newOutput += "" - newOutput += "Name | Description | Return Type | Since" - newOutput += "--- | --- | --- | ---" - KDFRegistry + val builder = MarkdownBuilder(licenced = true, getClass.getName) + + builder + .line("# Auxiliary SQL Functions") + .line("""Kyuubi provides several auxiliary SQL functions as supplement to Spark's + | [Built-in Functions](https://spark.apache.org/docs/latest/api/sql/index.html# + |built-in-functions)""") + .lines(""" + | Name | Description | Return Type | Since + | --- | --- | --- | --- + | + |""") KDFRegistry.registeredFunctions.foreach { func => - newOutput += s"${func.name} | ${func.description} | ${func.returnType} | ${func.since}" + builder.line(s"${func.name} | ${func.description} | ${func.returnType} | ${func.since}") } - newOutput += "" - TestUtils.verifyOutput(markdown, newOutput, getClass.getCanonicalName) + + MarkdownUtils.verifyOutput( + markdown, + builder, + getClass.getCanonicalName, + "externals/kyuubi-spark-sql-engine") } } diff --git a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/spark/KyuubiSparkContextHelper.scala b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/spark/KyuubiSparkContextHelper.scala new file mode 100644 index 00000000000..8293123ead7 --- /dev/null +++ b/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/spark/KyuubiSparkContextHelper.scala @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.spark + +import org.apache.spark.sql.SparkSession + +/** + * A place to invoke non-public APIs of [[SparkContext]], for test only. + */ +object KyuubiSparkContextHelper { + + def waitListenerBus(spark: SparkSession): Unit = { + spark.sparkContext.listenerBus.waitUntilEmpty() + } +} diff --git a/externals/kyuubi-trino-engine/pom.xml b/externals/kyuubi-trino-engine/pom.xml index 7e2f67370e6..7aea8f33a6f 100644 --- a/externals/kyuubi-trino-engine/pom.xml +++ b/externals/kyuubi-trino-engine/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../../pom.xml diff --git a/externals/kyuubi-trino-engine/src/main/scala/org/apache/kyuubi/engine/trino/session/TrinoSessionImpl.scala b/externals/kyuubi-trino-engine/src/main/scala/org/apache/kyuubi/engine/trino/session/TrinoSessionImpl.scala index a19d74d586c..81f973b1b5e 100644 --- a/externals/kyuubi-trino-engine/src/main/scala/org/apache/kyuubi/engine/trino/session/TrinoSessionImpl.scala +++ b/externals/kyuubi-trino-engine/src/main/scala/org/apache/kyuubi/engine/trino/session/TrinoSessionImpl.scala @@ -30,11 +30,12 @@ import org.apache.hive.service.rpc.thrift.{TGetInfoType, TGetInfoValue, TProtoco import org.apache.kyuubi.KyuubiSQLException import org.apache.kyuubi.Utils.currentUser import org.apache.kyuubi.config.{KyuubiConf, KyuubiReservedKeys} +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY import org.apache.kyuubi.engine.trino.{TrinoConf, TrinoContext, TrinoStatement} import org.apache.kyuubi.engine.trino.event.TrinoSessionEvent import org.apache.kyuubi.events.EventBus import org.apache.kyuubi.operation.{Operation, OperationHandle} -import org.apache.kyuubi.session.{AbstractSession, SessionManager} +import org.apache.kyuubi.session.{AbstractSession, SessionHandle, SessionManager} class TrinoSessionImpl( protocol: TProtocolVersion, @@ -45,6 +46,9 @@ class TrinoSessionImpl( sessionManager: SessionManager) extends AbstractSession(protocol, user, password, ipAddress, conf, sessionManager) { + override val handle: SessionHandle = + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).getOrElse(SessionHandle()) + var trinoContext: TrinoContext = _ private var clientSession: ClientSession = _ private var catalogName: String = null diff --git a/externals/kyuubi-trino-engine/src/main/scala/org/apache/kyuubi/engine/trino/session/TrinoSessionManager.scala b/externals/kyuubi-trino-engine/src/main/scala/org/apache/kyuubi/engine/trino/session/TrinoSessionManager.scala index 6d56d5c0541..e18b8f75817 100644 --- a/externals/kyuubi-trino-engine/src/main/scala/org/apache/kyuubi/engine/trino/session/TrinoSessionManager.scala +++ b/externals/kyuubi-trino-engine/src/main/scala/org/apache/kyuubi/engine/trino/session/TrinoSessionManager.scala @@ -20,6 +20,7 @@ package org.apache.kyuubi.engine.trino.session import org.apache.hive.service.rpc.thrift.TProtocolVersion import org.apache.kyuubi.config.KyuubiConf.ENGINE_SHARE_LEVEL +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_HANDLE_KEY import org.apache.kyuubi.engine.ShareLevel import org.apache.kyuubi.engine.trino.TrinoSqlEngine import org.apache.kyuubi.engine.trino.operation.TrinoOperationManager @@ -36,7 +37,10 @@ class TrinoSessionManager password: String, ipAddress: String, conf: Map[String, String]): Session = { - new TrinoSessionImpl(protocol, user, password, ipAddress, conf, this) + conf.get(KYUUBI_SESSION_HANDLE_KEY).map(SessionHandle.fromUUID).flatMap( + getSessionOption).getOrElse { + new TrinoSessionImpl(protocol, user, password, ipAddress, conf, this) + } } override def closeSession(sessionHandle: SessionHandle): Unit = { diff --git a/integration-tests/kyuubi-flink-it/pom.xml b/integration-tests/kyuubi-flink-it/pom.xml index 7f9a84a85bc..c6a55c62cb6 100644 --- a/integration-tests/kyuubi-flink-it/pom.xml +++ b/integration-tests/kyuubi-flink-it/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi integration-tests - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml @@ -75,7 +75,7 @@ org.apache.flink - flink-table-runtime${flink.module.scala.suffix} + flink-table-runtime test diff --git a/integration-tests/kyuubi-hive-it/pom.xml b/integration-tests/kyuubi-hive-it/pom.xml index 8b9813a2be0..ff9a6b35ea6 100644 --- a/integration-tests/kyuubi-hive-it/pom.xml +++ b/integration-tests/kyuubi-hive-it/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi integration-tests - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml diff --git a/integration-tests/kyuubi-jdbc-it/pom.xml b/integration-tests/kyuubi-jdbc-it/pom.xml index 0aef12fb3f3..2d95de78ed8 100644 --- a/integration-tests/kyuubi-jdbc-it/pom.xml +++ b/integration-tests/kyuubi-jdbc-it/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi integration-tests - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml diff --git a/integration-tests/kyuubi-kubernetes-it/pom.xml b/integration-tests/kyuubi-kubernetes-it/pom.xml index cb04e73c1d5..a796ccab59a 100644 --- a/integration-tests/kyuubi-kubernetes-it/pom.xml +++ b/integration-tests/kyuubi-kubernetes-it/pom.xml @@ -15,17 +15,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - + 4.0.0 org.apache.kyuubi integration-tests - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml - 4.0.0 kubernetes-integration-tests_2.12 Kyuubi Test Kubernetes IT @@ -62,12 +60,6 @@ test - - io.fabric8 - kubernetes-client - test - - org.apache.hadoop hadoop-client-minicluster diff --git a/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/MiniKube.scala b/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/MiniKube.scala index cd373873a6a..f4cd557bb0f 100644 --- a/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/MiniKube.scala +++ b/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/MiniKube.scala @@ -17,7 +17,11 @@ package org.apache.kyuubi.kubernetes.test -import io.fabric8.kubernetes.client.{Config, DefaultKubernetesClient} +import io.fabric8.kubernetes.client.{Config, KubernetesClient, KubernetesClientBuilder} +import io.fabric8.kubernetes.client.okhttp.OkHttpClientFactory +import okhttp3.{Dispatcher, OkHttpClient} + +import org.apache.kyuubi.util.ThreadUtils /** * This code copied from Aapache Spark @@ -44,7 +48,7 @@ object MiniKube { executeMinikube(true, "ip").head } - def getKubernetesClient: DefaultKubernetesClient = { + def getKubernetesClient: KubernetesClient = { // only the three-part version number is matched (the optional suffix like "-beta.0" is dropped) val versionArrayOpt = "\\d+\\.\\d+\\.\\d+".r .findFirstIn(minikubeVersionString.split(VERSION_PREFIX)(1)) @@ -65,7 +69,18 @@ object MiniKube { "For minikube version a three-part version number is expected (the optional " + "non-numeric suffix is intentionally dropped)") } + // https://github.com/fabric8io/kubernetes-client/issues/3547 + val dispatcher = new Dispatcher( + ThreadUtils.newDaemonCachedThreadPool("kubernetes-dispatcher")) + val factoryWithCustomDispatcher = new OkHttpClientFactory() { + override protected def additionalConfig(builder: OkHttpClient.Builder): Unit = { + builder.dispatcher(dispatcher) + } + } - new DefaultKubernetesClient(Config.autoConfigure("minikube")) + new KubernetesClientBuilder() + .withConfig(Config.autoConfigure("minikube")) + .withHttpClientFactory(factoryWithCustomDispatcher) + .build() } } diff --git a/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/WithKyuubiServerOnKubernetes.scala b/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/WithKyuubiServerOnKubernetes.scala index ed9cbce09fe..595fdd4314e 100644 --- a/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/WithKyuubiServerOnKubernetes.scala +++ b/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/WithKyuubiServerOnKubernetes.scala @@ -18,14 +18,14 @@ package org.apache.kyuubi.kubernetes.test import io.fabric8.kubernetes.api.model.Pod -import io.fabric8.kubernetes.client.DefaultKubernetesClient +import io.fabric8.kubernetes.client.KubernetesClient import org.apache.kyuubi.KyuubiFunSuite trait WithKyuubiServerOnKubernetes extends KyuubiFunSuite { protected def connectionConf: Map[String, String] = Map.empty - lazy val miniKubernetesClient: DefaultKubernetesClient = MiniKube.getKubernetesClient + lazy val miniKubernetesClient: KubernetesClient = MiniKube.getKubernetesClient lazy val kyuubiPod: Pod = miniKubernetesClient.pods().withName("kyuubi-test").get() lazy val kyuubiServerIp: String = kyuubiPod.getStatus.getPodIP lazy val miniKubeIp: String = MiniKube.getIp diff --git a/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/deployment/KyuubiOnKubernetesTestsSuite.scala b/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/deployment/KyuubiOnKubernetesTestsSuite.scala index c8894679d35..bc7c98a80c7 100644 --- a/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/deployment/KyuubiOnKubernetesTestsSuite.scala +++ b/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/deployment/KyuubiOnKubernetesTestsSuite.scala @@ -54,7 +54,7 @@ class KyuubiOnKubernetesWithSparkTestsBase extends WithKyuubiServerOnKubernetes super.connectionConf ++ Map( "spark.master" -> s"k8s://$miniKubeApiMaster", - "spark.kubernetes.container.image" -> "apache/spark:3.3.1", + "spark.kubernetes.container.image" -> "apache/spark:v3.3.2", "spark.executor.memory" -> "512M", "spark.driver.memory" -> "1024M", "spark.kubernetes.driver.request.cores" -> "250m", diff --git a/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/spark/SparkOnKubernetesTestsSuite.scala b/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/spark/SparkOnKubernetesTestsSuite.scala index e63c3704599..5141ff4d7ea 100644 --- a/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/spark/SparkOnKubernetesTestsSuite.scala +++ b/integration-tests/kyuubi-kubernetes-it/src/test/scala/org/apache/kyuubi/kubernetes/test/spark/SparkOnKubernetesTestsSuite.scala @@ -17,13 +17,16 @@ package org.apache.kyuubi.kubernetes.test.spark +import java.util.UUID + import scala.collection.JavaConverters._ import scala.concurrent.duration._ import org.apache.hadoop.conf.Configuration import org.apache.hadoop.net.NetUtils -import org.apache.kyuubi.{BatchTestHelper, KyuubiException, Logging, Utils, WithKyuubiServer, WithSimpleDFSService} +import org.apache.kyuubi._ +import org.apache.kyuubi.client.util.BatchUtils._ import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf.FRONTEND_THRIFT_BINARY_BIND_HOST import org.apache.kyuubi.engine.{ApplicationInfo, ApplicationOperation, KubernetesApplicationOperation} @@ -31,7 +34,7 @@ import org.apache.kyuubi.engine.ApplicationState.{FAILED, NOT_FOUND, RUNNING} import org.apache.kyuubi.engine.spark.SparkProcessBuilder import org.apache.kyuubi.kubernetes.test.MiniKube import org.apache.kyuubi.operation.SparkQueryTests -import org.apache.kyuubi.session.{KyuubiBatchSessionImpl, KyuubiSessionManager} +import org.apache.kyuubi.session.KyuubiSessionManager import org.apache.kyuubi.util.Validator.KUBERNETES_EXECUTOR_POD_NAME_PREFIX import org.apache.kyuubi.zookeeper.ZookeeperConf.ZK_CLIENT_PORT_ADDRESS @@ -45,7 +48,7 @@ abstract class SparkOnKubernetesSuiteBase // TODO Support more Spark version // Spark official docker image: https://hub.docker.com/r/apache/spark/tags KyuubiConf().set("spark.master", s"k8s://$apiServerAddress") - .set("spark.kubernetes.container.image", "apache/spark:v3.2.1") + .set("spark.kubernetes.container.image", "apache/spark:v3.3.2") .set("spark.kubernetes.container.image.pullPolicy", "IfNotPresent") .set("spark.executor.instances", "1") .set("spark.executor.memory", "512M") @@ -122,6 +125,7 @@ class SparkClusterModeOnKubernetesSuite override protected def jdbcUrl: String = getJdbcUrl } +// [KYUUBI #4467] KubernetesApplicationOperator doesn't support client mode class KyuubiOperationKubernetesClusterClientModeSuite extends SparkClientModeOnKubernetesSuiteBase { private lazy val k8sOperation: KubernetesApplicationOperation = { @@ -133,8 +137,9 @@ class KyuubiOperationKubernetesClusterClientModeSuite private def sessionManager: KyuubiSessionManager = server.backendService.sessionManager.asInstanceOf[KyuubiSessionManager] - test("Spark Client Mode On Kubernetes Kyuubi KubernetesApplicationOperation Suite") { - val batchRequest = newSparkBatchRequest(conf.getAll) + ignore("Spark Client Mode On Kubernetes Kyuubi KubernetesApplicationOperation Suite") { + val batchRequest = newSparkBatchRequest(conf.getAll ++ Map( + KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString)) val sessionHandle = sessionManager.openBatchSession( "kyuubi", @@ -193,7 +198,8 @@ class KyuubiOperationKubernetesClusterClusterModeSuite "spark.kubernetes.driver.pod.name", driverPodNamePrefix + "-" + System.currentTimeMillis()) - val batchRequest = newSparkBatchRequest(conf.getAll) + val batchRequest = newSparkBatchRequest(conf.getAll ++ Map( + KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString)) val sessionHandle = sessionManager.openBatchSession( "runner", @@ -202,19 +208,18 @@ class KyuubiOperationKubernetesClusterClusterModeSuite batchRequest.getConf.asScala.toMap, batchRequest) - val session = sessionManager.getSession(sessionHandle).asInstanceOf[KyuubiBatchSessionImpl] - val batchJobSubmissionOp = session.batchJobSubmissionOp - - eventually(timeout(3.minutes), interval(50.milliseconds)) { - val appInfo = batchJobSubmissionOp.currentApplicationInfo - assert(appInfo.nonEmpty) - assert(appInfo.exists(_.state == RUNNING)) - assert(appInfo.exists(_.name.startsWith(driverPodNamePrefix))) + // wait for driver pod start + eventually(timeout(3.minutes), interval(5.second)) { + // trigger k8sOperation init here + val appInfo = k8sOperation.getApplicationInfoByTag(sessionHandle.identifier.toString) + assert(appInfo.state == RUNNING) + assert(appInfo.name.startsWith(driverPodNamePrefix)) } val killResponse = k8sOperation.killApplicationByTag(sessionHandle.identifier.toString) assert(killResponse._1) - assert(killResponse._2 startsWith "Operation of deleted appId:") + assert(killResponse._2 endsWith "is completed") + assert(killResponse._2 contains sessionHandle.identifier.toString) eventually(timeout(3.minutes), interval(50.milliseconds)) { val appInfo = k8sOperation.getApplicationInfoByTag(sessionHandle.identifier.toString) diff --git a/integration-tests/kyuubi-trino-it/pom.xml b/integration-tests/kyuubi-trino-it/pom.xml index e62e58d1d23..107d621b075 100644 --- a/integration-tests/kyuubi-trino-it/pom.xml +++ b/integration-tests/kyuubi-trino-it/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi integration-tests - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml diff --git a/integration-tests/kyuubi-trino-it/src/test/scala/org/apache/kyuubi/it/trino/server/TrinoFrontendSuite.scala b/integration-tests/kyuubi-trino-it/src/test/scala/org/apache/kyuubi/it/trino/server/TrinoFrontendSuite.scala new file mode 100644 index 00000000000..4a175a28b7a --- /dev/null +++ b/integration-tests/kyuubi-trino-it/src/test/scala/org/apache/kyuubi/it/trino/server/TrinoFrontendSuite.scala @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.it.trino.server + +import scala.util.control.NonFatal + +import org.apache.kyuubi.WithKyuubiServer +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.operation.SparkMetadataTests + +/** + * This test is for Trino jdbc driver with Kyuubi Server and Spark engine: + * + * ------------------------------------------------------------- + * | JDBC | + * | Trino-driver ----> Kyuubi Server --> Spark Engine | + * | | + * ------------------------------------------------------------- + */ +class TrinoFrontendSuite extends WithKyuubiServer with SparkMetadataTests { + + test("execute statement - select 11 where 1=1") { + withJdbcStatement() { statement => + val resultSet = statement.executeQuery("SELECT 11 where 1<1") + while (resultSet.next()) { + assert(resultSet.getInt(1) === 11) + } + } + } + + test("execute preparedStatement - select 11 where 1 = 1") { + withJdbcPrepareStatement("select 11 where 1 = ? ") { statement => + statement.setInt(1, 1) + val rs = statement.executeQuery() + while (rs.next()) { + assert(rs.getInt(1) == 11) + } + } + } + + override protected val conf: KyuubiConf = { + KyuubiConf().set(KyuubiConf.FRONTEND_PROTOCOLS, Seq("TRINO")) + } + + override protected def jdbcUrl: String = { + s"jdbc:trino://${server.frontendServices.head.connectionUrl}/;" + } + + // trino jdbc driver requires enable SSL if specify password + override protected val password: String = "" + + override def beforeAll(): Unit = { + super.beforeAll() + // eagerly start spark engine before running test, it's a workaround for trino jdbc driver + // since it does not support changing http connect timeout + try { + withJdbcStatement() { statement => + statement.execute("SELECT 1") + } + } catch { + case NonFatal(e) => + } + } +} diff --git a/integration-tests/kyuubi-zookeeper-it/pom.xml b/integration-tests/kyuubi-zookeeper-it/pom.xml index eaeff5898a7..bded1585b71 100644 --- a/integration-tests/kyuubi-zookeeper-it/pom.xml +++ b/integration-tests/kyuubi-zookeeper-it/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi integration-tests - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 4e3431afb90..b6a48daaedc 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT integration-tests diff --git a/kyuubi-assembly/pom.xml b/kyuubi-assembly/pom.xml index 725126f84f6..0524470a20d 100644 --- a/kyuubi-assembly/pom.xml +++ b/kyuubi-assembly/pom.xml @@ -22,7 +22,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml diff --git a/kyuubi-common/pom.xml b/kyuubi-common/pom.xml index fc259eb07d0..d62761d72b3 100644 --- a/kyuubi-common/pom.xml +++ b/kyuubi-common/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml @@ -31,6 +31,12 @@ https://kyuubi.apache.org/ + + com.vladsch.flexmark + flexmark-all + test + + org.scala-lang scala-library @@ -82,6 +88,11 @@ runtime + + org.antlr + ST4 + + org.apache.commons commons-lang3 @@ -135,6 +146,12 @@ test + + org.scalatestplus + mockito-4-6_${scala.binary.version} + test + + com.google.guava failureaccess diff --git a/kyuubi-common/src/main/java/org/apache/kyuubi/reflection/DynConstructors.java b/kyuubi-common/src/main/java/org/apache/kyuubi/reflection/DynConstructors.java index 7495ce0ffb4..59c79b88502 100644 --- a/kyuubi-common/src/main/java/org/apache/kyuubi/reflection/DynConstructors.java +++ b/kyuubi-common/src/main/java/org/apache/kyuubi/reflection/DynConstructors.java @@ -119,6 +119,7 @@ public static Builder builder(Class baseClass) { return new Builder(baseClass); } + @SuppressWarnings("rawtypes") public static class Builder { private final Class baseClass; private ClassLoader loader = Thread.currentThread().getContextClassLoader(); @@ -182,7 +183,7 @@ public Builder hiddenImpl(Class... types) { return this; } - @SuppressWarnings("unchecked") + @SuppressWarnings({"unchecked", "rawtypes"}) public Builder hiddenImpl(String className, Class... types) { // don't do any work if an implementation has been found if (ctor != null) { diff --git a/kyuubi-common/src/main/java/org/apache/kyuubi/reflection/DynFields.java b/kyuubi-common/src/main/java/org/apache/kyuubi/reflection/DynFields.java index 39c83b1621a..9430d54e9bb 100644 --- a/kyuubi-common/src/main/java/org/apache/kyuubi/reflection/DynFields.java +++ b/kyuubi-common/src/main/java/org/apache/kyuubi/reflection/DynFields.java @@ -300,6 +300,7 @@ public Builder hiddenImpl(String className, String fieldName) { * @see Class#forName(String) * @see Class#getField(String) */ + @SuppressWarnings("rawtypes") public Builder hiddenImpl(Class targetClass, String fieldName) { // don't do any work if an implementation has been found if (field != null || targetClass == null) { diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/Logging.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/Logging.scala index 4944b9fcc14..1df598132fb 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/Logging.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/Logging.scala @@ -22,7 +22,6 @@ import org.apache.logging.log4j.core.{Logger => Log4jLogger, LoggerContext} import org.apache.logging.log4j.core.config.DefaultConfiguration import org.slf4j.{Logger, LoggerFactory} import org.slf4j.bridge.SLF4JBridgeHandler -import org.slf4j.impl.StaticLoggerBinder import org.apache.kyuubi.util.ClassUtils @@ -54,12 +53,24 @@ trait Logging { } } + def debug(message: => Any, t: Throwable): Unit = { + if (logger.isDebugEnabled) { + logger.debug(message.toString, t) + } + } + def info(message: => Any): Unit = { if (logger.isInfoEnabled) { logger.info(message.toString) } } + def info(message: => Any, t: Throwable): Unit = { + if (logger.isInfoEnabled) { + logger.info(message.toString, t) + } + } + def warn(message: => Any): Unit = { if (logger.isWarnEnabled) { logger.warn(message.toString) @@ -105,16 +116,16 @@ object Logging { // This distinguishes the log4j 1.2 binding, currently // org.slf4j.impl.Log4jLoggerFactory, from the log4j 2.0 binding, currently // org.apache.logging.slf4j.Log4jLoggerFactory - val binderClass = StaticLoggerBinder.getSingleton.getLoggerFactoryClassStr - "org.slf4j.impl.Log4jLoggerFactory".equals(binderClass) + "org.slf4j.impl.Log4jLoggerFactory" + .equals(LoggerFactory.getILoggerFactory.getClass.getName) } private[kyuubi] def isLog4j2: Boolean = { // This distinguishes the log4j 1.2 binding, currently // org.slf4j.impl.Log4jLoggerFactory, from the log4j 2.0 binding, currently // org.apache.logging.slf4j.Log4jLoggerFactory - val binderClass = StaticLoggerBinder.getSingleton.getLoggerFactoryClassStr - "org.apache.logging.slf4j.Log4jLoggerFactory".equals(binderClass) + "org.apache.logging.slf4j.Log4jLoggerFactory" + .equals(LoggerFactory.getILoggerFactory.getClass.getName) } /** diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/Utils.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/Utils.scala index 33a4e116e95..3a03682ff1b 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/Utils.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/Utils.scala @@ -20,8 +20,10 @@ package org.apache.kyuubi import java.io._ import java.net.{Inet4Address, InetAddress, NetworkInterface} import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Path, Paths} -import java.util.{Properties, TimeZone, UUID} +import java.nio.file.{Files, Path, Paths, StandardCopyOption} +import java.text.SimpleDateFormat +import java.util.{Date, Properties, TimeZone, UUID} +import java.util.concurrent.atomic.AtomicLong import scala.collection.JavaConverters._ import scala.sys.process._ @@ -40,6 +42,12 @@ object Utils extends Logging { import org.apache.kyuubi.config.KyuubiConf._ + /** + * An atomic counter used in writeToTempFile method + * avoiding duplication in temporary file name generation + */ + private lazy val tempFileIdCounter: AtomicLong = new AtomicLong(0) + def strToSeq(s: String, sp: String = ","): Seq[String] = { require(s != null) s.split(sp).map(_.trim).filter(_.nonEmpty) @@ -147,6 +155,50 @@ object Utils extends Logging { dir } + /** + * Copies bytes from an InputStream source to a newly created temporary file + * created in the directory destination. The temporary file will be created + * with new name by adding random identifiers before original file name's suffix, + * and the file will be deleted on JVM exit. The directories up to destination + * will be created if they don't already exist. destination will be overwritten + * if it already exists. The source stream is closed. + * @param source the InputStream to copy bytes from, must not be null, will be closed + * @param dir the directory path for temp file creation + * @param fileName original file name with suffix + * @return the created temp file in dir + */ + def writeToTempFile(source: InputStream, dir: Path, fileName: String): File = { + try { + if (source == null) { + throw new IOException("the source inputstream is null") + } + if (!dir.toFile.exists()) { + dir.toFile.mkdirs() + } + val (prefix, suffix) = fileName.lastIndexOf(".") match { + case i if i > 0 => (fileName.substring(0, i), fileName.substring(i)) + case _ => (fileName, "") + } + val currentTime = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + val identifier = s"$currentTime-${tempFileIdCounter.incrementAndGet()}" + val filePath = Paths.get(dir.toString, s"$prefix-$identifier$suffix") + try { + Files.copy(source, filePath, StandardCopyOption.REPLACE_EXISTING) + } finally { + source.close() + } + val file = filePath.toFile + file.deleteOnExit() + file + } catch { + case e: Exception => + error( + s"failed to write to temp file in path $dir, original file name: $fileName", + e) + throw e + } + } + def currentUser: String = UserGroupInformation.getCurrentUser.getShortUserName private val shortVersionRegex = """^(\d+\.\d+\.\d+)(.*)?$""".r @@ -169,6 +221,11 @@ object Utils extends Logging { */ val isWindows: Boolean = SystemUtils.IS_OS_WINDOWS + /** + * Whether the underlying operating system is MacOS. + */ + val isMac: Boolean = SystemUtils.IS_OS_MAC + /** * Indicates whether Kyuubi is currently running unit tests. */ @@ -335,4 +392,19 @@ object Utils extends Logging { Option(Thread.currentThread().getContextClassLoader).getOrElse(getKyuubiClassLoader) def isOnK8s: Boolean = Files.exists(Paths.get("/var/run/secrets/kubernetes.io")) + + /** + * Return a nice string representation of the exception. It will call "printStackTrace" to + * recursively generate the stack trace including the exception and its causes. + */ + def prettyPrint(e: Throwable): String = { + if (e == null) { + "" + } else { + // Use e.printStackTrace here because e.getStackTrace doesn't include the cause + val stringWriter = new StringWriter() + e.printStackTrace(new PrintWriter(stringWriter)) + stringWriter.toString + } + } } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala index 0e50e132ef7..b5229e2ad4f 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiConf.scala @@ -284,7 +284,7 @@ object KyuubiConf { .createOptional val KINIT_INTERVAL: ConfigEntry[Long] = buildConf("kyuubi.kinit.interval") - .doc("How often will Kyuubi server run `kinit -kt [keytab] [principal]` to renew the" + + .doc("How often will the Kyuubi server run `kinit -kt [keytab] [principal]` to renew the" + " local Kerberos credentials cache") .version("1.0.0") .serverOnly @@ -320,7 +320,7 @@ object KyuubiConf { val CREDENTIALS_UPDATE_WAIT_TIMEOUT: ConfigEntry[Long] = buildConf("kyuubi.credentials.update.wait.timeout") - .doc("How long to wait until credentials are ready.") + .doc("How long to wait until the credentials are ready.") .version("1.5.0") .timeConf .checkValue(t => t > 0, "must be positive integer") @@ -336,7 +336,7 @@ object KyuubiConf { val CREDENTIALS_IDLE_TIMEOUT: ConfigEntry[Long] = buildConf("kyuubi.credentials.idle.timeout") - .doc("inactive users' credentials will be expired after a configured timeout") + .doc("The inactive users' credentials will be expired after a configured timeout") .version("1.6.0") .timeConf .checkValue(_ >= Duration.ofSeconds(3).toMillis, "Minimum 3 seconds") @@ -376,7 +376,7 @@ object KyuubiConf { val FRONTEND_PROTOCOLS: ConfigEntry[Seq[String]] = buildConf("kyuubi.frontend.protocols") - .doc("A comma separated list for all frontend protocols " + + .doc("A comma-separated list for all frontend protocols " + "
      " + "
    • THRIFT_BINARY - HiveServer2 compatible thrift binary protocol.
    • " + "
    • THRIFT_HTTP - HiveServer2 compatible thrift http protocol.
    • " + @@ -391,7 +391,9 @@ object KyuubiConf { .checkValue( _.forall(FrontendProtocols.values.map(_.toString).contains), s"the frontend protocol should be one or more of ${FrontendProtocols.values.mkString(",")}") - .createWithDefault(Seq(FrontendProtocols.THRIFT_BINARY.toString)) + .createWithDefault(Seq( + FrontendProtocols.THRIFT_BINARY.toString, + FrontendProtocols.REST.toString)) val FRONTEND_BIND_HOST: OptionalConfigEntry[String] = buildConf("kyuubi.frontend.bind.host") .doc("Hostname or IP of the machine on which to run the frontend services.") @@ -403,7 +405,7 @@ object KyuubiConf { val FRONTEND_THRIFT_BINARY_BIND_HOST: ConfigEntry[Option[String]] = buildConf("kyuubi.frontend.thrift.binary.bind.host") .doc("Hostname or IP of the machine on which to run the thrift frontend service " + - "via binary protocol.") + "via the binary protocol.") .version("1.4.0") .serverOnly .fallbackConf(FRONTEND_BIND_HOST) @@ -454,7 +456,7 @@ object KyuubiConf { val FRONTEND_THRIFT_BINARY_SSL_INCLUDE_CIPHER_SUITES: ConfigEntry[Seq[String]] = buildConf("kyuubi.frontend.thrift.binary.ssl.include.ciphersuites") - .doc("A comma separated list of include SSL cipher suite names for thrift binary frontend.") + .doc("A comma-separated list of include SSL cipher suite names for thrift binary frontend.") .version("1.7.0") .stringConf .toSequence() @@ -463,7 +465,7 @@ object KyuubiConf { @deprecated("using kyuubi.frontend.thrift.binary.bind.port instead", "1.4.0") val FRONTEND_BIND_PORT: ConfigEntry[Int] = buildConf("kyuubi.frontend.bind.port") .doc("(deprecated) Port of the machine on which to run the thrift frontend service " + - "via binary protocol.") + "via the binary protocol.") .version("1.0.0") .serverOnly .intConf @@ -472,7 +474,8 @@ object KyuubiConf { val FRONTEND_THRIFT_BINARY_BIND_PORT: ConfigEntry[Int] = buildConf("kyuubi.frontend.thrift.binary.bind.port") - .doc("Port of the machine on which to run the thrift frontend service via binary protocol.") + .doc("Port of the machine on which to run the thrift frontend service " + + "via the binary protocol.") .version("1.4.0") .serverOnly .fallbackConf(FRONTEND_BIND_PORT) @@ -496,7 +499,7 @@ object KyuubiConf { val FRONTEND_MIN_WORKER_THREADS: ConfigEntry[Int] = buildConf("kyuubi.frontend.min.worker.threads") - .doc("(deprecated) Minimum number of threads in the of frontend worker thread pool for " + + .doc("(deprecated) Minimum number of threads in the frontend worker thread pool for " + "the thrift frontend service") .version("1.0.0") .intConf @@ -504,14 +507,14 @@ object KyuubiConf { val FRONTEND_THRIFT_MIN_WORKER_THREADS: ConfigEntry[Int] = buildConf("kyuubi.frontend.thrift.min.worker.threads") - .doc("Minimum number of threads in the of frontend worker thread pool for the thrift " + + .doc("Minimum number of threads in the frontend worker thread pool for the thrift " + "frontend service") .version("1.4.0") .fallbackConf(FRONTEND_MIN_WORKER_THREADS) val FRONTEND_MAX_WORKER_THREADS: ConfigEntry[Int] = buildConf("kyuubi.frontend.max.worker.threads") - .doc("(deprecated) Maximum number of threads in the of frontend worker thread pool for " + + .doc("(deprecated) Maximum number of threads in the frontend worker thread pool for " + "the thrift frontend service") .version("1.0.0") .intConf @@ -519,14 +522,14 @@ object KyuubiConf { val FRONTEND_THRIFT_MAX_WORKER_THREADS: ConfigEntry[Int] = buildConf("kyuubi.frontend.thrift.max.worker.threads") - .doc("Maximum number of threads in the of frontend worker thread pool for the thrift " + + .doc("Maximum number of threads in the frontend worker thread pool for the thrift " + "frontend service") .version("1.4.0") .fallbackConf(FRONTEND_MAX_WORKER_THREADS) val FRONTEND_REST_MAX_WORKER_THREADS: ConfigEntry[Int] = buildConf("kyuubi.frontend.rest.max.worker.threads") - .doc("Maximum number of threads in the of frontend worker thread pool for the rest " + + .doc("Maximum number of threads in the frontend worker thread pool for the rest " + "frontend service") .version("1.6.2") .fallbackConf(FRONTEND_MAX_WORKER_THREADS) @@ -624,7 +627,7 @@ object KyuubiConf { val FRONTEND_THRIFT_HTTP_COOKIE_AUTH_ENABLED: ConfigEntry[Boolean] = buildConf("kyuubi.frontend.thrift.http.cookie.auth.enabled") .doc("When true, Kyuubi in HTTP transport mode, " + - "will use cookie based authentication mechanism") + "will use cookie-based authentication mechanism") .version("1.6.0") .booleanConf .createWithDefault(true) @@ -659,7 +662,7 @@ object KyuubiConf { val FRONTEND_THRIFT_HTTP_XSRF_FILTER_ENABLED: ConfigEntry[Boolean] = buildConf("kyuubi.frontend.thrift.http.xsrf.filter.enabled") - .doc("If enabled, Kyuubi will block any requests made to it over http " + + .doc("If enabled, Kyuubi will block any requests made to it over HTTP " + "if an X-XSRF-HEADER header is not present") .version("1.6.0") .booleanConf @@ -699,7 +702,7 @@ object KyuubiConf { val FRONTEND_THRIFT_HTTP_SSL_EXCLUDE_CIPHER_SUITES: ConfigEntry[Seq[String]] = buildConf("kyuubi.frontend.thrift.http.ssl.exclude.ciphersuites") - .doc("A comma separated list of exclude SSL cipher suite names for thrift http frontend.") + .doc("A comma-separated list of exclude SSL cipher suite names for thrift http frontend.") .version("1.7.0") .stringConf .toSequence() @@ -715,18 +718,19 @@ object KyuubiConf { val FRONTEND_PROXY_HTTP_CLIENT_IP_HEADER: ConfigEntry[String] = buildConf("kyuubi.frontend.proxy.http.client.ip.header") - .doc("The http header to record the real client ip address. If your server is behind a load" + + .doc("The HTTP header to record the real client IP address. If your server is behind a load" + " balancer or other proxy, the server will see this load balancer or proxy IP address as" + " the client IP address, to get around this common issue, most load balancers or proxies" + " offer the ability to record the real remote IP address in an HTTP header that will be" + " added to the request for other devices to use. Note that, because the header value can" + - " be specified to any ip address, so it will not be used for authentication.") + " be specified to any IP address, so it will not be used for authentication.") .version("1.6.0") .stringConf .createWithDefault("X-Real-IP") val AUTHENTICATION_METHOD: ConfigEntry[Seq[String]] = buildConf("kyuubi.authentication") - .doc("A comma separated list of client authentication types.
        " + + .doc("A comma-separated list of client authentication types." + + "
          " + "
        • NOSASL: raw transport.
        • " + "
        • NONE: no authentication check.
        • " + "
        • KERBEROS: Kerberos/GSSAPI authentication.
        • " + @@ -734,11 +738,28 @@ object KyuubiConf { "
        • JDBC: JDBC query authentication.
        • " + "
        • LDAP: Lightweight Directory Access Protocol authentication.
        • " + "
        " + - " Note that: For KERBEROS, it is SASL/GSSAPI mechanism," + - " and for NONE, CUSTOM and LDAP, they are all SASL/PLAIN mechanism." + - " If only NOSASL is specified, the authentication will be NOSASL." + - " For SASL authentication, KERBEROS and PLAIN auth type are supported at the same time," + - " and only the first specified PLAIN auth type is valid.") + "The following tree describes the catalog of each option." + + "
          " + + "
        • NOSASL
        • " + + "
        • SASL" + + "
            " + + "
          • SASL/PLAIN
          • " + + "
              " + + "
            • NONE
            • " + + "
            • LDAP
            • " + + "
            • JDBC
            • " + + "
            • CUSTOM
            • " + + "
            " + + "
          • SASL/GSSAPI" + + "
              " + + "
            • KERBEROS
            • " + + "
            " + + "
          • " + + "
          " + + "
        • " + + "
        " + + " Note that: for SASL authentication, KERBEROS and PLAIN auth types are supported" + + " at the same time, and only the first specified PLAIN auth type is valid.") .version("1.0.0") .serverOnly .stringConf @@ -754,6 +775,7 @@ object KyuubiConf { .doc("User-defined authentication implementation of " + "org.apache.kyuubi.service.authentication.PasswdAuthenticationProvider") .version("1.3.0") + .serverOnly .stringConf .createOptional @@ -761,13 +783,16 @@ object KyuubiConf { buildConf("kyuubi.authentication.ldap.url") .doc("SPACE character separated LDAP connection URL(s).") .version("1.0.0") + .serverOnly .stringConf .createOptional - val AUTHENTICATION_LDAP_BASEDN: OptionalConfigEntry[String] = - buildConf("kyuubi.authentication.ldap.base.dn") + val AUTHENTICATION_LDAP_BASE_DN: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.baseDN") + .withAlternative("kyuubi.authentication.ldap.base.dn") .doc("LDAP base DN.") - .version("1.0.0") + .version("1.7.0") + .serverOnly .stringConf .createOptional @@ -775,21 +800,129 @@ object KyuubiConf { buildConf("kyuubi.authentication.ldap.domain") .doc("LDAP domain.") .version("1.0.0") + .serverOnly .stringConf .createOptional - val AUTHENTICATION_LDAP_GUIDKEY: ConfigEntry[String] = + val AUTHENTICATION_LDAP_GROUP_DN_PATTERN: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.groupDNPattern") + .doc("COLON-separated list of patterns to use to find DNs for group entities in " + + "this directory. Use %s where the actual group name is to be substituted for. " + + "For example: CN=%s,CN=Groups,DC=subdomain,DC=domain,DC=com.") + .version("1.7.0") + .serverOnly + .stringConf + .createOptional + + val AUTHENTICATION_LDAP_USER_DN_PATTERN: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.userDNPattern") + .doc("COLON-separated list of patterns to use to find DNs for users in this directory. " + + "Use %s where the actual group name is to be substituted for. " + + "For example: CN=%s,CN=Users,DC=subdomain,DC=domain,DC=com.") + .version("1.7.0") + .serverOnly + .stringConf + .createOptional + + val AUTHENTICATION_LDAP_GROUP_FILTER: ConfigEntry[Seq[String]] = + buildConf("kyuubi.authentication.ldap.groupFilter") + .doc("COMMA-separated list of LDAP Group names (short name not full DNs). " + + "For example: HiveAdmins,HadoopAdmins,Administrators") + .version("1.7.0") + .serverOnly + .stringConf + .toSequence() + .createWithDefault(Nil) + + val AUTHENTICATION_LDAP_USER_FILTER: ConfigEntry[Seq[String]] = + buildConf("kyuubi.authentication.ldap.userFilter") + .doc("COMMA-separated list of LDAP usernames (just short names, not full DNs). " + + "For example: hiveuser,impalauser,hiveadmin,hadoopadmin") + .version("1.7.0") + .serverOnly + .stringConf + .toSequence() + .createWithDefault(Nil) + + val AUTHENTICATION_LDAP_GUID_KEY: ConfigEntry[String] = buildConf("kyuubi.authentication.ldap.guidKey") - .doc("LDAP attribute name whose values are unique in this LDAP server." + - "For example:uid or cn.") + .doc("LDAP attribute name whose values are unique in this LDAP server. " + + "For example: uid or CN.") .version("1.2.0") + .serverOnly .stringConf .createWithDefault("uid") + val AUTHENTICATION_LDAP_GROUP_MEMBERSHIP_KEY: ConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.groupMembershipKey") + .doc("LDAP attribute name on the group object that contains the list of distinguished " + + "names for the user, group, and contact objects that are members of the group. " + + "For example: member, uniqueMember or memberUid") + .version("1.7.0") + .serverOnly + .stringConf + .createWithDefault("member") + + val AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.userMembershipKey") + .doc("LDAP attribute name on the user object that contains groups of which the user is " + + "a direct member, except for the primary group, which is represented by the " + + "primaryGroupId. For example: memberOf") + .version("1.7.0") + .serverOnly + .stringConf + .createOptional + + val AUTHENTICATION_LDAP_GROUP_CLASS_KEY: ConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.groupClassKey") + .doc("LDAP attribute name on the group entry that is to be used in LDAP group searches. " + + "For example: group, groupOfNames or groupOfUniqueNames.") + .version("1.7.0") + .serverOnly + .stringConf + .createWithDefault("groupOfNames") + + val AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.customLDAPQuery") + .doc("A full LDAP query that LDAP Atn provider uses to execute against LDAP Server. " + + "If this query returns a null resultset, the LDAP Provider fails the Authentication " + + "request, succeeds if the user is part of the resultset." + + "For example: `(&(objectClass=group)(objectClass=top)(instanceType=4)(cn=Domain*))`, " + + "`(&(objectClass=person)(|(sAMAccountName=admin)" + + "(|(memberOf=CN=Domain Admins,CN=Users,DC=domain,DC=com)" + + "(memberOf=CN=Administrators,CN=Builtin,DC=domain,DC=com))))`") + .version("1.7.0") + .serverOnly + .stringConf + .createOptional + + val AUTHENTICATION_LDAP_BIND_USER: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.binddn") + .doc("The user with which to bind to the LDAP server, and search for the full domain name " + + "of the user being authenticated. This should be the full domain name of the user, and " + + "should have search access across all users in the LDAP tree. If not specified, then " + + "the user being authenticated will be used as the bind user. " + + "For example: CN=bindUser,CN=Users,DC=subdomain,DC=domain,DC=com") + .version("1.7.0") + .serverOnly + .stringConf + .createOptional + + val AUTHENTICATION_LDAP_BIND_PASSWORD: OptionalConfigEntry[String] = + buildConf("kyuubi.authentication.ldap.bindpw") + .doc("The password for the bind user, to be used to search for the full name of the " + + "user being authenticated. If the username is specified, this parameter must also be " + + "specified.") + .version("1.7.0") + .serverOnly + .stringConf + .createOptional + val AUTHENTICATION_JDBC_DRIVER: OptionalConfigEntry[String] = buildConf("kyuubi.authentication.jdbc.driver.class") .doc("Driver class name for JDBC Authentication Provider.") .version("1.6.0") + .serverOnly .stringConf .createOptional @@ -797,6 +930,7 @@ object KyuubiConf { buildConf("kyuubi.authentication.jdbc.url") .doc("JDBC URL for JDBC Authentication Provider.") .version("1.6.0") + .serverOnly .stringConf .createOptional @@ -804,6 +938,7 @@ object KyuubiConf { buildConf("kyuubi.authentication.jdbc.user") .doc("Database user for JDBC Authentication Provider.") .version("1.6.0") + .serverOnly .stringConf .createOptional @@ -811,6 +946,7 @@ object KyuubiConf { buildConf("kyuubi.authentication.jdbc.password") .doc("Database password for JDBC Authentication Provider.") .version("1.6.0") + .serverOnly .stringConf .createOptional @@ -822,6 +958,7 @@ object KyuubiConf { "The SQL statement must start with the `SELECT` clause. " + "Available placeholders are `${user}` and `${password}`.") .version("1.6.0") + .serverOnly .stringConf .createOptional @@ -860,6 +997,7 @@ object KyuubiConf { "
      • auth-conf - authentication plus integrity and confidentiality protection. This is" + " applicable only if Kyuubi is configured to use Kerberos authentication.
      ") .version("1.0.0") + .serverOnly .stringConf .checkValues(SaslQOP.values.map(_.toString)) .transform(_.toLowerCase(Locale.ROOT)) @@ -954,14 +1092,14 @@ object KyuubiConf { val FRONTEND_TRINO_MAX_WORKER_THREADS: ConfigEntry[Int] = buildConf("kyuubi.frontend.trino.max.worker.threads") - .doc("Maximum number of threads in the of frontend worker thread pool for the trino " + + .doc("Maximum number of threads in the frontend worker thread pool for the Trino " + "frontend service") .version("1.7.0") .fallbackConf(FRONTEND_MAX_WORKER_THREADS) val KUBERNETES_CONTEXT: OptionalConfigEntry[String] = buildConf("kyuubi.kubernetes.context") - .doc("The desired context from your kubernetes config file used to configure the K8S " + + .doc("The desired context from your kubernetes config file used to configure the K8s " + "client for interacting with the cluster.") .version("1.6.0") .stringConf @@ -993,8 +1131,8 @@ object KyuubiConf { val KUBERNETES_AUTHENTICATE_OAUTH_TOKEN: OptionalConfigEntry[String] = buildConf("kyuubi.kubernetes.authenticate.oauthToken") .doc("The OAuth token to use when authenticating against the Kubernetes API server. " + - "Note that unlike the other authentication options, this must be the exact string value " + - "of the token to use for the authentication.") + "Note that unlike, the other authentication options, this must be the exact string value" + + " of the token to use for the authentication.") .version("1.7.0") .stringConf .createOptional @@ -1033,14 +1171,23 @@ object KyuubiConf { .booleanConf .createWithDefault(false) + val KUBERNETES_TERMINATED_APPLICATION_RETAIN_PERIOD: ConfigEntry[Long] = + buildConf("kyuubi.kubernetes.terminatedApplicationRetainPeriod") + .doc("The period for which the Kyuubi server retains application information after " + + "the application terminates.") + .version("1.7.1") + .timeConf + .checkValue(_ > 0, "must be positive number") + .createWithDefault(Duration.ofMinutes(5).toMillis) + // /////////////////////////////////////////////////////////////////////////////////////////////// // SQL Engine Configuration // // /////////////////////////////////////////////////////////////////////////////////////////////// val ENGINE_ERROR_MAX_SIZE: ConfigEntry[Int] = buildConf("kyuubi.session.engine.startup.error.max.size") - .doc("During engine bootstrapping, if error occurs, using this config to limit the length" + - " error message(characters).") + .doc("During engine bootstrapping, if anderror occurs, using this config to limit" + + " the length of error message(characters).") .version("1.1.0") .intConf .checkValue(v => v >= 200 && v <= 8192, s"must in [200, 8192]") @@ -1067,7 +1214,7 @@ object KyuubiConf { .doc("Specify a profile to load session-level configurations from " + "`$KYUUBI_CONF_DIR/kyuubi-session-.conf`. " + "This configuration will be ignored if the file does not exist. " + - "This configuration only has effect when `kyuubi.session.conf.advisor` " + + "This configuration only takes effect when `kyuubi.session.conf.advisor` " + "is set as `org.apache.kyuubi.session.FileSessionConfAdvisor`.") .version("1.7.0") .stringConf @@ -1084,7 +1231,7 @@ object KyuubiConf { val ENGINE_SPARK_MAX_LIFETIME: ConfigEntry[Long] = buildConf("kyuubi.session.engine.spark.max.lifetime") - .doc("Max lifetime for spark engine, the engine will self-terminate when it reaches the" + + .doc("Max lifetime for Spark engine, the engine will self-terminate when it reaches the" + " end of life. 0 or negative means not to self-terminate.") .version("1.6.0") .timeConf @@ -1100,7 +1247,7 @@ object KyuubiConf { val ENGINE_FLINK_MAX_ROWS: ConfigEntry[Int] = buildConf("kyuubi.session.engine.flink.max.rows") - .doc("Max rows of Flink query results. For batch queries, rows that exceeds the limit " + + .doc("Max rows of Flink query results. For batch queries, rows exceeding the limit " + "would be ignored. For streaming queries, the query would be canceled if the limit " + "is reached.") .version("1.5.0") @@ -1117,28 +1264,28 @@ object KyuubiConf { val ENGINE_TRINO_CONNECTION_URL: OptionalConfigEntry[String] = buildConf("kyuubi.session.engine.trino.connection.url") - .doc("The server url that trino engine will connect to") + .doc("The server url that Trino engine will connect to") .version("1.5.0") .stringConf .createOptional val ENGINE_TRINO_CONNECTION_CATALOG: OptionalConfigEntry[String] = buildConf("kyuubi.session.engine.trino.connection.catalog") - .doc("The default catalog that trino engine will connect to") + .doc("The default catalog that Trino engine will connect to") .version("1.5.0") .stringConf .createOptional val ENGINE_TRINO_SHOW_PROGRESS: ConfigEntry[Boolean] = buildConf("kyuubi.session.engine.trino.showProgress") - .doc("When true, show the progress bar and final info in the trino engine log.") + .doc("When true, show the progress bar and final info in the Trino engine log.") .version("1.6.0") .booleanConf .createWithDefault(true) val ENGINE_TRINO_SHOW_PROGRESS_DEBUG: ConfigEntry[Boolean] = buildConf("kyuubi.session.engine.trino.showProgress.debug") - .doc("When true, show the progress debug info in the trino engine log.") + .doc("When true, show the progress debug info in the Trino engine log.") .version("1.6.0") .booleanConf .createWithDefault(false) @@ -1160,7 +1307,7 @@ object KyuubiConf { val ENGINE_ALIVE_PROBE_ENABLED: ConfigEntry[Boolean] = buildConf("kyuubi.session.engine.alive.probe.enabled") .doc("Whether to enable the engine alive probe, it true, we will create a companion thrift" + - " client that sends simple request to check whether the engine is keep alive.") + " client that keeps sending simple requests to check whether the engine is alive.") .version("1.6.0") .booleanConf .createWithDefault(false) @@ -1189,7 +1336,7 @@ object KyuubiConf { val ENGINE_OPEN_RETRY_WAIT: ConfigEntry[Long] = buildConf("kyuubi.session.engine.open.retry.wait") - .doc("How long to wait before retrying to open engine after a failure.") + .doc("How long to wait before retrying to open the engine after failure.") .version("1.7.0") .timeConf .createWithDefault(Duration.ofSeconds(10).toMillis) @@ -1220,6 +1367,14 @@ object KyuubiConf { .version("1.2.0") .fallbackConf(SESSION_TIMEOUT) + val SESSION_CLOSE_ON_DISCONNECT: ConfigEntry[Boolean] = + buildConf("kyuubi.session.close.on.disconnect") + .doc("Session will be closed when client disconnects from kyuubi gateway. " + + "Set this to false to have session outlive its parent connection.") + .version("1.8.0") + .booleanConf + .createWithDefault(true) + val BATCH_SESSION_IDLE_TIMEOUT: ConfigEntry[Long] = buildConf("kyuubi.batch.session.idle.timeout") .doc("Batch session idle timeout, it will be closed when it's not accessed for this duration") .version("1.6.2") @@ -1241,7 +1396,7 @@ object KyuubiConf { val SESSION_CONF_IGNORE_LIST: ConfigEntry[Seq[String]] = buildConf("kyuubi.session.conf.ignore.list") - .doc("A comma separated list of ignored keys. If the client connection contains any of" + + .doc("A comma-separated list of ignored keys. If the client connection contains any of" + " them, the key and the corresponding value will be removed silently during engine" + " bootstrap and connection setup." + " Note that this rule is for server-side protection defined via administrators to" + @@ -1254,7 +1409,7 @@ object KyuubiConf { val SESSION_CONF_RESTRICT_LIST: ConfigEntry[Seq[String]] = buildConf("kyuubi.session.conf.restrict.list") - .doc("A comma separated list of restricted keys. If the client connection contains any of" + + .doc("A comma-separated list of restricted keys. If the client connection contains any of" + " them, the connection will be rejected explicitly during engine bootstrap and connection" + " setup." + " Note that this rule is for server-side protection defined via administrators to" + @@ -1268,15 +1423,16 @@ object KyuubiConf { val SESSION_USER_SIGN_ENABLED: ConfigEntry[Boolean] = buildConf("kyuubi.session.user.sign.enabled") .doc("Whether to verify the integrity of session user name" + - " on engine side, e.g. Authz plugin in Spark.") + " on the engine side, e.g. Authz plugin in Spark.") .version("1.7.0") .booleanConf .createWithDefault(false) val SESSION_ENGINE_STARTUP_MAX_LOG_LINES: ConfigEntry[Int] = buildConf("kyuubi.session.engine.startup.maxLogLines") - .doc("The maximum number of engine log lines when errors occur during engine startup phase." + - " Note that this max lines is for client-side to help track engine startup issue.") + .doc("The maximum number of engine log lines when errors occur during the engine" + + " startup phase. Note that this config effects on client-side to" + + " help track engine startup issues.") .version("1.4.0") .intConf .checkValue(_ > 0, "the maximum must be positive integer.") @@ -1284,17 +1440,17 @@ object KyuubiConf { val SESSION_ENGINE_STARTUP_WAIT_COMPLETION: ConfigEntry[Boolean] = buildConf("kyuubi.session.engine.startup.waitCompletion") - .doc("Whether to wait for completion after engine starts." + + .doc("Whether to wait for completion after the engine starts." + " If false, the startup process will be destroyed after the engine is started." + " Note that only use it when the driver is not running locally," + - " such as yarn-cluster mode; Otherwise, the engine will be killed.") + " such as in yarn-cluster mode; Otherwise, the engine will be killed.") .version("1.5.0") .booleanConf .createWithDefault(true) val SESSION_ENGINE_LAUNCH_ASYNC: ConfigEntry[Boolean] = buildConf("kyuubi.session.engine.launch.async") - .doc("When opening kyuubi session, whether to launch backend engine asynchronously." + + .doc("When opening kyuubi session, whether to launch the backend engine asynchronously." + " When true, the Kyuubi server will set up the connection with the client without delay" + " as the backend engine will be created asynchronously.") .version("1.4.0") @@ -1303,11 +1459,12 @@ object KyuubiConf { val SESSION_LOCAL_DIR_ALLOW_LIST: ConfigEntry[Seq[String]] = buildConf("kyuubi.session.local.dir.allow.list") - .doc("The local dir list that are allowed to access by the kyuubi session application. User" + - " might set some parameters such as `spark.files` and it will upload some local files" + - " when launching the kyuubi engine, if the local dir allow list is defined, kyuubi will" + + .doc("The local dir list that are allowed to access by the kyuubi session application. " + + " End-users might set some parameters such as `spark.files` and it will " + + " upload some local files when launching the kyuubi engine," + + " if the local dir allow list is defined, kyuubi will" + " check whether the path to upload is in the allow list. Note that, if it is empty, there" + - " is no limitation for that and please use absolute path list.") + " is no limitation for that. And please use absolute paths.") .version("1.6.0") .serverOnly .stringConf @@ -1332,14 +1489,14 @@ object KyuubiConf { val BATCH_CONF_IGNORE_LIST: ConfigEntry[Seq[String]] = buildConf("kyuubi.batch.conf.ignore.list") - .doc("A comma separated list of ignored keys for batch conf. If the batch conf contains" + + .doc("A comma-separated list of ignored keys for batch conf. If the batch conf contains" + " any of them, the key and the corresponding value will be removed silently during batch" + " job submission." + " Note that this rule is for server-side protection defined via administrators to" + " prevent some essential configs from tampering." + - " You can also pre-define some config for batch job submission with prefix:" + + " You can also pre-define some config for batch job submission with the prefix:" + " kyuubi.batchConf.[batchType]. For example, you can pre-define `spark.master`" + - " for spark batch job with key `kyuubi.batchConf.spark.spark.master`.") + " for the Spark batch job with key `kyuubi.batchConf.spark.spark.master`.") .version("1.6.0") .stringConf .toSequence() @@ -1373,6 +1530,14 @@ object KyuubiConf { .timeConf .createWithDefault(Duration.ofSeconds(5).toMillis) + val BATCH_RESOURCE_UPLOAD_ENABLED: ConfigEntry[Boolean] = + buildConf("kyuubi.batch.resource.upload.enabled") + .internal + .doc("Whether to enable Kyuubi batch resource upload function.") + .version("1.7.1") + .booleanConf + .createWithDefault(true) + val SERVER_EXEC_POOL_SIZE: ConfigEntry[Int] = buildConf("kyuubi.backend.server.exec.pool.size") .doc("Number of threads in the operation execution thread pool of Kyuubi server") @@ -1403,14 +1568,14 @@ object KyuubiConf { val METADATA_CLEANER_ENABLED: ConfigEntry[Boolean] = buildConf("kyuubi.metadata.cleaner.enabled") .doc("Whether to clean the metadata periodically. If it is enabled, Kyuubi will clean the" + - " metadata that is in terminate state with max age limitation.") + " metadata that is in the terminate state with max age limitation.") .version("1.6.0") .booleanConf .createWithDefault(true) val METADATA_MAX_AGE: ConfigEntry[Long] = buildConf("kyuubi.metadata.max.age") - .doc("The maximum age of metadata, the metadata that exceeds the age will be cleaned.") + .doc("The maximum age of metadata, the metadata exceeding the age will be cleaned.") .version("1.6.0") .timeConf .createWithDefault(Duration.ofDays(3).toMillis) @@ -1424,16 +1589,8 @@ object KyuubiConf { val METADATA_RECOVERY_THREADS: ConfigEntry[Int] = buildConf("kyuubi.metadata.recovery.threads") - .doc("The number of threads for recovery from metadata store when Kyuubi server restarting.") - .version("1.6.0") - .intConf - .createWithDefault(10) - - val METADATA_REQUEST_RETRY_THREADS: ConfigEntry[Int] = - buildConf("kyuubi.metadata.request.retry.threads") - .doc("Number of threads in the metadata request retry manager thread pool. The metadata" + - " store might be unavailable sometimes and the requests will fail, to tolerant for this" + - " case and unblock the main thread, we support to retry the failed requests in async way.") + .doc("The number of threads for recovery from the metadata store " + + "when the Kyuubi server restarts.") .version("1.6.0") .intConf .createWithDefault(10) @@ -1445,10 +1602,31 @@ object KyuubiConf { .timeConf .createWithDefault(Duration.ofSeconds(5).toMillis) - val METADATA_REQUEST_RETRY_QUEUE_SIZE: ConfigEntry[Int] = - buildConf("kyuubi.metadata.request.retry.queue.size") + val METADATA_REQUEST_ASYNC_RETRY_ENABLED: ConfigEntry[Boolean] = + buildConf("kyuubi.metadata.request.async.retry.enabled") + .doc("Whether to retry in async when metadata request failed. When true, return " + + "success response immediately even the metadata request failed, and schedule " + + "it in background until success, to tolerate long-time metadata store outages " + + "w/o blocking the submission request.") + .version("1.7.0") + .booleanConf + .createWithDefault(true) + + val METADATA_REQUEST_ASYNC_RETRY_THREADS: ConfigEntry[Int] = + buildConf("kyuubi.metadata.request.async.retry.threads") + .withAlternative("kyuubi.metadata.request.retry.threads") + .doc("Number of threads in the metadata request async retry manager thread pool. Only " + + s"take affect when ${METADATA_REQUEST_ASYNC_RETRY_ENABLED.key} is `true`.") + .version("1.6.0") + .intConf + .createWithDefault(10) + + val METADATA_REQUEST_ASYNC_RETRY_QUEUE_SIZE: ConfigEntry[Int] = + buildConf("kyuubi.metadata.request.async.retry.queue.size") + .withAlternative("kyuubi.metadata.request.retry.queue.size") .doc("The maximum queue size for buffering metadata requests in memory when the external" + - " metadata storage is down. Requests will be dropped if the queue exceeds.") + " metadata storage is down. Requests will be dropped if the queue exceeds. Only" + + s" take affect when ${METADATA_REQUEST_ASYNC_RETRY_ENABLED.key} is `true`.") .version("1.6.0") .intConf .createWithDefault(65536) @@ -1514,22 +1692,32 @@ object KyuubiConf { val OPERATION_QUERY_TIMEOUT: OptionalConfigEntry[Long] = buildConf("kyuubi.operation.query.timeout") - .doc("Timeout for query executions at server-side, take affect with client-side timeout(" + + .doc("Timeout for query executions at server-side, take effect with client-side timeout(" + "`java.sql.Statement.setQueryTimeout`) together, a running query will be cancelled" + - " automatically if timeout. It's off by default, which means only client-side take fully" + - " control whether the query should timeout or not. If set, client-side timeout capped at" + - " this point. To cancel the queries right away without waiting task to finish, consider" + - s" enabling ${OPERATION_FORCE_CANCEL.key} together.") + " automatically if timeout. It's off by default, which means only client-side take full" + + " control of whether the query should timeout or not." + + " If set, client-side timeout is capped at this point." + + " To cancel the queries right away without waiting for task to finish," + + s" consider enabling ${OPERATION_FORCE_CANCEL.key} together.") .version("1.2.0") .timeConf .checkValue(_ >= 1000, "must >= 1s if set") .createOptional + val OPERATION_RESULT_MAX_ROWS: ConfigEntry[Int] = + buildConf("kyuubi.operation.result.max.rows") + .doc("Max rows of Spark query results. Rows exceeding the limit would be ignored. " + + "By setting this value to 0 to disable the max rows limit.") + .version("1.6.0") + .intConf + .createWithDefault(0) + val OPERATION_INCREMENTAL_COLLECT: ConfigEntry[Boolean] = buildConf("kyuubi.operation.incremental.collect") .internal .doc("When true, the executor side result will be sequentially calculated and returned to" + - " the Spark driver side.") + s" the Spark driver side. Note that, ${OPERATION_RESULT_MAX_ROWS.key} will be ignored" + + " on incremental collect mode.") .version("1.4.0") .booleanConf .createWithDefault(false) @@ -1547,13 +1735,13 @@ object KyuubiConf { .transform(_.toLowerCase(Locale.ROOT)) .createWithDefault("thrift") - val OPERATION_RESULT_MAX_ROWS: ConfigEntry[Int] = - buildConf("kyuubi.operation.result.max.rows") - .doc("Max rows of Spark query results. Rows that exceeds the limit would be ignored. " + - "By setting this value to 0 to disable the max rows limit.") - .version("1.6.0") - .intConf - .createWithDefault(0) + val ARROW_BASED_ROWSET_TIMESTAMP_AS_STRING: ConfigEntry[Boolean] = + buildConf("kyuubi.operation.result.arrow.timestampAsString") + .doc("When true, arrow-based rowsets will convert columns of type timestamp to strings for" + + " transmission.") + .version("1.7.0") + .booleanConf + .createWithDefault(false) val SERVER_OPERATION_LOG_DIR_ROOT: ConfigEntry[String] = buildConf("kyuubi.operation.log.dir.root") @@ -1591,8 +1779,8 @@ object KyuubiConf { val ENGINE_SHARE_LEVEL_SUBDOMAIN: ConfigEntry[Option[String]] = buildConf("kyuubi.engine.share.level.subdomain") .doc("Allow end-users to create a subdomain for the share level of an engine. A" + - " subdomain is a case-insensitive string values that must be a valid zookeeper sub path." + - " For example, for `USER` share level, an end-user can share a certain engine within" + + " subdomain is a case-insensitive string values that must be a valid zookeeper subpath." + + " For example, for the `USER` share level, an end-user can share a certain engine within" + " a subdomain, not for all of its clients. End-users are free to create multiple" + " engines in the `USER` share level. When disable engine pool, use 'default' if absent.") .version("1.4.0") @@ -1602,7 +1790,7 @@ object KyuubiConf { val ENGINE_CONNECTION_URL_USE_HOSTNAME: ConfigEntry[Boolean] = buildConf("kyuubi.engine.connection.url.use.hostname") .doc("(deprecated) " + - "When true, engine register with hostname to zookeeper. When spark run on k8s" + + "When true, the engine registers with hostname to zookeeper. When Spark runs on K8s" + " with cluster mode, set to false to ensure that server can connect to engine") .version("1.3.0") .booleanConf @@ -1612,7 +1800,7 @@ object KyuubiConf { buildConf("kyuubi.frontend.connection.url.use.hostname") .doc("When true, frontend services prefer hostname, otherwise, ip address. Note that, " + "the default value is set to `false` when engine running on Kubernetes to prevent " + - "potential network issue.") + "potential network issues.") .version("1.5.0") .fallbackConf(ENGINE_CONNECTION_URL_USE_HOSTNAME) @@ -1622,10 +1810,11 @@ object KyuubiConf { " connection" + "
    • USER: engine will be shared by all sessions created by a unique username," + s" see also ${ENGINE_SHARE_LEVEL_SUBDOMAIN.key}
    • " + - "
    • GROUP: engine will be shared by all sessions created by all users belong to the same" + - " primary group name. The engine will be launched by the group name as the effective" + - " username, so here the group name is kind of special user who is able to visit the" + - " compute resources/data of a team. It follows the" + + "
    • GROUP: the engine will be shared by all sessions created" + + " by all users belong to the same primary group name." + + " The engine will be launched by the group name as the effective" + + " username, so here the group name is in value of special user who is able to visit the" + + " computing resources/data of the team. It follows the" + " [Hadoop GroupsMapping](https://reurl.cc/xE61Y5) to map user to a primary group. If the" + " primary group is not found, it fallback to the USER level." + "
    • SERVER: the App will be shared by Kyuubi servers
    ") @@ -1633,7 +1822,7 @@ object KyuubiConf { .fallbackConf(LEGACY_ENGINE_SHARE_LEVEL) val ENGINE_TYPE: ConfigEntry[String] = buildConf("kyuubi.engine.type") - .doc("Specify the detailed engine that supported by the Kyuubi. The engine type bindings to" + + .doc("Specify the detailed engine supported by Kyuubi. The engine type bindings to" + " SESSION scope. This configuration is experimental. Currently, available configs are:
      " + "
    • SPARK_SQL: specify this engine type will launch a Spark engine which can provide" + " all the capacity of the Apache Spark. Note, it's a default engine type.
    • " + @@ -1644,7 +1833,8 @@ object KyuubiConf { "
    • HIVE_SQL: specify this engine type will launch a Hive engine which can provide" + " all the capacity of the Hive Server2.
    • " + "
    • JDBC: specify this engine type will launch a JDBC engine which can provide" + - " a mysql protocol connector, for now we only support Doris dialect.
    • " + + " a MySQL protocol connector, for now we only support Doris dialect." + + "
    • CHAT: specify this engine type will launch a Chat engine.
    • " + "
    ") .version("1.4.0") .stringConf @@ -1662,22 +1852,22 @@ object KyuubiConf { .createWithDefault(false) val ENGINE_POOL_NAME: ConfigEntry[String] = buildConf("kyuubi.engine.pool.name") - .doc("The name of engine pool.") + .doc("The name of the engine pool.") .version("1.5.0") .stringConf .checkValue(validZookeeperSubPath.matcher(_).matches(), "must be valid zookeeper sub path.") .createWithDefault("engine-pool") val ENGINE_POOL_SIZE_THRESHOLD: ConfigEntry[Int] = buildConf("kyuubi.engine.pool.size.threshold") - .doc("This parameter is introduced as a server-side parameter, " + - "and controls the upper limit of the engine pool.") + .doc("This parameter is introduced as a server-side parameter " + + "controlling the upper limit of the engine pool.") .version("1.4.0") .intConf .checkValue(s => s > 0 && s < 33, "Invalid engine pool threshold, it should be in [1, 32]") .createWithDefault(9) val ENGINE_POOL_SIZE: ConfigEntry[Int] = buildConf("kyuubi.engine.pool.size") - .doc("The size of engine pool. Note that, " + + .doc("The size of the engine pool. Note that, " + "if the size is less than 1, the engine pool will not be enabled; " + "otherwise, the size of the engine pool will be " + s"min(this, ${ENGINE_POOL_SIZE_THRESHOLD.key}).") @@ -1685,7 +1875,7 @@ object KyuubiConf { .intConf .createWithDefault(-1) - val ENGINE_POOL_BALANCE_POLICY: ConfigEntry[String] = + val ENGINE_POOL_SELECT_POLICY: ConfigEntry[String] = buildConf("kyuubi.engine.pool.selectPolicy") .doc("The select policy of an engine from the corresponding engine pool engine for " + "a session.
      " + @@ -1720,7 +1910,7 @@ object KyuubiConf { val ENGINE_DEREGISTER_EXCEPTION_CLASSES: ConfigEntry[Seq[String]] = buildConf("kyuubi.engine.deregister.exception.classes") - .doc("A comma separated list of exception classes. If there is any exception thrown," + + .doc("A comma-separated list of exception classes. If there is any exception thrown," + " whose class matches the specified classes, the engine would deregister itself.") .version("1.2.0") .stringConf @@ -1729,7 +1919,7 @@ object KyuubiConf { val ENGINE_DEREGISTER_EXCEPTION_MESSAGES: ConfigEntry[Seq[String]] = buildConf("kyuubi.engine.deregister.exception.messages") - .doc("A comma separated list of exception messages. If there is any exception thrown," + + .doc("A comma-separated list of exception messages. If there is any exception thrown," + " whose message or stacktrace matches the specified message list, the engine would" + " deregister itself.") .version("1.2.0") @@ -1760,8 +1950,8 @@ object KyuubiConf { val OPERATION_SCHEDULER_POOL: OptionalConfigEntry[String] = buildConf("kyuubi.operation.scheduler.pool") - .doc("The scheduler pool of job. Note that, this config should be used after change Spark " + - "config spark.scheduler.mode=FAIR.") + .doc("The scheduler pool of job. Note that, this config should be used after changing " + + "Spark config spark.scheduler.mode=FAIR.") .version("1.1.1") .stringConf .createOptional @@ -1778,8 +1968,8 @@ object KyuubiConf { val ENGINE_USER_ISOLATED_SPARK_SESSION: ConfigEntry[Boolean] = buildConf("kyuubi.engine.user.isolated.spark.session") .doc("When set to false, if the engine is running in a group or server share level, " + - "all the JDBC/ODBC connections will be isolated against the user. Including: " + - "the temporary views, function registries, SQL configuration and the current database. " + + "all the JDBC/ODBC connections will be isolated against the user. Including " + + "the temporary views, function registries, SQL configuration, and the current database. " + "Note that, it does not affect if the share level is connection or user.") .version("1.6.0") .booleanConf @@ -1788,21 +1978,21 @@ object KyuubiConf { val ENGINE_USER_ISOLATED_SPARK_SESSION_IDLE_TIMEOUT: ConfigEntry[Long] = buildConf("kyuubi.engine.user.isolated.spark.session.idle.timeout") .doc(s"If ${ENGINE_USER_ISOLATED_SPARK_SESSION.key} is false, we will release the " + - s"spark session if its corresponding user is inactive after this configured timeout.") + s"Spark session if its corresponding user is inactive after this configured timeout.") .version("1.6.0") .timeConf .createWithDefault(Duration.ofHours(6).toMillis) val ENGINE_USER_ISOLATED_SPARK_SESSION_IDLE_INTERVAL: ConfigEntry[Long] = buildConf("kyuubi.engine.user.isolated.spark.session.idle.interval") - .doc(s"The interval to check if the user isolated spark session is timeout.") + .doc(s"The interval to check if the user-isolated Spark session is timeout.") .version("1.6.0") .timeConf .createWithDefault(Duration.ofMinutes(1).toMillis) val SERVER_EVENT_JSON_LOG_PATH: ConfigEntry[String] = buildConf("kyuubi.backend.server.event.json.log.path") - .doc("The location of server events go for the builtin JSON logger") + .doc("The location of server events go for the built-in JSON logger") .version("1.4.0") .serverOnly .stringConf @@ -1810,7 +2000,7 @@ object KyuubiConf { val ENGINE_EVENT_JSON_LOG_PATH: ConfigEntry[String] = buildConf("kyuubi.engine.event.json.log.path") - .doc("The location of all the engine events go for the builtin JSON logger.
        " + + .doc("The location where all the engine events go for the built-in JSON logger.
          " + "
        • Local Path: start with 'file://'
        • " + "
        • HDFS Path: start with 'hdfs://'
        ") .version("1.3.0") @@ -1819,7 +2009,7 @@ object KyuubiConf { val SERVER_EVENT_LOGGERS: ConfigEntry[Seq[String]] = buildConf("kyuubi.backend.server.event.loggers") - .doc("A comma separated list of server history loggers, where session/operation etc" + + .doc("A comma-separated list of server history loggers, where session/operation etc" + " events go.
          " + s"
        • JSON: the events will be written to the location of" + s" ${SERVER_EVENT_JSON_LOG_PATH.key}
        • " + @@ -1827,9 +2017,9 @@ object KyuubiConf { s"
        • CUSTOM: User-defined event handlers.
        " + " Note that: Kyuubi supports custom event handlers with the Java SPI." + " To register a custom event handler," + - " user need to implement a class" + + " the user needs to implement a class" + " which is a child of org.apache.kyuubi.events.handler.CustomEventHandlerProvider" + - " which has zero-arg constructor.") + " which has a zero-arg constructor.") .version("1.4.0") .serverOnly .stringConf @@ -1841,18 +2031,18 @@ object KyuubiConf { @deprecated("using kyuubi.engine.spark.event.loggers instead", "1.6.0") val ENGINE_EVENT_LOGGERS: ConfigEntry[Seq[String]] = buildConf("kyuubi.engine.event.loggers") - .doc("A comma separated list of engine history loggers, where engine/session/operation etc" + + .doc("A comma-separated list of engine history loggers, where engine/session/operation etc" + " events go.
          " + - "
        • SPARK: the events will be written to the spark listener bus.
        • " + + "
        • SPARK: the events will be written to the Spark listener bus.
        • " + "
        • JSON: the events will be written to the location of" + s" ${ENGINE_EVENT_JSON_LOG_PATH.key}
        • " + "
        • JDBC: to be done
        • " + "
        • CUSTOM: User-defined event handlers.
        " + " Note that: Kyuubi supports custom event handlers with the Java SPI." + " To register a custom event handler," + - " user need to implement a class" + - " which is a child of org.apache.kyuubi.events.handler.CustomEventHandlerProvider" + - " which has zero-arg constructor.") + " the user needs to implement a subclass" + + " of `org.apache.kyuubi.events.handler.CustomEventHandlerProvider`" + + " which has a zero-arg constructor.") .version("1.3.0") .stringConf .transform(_.toUpperCase(Locale.ROOT)) @@ -1914,11 +2104,26 @@ object KyuubiConf { buildConf("kyuubi.engine.security.secret.provider") .internal .doc("The class used to manage the internal security secret. This class must be a " + - "subclass of EngineSecuritySecretProvider.") + "subclass of `EngineSecuritySecretProvider`.") .version("1.5.0") .stringConf - .createWithDefault( - "org.apache.kyuubi.service.authentication.ZooKeeperEngineSecuritySecretProviderImpl") + .transform { + case "simple" => + "org.apache.kyuubi.service.authentication.SimpleEngineSecuritySecretProviderImpl" + case "zookeeper" => + "org.apache.kyuubi.service.authentication.ZooKeeperEngineSecuritySecretProviderImpl" + case other => other + } + .createWithDefault("zookeeper") + + val SIMPLE_SECURITY_SECRET_PROVIDER_PROVIDER_SECRET: OptionalConfigEntry[String] = + buildConf("kyuubi.engine.security.secret.provider.simple.secret") + .internal + .doc("The secret key used for internal security access. Only take affects when " + + s"${ENGINE_SECURITY_SECRET_PROVIDER.key} is 'simple'") + .version("1.7.0") + .stringConf + .createOptional val ENGINE_SECURITY_CRYPTO_KEY_LENGTH: ConfigEntry[Int] = buildConf("kyuubi.engine.security.crypto.keyLength") @@ -1956,8 +2161,8 @@ object KyuubiConf { val SESSION_NAME: OptionalConfigEntry[String] = buildConf("kyuubi.session.name") - .doc("A human readable name of session and we use empty string by default. " + - "This name will be recorded in event. Note that, we only apply this value from " + + .doc("A human readable name of the session and we use empty string by default. " + + "This name will be recorded in the event. Note that, we only apply this value from " + "session conf.") .version("1.4.0") .stringConf @@ -1991,8 +2196,9 @@ object KyuubiConf { val OPERATION_PLAN_ONLY_OUT_STYLE: ConfigEntry[String] = buildConf("kyuubi.operation.plan.only.output.style") - .doc("Configures the planOnly output style, The value can be 'plain' and 'json', default " + - "value is 'plain', this configuration supports only the output styles of the Spark engine") + .doc("Configures the planOnly output style. The value can be 'plain' or 'json', and " + + "the default value is 'plain'. This configuration supports only the output styles " + + "of the Spark engine") .version("1.7.0") .stringConf .transform(_.toUpperCase(Locale.ROOT)) @@ -2005,8 +2211,8 @@ object KyuubiConf { val OPERATION_PLAN_ONLY_EXCLUDES: ConfigEntry[Seq[String]] = buildConf("kyuubi.operation.plan.only.excludes") .doc("Comma-separated list of query plan names, in the form of simple class names, i.e, " + - "for `set abc=xyz`, the value will be `SetCommand`. For those auxiliary plans, such as " + - "`switch databases`, `set properties`, or `create temporary view` e.t.c, " + + "for `SET abc=xyz`, the value will be `SetCommand`. For those auxiliary plans, such as " + + "`switch databases`, `set properties`, or `create temporary view` etc., " + "which are used for setup evaluating environments for analyzing actual queries, " + "we can use this config to exclude them and let them take effect. " + s"See also ${OPERATION_PLAN_ONLY_MODE.key}.") @@ -2038,8 +2244,12 @@ object KyuubiConf { val OPERATION_LANGUAGE: ConfigEntry[String] = buildConf("kyuubi.operation.language") .doc("Choose a programing language for the following inputs" + - "
        • SQL: (Default) Run all following statements as SQL queries.
        • " + - "
        • SCALA: Run all following input a scala codes
        ") + "
          " + + "
        • SQL: (Default) Run all following statements as SQL queries.
        • " + + "
        • SCALA: Run all following input as scala codes
        • " + + "
        • PYTHON: (Experimental) Run all following input as Python codes with Spark engine" + + "
        • " + + "
        ") .version("1.5.0") .stringConf .transform(_.toUpperCase(Locale.ROOT)) @@ -2049,9 +2259,9 @@ object KyuubiConf { val SESSION_CONF_ADVISOR: OptionalConfigEntry[String] = buildConf("kyuubi.session.conf.advisor") .doc("A config advisor plugin for Kyuubi Server. This plugin can provide some custom " + - "configs for different user or session configs and overwrite the session configs before " + - "open a new session. This config value should be a class which is a child of " + - "'org.apache.kyuubi.plugin.SessionConfAdvisor' which has zero-arg constructor.") + "configs for different users or session configs and overwrite the session configs before " + + "opening a new session. This config value should be a subclass of " + + "`org.apache.kyuubi.plugin.SessionConfAdvisor` which has a zero-arg constructor.") .version("1.5.0") .stringConf .createOptional @@ -2059,9 +2269,9 @@ object KyuubiConf { val GROUP_PROVIDER: ConfigEntry[String] = buildConf("kyuubi.session.group.provider") .doc("A group provider plugin for Kyuubi Server. This plugin can provide primary group " + - "and groups information for different user or session configs. This config value " + - "should be a class which is a child of 'org.apache.kyuubi.plugin.GroupProvider' which " + - "has zero-arg constructor. Kyuubi provides the following built-in implementations: " + + "and groups information for different users or session configs. This config value " + + "should be a subclass of `org.apache.kyuubi.plugin.GroupProvider` which " + + "has a zero-arg constructor. Kyuubi provides the following built-in implementations: " + "
      • hadoop: delegate the user group mapping to hadoop UserGroupInformation.
      • ") .version("1.7.0") .stringConf @@ -2091,7 +2301,7 @@ object KyuubiConf { val ENGINE_SPARK_SHOW_PROGRESS: ConfigEntry[Boolean] = buildConf("kyuubi.session.engine.spark.showProgress") - .doc("When true, show the progress bar in the spark engine log.") + .doc("When true, show the progress bar in the Spark's engine log.") .version("1.6.0") .booleanConf .createWithDefault(false) @@ -2113,65 +2323,65 @@ object KyuubiConf { val ENGINE_TRINO_MEMORY: ConfigEntry[String] = buildConf("kyuubi.engine.trino.memory") - .doc("The heap memory for the trino query engine") + .doc("The heap memory for the Trino query engine") .version("1.6.0") .stringConf .createWithDefault("1g") val ENGINE_TRINO_JAVA_OPTIONS: OptionalConfigEntry[String] = buildConf("kyuubi.engine.trino.java.options") - .doc("The extra java options for the trino query engine") + .doc("The extra Java options for the Trino query engine") .version("1.6.0") .stringConf .createOptional val ENGINE_TRINO_EXTRA_CLASSPATH: OptionalConfigEntry[String] = buildConf("kyuubi.engine.trino.extra.classpath") - .doc("The extra classpath for the trino query engine, " + - "for configuring other libs which may need by the trino engine ") + .doc("The extra classpath for the Trino query engine, " + + "for configuring other libs which may need by the Trino engine ") .version("1.6.0") .stringConf .createOptional val ENGINE_HIVE_MEMORY: ConfigEntry[String] = buildConf("kyuubi.engine.hive.memory") - .doc("The heap memory for the hive query engine") + .doc("The heap memory for the Hive query engine") .version("1.6.0") .stringConf .createWithDefault("1g") val ENGINE_HIVE_JAVA_OPTIONS: OptionalConfigEntry[String] = buildConf("kyuubi.engine.hive.java.options") - .doc("The extra java options for the hive query engine") + .doc("The extra Java options for the Hive query engine") .version("1.6.0") .stringConf .createOptional val ENGINE_HIVE_EXTRA_CLASSPATH: OptionalConfigEntry[String] = buildConf("kyuubi.engine.hive.extra.classpath") - .doc("The extra classpath for the hive query engine, for configuring location" + - " of hadoop client jars, etc") + .doc("The extra classpath for the Hive query engine, for configuring location" + + " of the hadoop client jars and etc.") .version("1.6.0") .stringConf .createOptional val ENGINE_FLINK_MEMORY: ConfigEntry[String] = buildConf("kyuubi.engine.flink.memory") - .doc("The heap memory for the flink sql engine") + .doc("The heap memory for the Flink SQL engine") .version("1.6.0") .stringConf .createWithDefault("1g") val ENGINE_FLINK_JAVA_OPTIONS: OptionalConfigEntry[String] = buildConf("kyuubi.engine.flink.java.options") - .doc("The extra java options for the flink sql engine") + .doc("The extra Java options for the Flink SQL engine") .version("1.6.0") .stringConf .createOptional val ENGINE_FLINK_EXTRA_CLASSPATH: OptionalConfigEntry[String] = buildConf("kyuubi.engine.flink.extra.classpath") - .doc("The extra classpath for the flink sql engine, for configuring location" + + .doc("The extra classpath for the Flink SQL engine, for configuring the location" + " of hadoop client jars, etc") .version("1.6.0") .stringConf @@ -2206,7 +2416,7 @@ object KyuubiConf { val SERVER_LIMIT_CONNECTIONS_USER_UNLIMITED_LIST: ConfigEntry[Seq[String]] = buildConf("kyuubi.server.limit.connections.user.unlimited.list") - .doc("The maximin connections of the user in the white list will not be limited.") + .doc("The maximum connections of the user in the white list will not be limited.") .version("1.7.0") .serverOnly .stringConf @@ -2214,7 +2424,7 @@ object KyuubiConf { .createWithDefault(Nil) val SERVER_LIMIT_BATCH_CONNECTIONS_PER_USER: OptionalConfigEntry[Int] = - buildConf("kyuubi.server.batch.limit.connections.per.user") + buildConf("kyuubi.server.limit.batch.connections.per.user") .doc("Maximum kyuubi server batch connections per user." + " Any user exceeding this limit will not be allowed to connect.") .version("1.7.0") @@ -2223,7 +2433,7 @@ object KyuubiConf { .createOptional val SERVER_LIMIT_BATCH_CONNECTIONS_PER_IPADDRESS: OptionalConfigEntry[Int] = - buildConf("kyuubi.server.batch.limit.connections.per.ipaddress") + buildConf("kyuubi.server.limit.batch.connections.per.ipaddress") .doc("Maximum kyuubi server batch connections per ipaddress." + " Any user exceeding this limit will not be allowed to connect.") .version("1.7.0") @@ -2232,7 +2442,7 @@ object KyuubiConf { .createOptional val SERVER_LIMIT_BATCH_CONNECTIONS_PER_USER_IPADDRESS: OptionalConfigEntry[Int] = - buildConf("kyuubi.server.batch.limit.connections.per.user.ipaddress") + buildConf("kyuubi.server.limit.batch.connections.per.user.ipaddress") .doc("Maximum kyuubi server batch connections per user:ipaddress combination." + " Any user-ipaddress exceeding this limit will not be allowed to connect.") .version("1.7.0") @@ -2240,6 +2450,15 @@ object KyuubiConf { .intConf .createOptional + val SERVER_LIMIT_CLIENT_FETCH_MAX_ROWS: OptionalConfigEntry[Int] = + buildConf("kyuubi.server.limit.client.fetch.max.rows") + .doc("Max rows limit for getting result row set operation. If the max rows specified " + + "by client-side is larger than the limit, request will fail directly.") + .version("1.8.0") + .serverOnly + .intConf + .createOptional + val SESSION_PROGRESS_ENABLE: ConfigEntry[Boolean] = buildConf("kyuubi.operation.progress.enabled") .doc("Whether to enable the operation progress. When true," + @@ -2256,17 +2475,35 @@ object KyuubiConf { .regexConf .createOptional + val SERVER_PERIODIC_GC_INTERVAL: ConfigEntry[Long] = + buildConf("kyuubi.server.periodicGC.interval") + .doc("How often to trigger a garbage collection.") + .version("1.7.0") + .serverOnly + .timeConf + .createWithDefaultString("PT30M") + + val SERVER_ADMINISTRATORS: ConfigEntry[Seq[String]] = + buildConf("kyuubi.server.administrators") + .doc("Comma-separated list of Kyuubi service administrators. " + + "We use this config to grant admin permission to any service accounts.") + .version("1.8.0") + .serverOnly + .stringConf + .toSequence() + .createWithDefault(Nil) + val OPERATION_SPARK_LISTENER_ENABLED: ConfigEntry[Boolean] = buildConf("kyuubi.operation.spark.listener.enabled") - .doc("When set to true, Spark engine registers a SQLOperationListener before executing " + - "the statement, logs a few summary statistics when each stage completes.") + .doc("When set to true, Spark engine registers an SQLOperationListener before executing " + + "the statement, logging a few summary statistics when each stage completes.") .version("1.6.0") .booleanConf .createWithDefault(true) val ENGINE_JDBC_DRIVER_CLASS: OptionalConfigEntry[String] = buildConf("kyuubi.engine.jdbc.driver.class") - .doc("The driver class for jdbc engine connection") + .doc("The driver class for JDBC engine connection") .version("1.6.0") .stringConf .createOptional @@ -2302,14 +2539,14 @@ object KyuubiConf { val ENGINE_JDBC_CONNECTION_PROVIDER: OptionalConfigEntry[String] = buildConf("kyuubi.engine.jdbc.connection.provider") - .doc("The connection provider is used for getting a connection from server") + .doc("The connection provider is used for getting a connection from the server") .version("1.6.0") .stringConf .createOptional val ENGINE_JDBC_SHORT_NAME: OptionalConfigEntry[String] = buildConf("kyuubi.engine.jdbc.type") - .doc("The short name of jdbc type") + .doc("The short name of JDBC type") .version("1.6.0") .stringConf .createOptional @@ -2322,6 +2559,15 @@ object KyuubiConf { .booleanConf .createWithDefault(true) + val ENGINE_SUBMIT_TIMEOUT: ConfigEntry[Long] = + buildConf("kyuubi.engine.submit.timeout") + .doc("Period to tolerant Driver Pod ephemerally invisible after submitting. " + + "In some Resource Managers, e.g. K8s, the Driver Pod is not visible immediately " + + "after `spark-submit` is returned.") + .version("1.7.1") + .timeConf + .createWithDefaultString("PT30S") + /** * Holds information about keys that have been deprecated. * @@ -2393,33 +2639,111 @@ object KyuubiConf { Map(configs.map { cfg => cfg.key -> cfg }: _*) } + val ENGINE_CHAT_MEMORY: ConfigEntry[String] = + buildConf("kyuubi.engine.chat.memory") + .doc("The heap memory for the Chat engine") + .version("1.8.0") + .stringConf + .createWithDefault("1g") + + val ENGINE_CHAT_JAVA_OPTIONS: OptionalConfigEntry[String] = + buildConf("kyuubi.engine.chat.java.options") + .doc("The extra Java options for the Chat engine") + .version("1.8.0") + .stringConf + .createOptional + + val ENGINE_CHAT_PROVIDER: ConfigEntry[String] = + buildConf("kyuubi.engine.chat.provider") + .doc("The provider for the Chat engine. Candidates:
          " + + "
        • ECHO: simply replies a welcome message.
        • " + + "
        • GPT: a.k.a ChatGPT, powered by OpenAI.
        • " + + "
        ") + .version("1.8.0") + .stringConf + .transform { + case "ECHO" | "echo" => "org.apache.kyuubi.engine.chat.provider.EchoProvider" + case "GPT" | "gpt" | "ChatGPT" => "org.apache.kyuubi.engine.chat.provider.ChatGPTProvider" + case other => other + } + .createWithDefault("ECHO") + + val ENGINE_CHAT_GPT_API_KEY: OptionalConfigEntry[String] = + buildConf("kyuubi.engine.chat.gpt.apiKey") + .doc("The key to access OpenAI open API, which could be got at " + + "https://platform.openai.com/account/api-keys") + .version("1.8.0") + .stringConf + .createOptional + + val ENGINE_CHAT_GPT_MODEL: ConfigEntry[String] = + buildConf("kyuubi.engine.chat.gpt.model") + .doc("ID of the model used in ChatGPT. Available models refer to OpenAI's " + + "[Model overview](https://platform.openai.com/docs/models/overview).") + .version("1.8.0") + .stringConf + .createWithDefault("gpt-3.5-turbo") + + val ENGINE_CHAT_EXTRA_CLASSPATH: OptionalConfigEntry[String] = + buildConf("kyuubi.engine.chat.extra.classpath") + .doc("The extra classpath for the Chat engine, for configuring the location " + + "of the SDK and etc.") + .version("1.8.0") + .stringConf + .createOptional + + val ENGINE_CHAT_GPT_HTTP_PROXY: OptionalConfigEntry[String] = + buildConf("kyuubi.engine.chat.gpt.http.proxy") + .doc("HTTP proxy url for API calling in Chat GPT engine. e.g. http://127.0.0.1:1087") + .version("1.8.0") + .stringConf + .createOptional + + val ENGINE_CHAT_GPT_HTTP_CONNECT_TIMEOUT: ConfigEntry[Long] = + buildConf("kyuubi.engine.chat.gpt.http.connect.timeout") + .doc("The timeout[ms] for establishing the connection with the Chat GPT server. " + + "A timeout value of zero is interpreted as an infinite timeout.") + .version("1.8.0") + .timeConf + .checkValue(_ >= 0, "must be 0 or positive number") + .createWithDefault(Duration.ofSeconds(120).toMillis) + + val ENGINE_CHAT_GPT_HTTP_SOCKET_TIMEOUT: ConfigEntry[Long] = + buildConf("kyuubi.engine.chat.gpt.http.socket.timeout") + .doc("The timeout[ms] for waiting for data packets after Chat GPT server " + + "connection is established. A timeout value of zero is interpreted as an infinite timeout.") + .version("1.8.0") + .timeConf + .checkValue(_ >= 0, "must be 0 or positive number") + .createWithDefault(Duration.ofSeconds(120).toMillis) + val ENGINE_JDBC_MEMORY: ConfigEntry[String] = buildConf("kyuubi.engine.jdbc.memory") - .doc("The heap memory for the jdbc query engine") + .doc("The heap memory for the JDBC query engine") .version("1.6.0") .stringConf .createWithDefault("1g") val ENGINE_JDBC_JAVA_OPTIONS: OptionalConfigEntry[String] = buildConf("kyuubi.engine.jdbc.java.options") - .doc("The extra java options for the jdbc query engine") + .doc("The extra Java options for the JDBC query engine") .version("1.6.0") .stringConf .createOptional val ENGINE_JDBC_EXTRA_CLASSPATH: OptionalConfigEntry[String] = buildConf("kyuubi.engine.jdbc.extra.classpath") - .doc("The extra classpath for the jdbc query engine, for configuring location" + - " of jdbc driver, etc") + .doc("The extra classpath for the JDBC query engine, for configuring the location" + + " of the JDBC driver and etc.") .version("1.6.0") .stringConf .createOptional val ENGINE_SPARK_EVENT_LOGGERS: ConfigEntry[Seq[String]] = buildConf("kyuubi.engine.spark.event.loggers") - .doc("A comma separated list of engine loggers, where engine/session/operation etc" + + .doc("A comma-separated list of engine loggers, where engine/session/operation etc" + " events go.
          " + - "
        • SPARK: the events will be written to the spark listener bus.
        • " + + "
        • SPARK: the events will be written to the Spark listener bus.
        • " + "
        • JSON: the events will be written to the location of" + s" ${ENGINE_EVENT_JSON_LOG_PATH.key}
        • " + "
        • JDBC: to be done
        • " + @@ -2430,28 +2754,37 @@ object KyuubiConf { val ENGINE_SPARK_PYTHON_HOME_ARCHIVE: OptionalConfigEntry[String] = buildConf("kyuubi.engine.spark.python.home.archive") .doc("Spark archive containing $SPARK_HOME/python directory, which is used to init session" + - " python worker for python language mode.") + " Python worker for Python language mode.") .version("1.7.0") .stringConf .createOptional val ENGINE_SPARK_PYTHON_ENV_ARCHIVE: OptionalConfigEntry[String] = buildConf("kyuubi.engine.spark.python.env.archive") - .doc("Portable python env archive used for Spark engine python language mode.") + .doc("Portable Python env archive used for Spark engine Python language mode.") .version("1.7.0") .stringConf .createOptional val ENGINE_SPARK_PYTHON_ENV_ARCHIVE_EXEC_PATH: ConfigEntry[String] = buildConf("kyuubi.engine.spark.python.env.archive.exec.path") - .doc("The python exec path under the python env archive.") + .doc("The Python exec path under the Python env archive.") .version("1.7.0") .stringConf .createWithDefault("bin/python") + val ENGINE_SPARK_REGISTER_ATTRIBUTES: ConfigEntry[Seq[String]] = + buildConf("kyuubi.engine.spark.register.attributes") + .internal + .doc("The extra attributes to expose when registering for Spark engine.") + .version("1.8.0") + .stringConf + .toSequence() + .createWithDefault(Seq("spark.driver.memory", "spark.executor.memory")) + val ENGINE_HIVE_EVENT_LOGGERS: ConfigEntry[Seq[String]] = buildConf("kyuubi.engine.hive.event.loggers") - .doc("A comma separated list of engine history loggers, where engine/session/operation etc" + + .doc("A comma-separated list of engine history loggers, where engine/session/operation etc" + " events go.
            " + "
          • JSON: the events will be written to the location of" + s" ${ENGINE_EVENT_JSON_LOG_PATH.key}
          • " + @@ -2468,7 +2801,7 @@ object KyuubiConf { val ENGINE_TRINO_EVENT_LOGGERS: ConfigEntry[Seq[String]] = buildConf("kyuubi.engine.trino.event.loggers") - .doc("A comma separated list of engine history loggers, where engine/session/operation etc" + + .doc("A comma-separated list of engine history loggers, where engine/session/operation etc" + " events go.
              " + "
            • JSON: the events will be written to the location of" + s" ${ENGINE_EVENT_JSON_LOG_PATH.key}
            • " + @@ -2504,4 +2837,11 @@ object KyuubiConf { .version("1.7.0") .timeConf .createWithDefault(Duration.ofSeconds(60).toMillis) + + val OPERATION_GET_TABLES_IGNORE_TABLE_PROPERTIES: ConfigEntry[Boolean] = + buildConf("kyuubi.operation.getTables.ignoreTableProperties") + .doc("Speed up the `GetTables` operation by returning table identities only.") + .version("1.8.0") + .booleanConf + .createWithDefault(false) } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiReservedKeys.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiReservedKeys.scala index 50dae6275c5..8b42e659f82 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiReservedKeys.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/config/KyuubiReservedKeys.scala @@ -19,18 +19,21 @@ package org.apache.kyuubi.config object KyuubiReservedKeys { final val KYUUBI_CLIENT_IP_KEY = "kyuubi.client.ipAddress" + final val KYUUBI_CLIENT_VERSION_KEY = "kyuubi.client.version" final val KYUUBI_SERVER_IP_KEY = "kyuubi.server.ipAddress" final val KYUUBI_SESSION_USER_KEY = "kyuubi.session.user" final val KYUUBI_SESSION_SIGN_PUBLICKEY = "kyuubi.session.sign.publickey" final val KYUUBI_SESSION_USER_SIGN = "kyuubi.session.user.sign" final val KYUUBI_SESSION_REAL_USER_KEY = "kyuubi.session.real.user" final val KYUUBI_SESSION_CONNECTION_URL_KEY = "kyuubi.session.connection.url" + final val KYUUBI_BATCH_RESOURCE_UPLOADED_KEY = "kyuubi.batch.resource.uploaded" final val KYUUBI_STATEMENT_ID_KEY = "kyuubi.statement.id" final val KYUUBI_ENGINE_ID = "kyuubi.engine.id" final val KYUUBI_ENGINE_NAME = "kyuubi.engine.name" final val KYUUBI_ENGINE_URL = "kyuubi.engine.url" final val KYUUBI_ENGINE_SUBMIT_TIME_KEY = "kyuubi.engine.submit.time" final val KYUUBI_ENGINE_CREDENTIALS_KEY = "kyuubi.engine.credentials" + final val KYUUBI_SESSION_HANDLE_KEY = "kyuubi.session.handle" final val KYUUBI_SESSION_ENGINE_LAUNCH_HANDLE_GUID = "kyuubi.session.engine.launch.handle.guid" final val KYUUBI_SESSION_ENGINE_LAUNCH_HANDLE_SECRET = @@ -39,4 +42,5 @@ object KyuubiReservedKeys { final val KYUUBI_OPERATION_GET_CURRENT_CATALOG = "kyuubi.operation.get.current.catalog" final val KYUUBI_OPERATION_SET_CURRENT_DATABASE = "kyuubi.operation.set.current.database" final val KYUUBI_OPERATION_GET_CURRENT_DATABASE = "kyuubi.operation.get.current.database" + final val KYUUBI_OPERATION_HANDLE_KEY = "kyuubi.operation.handle" } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/engine/EngineType.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/engine/EngineType.scala index 88680a8c757..3d850ba14f5 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/engine/EngineType.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/engine/EngineType.scala @@ -23,5 +23,5 @@ package org.apache.kyuubi.engine object EngineType extends Enumeration { type EngineType = Value - val SPARK_SQL, FLINK_SQL, TRINO, HIVE_SQL, JDBC = Value + val SPARK_SQL, FLINK_SQL, CHAT, TRINO, HIVE_SQL, JDBC = Value } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/AbstractOperation.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/AbstractOperation.scala index 9cdd6a8f0c9..d50cb8e243f 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/AbstractOperation.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/AbstractOperation.scala @@ -36,7 +36,7 @@ abstract class AbstractOperation(session: Session) extends Operation with Loggin final protected val opType: String = getClass.getSimpleName final protected val createTime = System.currentTimeMillis() - final private val handle = OperationHandle() + protected val handle = OperationHandle() final private val operationTimeout: Long = { session.sessionManager.getConf.get(OPERATION_IDLE_TIMEOUT) } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/OperationManager.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/OperationManager.scala index fe38263db64..df45e6dee01 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/OperationManager.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/OperationManager.scala @@ -17,6 +17,8 @@ package org.apache.kyuubi.operation +import scala.collection.JavaConverters._ + import org.apache.hive.service.rpc.thrift._ import org.apache.kyuubi.KyuubiSQLException @@ -41,6 +43,8 @@ abstract class OperationManager(name: String) extends AbstractService(name) { def getOperationCount: Int = handleToOperation.size() + def allOperations(): Iterable[Operation] = handleToOperation.values().asScala + override def initialize(conf: KyuubiConf): Unit = { LogDivertAppender.initialize(skipOperationLog) super.initialize(conf) diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/Log4j12DivertAppender.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/Log4j12DivertAppender.scala index 1191e94ae29..df2ef93d83b 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/Log4j12DivertAppender.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/Log4j12DivertAppender.scala @@ -39,7 +39,7 @@ class Log4j12DivertAppender extends WriterAppender { setLayout(lo) addFilter { _: LoggingEvent => - if (OperationLog.getCurrentOperationLog == null) Filter.DENY else Filter.NEUTRAL + if (OperationLog.getCurrentOperationLog.isDefined) Filter.NEUTRAL else Filter.DENY } /** @@ -51,8 +51,7 @@ class Log4j12DivertAppender extends WriterAppender { // That should've gone into our writer. Notify the LogContext. val logOutput = writer.toString writer.reset() - val log = OperationLog.getCurrentOperationLog - if (log != null) log.write(logOutput) + OperationLog.getCurrentOperationLog.foreach(_.write(logOutput)) } } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/Log4j2DivertAppender.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/Log4j2DivertAppender.scala index 68753cf9865..dc4b24a8ca6 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/Log4j2DivertAppender.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/Log4j2DivertAppender.scala @@ -18,6 +18,7 @@ package org.apache.kyuubi.operation.log import java.io.CharArrayWriter +import java.util.concurrent.locks.ReadWriteLock import scala.collection.JavaConverters._ @@ -27,6 +28,8 @@ import org.apache.logging.log4j.core.appender.{AbstractWriterAppender, ConsoleAp import org.apache.logging.log4j.core.filter.AbstractFilter import org.apache.logging.log4j.core.layout.PatternLayout +import org.apache.kyuubi.reflection.DynFields + class Log4j2DivertAppender( name: String, layout: StringLayout, @@ -52,22 +55,19 @@ class Log4j2DivertAppender( addFilter(new AbstractFilter() { override def filter(event: LogEvent): Filter.Result = { - if (OperationLog.getCurrentOperationLog == null) { - Filter.Result.DENY - } else { + if (OperationLog.getCurrentOperationLog.isDefined) { Filter.Result.NEUTRAL + } else { + Filter.Result.DENY } } }) - def initLayout(): StringLayout = { - LogManager.getRootLogger.asInstanceOf[org.apache.logging.log4j.core.Logger] - .getAppenders.values().asScala - .find(ap => ap.isInstanceOf[ConsoleAppender] && ap.getLayout.isInstanceOf[StringLayout]) - .map(_.getLayout.asInstanceOf[StringLayout]) - .getOrElse(PatternLayout.newBuilder().withPattern( - "%d{yy/MM/dd HH:mm:ss} %p %c{2}: %m%n").build()) - } + private val writeLock = DynFields.builder() + .hiddenImpl(classOf[AbstractWriterAppender[_]], "readWriteLock") + .build[ReadWriteLock](this) + .get() + .writeLock /** * Overrides AbstractWriterAppender.append(), which does the real logging. No need @@ -75,11 +75,15 @@ class Log4j2DivertAppender( */ override def append(event: LogEvent): Unit = { super.append(event) - // That should've gone into our writer. Notify the LogContext. - val logOutput = writer.toString - writer.reset() - val log = OperationLog.getCurrentOperationLog - if (log != null) log.write(logOutput) + writeLock.lock() + try { + // That should've gone into our writer. Notify the LogContext. + val logOutput = writer.toString + writer.reset() + OperationLog.getCurrentOperationLog.foreach(_.write(logOutput)) + } finally { + writeLock.unlock() + } } } @@ -95,7 +99,7 @@ object Log4j2DivertAppender { def initialize(): Unit = { val ap = new Log4j2DivertAppender() - org.apache.logging.log4j.LogManager.getRootLogger() + org.apache.logging.log4j.LogManager.getRootLogger .asInstanceOf[org.apache.logging.log4j.core.Logger].addAppender(ap) ap.start() } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/OperationLog.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/OperationLog.scala index 84c4ed55c0f..e6312d0fb84 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/OperationLog.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/operation/log/OperationLog.scala @@ -44,7 +44,7 @@ object OperationLog extends Logging { OPERATION_LOG.set(operationLog) } - def getCurrentOperationLog: OperationLog = OPERATION_LOG.get() + def getCurrentOperationLog: Option[OperationLog] = Option(OPERATION_LOG.get) def removeCurrentOperationLog(): Unit = OPERATION_LOG.remove() diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/package.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/package.scala index 11871c5d046..e05ad9fbe73 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/package.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/package.scala @@ -53,6 +53,7 @@ package object kyuubi { val trino_version: String = props.getProperty("kyuubi_trino_version", unknown) val branch: String = props.getProperty("branch", unknown) val revision: String = props.getProperty("revision", unknown) + val revisionTime: String = props.getProperty("revision_time", unknown) val user: String = props.getProperty("user", unknown) val repoUrl: String = props.getProperty("url", unknown) val buildDate: String = props.getProperty("date", unknown) @@ -68,6 +69,7 @@ package object kyuubi { val TRINO_COMPILE_VERSION: String = BuildInfo.trino_version val BRANCH: String = BuildInfo.branch val REVISION: String = BuildInfo.revision + val REVISION_TIME: String = BuildInfo.revisionTime val BUILD_USER: String = BuildInfo.user val REPO_URL: String = BuildInfo.repoUrl val BUILD_DATE: String = BuildInfo.buildDate diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/AbstractBackendService.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/AbstractBackendService.scala index e7c2d836573..171e0490137 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/AbstractBackendService.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/AbstractBackendService.scala @@ -21,7 +21,7 @@ import java.util.concurrent.{ExecutionException, TimeoutException, TimeUnit} import scala.concurrent.CancellationException -import org.apache.hive.service.rpc.thrift.{TGetInfoType, TGetInfoValue, TGetResultSetMetadataResp, TProtocolVersion, TRowSet} +import org.apache.hive.service.rpc.thrift._ import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.operation.{OperationHandle, OperationStatus} @@ -35,6 +35,7 @@ abstract class AbstractBackendService(name: String) extends CompositeService(name) with BackendService { private lazy val timeout = conf.get(KyuubiConf.OPERATION_STATUS_POLLING_TIMEOUT) + private lazy val maxRowsLimit = conf.get(KyuubiConf.SERVER_LIMIT_CLIENT_FETCH_MAX_ROWS) override def openSession( protocol: TProtocolVersion, @@ -156,11 +157,14 @@ abstract class AbstractBackendService(name: String) queryId } - override def getOperationStatus(operationHandle: OperationHandle): OperationStatus = { + override def getOperationStatus( + operationHandle: OperationHandle, + maxWait: Option[Long]): OperationStatus = { val operation = sessionManager.operationManager.getOperation(operationHandle) if (operation.shouldRunAsync) { try { - operation.getBackgroundHandle.get(timeout, TimeUnit.MILLISECONDS) + val waitTime = maxWait.getOrElse(timeout) + operation.getBackgroundHandle.get(waitTime, TimeUnit.MILLISECONDS) } catch { case e: TimeoutException => debug(s"$operationHandle: Long polling timed out, ${e.getMessage}") @@ -198,6 +202,12 @@ abstract class AbstractBackendService(name: String) orientation: FetchOrientation, maxRows: Int, fetchLog: Boolean): TRowSet = { + maxRowsLimit.foreach(limit => + if (maxRows > limit) { + throw new IllegalArgumentException(s"Max rows for fetching results " + + s"operation should not exceed the limit: $limit") + }) + sessionManager.operationManager .getOperation(operationHandle) .getSession diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/BackendService.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/BackendService.scala index e1841156664..968a94197d2 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/BackendService.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/BackendService.scala @@ -91,7 +91,9 @@ trait BackendService { foreignTable: String): OperationHandle def getQueryId(operationHandle: OperationHandle): String - def getOperationStatus(operationHandle: OperationHandle): OperationStatus + def getOperationStatus( + operationHandle: OperationHandle, + maxWait: Option[Long] = None): OperationStatus def cancelOperation(operationHandle: OperationHandle): Unit def closeOperation(operationHandle: OperationHandle): Unit def getResultSetMetadata(operationHandle: OperationHandle): TGetResultSetMetadataResp diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/ServiceUtils.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/ServiceUtils.scala index d481aea77ab..955144af847 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/ServiceUtils.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/ServiceUtils.scala @@ -17,6 +17,10 @@ package org.apache.kyuubi.service +import java.io.{Closeable, IOException} + +import org.slf4j.Logger + object ServiceUtils { /** @@ -49,4 +53,24 @@ object ServiceUtils { userName.substring(0, indexOfDomainMatch) } } + + /** + * Close the Closeable objects and ignore any [[IOException]] or + * null pointers. Must only be used for cleanup in exception handlers. + * + * @param log the log to record problems to at debug level. Can be null. + * @param closeables the objects to close + */ + def cleanup(log: Logger, closeables: Closeable*): Unit = { + closeables.filter(_ != null).foreach { c => + try { + c.close() + } catch { + case e: IOException => + if (log != null && log.isDebugEnabled) { + log.debug(s"Exception in closing $c", e) + } + } + } + } } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/TBinaryFrontendService.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/TBinaryFrontendService.scala index 74cf4e2e6ef..2e8a8b765e2 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/TBinaryFrontendService.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/TBinaryFrontendService.scala @@ -163,7 +163,7 @@ abstract class TBinaryFrontendService(name: String) } } sslServerSocket.setEnabledProtocols(enabledProtocols) - info(s"SSL Server Socket enabled protocols: $enabledProtocols") + info(s"SSL Server Socket enabled protocols: ${enabledProtocols.mkString(",")}") case _ => } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/TFrontendService.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/TFrontendService.scala index 4efc617868c..e541c37c015 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/TFrontendService.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/TFrontendService.scala @@ -31,7 +31,7 @@ import org.apache.thrift.transport.TTransport import org.apache.kyuubi.{KyuubiSQLException, Logging, Utils} import org.apache.kyuubi.Utils.stringifyException -import org.apache.kyuubi.config.KyuubiConf.FRONTEND_CONNECTION_URL_USE_HOSTNAME +import org.apache.kyuubi.config.KyuubiConf.{FRONTEND_CONNECTION_URL_USE_HOSTNAME, SESSION_CLOSE_ON_DISCONNECT} import org.apache.kyuubi.config.KyuubiReservedKeys._ import org.apache.kyuubi.operation.{FetchOrientation, OperationHandle} import org.apache.kyuubi.service.authentication.KyuubiAuthenticationFactory @@ -228,7 +228,7 @@ abstract class TFrontendService(name: String) resp.setStatus(OK_STATUS) } catch { case e: Exception => - error("Error getting type info: ", e) + error("Error getting info: ", e) resp.setInfoValue(TGetInfoValue.lenValue(0)) resp.setStatus(KyuubiSQLException.toTStatus(e)) } @@ -608,7 +608,14 @@ abstract class TFrontendService(name: String) if (handle != null) { info(s"Session [$handle] disconnected without closing properly, close it now") try { - be.closeSession(handle) + val needToClose = be.sessionManager.getSession(handle).conf + .get(SESSION_CLOSE_ON_DISCONNECT.key).getOrElse("true").toBoolean + if (needToClose) { + be.closeSession(handle) + } else { + warn(s"Session not actually closed because configuration " + + s"${SESSION_CLOSE_ON_DISCONNECT.key} is set to false") + } } catch { case e: KyuubiSQLException => error("Failed closing session", e) diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/EngineSecuritySecretProvider.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/EngineSecuritySecretProvider.scala index 5bd9e4092eb..2bcfe9a676b 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/EngineSecuritySecretProvider.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/EngineSecuritySecretProvider.scala @@ -18,7 +18,7 @@ package org.apache.kyuubi.service.authentication import org.apache.kyuubi.config.KyuubiConf -import org.apache.kyuubi.config.KyuubiConf.ENGINE_SECURITY_SECRET_PROVIDER +import org.apache.kyuubi.config.KyuubiConf._ trait EngineSecuritySecretProvider { @@ -33,6 +33,21 @@ trait EngineSecuritySecretProvider { def getSecret(): String } +class SimpleEngineSecuritySecretProviderImpl extends EngineSecuritySecretProvider { + + private var _conf: KyuubiConf = _ + + override def initialize(conf: KyuubiConf): Unit = _conf = conf + + override def getSecret(): String = { + _conf.get(SIMPLE_SECURITY_SECRET_PROVIDER_PROVIDER_SECRET).getOrElse { + throw new IllegalArgumentException( + s"${SIMPLE_SECURITY_SECRET_PROVIDER_PROVIDER_SECRET.key} must be configured " + + s"when ${ENGINE_SECURITY_SECRET_PROVIDER.key} is `simple`.") + } + } +} + object EngineSecuritySecretProvider { def create(conf: KyuubiConf): EngineSecuritySecretProvider = { val providerClass = Class.forName(conf.get(ENGINE_SECURITY_SECRET_PROVIDER)) diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImpl.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImpl.scala index b5e08def541..06d08f3e472 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImpl.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImpl.scala @@ -17,17 +17,25 @@ package org.apache.kyuubi.service.authentication -import javax.naming.{Context, NamingException} -import javax.naming.directory.InitialDirContext +import javax.naming.NamingException import javax.security.sasl.AuthenticationException import org.apache.commons.lang3.StringUtils +import org.apache.kyuubi.Logging import org.apache.kyuubi.config.KyuubiConf -import org.apache.kyuubi.config.KyuubiConf._ import org.apache.kyuubi.service.ServiceUtils +import org.apache.kyuubi.service.authentication.LdapAuthenticationProviderImpl.FILTER_FACTORIES +import org.apache.kyuubi.service.authentication.ldap._ -class LdapAuthenticationProviderImpl(conf: KyuubiConf) extends PasswdAuthenticationProvider { +class LdapAuthenticationProviderImpl( + conf: KyuubiConf, + searchFactory: DirSearchFactory = new LdapSearchFactory) + extends PasswdAuthenticationProvider with Logging { + + private val filterOpt: Option[Filter] = FILTER_FACTORIES + .map { f => f.getInstance(conf) } + .collectFirst { case Some(f: Filter) => f } /** * The authenticate method is called by the Kyuubi Server authentication layer @@ -41,47 +49,72 @@ class LdapAuthenticationProviderImpl(conf: KyuubiConf) extends PasswdAuthenticat * @throws AuthenticationException When a user is found to be invalid by the implementation */ override def authenticate(user: String, password: String): Unit = { + + val (usedBind, bindUser, bindPassword) = ( + conf.get(KyuubiConf.AUTHENTICATION_LDAP_BIND_USER), + conf.get(KyuubiConf.AUTHENTICATION_LDAP_BIND_PASSWORD)) match { + case (Some(_bindUser), Some(_bindPw)) => (true, _bindUser, _bindPw) + case _ => + // If no bind user or bind password was specified, + // we assume the user we are authenticating has the ability to search + // the LDAP tree, so we use it as the "binding" account. + // This is the way it worked before bind users were allowed in the LDAP authenticator, + // so we keep existing systems working. + (false, user, password) + } + + var search: DirSearch = null + try { + search = createDirSearch(bindUser, bindPassword) + applyFilter(search, user) + if (usedBind) { + // If we used the bind user, then we need to authenticate again, + // this time using the full user name we got during the bind process. + createDirSearch(search.findUserDn(user), password) + } + } catch { + case e: NamingException => + throw new AuthenticationException( + s"Unable to find the user in the LDAP tree. ${e.getMessage}") + } finally { + ServiceUtils.cleanup(logger, search) + } + } + + @throws[AuthenticationException] + private def createDirSearch(user: String, password: String): DirSearch = { if (StringUtils.isBlank(user)) { throw new AuthenticationException(s"Error validating LDAP user, user is null" + s" or contains blank space") } - if (StringUtils.isBlank(password)) { + if (StringUtils.isBlank(password) || password.getBytes()(0) == 0) { throw new AuthenticationException(s"Error validating LDAP user, password is null" + s" or contains blank space") } - val env = new java.util.Hashtable[String, Any]() - env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory") - env.put(Context.SECURITY_AUTHENTICATION, "simple") - - conf.get(AUTHENTICATION_LDAP_URL).foreach(env.put(Context.PROVIDER_URL, _)) - - val domain = conf.get(AUTHENTICATION_LDAP_DOMAIN) - val u = - if (!hasDomain(user) && domain.nonEmpty) { - user + "@" + domain.get - } else { - user + val principals = LdapUtils.createCandidatePrincipals(conf, user) + val iterator = principals.iterator + while (iterator.hasNext) { + val principal = iterator.next + try { + return searchFactory.getInstance(conf, principal, password) + } catch { + case ex: AuthenticationException => if (iterator.isEmpty) throw ex } - - val guidKey = conf.get(AUTHENTICATION_LDAP_GUIDKEY) - val bindDn = conf.get(AUTHENTICATION_LDAP_BASEDN) match { - case Some(dn) => guidKey + "=" + u + "," + dn - case _ => u } + throw new AuthenticationException(s"No candidate principals for $user was found.") + } - env.put(Context.SECURITY_PRINCIPAL, bindDn) - env.put(Context.SECURITY_CREDENTIALS, password) - - try { - val ctx = new InitialDirContext(env) - ctx.close() - } catch { - case e: NamingException => - throw new AuthenticationException(s"Error validating LDAP user: $bindDn", e) - } + @throws[AuthenticationException] + private def applyFilter(client: DirSearch, user: String): Unit = filterOpt.foreach { filter => + val username = if (LdapUtils.hasDomain(user)) LdapUtils.extractUserName(user) else user + filter.apply(client, username) } +} - private def hasDomain(userName: String): Boolean = ServiceUtils.indexOfDomainMatch(userName) > 0 +object LdapAuthenticationProviderImpl { + val FILTER_FACTORIES: Array[FilterFactory] = Array[FilterFactory]( + CustomQueryFilterFactory, + new ChainFilterFactory(UserSearchFilterFactory, UserFilterFactory, GroupFilterFactory)) } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterFactory.scala new file mode 100644 index 00000000000..a5badb15d76 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterFactory.scala @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.security.sasl.AuthenticationException + +import org.apache.kyuubi.config.KyuubiConf + +/** + * A factory that produces a [[Filter]] that is implemented as a chain of other filters. + * The chain of filters are created as a result of [[ChainFilterFactory#getInstance]] method call. + * The resulting object filters out all users that don't pass all chained filters. + * The filters will be applied in the order they are mentioned in the factory constructor. + */ + +class ChainFilterFactory(chainedFactories: FilterFactory*) extends FilterFactory { + override def getInstance(conf: KyuubiConf): Option[Filter] = { + val maybeFilters = chainedFactories.map(_.getInstance(conf)) + val filters = maybeFilters.flatten + if (filters.isEmpty) None else Some(new ChainFilter(filters)) + } +} + +class ChainFilter(chainedFilters: Seq[Filter]) extends Filter { + @throws[AuthenticationException] + override def apply(client: DirSearch, user: String): Unit = { + chainedFilters.foreach(_.apply(client, user)) + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterFactory.scala new file mode 100644 index 00000000000..d10e6523b3f --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterFactory.scala @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.naming.NamingException +import javax.security.sasl.AuthenticationException + +import org.apache.kyuubi.Logging +import org.apache.kyuubi.config.KyuubiConf + +/** + * A factory for a [[Filter]] based on a custom query. + *
              + * The produced filter object filters out all users that are not found in the search result + * of the query provided in Kyuubi configuration. + * + * @see [[KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY]] + */ +object CustomQueryFilterFactory extends FilterFactory { + override def getInstance(conf: KyuubiConf): Option[Filter] = + conf.get(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY) + .map { customQuery => new CustomQueryFilter(customQuery) } +} +class CustomQueryFilter(query: String) extends Filter with Logging { + @throws[AuthenticationException] + override def apply(client: DirSearch, user: String): Unit = { + var resultList: Array[String] = null + try { + resultList = client.executeCustomQuery(query) + } catch { + case e: NamingException => + throw new AuthenticationException(s"LDAP Authentication failed for $user", e) + } + if (resultList != null) { + resultList.foreach { matchedDn => + val shortUserName = LdapUtils.getShortName(matchedDn) + info(s"") + if (shortUserName.equalsIgnoreCase(user) || matchedDn.equalsIgnoreCase(user)) { + info("Authentication succeeded based on result set from LDAP query") + return + } + } + // try a generic user search + if (query.contains("%s")) { + val userSearchQuery = query.replace("%s", user) + info("Trying with generic user search in ldap:" + userSearchQuery) + try resultList = client.executeCustomQuery(userSearchQuery) + catch { + case e: NamingException => + throw new AuthenticationException("LDAP Authentication failed for user", e) + } + if (resultList != null && resultList.length == 1) { + info("Authentication succeeded based on result from custom user search query") + return + } + } + } + info("Authentication failed based on result set from custom LDAP query") + throw new AuthenticationException( + "Authentication failed: LDAP query from property returned no data") + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearch.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearch.scala new file mode 100644 index 00000000000..c1c4d506038 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearch.scala @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import java.io.Closeable +import javax.naming.NamingException + +/** + * The object used for executing queries on the Directory Service. + */ +trait DirSearch extends Closeable { + + /** + * Finds user's distinguished name. + * + * @param user username + * @return DN for the specified username + */ + @throws[NamingException] + def findUserDn(user: String): String + + /** + * Finds group's distinguished name. + * + * @param group group name or unique identifier + * @return DN for the specified group name + */ + @throws[NamingException] + def findGroupDn(group: String): String + + /** + * Verifies that specified user is a member of specified group. + * + * @param user user id or distinguished name + * @param groupDn group's DN + * @return true if the user is a member of the group, false - otherwise. + */ + @throws[NamingException] + def isUserMemberOfGroup(user: String, groupDn: String): Boolean + + /** + * Finds groups that contain the specified user. + * + * @param userDn user's distinguished name + * @return list of groups + */ + @throws[NamingException] + def findGroupsForUser(userDn: String): Array[String] + + /** + * Executes an arbitrary query. + * + * @param query any query + * @return list of names in the namespace + */ + @throws[NamingException] + def executeCustomQuery(query: String): Array[String] +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearchFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearchFactory.scala new file mode 100644 index 00000000000..2046632d87d --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/DirSearchFactory.scala @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.security.sasl.AuthenticationException + +import org.apache.kyuubi.config.KyuubiConf + +/** + * A factory for [[DirSearch]]. + */ +trait DirSearchFactory { + + /** + * Returns an instance of [[DirSearch]]. + * + * @param conf Kyuubi configuration + * @param user username + * @param password user password + * @return instance of [[DirSearch]] + */ + @throws[AuthenticationException] + def getInstance(conf: KyuubiConf, user: String, password: String): DirSearch +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Filter.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Filter.scala new file mode 100644 index 00000000000..e57eddb0d32 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Filter.scala @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.security.sasl.AuthenticationException + +/** + * The object that filters LDAP users. + *
              + * The assumption is that this user was already authenticated by a previous bind operation. + */ +trait Filter { + + /** + * Applies this filter to the authenticated user. + * + * @param client LDAP client that will be used for execution of LDAP queries. + * @param user username + */ + @throws[AuthenticationException] + def apply(client: DirSearch, user: String): Unit +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/FilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/FilterFactory.scala new file mode 100644 index 00000000000..d85104684a0 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/FilterFactory.scala @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import org.apache.kyuubi.config.KyuubiConf + +/** + * Factory for the filter. + */ +trait FilterFactory { + + /** + * Returns an instance of the corresponding filter. + * + * @param conf Kyuubi configurations used to configure the filter. + * @return Some(filter) or None if this filter doesn't support provided set of properties + */ + def getInstance(conf: KyuubiConf): Option[Filter] +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterFactory.scala new file mode 100644 index 00000000000..fd1c907eccd --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterFactory.scala @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.naming.NamingException +import javax.security.sasl.AuthenticationException + +import scala.collection.mutable.ArrayBuffer + +import org.apache.kyuubi.Logging +import org.apache.kyuubi.config.KyuubiConf + +object GroupFilterFactory extends FilterFactory { + override def getInstance(conf: KyuubiConf): Option[Filter] = { + val groupFilter = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER) + if (groupFilter.isEmpty) { + None + } else if (conf.get(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY).isDefined) { + Some(new UserMembershipKeyFilter(groupFilter)) + } else { + Some(new GroupMembershipKeyFilter(groupFilter)) + } + } +} + +class GroupMembershipKeyFilter(groupFilter: Seq[String]) extends Filter with Logging { + + @throws[AuthenticationException] + override def apply(ldap: DirSearch, user: String): Unit = { + info(s"Authenticating user '$user' using ${classOf[GroupMembershipKeyFilter].getSimpleName})") + + var memberOf: Array[String] = null + try { + val userDn = ldap.findUserDn(user) + // Workaround for magic things on Mockito: + // unmatched invocation returns an empty list if the method return type is JList, + // but null if the method return type is Array + memberOf = Option(ldap.findGroupsForUser(userDn)).getOrElse(Array.empty) + debug(s"User $userDn member of: ${memberOf.mkString(",")}") + } catch { + case e: NamingException => + throw new AuthenticationException("LDAP Authentication failed for user", e) + } + memberOf.foreach { groupDn => + val shortName = LdapUtils.getShortName(groupDn) + if (groupFilter.exists(shortName.equalsIgnoreCase)) { + debug(s"GroupMembershipKeyFilter passes: user '$user' is a member of '$groupDn' group") + info("Authentication succeeded based on group membership") + return + } + } + info("Authentication failed based on user membership") + throw new AuthenticationException( + "Authentication failed: User not a member of specified list") + } +} + +class UserMembershipKeyFilter(groupFilter: Seq[String]) extends Filter with Logging { + @throws[AuthenticationException] + override def apply(ldap: DirSearch, user: String): Unit = { + info(s"Authenticating user '$user' using $classOf[UserMembershipKeyFilter].getSimpleName") + val groupDns = new ArrayBuffer[String] + groupFilter.foreach { groupId => + try { + val groupDn = ldap.findGroupDn(groupId) + groupDns += groupDn + } catch { + case e: NamingException => + warn("Cannot find DN for group", e) + debug(s"Cannot find DN for group $groupId", e) + } + } + if (groupDns.isEmpty) { + debug(s"No DN(s) has been found for any of group(s): ${groupFilter.mkString(",")}") + throw new AuthenticationException("No DN(s) has been found for any of specified group(s)") + } + groupDns.foreach { groupDn => + try { + if (ldap.isUserMemberOfGroup(user, groupDn)) { + debug(s"UserMembershipKeyFilter passes: user '$user' is a member of '$groupDn' group") + info("Authentication succeeded based on user membership") + return + } + } catch { + case e: NamingException => + warn("Cannot match user and group", e) + debug(s"Cannot match user '$user' and group '$groupDn'", e) + } + } + throw new AuthenticationException( + s"Authentication failed: User '$user' is not a member of listed groups") + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearch.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearch.scala new file mode 100644 index 00000000000..09dca1d5c3a --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearch.scala @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.naming.{NamingEnumeration, NamingException} +import javax.naming.directory.{DirContext, SearchResult} + +import scala.collection.mutable.ArrayBuffer + +import org.apache.kyuubi.Logging +import org.apache.kyuubi.config.KyuubiConf + +/** + * Implements search for LDAP. + * @param conf Kyuubi configuration + * @param ctx Directory service that will be used for the queries. + */ +class LdapSearch(conf: KyuubiConf, ctx: DirContext) extends DirSearch with Logging { + + final private val baseDn = conf.get(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN).orNull + final private val groupBases: Array[String] = + LdapUtils.patternsToBaseDns( + LdapUtils.parseDnPatterns(conf, KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN)) + final private val userPatterns: Array[String] = + LdapUtils.parseDnPatterns(conf, KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN) + final private val userBases: Array[String] = LdapUtils.patternsToBaseDns(userPatterns) + final private val queries: QueryFactory = new QueryFactory(conf) + + /** + * Closes this search object and releases any system resources associated + * with it. If the search object is already closed then invoking this + * method has no effect. + */ + override def close(): Unit = { + try ctx.close() + catch { + case e: NamingException => + warn("Exception when closing LDAP context:", e) + } + } + + @throws[NamingException] + override def findUserDn(user: String): String = { + var allLdapNames: Array[String] = null + if (LdapUtils.isDn(user)) { + val userBaseDn: String = LdapUtils.extractBaseDn(user) + val userRdn: String = LdapUtils.extractFirstRdn(user) + allLdapNames = execute(Array(userBaseDn), queries.findUserDnByRdn(userRdn)).getAllLdapNames + } else { + allLdapNames = findDnByPattern(userPatterns, user) + if (allLdapNames.isEmpty) { + allLdapNames = execute(userBases, queries.findUserDnByName(user)).getAllLdapNames + } + } + if (allLdapNames.length == 1) allLdapNames.head + else { + info(s"Expected exactly one user result for the user: $user, " + + s"but got ${allLdapNames.length}. Returning null") + debug("Matched users: $allLdapNames") + null + } + } + + @throws[NamingException] + private def findDnByPattern(patterns: Seq[String], name: String): Array[String] = { + for (pattern <- patterns) { + val baseDnFromPattern: String = LdapUtils.extractBaseDn(pattern) + val rdn = LdapUtils.extractFirstRdn(pattern).replaceAll("%s", name) + val names = execute(Array(baseDnFromPattern), queries.findDnByPattern(rdn)).getAllLdapNames + if (!names.isEmpty) return names + } + Array.empty + } + + @throws[NamingException] + override def findGroupDn(group: String): String = + execute(groupBases, queries.findGroupDnById(group)).getSingleLdapName + + @throws[NamingException] + override def isUserMemberOfGroup(user: String, groupDn: String): Boolean = { + val userId = LdapUtils.extractUserName(user) + execute(userBases, queries.isUserMemberOfGroup(userId, groupDn)).hasSingleResult + } + + @throws[NamingException] + override def findGroupsForUser(userDn: String): Array[String] = { + val userName = LdapUtils.extractUserName(userDn) + execute(groupBases, queries.findGroupsForUser(userName, userDn)).getAllLdapNames + } + + @throws[NamingException] + override def executeCustomQuery(query: String): Array[String] = + execute(Array(baseDn), queries.customQuery(query)).getAllLdapNamesAndAttributes + + private def execute(baseDns: Array[String], query: Query): SearchResultHandler = { + val searchResults = new ArrayBuffer[NamingEnumeration[SearchResult]] + debug(s"Executing a query: '${query.filter}' with base DNs ${baseDns.mkString(",")}") + baseDns.foreach { baseDn => + try { + val searchResult = ctx.search(baseDn, query.filter, query.controls) + if (searchResult != null) searchResults += searchResult + } catch { + case ex: NamingException => + debug( + s"Exception happened for query '${query.filter}' with base DN '$baseDn'", + ex) + } + } + new SearchResultHandler(searchResults.toArray) + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactory.scala new file mode 100644 index 00000000000..e3649d359e7 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchFactory.scala @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import java.util +import javax.naming.{Context, NamingException} +import javax.naming.directory.{DirContext, InitialDirContext} +import javax.security.sasl.AuthenticationException + +import org.apache.kyuubi.Logging +import org.apache.kyuubi.config.KyuubiConf + +class LdapSearchFactory extends DirSearchFactory with Logging { + @throws[AuthenticationException] + override def getInstance(conf: KyuubiConf, principal: String, password: String): DirSearch = { + try { + val ctx = createDirContext(conf, principal, password) + new LdapSearch(conf, ctx) + } catch { + case e: NamingException => + debug(s"Could not connect to the LDAP Server: Authentication failed for $principal") + throw new AuthenticationException(s"Error validating LDAP user: $principal", e) + } + } + + @throws[NamingException] + private def createDirContext( + conf: KyuubiConf, + principal: String, + password: String): DirContext = { + val ldapUrl = conf.get(KyuubiConf.AUTHENTICATION_LDAP_URL) + val env = new util.Hashtable[String, AnyRef] + ldapUrl.foreach(env.put(Context.PROVIDER_URL, _)) + env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory") + env.put(Context.SECURITY_AUTHENTICATION, "simple") + env.put(Context.SECURITY_PRINCIPAL, principal) + env.put(Context.SECURITY_CREDENTIALS, password) + debug(s"Connecting using principal $principal to ldap server: ${ldapUrl.orNull}") + new InitialDirContext(env) + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtils.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtils.scala new file mode 100644 index 00000000000..a48f9f48f2b --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtils.scala @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import scala.collection.mutable.ArrayBuffer + +import org.apache.kyuubi.Logging +import org.apache.kyuubi.config.{KyuubiConf, OptionalConfigEntry} +import org.apache.kyuubi.service.ServiceUtils + +/** + * Static utility methods related to LDAP authentication module. + */ +object LdapUtils extends Logging { + + /** + * Extracts a base DN from the provided distinguished name. + *
              + * Example: + *
              + * "ou=CORP,dc=mycompany,dc=com" is the base DN for "cn=user1,ou=CORP,dc=mycompany,dc=com" + * + * @param dn distinguished name + * @return base DN + */ + def extractBaseDn(dn: String): String = { + val indexOfFirstDelimiter = dn.indexOf(",") + if (indexOfFirstDelimiter > -1) { + return dn.substring(indexOfFirstDelimiter + 1) + } + null + } + + /** + * Extracts the first Relative Distinguished Name (RDN). + *
              + * Example: + *
              + * For DN "cn=user1,ou=CORP,dc=mycompany,dc=com" this method will return "cn=user1" + * + * @param dn distinguished name + * @return first RDN + */ + def extractFirstRdn(dn: String): String = dn.substring(0, dn.indexOf(",")) + + /** + * Extracts username from user DN. + *
              + * Examples: + *
              +   * LdapUtils.extractUserName("UserName")                        = "UserName"
              +   * LdapUtils.extractUserName("UserName@mycorp.com")             = "UserName"
              +   * LdapUtils.extractUserName("cn=UserName,dc=mycompany,dc=com") = "UserName"
              +   * 
              + */ + def extractUserName(userDn: String): String = { + if (!isDn(userDn) && !hasDomain(userDn)) { + return userDn + } + val domainIdx: Int = ServiceUtils.indexOfDomainMatch(userDn) + if (domainIdx > 0) { + return userDn.substring(0, domainIdx) + } + if (userDn.contains("=")) { + return userDn.substring(userDn.indexOf("=") + 1, userDn.indexOf(",")) + } + userDn + } + + /** + * Gets value part of the first attribute in the provided RDN. + *
              + * Example: + *
              + * For RDN "cn=user1,ou=CORP" this method will return "user1" + * + * @param rdn Relative Distinguished Name + * @return value part of the first attribute + */ + def getShortName(rdn: String): String = rdn.split(",")(0).split("=")(1) + + /** + * Check for a domain part in the provided username. + *
              + * Example: + *
              + *
              +   * LdapUtils.hasDomain("user1@mycorp.com") = true
              +   * LdapUtils.hasDomain("user1")            = false
              +   * 
              + * + * @param userName username + * @return true if `userName`` contains `@` part + */ + def hasDomain(userName: String): Boolean = { + ServiceUtils.indexOfDomainMatch(userName) > 0 + } + + /** + * Detects DN names. + *
              + * Example: + *
              + *
              +   * LdapUtils.isDn("cn=UserName,dc=mycompany,dc=com") = true
              +   * LdapUtils.isDn("user1")                           = false
              +   * 
              + * + * @param name name to be checked + * @return true if the provided name is a distinguished name + */ + def isDn(name: String): Boolean = { + name.contains("=") + } + + /** + * Reads and parses DN patterns from Kyuubi configuration. + *
              + * If no patterns are provided in the configuration, then the base DN will be used. + * + * @param conf Kyuubi configuration + * @param confKey configuration key to be read + * @return a list of DN patterns + * @see [[KyuubiConf.AUTHENTICATION_LDAP_BASE_DN]] + * @see [[KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY]] + * @see [[KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN]] + * @see [[KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN]] + */ + def parseDnPatterns(conf: KyuubiConf, confKey: OptionalConfigEntry[String]): Array[String] = { + val result = new ArrayBuffer[String] + conf.get(confKey).map { patternsString => + patternsString.split(":").foreach { pattern => + if (pattern.contains(",") && pattern.contains("=")) { + result += pattern + } else { + warn(s"Unexpected format for $confKey, ignoring $pattern") + } + } + }.getOrElse { + val guidAttr = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY) + conf.get(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN).foreach { defaultBaseDn => + result += s"$guidAttr=%s,$defaultBaseDn" + } + } + result.toArray + } + + private def patternToBaseDn(pattern: String): String = + if (pattern.contains("=%s")) pattern.split(",", 2)(1) else pattern + + /** + * Converts a collection of Distinguished Name patterns to a collection of base DNs. + * + * @param patterns Distinguished Name patterns + * @return a list of base DNs + * @see [[KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN]] + * @see [[KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN]] + */ + def patternsToBaseDns(patterns: Array[String]): Array[String] = { + patterns.map(patternToBaseDn) + } + + /** + * Creates a list of principals to be used for user authentication. + * + * @param conf Kyuubi configuration + * @param user username + * @return a list of user's principals + */ + def createCandidatePrincipals(conf: KyuubiConf, user: String): Array[String] = { + if (hasDomain(user) || isDn(user)) { + return Array(user) + } + conf.get(KyuubiConf.AUTHENTICATION_LDAP_DOMAIN).map { ldapDomain => + Array(user + "@" + ldapDomain) + }.getOrElse { + val userPatterns = parseDnPatterns(conf, KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN) + if (userPatterns.isEmpty) { + return Array(user) + } + userPatterns.map(_.replaceAll("%s", user)) + } + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Query.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Query.scala new file mode 100644 index 00000000000..ce9a7d47214 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/Query.scala @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import java.util +import javax.naming.directory.SearchControls + +import org.stringtemplate.v4.ST + +/** + * The object that encompasses all components of a Directory Service search query. + * + * @see [[LdapSearch]] + */ +object Query { + + /** + * Creates Query Builder. + * + * @return query builder. + */ + def builder: Query.QueryBuilder = new Query.QueryBuilder + + /** + * A builder of the [[Query]]. + */ + final class QueryBuilder { + private var filterTemplate: ST = _ + private val controls: SearchControls = { + val _controls = new SearchControls + _controls.setSearchScope(SearchControls.SUBTREE_SCOPE) + _controls.setReturningAttributes(new Array[String](0)) + _controls + } + private val returningAttributes: util.List[String] = new util.ArrayList[String] + + /** + * Sets search filter template. + * + * @param filterTemplate search filter template + * @return the current instance of the builder + */ + def filter(filterTemplate: String): Query.QueryBuilder = { + this.filterTemplate = new ST(filterTemplate) + this + } + + /** + * Sets mapping between names in the search filter template and actual values. + * + * @param key marker in the search filter template. + * @param value actual value + * @return the current instance of the builder + */ + def map(key: String, value: String): Query.QueryBuilder = { + filterTemplate.add(key, value) + this + } + + /** + * Sets mapping between names in the search filter template and actual values. + * + * @param key marker in the search filter template. + * @param values array of values + * @return the current instance of the builder + */ + def map(key: String, values: Array[String]): Query.QueryBuilder = { + filterTemplate.add(key, values) + this + } + + /** + * Sets attribute that should be returned in results for the query. + * + * @param attributeName attribute name + * @return the current instance of the builder + */ + def returnAttribute(attributeName: String): Query.QueryBuilder = { + returningAttributes.add(attributeName) + this + } + + /** + * Sets the maximum number of entries to be returned as a result of the search. + *
              + * 0 indicates no limit: all entries will be returned. + * + * @param limit The maximum number of entries that will be returned. + * @return the current instance of the builder + */ + def limit(limit: Int): Query.QueryBuilder = { + controls.setCountLimit(limit) + this + } + + private def validate(): Unit = { + require(filterTemplate != null, "filter is required for LDAP search query") + } + + private def createFilter: String = filterTemplate.render + + private def updateControls(): Unit = { + if (!returningAttributes.isEmpty) controls.setReturningAttributes( + returningAttributes.toArray(new Array[String](returningAttributes.size))) + } + + /** + * Builds an instance of [[Query]]. + * + * @return configured directory service query + */ + def build: Query = { + validate() + val filter: String = createFilter + updateControls() + new Query(filter, controls) + } + } +} + +case class Query(filter: String, controls: SearchControls) diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactory.scala new file mode 100644 index 00000000000..849006e3845 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactory.scala @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import org.apache.kyuubi.config.KyuubiConf + +/** + * A factory for common types of directory service search queries. + */ +final class QueryFactory(conf: KyuubiConf) { + private val USER_OBJECT_CLASSES = Array("person", "user", "inetOrgPerson") + + private val guidAttr = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY) + private val groupClassAttr = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GROUP_CLASS_KEY) + private val groupMembershipAttr = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GROUP_MEMBERSHIP_KEY) + private val userMembershipAttrOpt = conf.get(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY) + + /** + * Returns a query for finding Group DN based on group unique ID. + * + * @param groupId group unique identifier + * @return an instance of [[Query]] + */ + def findGroupDnById(groupId: String): Query = Query.builder + .filter("(&(objectClass=)(=))") + .map("guidAttr", guidAttr) + .map("groupClassAttr", groupClassAttr) + .map("groupID", groupId).limit(2) + .build + + /** + * Returns a query for finding user DN based on user RDN. + * + * @param userRdn user RDN + * @return an instance of [[Query]] + */ + def findUserDnByRdn(userRdn: String): Query = Query.builder + .filter("(&(|)}>)())") + .limit(2) + .map("classes", USER_OBJECT_CLASSES) + .map("userRdn", userRdn).build + + /** + * Returns a query for finding user DN based on DN pattern. + *
              + * Name of this method was derived from the original implementation of LDAP authentication. + * This method should be replaced by [[QueryFactory.findUserDnByRdn]]. + * + * @param rdn user RDN + * @return an instance of [[Query]] + */ + def findDnByPattern(rdn: String): Query = Query.builder + .filter("()") + .map("rdn", rdn) + .limit(2) + .build + + /** + * Returns a query for finding user DN based on user unique name. + * + * @param userName user unique name (uid or sAMAccountName) + * @return an instance of [[Query]] + */ + def findUserDnByName(userName: String): Query = Query.builder + .filter("(&(|)}>)" + + "(|(uid=)(sAMAccountName=)))") + .map("classes", USER_OBJECT_CLASSES) + .map("userName", userName) + .limit(2) + .build + + /** + * Returns a query for finding groups to which the user belongs. + * + * @param userName username + * @param userDn user DN + * @return an instance of [[Query]] + */ + def findGroupsForUser(userName: String, userDn: String): Query = Query.builder + .filter("(&(objectClass=)" + + "(|(=)(=)))") + .map("groupClassAttr", groupClassAttr) + .map("groupMembershipAttr", groupMembershipAttr) + .map("userName", userName) + .map("userDn", userDn) + .build + + /** + * Returns a query for checking whether specified user is a member of specified group. + * + * The query requires [[KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY]] + * configuration property to be set. + * + * @param userId user unique identifier + * @param groupDn group DN + * @return an instance of [[Query]] + * @see [[KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY]] + */ + def isUserMemberOfGroup(userId: String, groupDn: String): Query = { + require( + userMembershipAttrOpt.isDefined, + s"${KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY.key} is not configured.") + + Query.builder + .filter("(&(|)}>)" + + "(=)(=))") + .map("classes", USER_OBJECT_CLASSES) + .map("guidAttr", guidAttr) + .map("userMembershipAttr", userMembershipAttrOpt.get) + .map("userId", userId) + .map("groupDn", groupDn) + .limit(2) + .build + } + + /** + * Returns a query object created for the custom filter. + *
              + * This query is configured to return a group membership attribute as part of the search result. + * + * @param searchFilter custom search filter + * @return an instance of [[Query]] + */ + def customQuery(searchFilter: String): Query = { + val builder = Query.builder + builder.filter(searchFilter) + builder.returnAttribute(groupMembershipAttr) + builder.build + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandler.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandler.scala new file mode 100644 index 00000000000..52d5b6a906b --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandler.scala @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.naming.{NamingEnumeration, NamingException} +import javax.naming.directory.SearchResult + +import scala.collection.mutable.ArrayBuffer + +import org.apache.kyuubi.Logging + +/** + * The object that handles Directory Service search results. + * In most cases it converts search results into a list of names in the namespace. + */ +object SearchResultHandler { + + /** + * An interface used by [[SearchResultHandler]] for processing records of + * a [[SearchResult]] on a per-record basis. + *
              + * Implementations of this interface perform the actual work of processing each record, + * but don't need to worry about exception handling, closing underlying data structures, + * and combining results from several search requests. + * + * @see SearchResultHandler + */ + trait RecordProcessor extends (SearchResult => Boolean) { + + /** + * Implementations must implement this method to process each record in [[SearchResult]]. + * + * @param record the [[SearchResult]] to precess + * @return true to continue processing, false to stop iterating + * over search results + */ + @throws[NamingException] + override def apply(record: SearchResult): Boolean + } +} + +/** + * Constructs a search result handler object for the provided search results. + * + * @param searchResults directory service search results + */ +class SearchResultHandler(val searchResults: Array[NamingEnumeration[SearchResult]]) + extends Logging { + + /** + * Returns all entries from the search result. + * + * @return a list of names in the namespace + */ + @throws[NamingException] + def getAllLdapNames: Array[String] = { + val result = new ArrayBuffer[String] + handle { record => result += record.getNameInNamespace; true } + result.toArray + } + + /** + * Checks whether search result contains exactly one entry. + * + * @return true if the search result contains a single entry. + */ + @throws[NamingException] + def hasSingleResult: Boolean = { + val allResults = getAllLdapNames + allResults != null && allResults.length == 1 + } + + /** + * Returns a single entry from the search result. + * Throws [[NamingException]] if the search result doesn't contain exactly one entry. + * + * @return name in the namespace + */ + @throws[NamingException] + def getSingleLdapName: String = { + val allLdapNames = getAllLdapNames + if (allLdapNames.length == 1) return allLdapNames.head + throw new NamingException("Single result was expected") + } + + /** + * Returns all entries and all attributes for these entries. + * + * @return a list that includes all entries and all attributes from these entries. + */ + @throws[NamingException] + def getAllLdapNamesAndAttributes: Array[String] = { + val result = new ArrayBuffer[String] + + @throws[NamingException] + def addAllAttributeValuesToResult(values: NamingEnumeration[_]): Unit = { + while (values.hasMore) result += String.valueOf(values.next) + } + handle { record => + result += record.getNameInNamespace + val allAttributes = record.getAttributes.getAll + while (allAttributes.hasMore) { + val attribute = allAttributes.next + addAllAttributeValuesToResult(attribute.getAll) + } + true + } + result.toArray + } + + /** + * Allows for custom processing of the search results. + * + * @param processor [[SearchResultHandler.RecordProcessor]] implementation + */ + @throws[NamingException] + def handle(processor: SearchResultHandler.RecordProcessor): Unit = { + try { + searchResults.foreach { searchResult => + while (searchResult.hasMore) if (!processor.apply(searchResult.next)) return + } + } finally { + searchResults.foreach { searchResult => + try { + searchResult.close() + } catch { + case ex: NamingException => + warn("Failed to close LDAP search result", ex) + } + } + } + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterFactory.scala new file mode 100644 index 00000000000..7c2f22ed869 --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterFactory.scala @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.security.sasl.AuthenticationException + +import org.apache.kyuubi.Logging +import org.apache.kyuubi.config.KyuubiConf + +object UserFilterFactory extends FilterFactory with Logging { + override def getInstance(conf: KyuubiConf): Option[Filter] = { + val userFilter = conf.get(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER) + if (userFilter.isEmpty) None else Some(new UserFilter(userFilter)) + } +} + +class UserFilter(_userFilter: Seq[String]) extends Filter with Logging { + + lazy val userFilter: Seq[String] = _userFilter.map(_.toLowerCase) + + @throws[AuthenticationException] + override def apply(ldap: DirSearch, user: String): Unit = { + info("Authenticating user '$user' using user filter") + val userName = LdapUtils.extractUserName(user).toLowerCase + if (!userFilter.contains(userName)) { + info("Authentication failed based on user membership") + throw new AuthenticationException( + "Authentication failed: User not a member of specified list") + } + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterFactory.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterFactory.scala new file mode 100644 index 00000000000..9e8bdf3640b --- /dev/null +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterFactory.scala @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.naming.NamingException +import javax.security.sasl.AuthenticationException + +import org.apache.kyuubi.config.KyuubiConf + +/** + * A factory for a [[Filter]] that check whether provided user could be found in the directory. + *
              + * The produced filter object filters out all users that are not found in the directory. + */ +object UserSearchFilterFactory extends FilterFactory { + override def getInstance(conf: KyuubiConf): Option[Filter] = { + val groupFilter = conf.get(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER) + val userFilter = conf.get(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER) + if (groupFilter.isEmpty && userFilter.isEmpty) None else Some(UserSearchFilter) + } +} + +object UserSearchFilter extends Filter { + @throws[AuthenticationException] + override def apply(client: DirSearch, user: String): Unit = { + try { + val userDn = client.findUserDn(user) + // This should not be null because we were allowed to bind with this username + // safe check in case we were able to bind anonymously. + if (userDn == null) { + throw new AuthenticationException("Authentication failed: User search failed") + } + } catch { + case e: NamingException => + throw new AuthenticationException("LDAP Authentication failed for user", e) + } + } +} diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/session/SessionManager.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/session/SessionManager.scala index 662ac3e58f0..aa46b8d6f76 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/session/SessionManager.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/session/SessionManager.scala @@ -172,6 +172,11 @@ abstract class SessionManager(name: String) extends CompositeService(name) { execPool.getActiveCount } + def getWorkQueueSize: Int = { + assert(execPool != null) + execPool.getQueue.size() + } + private var _confRestrictList: Set[String] = _ private var _confIgnoreList: Set[String] = _ private var _batchConfIgnoreList: Set[String] = _ @@ -283,9 +288,9 @@ abstract class SessionManager(name: String) extends CompositeService(name) { shutdown = true val shutdownTimeout: Long = if (isServer) { - conf.get(ENGINE_EXEC_POOL_SHUTDOWN_TIMEOUT) - } else { conf.get(SERVER_EXEC_POOL_SHUTDOWN_TIMEOUT) + } else { + conf.get(ENGINE_EXEC_POOL_SHUTDOWN_TIMEOUT) } ThreadUtils.shutdown(timeoutChecker, Duration(shutdownTimeout, TimeUnit.MILLISECONDS)) diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/util/JdbcUtils.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/util/JdbcUtils.scala index df72ee339ba..b89580f4c8d 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/util/JdbcUtils.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/util/JdbcUtils.scala @@ -104,4 +104,12 @@ object JdbcUtils extends Logging { case _ => "(empty)" } } + + def isDuplicatedKeyDBErr(cause: Throwable): Boolean = { + val duplicatedKeyKeywords = Seq( + "duplicate key value in a unique or primary key constraint or unique index", // Derby + "Duplicate entry" // MySQL + ) + duplicatedKeyKeywords.exists(cause.getMessage.contains) + } } diff --git a/kyuubi-common/src/main/scala/org/apache/kyuubi/util/RowSetUtils.scala b/kyuubi-common/src/main/scala/org/apache/kyuubi/util/RowSetUtils.scala index 82417a73092..f320fd90293 100644 --- a/kyuubi-common/src/main/scala/org/apache/kyuubi/util/RowSetUtils.scala +++ b/kyuubi-common/src/main/scala/org/apache/kyuubi/util/RowSetUtils.scala @@ -18,14 +18,11 @@ package org.apache.kyuubi.util import java.nio.ByteBuffer -import java.sql.Timestamp -import java.time.{Duration, Instant, LocalDate, LocalDateTime, Period, ZoneId} +import java.time.{Instant, LocalDate, LocalDateTime, LocalTime, ZoneId} import java.time.chrono.IsoChronology -import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatterBuilder import java.time.temporal.ChronoField -import java.util.{Date, Locale, TimeZone} -import java.util.concurrent.TimeUnit +import java.util.{Date, Locale} import scala.language.implicitConversions @@ -37,24 +34,24 @@ private[kyuubi] object RowSetUtils { final private val SECOND_PER_HOUR: Long = SECOND_PER_MINUTE * 60L final private val SECOND_PER_DAY: Long = SECOND_PER_HOUR * 24L - private lazy val dateFormatter = { - createDateTimeFormatterBuilder().appendPattern("yyyy-MM-dd") - .toFormatter(Locale.US) - .withChronology(IsoChronology.INSTANCE) - } + private lazy val dateFormatter = createDateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd") + .toFormatter(Locale.US) + .withChronology(IsoChronology.INSTANCE) private lazy val legacyDateFormatter = FastDateFormat.getInstance("yyyy-MM-dd", Locale.US) - private lazy val timestampFormatter: DateTimeFormatter = { - createDateTimeFormatterBuilder().appendPattern("yyyy-MM-dd HH:mm:ss") - .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) - .toFormatter(Locale.US) - .withChronology(IsoChronology.INSTANCE) - } + private lazy val timeFormatter = createDateTimeFormatterBuilder() + .appendPattern("HH:mm:ss") + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .toFormatter(Locale.US) + .withChronology(IsoChronology.INSTANCE) - private lazy val legacyTimestampFormatter = { - FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) - } + private lazy val timestampFormatter = createDateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd HH:mm:ss") + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .toFormatter(Locale.US) + .withChronology(IsoChronology.INSTANCE) private def createDateTimeFormatterBuilder(): DateTimeFormatterBuilder = { new DateTimeFormatterBuilder().parseCaseInsensitive() @@ -68,6 +65,10 @@ private[kyuubi] object RowSetUtils { dateFormatter.format(ld) } + def formatLocalTime(lt: LocalTime): String = { + timeFormatter.format(lt) + } + def formatLocalDateTime(ldt: LocalDateTime): String = { timestampFormatter.format(ldt) } @@ -77,40 +78,7 @@ private[kyuubi] object RowSetUtils { .getOrElse(timestampFormatter.format(i)) } - def formatTimestamp(t: Timestamp, timeZone: Option[ZoneId] = None): String = { - timeZone.map(zoneId => { - FastDateFormat.getInstance( - legacyTimestampFormatter.getPattern, - TimeZone.getTimeZone(zoneId), - legacyTimestampFormatter.getLocale) - .format(t) - }).getOrElse(legacyTimestampFormatter.format(t)) - } - implicit def bitSetToBuffer(bitSet: java.util.BitSet): ByteBuffer = { ByteBuffer.wrap(bitSet.toByteArray) } - - def toDayTimeIntervalString(d: Duration): String = { - var rest = d.getSeconds - var sign = "" - if (d.getSeconds < 0) { - sign = "-" - rest = -rest - } - val days = TimeUnit.SECONDS.toDays(rest) - rest %= SECOND_PER_DAY - val hours = TimeUnit.SECONDS.toHours(rest) - rest %= SECOND_PER_HOUR - val minutes = TimeUnit.SECONDS.toMinutes(rest) - val seconds = rest % SECOND_PER_MINUTE - f"$sign$days $hours%02d:$minutes%02d:$seconds%02d.${d.getNano}%09d" - } - - def toYearMonthIntervalString(d: Period): String = { - val years = d.getYears - val months = d.getMonths - val sign = if (years < 0 || months < 0) "-" else "" - s"$sign${Math.abs(years)}-${Math.abs(months)}" - } } diff --git a/kyuubi-common/src/test/resources/ldap/ad.example.com.ldif b/kyuubi-common/src/test/resources/ldap/ad.example.com.ldif new file mode 100644 index 00000000000..68cd01d0f31 --- /dev/null +++ b/kyuubi-common/src/test/resources/ldap/ad.example.com.ldif @@ -0,0 +1,150 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +dn: dc=ad,dc=example,dc=com +dc: ad +objectClass: top +objectClass: domain + +dn: ou=Engineering,dc=ad,dc=example,dc=com +objectClass: top +objectClass: organizationalUnit +ou: Engineering + +dn: ou=Management,dc=ad,dc=example,dc=com +objectClass: top +objectClass: organizationalUnit +ou: Management + +dn: ou=Administration,dc=ad,dc=example,dc=com +objectClass: top +objectClass: organizationalUnit +ou: Administration + +dn: ou=Teams,dc=ad,dc=example,dc=com +objectClass: top +objectClass: organizationalUnit +ou: Teams + +dn: ou=Resources,dc=ad,dc=example,dc=com +objectClass: top +objectClass: organizationalUnit +ou: Resources + +dn: cn=Team 1,ou=Teams,dc=ad,dc=example,dc=com +objectClass: top +objectClass: groupOfNames +objectClass: microsoftSecurityPrincipal +sAMAccountName: team1 +cn: Team 1 +member: sAMAccountName=engineer1,ou=Engineering,dc=ad,dc=example,dc=com +member: sAMAccountName=manager1,ou=Management,dc=ad,dc=example,dc=com + +dn: cn=Team 2,ou=Teams,dc=ad,dc=example,dc=com +objectClass: top +objectClass: groupOfNames +objectClass: microsoftSecurityPrincipal +sAMAccountName: team2 +cn: Team 2 +member: sAMAccountName=engineer2,ou=Engineering,dc=ad,dc=example,dc=com +member: sAMAccountName=manager2,ou=Management,dc=ad,dc=example,dc=com + +dn: cn=Resource 1,ou=Resources,dc=ad,dc=example,dc=com +objectClass: top +objectClass: groupOfNames +objectClass: microsoftSecurityPrincipal +sAMAccountName: resource1 +cn: Resource 1 +member: sAMAccountName=engineer1,ou=Engineering,dc=ad,dc=example,dc=com + +dn: cn=Resource 2,ou=Resources,dc=ad,dc=example,dc=com +objectClass: top +objectClass: groupOfNames +objectClass: microsoftSecurityPrincipal +sAMAccountName: resource2 +cn: Resource 2 +member: sAMAccountName=engineer2,ou=Engineering,dc=ad,dc=example,dc=com + +dn: cn=Admins,ou=Administration,dc=ad,dc=example,dc=com +objectClass: top +objectClass: groupOfUniqueNames +objectClass: microsoftSecurityPrincipal +sAMAccountName: admins +cn: Admins +uniqueMember: sAMAccountName=admin1,ou=Administration,dc=ad,dc=example,dc=com + +dn: sAMAccountName=engineer1,ou=Engineering,dc=ad,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: microsoftSecurityPrincipal +sAMAccountName: engineer1 +cn: Engineer 1 +sn: Surname 1 +userPassword: engineer1-password +memberOf: cn=Team 1,ou=Teams,dc=ad,dc=example,dc=com +memberOf: cn=Resource 1,ou=Resources,dc=ad,dc=example,dc=com + +dn: sAMAccountName=engineer2,ou=Engineering,dc=ad,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: microsoftSecurityPrincipal +sAMAccountName: engineer2 +cn: Engineer 2 +sn: Surname 2 +userPassword: engineer2-password +memberOf: cn=Team 2,ou=Teams,dc=ad,dc=example,dc=com +memberOf: cn=Resource 2,ou=Resources,dc=ad,dc=example,dc=com + +dn: sAMAccountName=manager1,ou=Management,dc=ad,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: microsoftSecurityPrincipal +sAMAccountName: manager1 +cn: Manager 1 +sn: Surname 1 +userPassword: manager1-password +memberOf: cn=Team 1,ou=Teams,dc=ad,dc=example,dc=com + +dn: sAMAccountName=manager2,ou=Management,dc=ad,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: microsoftSecurityPrincipal +sAMAccountName: manager2 +cn: Manager 2 +sn: Surname 2 +userPassword: manager2-password +memberOf: cn=Team 2,ou=Teams,dc=ad,dc=example,dc=com + +dn: sAMAccountName=admin1,ou=Administration,dc=ad,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: microsoftSecurityPrincipal +sAMAccountName: admin1 +cn: Admin 1 +sn: Surname 1 +userPassword: admin1-password +memberOf: cn=Admins,ou=Administration,dc=ad,dc=example,dc=com diff --git a/kyuubi-common/src/test/resources/ldap/example.com.ldif b/kyuubi-common/src/test/resources/ldap/example.com.ldif new file mode 100644 index 00000000000..f19eb2f930c --- /dev/null +++ b/kyuubi-common/src/test/resources/ldap/example.com.ldif @@ -0,0 +1,113 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +dn: ou=People,dc=example,dc=com +objectClass: top +objectClass: organizationalUnit +ou: People +description: Contains entries which describe persons (seamen) + +dn: ou=Groups,dc=example,dc=com +objectClass: top +objectClass: organizationalUnit +ou: Groups +description: Contains entries which describe groups (crews, for instance) + +dn: uid=group1,ou=Groups,dc=example,dc=com +objectClass: top +objectClass: groupOfNames +objectClass: uidObject +uid: group1 +cn: group1 +ou: Groups +member: uid=user1,ou=People,dc=example,dc=com + +dn: uid=group2,ou=Groups,dc=example,dc=com +objectClass: top +objectClass: groupOfNames +objectClass: uidObject +uid: group2 +cn: group2 +ou: Groups +member: uid=user2,ou=People,dc=example,dc=com + +dn: cn=group3,ou=Groups,dc=example,dc=com +objectClass: top +objectClass: groupOfNames +objectClass: uidObject +uid: group3 +cn: group3 +ou: Groups +member: cn=user3,ou=People,dc=example,dc=com + +dn: cn=group4,ou=Groups,dc=example,dc=com +objectClass: top +objectClass: groupOfUniqueNames +objectClass: uidObject +uid: group4 +ou: Groups +cn: group4 +uniqueMember: cn=user4,ou=People,dc=example,dc=com + +dn: uid=user1,ou=People,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: uidObject +givenName: Test1 +cn: Test User1 +sn: user1 +uid: user1 +userPassword: user1 + +dn: uid=user2,ou=People,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: uidObject +givenName: Test2 +cn: Test User2 +sn: user2 +uid: user2 +userPassword: user2 + +dn: cn=user3,ou=People,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: uidObject +givenName: Test3 +cn: Test User3 +sn: user3 +uid: user3 +userPassword: user3 + +dn: cn=user4,ou=People,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +objectClass: uidObject +givenName: Test4 +cn: Test User4 +sn: user4 +uid: user4 +userPassword: user4 + diff --git a/kyuubi-common/src/test/resources/ldap/microsoft.schema.ldif b/kyuubi-common/src/test/resources/ldap/microsoft.schema.ldif new file mode 100644 index 00000000000..3e3a9a5c1be --- /dev/null +++ b/kyuubi-common/src/test/resources/ldap/microsoft.schema.ldif @@ -0,0 +1,62 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +dn: cn=microsoft, ou=schema +objectclass: metaSchema +objectclass: top +cn: microsoft + +dn: ou=attributetypes, cn=microsoft, ou=schema +objectclass: organizationalUnit +objectclass: top +ou: attributetypes + +dn: m-oid=1.2.840.113556.1.4.221, ou=attributetypes, cn=microsoft, ou=schema +objectclass: metaAttributeType +objectclass: metaTop +objectclass: top +m-oid: 1.2.840.113556.1.4.221 +m-name: sAMAccountName +m-equality: caseIgnoreMatch +m-syntax: 1.3.6.1.4.1.1466.115.121.1.15 +m-singleValue: TRUE + +dn: m-oid=1.2.840.113556.1.4.222, ou=attributetypes, cn=microsoft, ou=schema +objectclass: metaAttributeType +objectclass: metaTop +objectclass: top +m-oid: 1.2.840.113556.1.4.222 +m-name: memberOf +m-equality: caseIgnoreMatch +m-syntax: 1.3.6.1.4.1.1466.115.121.1.15 +m-singleValue: FALSE + +dn: ou=objectClasses, cn=microsoft, ou=schema +objectclass: organizationalUnit +objectclass: top +ou: objectClasses + +dn: m-oid=1.2.840.113556.1.5.6, ou=objectClasses, cn=microsoft, ou=schema +objectclass: metaObjectClass +objectclass: metaTop +objectclass: top +m-oid: 1.2.840.113556.1.5.6 +m-name: microsoftSecurityPrincipal +m-supObjectClass: top +m-typeObjectClass: AUXILIARY +m-must: sAMAccountName +m-may: memberOf diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/MarkdownUtils.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/MarkdownUtils.scala new file mode 100644 index 00000000000..25a768b75be --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/MarkdownUtils.scala @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi + +import java.nio.charset.StandardCharsets +import java.nio.file.{Files, Path, StandardOpenOption} + +import scala.collection.JavaConverters._ +import scala.collection.mutable.ArrayBuffer +import scala.compat.Platform.EOL + +import com.vladsch.flexmark.formatter.Formatter +import com.vladsch.flexmark.parser.{Parser, ParserEmulationProfile, PegdownExtensions} +import com.vladsch.flexmark.profile.pegdown.PegdownOptionsAdapter +import com.vladsch.flexmark.util.data.{MutableDataHolder, MutableDataSet} +import org.scalatest.Assertions.{assertResult, withClue} + +object MarkdownUtils { + + def verifyOutput( + markdown: Path, + newOutput: MarkdownBuilder, + agent: String, + module: String): Unit = { + val formatted = newOutput.formatMarkdown() + if (System.getenv("KYUUBI_UPDATE") == "1") { + Files.write( + markdown, + formatted.asJava, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING) + } else { + val linesInFile = Files.readAllLines(markdown, StandardCharsets.UTF_8) + linesInFile.asScala.zipWithIndex.zip(formatted).foreach { case ((str1, index), str2) => + withClue(s"$markdown out of date, as line ${index + 1} is not expected." + + " Please update doc with KYUUBI_UPDATE=1 build/mvn clean test" + + s" -pl $module -am -Pflink-provided,spark-provided,hive-provided" + + s" -Dtest=none -DwildcardSuites=$agent ") { + assertResult(str2)(str1) + } + } + } + } + + def line(str: String): String = { + str.stripMargin.replaceAll(EOL, "") + } + + def appendBlankLine(buffer: ArrayBuffer[String]): Unit = buffer += "" + + def appendFileContent(buffer: ArrayBuffer[String], path: Path): Unit = { + buffer += "```bash" + buffer ++= Files.readAllLines(path).asScala + buffer += "```" + } +} + +class MarkdownBuilder { + private val buffer = new ArrayBuffer[String]() + + /** + * append a single line + * with replacing EOL to empty string + * @param str single line + * @return + */ + def line(str: String = ""): MarkdownBuilder = { + buffer += str.stripMargin.replaceAll(EOL, "") + this + } + + /** + * append the multiline + * with splitting EOL into single lines + * @param multiline multiline with default line margin "|" + * @return + */ + def lines(multiline: String): MarkdownBuilder = { + buffer ++= multiline.stripMargin.split(EOL) + this + } + + /** + * append the licence + * @return + */ + def licence(): MarkdownBuilder = { + lines(""" + | + |""") + } + + /** + * append the auto-generation hint + * @param className the full class name of agent suite + * @return + */ + def generationHint(className: String): MarkdownBuilder = { + lines(s""" + | + | + |""") + } + + /** + * append file content + * @param path file path + * @return + */ + def file(path: Path): MarkdownBuilder = { + buffer ++= Files.readAllLines(path).asScala + this + } + + /** + * append file content with code block quote + * @param path path to file + * @param language language of codeblock + * @return + */ + def fileWithBlock(path: Path, language: String = "bash"): MarkdownBuilder = { + buffer += s"```$language" + file(path) + buffer += "```" + this + } + + def formatMarkdown(): Stream[String] = { + def createParserOptions(emulationProfile: ParserEmulationProfile): MutableDataHolder = { + PegdownOptionsAdapter.flexmarkOptions(PegdownExtensions.ALL).toMutable + .set(Parser.PARSER_EMULATION_PROFILE, emulationProfile) + } + + def createFormatterOptions( + parserOptions: MutableDataHolder, + emulationProfile: ParserEmulationProfile): MutableDataSet = { + new MutableDataSet() + .set(Parser.EXTENSIONS, Parser.EXTENSIONS.get(parserOptions)) + .set(Formatter.FORMATTER_EMULATION_PROFILE, emulationProfile) + } + + val emulationProfile = ParserEmulationProfile.COMMONMARK + val parserOptions = createParserOptions(emulationProfile) + val formatterOptions = createFormatterOptions(parserOptions, emulationProfile) + val parser = Parser.builder(parserOptions).build + val renderer = Formatter.builder(formatterOptions).build + val document = parser.parse(buffer.mkString(EOL)) + val formattedLines = new ArrayBuffer[String](buffer.length) + val formattedLinesAppendable = new Appendable { + override def append(csq: CharSequence): Appendable = { + if (csq.length() > 0) { + formattedLines.append(csq.toString) + } + this + } + + override def append(csq: CharSequence, start: Int, end: Int): Appendable = { + append(csq.toString.substring(start, end)) + } + + override def append(c: Char): Appendable = { + append(c.toString) + } + } + renderer.render(document, formattedLinesAppendable) + // trim the ending EOL appended by renderer for each line + formattedLines.toStream.map(str => + if (str.endsWith(EOL)) { + str.substring(0, str.length - 1) + } else { + str + }) + } +} + +object MarkdownBuilder { + def apply(licenced: Boolean = true, className: String = null): MarkdownBuilder = { + val builder = new MarkdownBuilder + if (licenced) { builder.licence() } + if (className != null) { builder.generationHint(className) } + builder + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/TestUtils.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/TestUtils.scala index 0c1f9dee116..97675768aec 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/TestUtils.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/TestUtils.scala @@ -14,54 +14,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.apache.kyuubi -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Path, StandardOpenOption} import java.sql.ResultSet import scala.collection.mutable.ArrayBuffer import com.jakewharton.fliptables.FlipTable -import org.scalatest.Assertions.convertToEqualizer object TestUtils { - - def verifyOutput(markdown: Path, newOutput: ArrayBuffer[String], agent: String): Unit = { - if (System.getenv("KYUUBI_UPDATE") == "1") { - val writer = Files.newBufferedWriter( - markdown, - StandardCharsets.UTF_8, - StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.CREATE) - try { - newOutput.foreach { line => - writer.write(line) - writer.newLine() - } - } finally { - writer.close() - } - } else { - val expected = new ArrayBuffer[String]() - - val reader = Files.newBufferedReader(markdown, StandardCharsets.UTF_8) - var line = reader.readLine() - while (line != null) { - expected += line - line = reader.readLine() - } - reader.close() - val hint = s"$markdown out of date, please update doc with " + - s"KYUUBI_UPDATE=1 build/mvn clean install -Pflink-provided,spark-provided,hive-provided " + - s"-DwildcardSuites=$agent" - assert(newOutput.size === expected.size, hint) - - newOutput.zip(expected).foreach { case (out, in) => assert(out === in, hint) } - } - } - def displayResultSet(resultSet: ResultSet): Unit = { if (resultSet == null) throw new NullPointerException("resultSet == null") val resultSetMetaData = resultSet.getMetaData diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/UtilsSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/UtilsSuite.scala index f75f299aed3..5973fc6e7a6 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/UtilsSuite.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/UtilsSuite.scala @@ -49,6 +49,7 @@ class UtilsSuite extends KyuubiFunSuite { assert(props.getProperty("kyuubi_trino_version") === TRINO_COMPILE_VERSION) assert(props.getProperty("branch") === BRANCH) assert(props.getProperty("revision") === REVISION) + assert(props.getProperty("revision_time") === REVISION_TIME) assert(props.getProperty("user") === BUILD_USER) assert(props.getProperty("url") === REPO_URL) assert(props.getProperty("date") === BUILD_DATE) diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/HiveMetadataTests.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/HiveMetadataTests.scala index fe1f5f47b38..aad31d5b8d4 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/HiveMetadataTests.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/HiveMetadataTests.scala @@ -17,7 +17,11 @@ package org.apache.kyuubi.operation -import org.apache.kyuubi.Utils +import java.sql.{DatabaseMetaData, ResultSet, SQLException, SQLFeatureNotSupportedException} + +import scala.util.Random + +import org.apache.kyuubi.{KYUUBI_VERSION, KyuubiSQLException, Utils} import org.apache.kyuubi.operation.meta.ResultSetSchemaConstant._ // For `hive` external catalog only @@ -98,4 +102,186 @@ trait HiveMetadataTests extends SparkMetadataTests { statement.execute(s"DROP VIEW IF EXISTS ${schemas(3)}.$view_global_test") } } + + test("audit Kyuubi Hive JDBC connection common MetaData") { + withJdbcStatement() { statement => + val metaData = statement.getConnection.getMetaData + Seq( + () => metaData.allProceduresAreCallable(), + () => metaData.getURL, + () => metaData.getUserName, + () => metaData.isReadOnly, + () => metaData.nullsAreSortedHigh, + () => metaData.nullsAreSortedLow, + () => metaData.nullsAreSortedAtStart(), + () => metaData.nullsAreSortedAtEnd(), + () => metaData.usesLocalFiles(), + () => metaData.usesLocalFilePerTable(), + () => metaData.supportsMixedCaseIdentifiers(), + () => metaData.supportsMixedCaseQuotedIdentifiers(), + () => metaData.storesUpperCaseIdentifiers(), + () => metaData.storesUpperCaseQuotedIdentifiers(), + () => metaData.storesLowerCaseIdentifiers(), + () => metaData.storesLowerCaseQuotedIdentifiers(), + () => metaData.storesMixedCaseIdentifiers(), + () => metaData.storesMixedCaseQuotedIdentifiers(), + () => metaData.nullPlusNonNullIsNull, + () => metaData.supportsConvert, + () => metaData.supportsTableCorrelationNames, + () => metaData.supportsDifferentTableCorrelationNames, + () => metaData.supportsExpressionsInOrderBy(), + () => metaData.supportsOrderByUnrelated, + () => metaData.supportsGroupByUnrelated, + () => metaData.supportsGroupByBeyondSelect, + () => metaData.supportsLikeEscapeClause, + () => metaData.supportsMultipleTransactions, + () => metaData.supportsMinimumSQLGrammar, + () => metaData.supportsCoreSQLGrammar, + () => metaData.supportsExtendedSQLGrammar, + () => metaData.supportsANSI92EntryLevelSQL, + () => metaData.supportsANSI92IntermediateSQL, + () => metaData.supportsANSI92FullSQL, + () => metaData.supportsIntegrityEnhancementFacility, + () => metaData.isCatalogAtStart, + () => metaData.supportsSubqueriesInComparisons, + () => metaData.supportsSubqueriesInExists, + () => metaData.supportsSubqueriesInIns, + () => metaData.supportsSubqueriesInQuantifieds, + // Spark support this, see https://issues.apache.org/jira/browse/SPARK-18455 + () => metaData.supportsCorrelatedSubqueries, + () => metaData.supportsOpenCursorsAcrossCommit, + () => metaData.supportsOpenCursorsAcrossRollback, + () => metaData.supportsOpenStatementsAcrossCommit, + () => metaData.supportsOpenStatementsAcrossRollback, + () => metaData.getMaxBinaryLiteralLength, + () => metaData.getMaxCharLiteralLength, + () => metaData.getMaxColumnsInGroupBy, + () => metaData.getMaxColumnsInIndex, + () => metaData.getMaxColumnsInOrderBy, + () => metaData.getMaxColumnsInSelect, + () => metaData.getMaxColumnsInTable, + () => metaData.getMaxConnections, + () => metaData.getMaxCursorNameLength, + () => metaData.getMaxIndexLength, + () => metaData.getMaxSchemaNameLength, + () => metaData.getMaxProcedureNameLength, + () => metaData.getMaxCatalogNameLength, + () => metaData.getMaxRowSize, + () => metaData.doesMaxRowSizeIncludeBlobs, + () => metaData.getMaxStatementLength, + () => metaData.getMaxStatements, + () => metaData.getMaxTableNameLength, + () => metaData.getMaxTablesInSelect, + () => metaData.getMaxUserNameLength, + () => metaData.supportsTransactionIsolationLevel(1), + () => metaData.supportsDataDefinitionAndDataManipulationTransactions, + () => metaData.supportsDataManipulationTransactionsOnly, + () => metaData.dataDefinitionCausesTransactionCommit, + () => metaData.dataDefinitionIgnoredInTransactions, + () => metaData.getColumnPrivileges("", "%", "%", "%"), + () => metaData.getTablePrivileges("", "%", "%"), + () => metaData.getBestRowIdentifier("", "%", "%", 0, true), + () => metaData.getVersionColumns("", "%", "%"), + () => metaData.getExportedKeys("", "default", ""), + () => metaData.supportsResultSetConcurrency(ResultSet.TYPE_FORWARD_ONLY, 2), + () => metaData.ownUpdatesAreVisible(ResultSet.TYPE_FORWARD_ONLY), + () => metaData.ownDeletesAreVisible(ResultSet.TYPE_FORWARD_ONLY), + () => metaData.ownInsertsAreVisible(ResultSet.TYPE_FORWARD_ONLY), + () => metaData.othersUpdatesAreVisible(ResultSet.TYPE_FORWARD_ONLY), + () => metaData.othersDeletesAreVisible(ResultSet.TYPE_FORWARD_ONLY), + () => metaData.othersInsertsAreVisible(ResultSet.TYPE_FORWARD_ONLY), + () => metaData.updatesAreDetected(ResultSet.TYPE_FORWARD_ONLY), + () => metaData.deletesAreDetected(ResultSet.TYPE_FORWARD_ONLY), + () => metaData.insertsAreDetected(ResultSet.TYPE_FORWARD_ONLY), + () => metaData.supportsNamedParameters(), + () => metaData.supportsMultipleOpenResults, + () => metaData.supportsGetGeneratedKeys, + () => metaData.getSuperTypes("", "%", "%"), + () => metaData.getSuperTables("", "%", "%"), + () => metaData.getAttributes("", "%", "%", "%"), + () => metaData.getResultSetHoldability, + () => metaData.locatorsUpdateCopy, + () => metaData.supportsStatementPooling, + () => metaData.getRowIdLifetime, + () => metaData.supportsStoredFunctionsUsingCallSyntax, + () => metaData.autoCommitFailureClosesAllResultSets, + () => metaData.getFunctionColumns("", "%", "%", "%"), + () => metaData.getPseudoColumns("", "%", "%", "%"), + () => metaData.generatedKeyAlwaysReturned).foreach { func => + val e = intercept[SQLFeatureNotSupportedException](func()) + assert(e.getMessage === "Method not supported") + } + + assert(metaData.allTablesAreSelectable) + assert(metaData.getClientInfoProperties.next) + assert(metaData.getDriverName === "Kyuubi Project Hive JDBC Client" || + metaData.getDriverName === "Kyuubi Project Hive JDBC Shaded Client") + assert(metaData.getDriverVersion === KYUUBI_VERSION) + assert( + metaData.getIdentifierQuoteString === " ", + "This method returns a space \" \" if identifier quoting is not supported") + assert(metaData.getNumericFunctions === "") + assert(metaData.getStringFunctions === "") + assert(metaData.getSystemFunctions === "") + assert(metaData.getTimeDateFunctions === "") + assert(metaData.getSearchStringEscape === "\\") + assert(metaData.getExtraNameCharacters === "") + assert(metaData.supportsAlterTableWithAddColumn()) + assert(!metaData.supportsAlterTableWithDropColumn()) + assert(metaData.supportsColumnAliasing()) + assert(metaData.supportsGroupBy) + assert(!metaData.supportsMultipleResultSets) + assert(!metaData.supportsNonNullableColumns) + assert(metaData.supportsOuterJoins) + assert(metaData.supportsFullOuterJoins) + assert(metaData.supportsLimitedOuterJoins) + assert(metaData.getSchemaTerm === "database") + assert(metaData.getProcedureTerm === "UDF") + assert(metaData.getCatalogTerm === "catalog") + assert(metaData.getCatalogSeparator === ".") + assert(metaData.supportsSchemasInDataManipulation) + assert(!metaData.supportsSchemasInProcedureCalls) + assert(metaData.supportsSchemasInTableDefinitions) + assert(!metaData.supportsSchemasInIndexDefinitions) + assert(!metaData.supportsSchemasInPrivilegeDefinitions) + assert(metaData.supportsCatalogsInDataManipulation) + assert(metaData.supportsCatalogsInProcedureCalls) + assert(metaData.supportsCatalogsInTableDefinitions) + assert(metaData.supportsCatalogsInIndexDefinitions) + assert(metaData.supportsCatalogsInPrivilegeDefinitions) + assert(!metaData.supportsPositionedDelete) + assert(!metaData.supportsPositionedUpdate) + assert(!metaData.supportsSelectForUpdate) + assert(!metaData.supportsStoredProcedures) + // This is actually supported, but hive jdbc package return false + assert(!metaData.supportsUnion) + assert(metaData.supportsUnionAll) + assert(metaData.getMaxColumnNameLength === 128) + assert(metaData.getDefaultTransactionIsolation === java.sql.Connection.TRANSACTION_NONE) + assert(!metaData.supportsTransactions) + assert(!metaData.getProcedureColumns("", "%", "%", "%").next()) + val e1 = intercept[SQLException] { + metaData.getPrimaryKeys("", "default", "src").next() + } + assert(e1.getMessage.contains(KyuubiSQLException.featureNotSupported().getMessage)) + assert(!metaData.getImportedKeys("", "default", "").next()) + + val e2 = intercept[SQLException] { + metaData.getCrossReference("", "default", "src", "", "default", "src2").next() + } + assert(e2.getMessage.contains(KyuubiSQLException.featureNotSupported().getMessage)) + assert(!metaData.getIndexInfo("", "default", "src", true, true).next()) + + assert(metaData.supportsResultSetType(new Random().nextInt())) + assert(!metaData.supportsBatchUpdates) + assert(!metaData.getUDTs(",", "%", "%", null).next()) + assert(!metaData.supportsSavepoints) + assert(!metaData.supportsResultSetHoldability(ResultSet.HOLD_CURSORS_OVER_COMMIT)) + assert(metaData.getJDBCMajorVersion === 3) + assert(metaData.getJDBCMinorVersion === 0) + assert(metaData.getSQLStateType === DatabaseMetaData.sqlStateSQL) + assert(metaData.getMaxLogicalLobSize === 0) + assert(!metaData.supportsRefCursors) + } + } } diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/IcebergMetadataTests.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/IcebergMetadataTests.scala index d14224a842f..e3bb4ccb730 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/IcebergMetadataTests.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/IcebergMetadataTests.scala @@ -17,11 +17,11 @@ package org.apache.kyuubi.operation -import org.apache.kyuubi.IcebergSuiteMixin +import org.apache.kyuubi.{IcebergSuiteMixin, SPARK_COMPILE_VERSION} import org.apache.kyuubi.operation.meta.ResultSetSchemaConstant._ -import org.apache.kyuubi.util.SparkVersionUtil.isSparkVersionAtLeast +import org.apache.kyuubi.util.SparkVersionUtil -trait IcebergMetadataTests extends HiveJDBCTestHelper with IcebergSuiteMixin { +trait IcebergMetadataTests extends HiveJDBCTestHelper with IcebergSuiteMixin with SparkVersionUtil { test("get catalogs") { withJdbcStatement() { statement => @@ -153,11 +153,11 @@ trait IcebergMetadataTests extends HiveJDBCTestHelper with IcebergSuiteMixin { "date", "timestamp", // SPARK-37931 - if (isSparkVersionAtLeast("3.3")) "struct" + if (SPARK_ENGINE_RUNTIME_VERSION >= "3.3") "struct" else "struct<`X`: bigint, `Y`: double>", "binary", // SPARK-37931 - if (isSparkVersionAtLeast("3.3")) "struct" else "struct<`X`: string>") + if (SPARK_COMPILE_VERSION >= "3.3") "struct" else "struct<`X`: string>") val cols = dataTypes.zipWithIndex.map { case (dt, idx) => s"c$idx" -> dt } val (colNames, _) = cols.unzip diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/JDBCTestHelper.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/JDBCTestHelper.scala index 663fd181644..97330837dc0 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/JDBCTestHelper.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/JDBCTestHelper.scala @@ -17,7 +17,7 @@ package org.apache.kyuubi.operation -import java.sql.{DriverManager, SQLException, Statement} +import java.sql.{DriverManager, PreparedStatement, SQLException, Statement} import java.util.Locale import org.apache.kyuubi.KyuubiFunSuite @@ -75,6 +75,31 @@ trait JDBCTestHelper extends KyuubiFunSuite { } } + def withMultipleConnectionJdbcPrepareStatement( + sql: String, + tableNames: String*)(fs: (PreparedStatement => Unit)*): Unit = { + val connections = fs.map { _ => DriverManager.getConnection(jdbcUrlWithConf, user, password) } + val statements = connections.map(_.prepareStatement(sql)) + + try { + statements.zip(fs).foreach { case (s, f) => f(s) } + } finally { + tableNames.foreach { name => + if (name.toUpperCase(Locale.ROOT).startsWith("VIEW")) { + statements.head.execute(s"DROP VIEW IF EXISTS $name") + } else { + statements.head.execute(s"DROP TABLE IF EXISTS $name") + } + } + info("Closing statements") + statements.foreach(_.close()) + info("Closed statements") + info("Closing connections") + connections.foreach(_.close()) + info("Closed connections") + } + } + def withDatabases(dbNames: String*)(fs: (Statement => Unit)*): Unit = { val connections = fs.map { _ => DriverManager.getConnection(jdbcUrlWithConf, user, password) } val statements = connections.map(_.createStatement()) @@ -97,4 +122,10 @@ trait JDBCTestHelper extends KyuubiFunSuite { def withJdbcStatement(tableNames: String*)(f: Statement => Unit): Unit = { withMultipleConnectionJdbcStatement(tableNames: _*)(f) } + + def withJdbcPrepareStatement( + sql: String, + tableNames: String*)(f: PreparedStatement => Unit): Unit = { + withMultipleConnectionJdbcPrepareStatement(sql, tableNames: _*)(f) + } } diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkDataTypeTests.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkDataTypeTests.scala index 6881677034e..f0dd3e72374 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkDataTypeTests.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkDataTypeTests.scala @@ -19,15 +19,16 @@ package org.apache.kyuubi.operation import java.sql.{Date, Timestamp} -import org.apache.kyuubi.engine.SemanticVersion +import org.apache.kyuubi.util.SparkVersionUtil -trait SparkDataTypeTests extends HiveJDBCTestHelper { - protected lazy val SPARK_ENGINE_VERSION = sparkEngineMajorMinorVersion +trait SparkDataTypeTests extends HiveJDBCTestHelper with SparkVersionUtil { def resultFormat: String = "thrift" test("execute statement - select null") { - assume(resultFormat == "thrift" || (resultFormat == "arrow" && SPARK_ENGINE_VERSION >= "3.2")) + assume( + resultFormat == "thrift" || + (resultFormat == "arrow" && SPARK_ENGINE_RUNTIME_VERSION >= "3.2")) withJdbcStatement() { statement => val resultSet = statement.executeQuery("SELECT NULL AS col") assert(resultSet.next()) @@ -159,9 +160,10 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { } } - test("execute statement - select timestamp") { + test("execute statement - select timestamp - second") { withJdbcStatement() { statement => - val resultSet = statement.executeQuery("SELECT TIMESTAMP '2018-11-17 13:33:33' AS col") + val resultSet = statement.executeQuery( + "SELECT TIMESTAMP '2018-11-17 13:33:33' AS col") assert(resultSet.next()) assert(resultSet.getTimestamp("col") === Timestamp.valueOf("2018-11-17 13:33:33")) val metaData = resultSet.getMetaData @@ -171,13 +173,39 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { } } + test("execute statement - select timestamp - millisecond") { + withJdbcStatement() { statement => + val resultSet = statement.executeQuery( + "SELECT TIMESTAMP '2018-11-17 13:33:33.12345' AS col") + assert(resultSet.next()) + assert(resultSet.getTimestamp("col") === Timestamp.valueOf("2018-11-17 13:33:33.12345")) + val metaData = resultSet.getMetaData + assert(metaData.getColumnType(1) === java.sql.Types.TIMESTAMP) + assert(metaData.getPrecision(1) === 29) + assert(metaData.getScale(1) === 9) + } + } + + test("execute statement - select timestamp - overflow") { + withJdbcStatement() { statement => + val resultSet = statement.executeQuery( + "SELECT TIMESTAMP '2018-11-17 13:33:33.1234567' AS col") + assert(resultSet.next()) + assert(resultSet.getTimestamp("col") === Timestamp.valueOf("2018-11-17 13:33:33.123456")) + val metaData = resultSet.getMetaData + assert(metaData.getColumnType(1) === java.sql.Types.TIMESTAMP) + assert(metaData.getPrecision(1) === 29) + assert(metaData.getScale(1) === 9) + } + } + test("execute statement - select timestamp_ntz") { - assume(SPARK_ENGINE_VERSION >= "3.4") + assume(SPARK_ENGINE_RUNTIME_VERSION >= "3.4") withJdbcStatement() { statement => val resultSet = statement.executeQuery( - "SELECT make_timestamp_ntz(2022, 03, 24, 18, 08, 31.800) AS col") + "SELECT make_timestamp_ntz(2022, 03, 24, 18, 08, 31.8888) AS col") assert(resultSet.next()) - assert(resultSet.getTimestamp("col") === Timestamp.valueOf("2022-03-24 18:08:31.800")) + assert(resultSet.getTimestamp("col") === Timestamp.valueOf("2022-03-24 18:08:31.8888")) val metaData = resultSet.getMetaData assert(metaData.getColumnType(1) === java.sql.Types.TIMESTAMP) assert(metaData.getPrecision(1) === 29) @@ -186,7 +214,9 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { } test("execute statement - select daytime interval") { - assume(resultFormat == "thrift" || (resultFormat == "arrow" && SPARK_ENGINE_VERSION >= "3.3")) + assume( + resultFormat == "thrift" || + (resultFormat == "arrow" && SPARK_ENGINE_RUNTIME_VERSION >= "3.3")) withJdbcStatement() { statement => Map( "interval 1 day 1 hour -60 minutes 30 seconds" -> @@ -215,7 +245,7 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { assert(resultSet.next()) val result = resultSet.getString("col") val metaData = resultSet.getMetaData - if (SPARK_ENGINE_VERSION < "3.2") { + if (SPARK_ENGINE_RUNTIME_VERSION < "3.2") { // for spark 3.1 and backwards assert(result === kv._2._2) assert(metaData.getPrecision(1) === Int.MaxValue) @@ -231,7 +261,9 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { } test("execute statement - select year/month interval") { - assume(resultFormat == "thrift" || (resultFormat == "arrow" && SPARK_ENGINE_VERSION >= "3.3")) + assume( + resultFormat == "thrift" || + (resultFormat == "arrow" && SPARK_ENGINE_RUNTIME_VERSION >= "3.3")) withJdbcStatement() { statement => Map( "INTERVAL 2022 YEAR" -> Tuple2("2022-0", "2022 years"), @@ -244,7 +276,7 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { assert(resultSet.next()) val result = resultSet.getString("col") val metaData = resultSet.getMetaData - if (SPARK_ENGINE_VERSION < "3.2") { + if (SPARK_ENGINE_RUNTIME_VERSION < "3.2") { // for spark 3.1 and backwards assert(result === kv._2._2) assert(metaData.getPrecision(1) === Int.MaxValue) @@ -260,7 +292,9 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { } test("execute statement - select array") { - assume(resultFormat == "thrift" || (resultFormat == "arrow" && SPARK_ENGINE_VERSION >= "3.2")) + assume( + resultFormat == "thrift" || + (resultFormat == "arrow" && SPARK_ENGINE_RUNTIME_VERSION >= "3.2")) withJdbcStatement() { statement => val resultSet = statement.executeQuery( "SELECT array() AS col1, array(1) AS col2, array(null) AS col3") @@ -278,7 +312,9 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { } test("execute statement - select map") { - assume(resultFormat == "thrift" || (resultFormat == "arrow" && SPARK_ENGINE_VERSION >= "3.2")) + assume( + resultFormat == "thrift" || + (resultFormat == "arrow" && SPARK_ENGINE_RUNTIME_VERSION >= "3.2")) withJdbcStatement() { statement => val resultSet = statement.executeQuery( "SELECT map() AS col1, map(1, 2, 3, 4) AS col2, map(1, null) AS col3") @@ -296,7 +332,9 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { } test("execute statement - select struct") { - assume(resultFormat == "thrift" || (resultFormat == "arrow" && SPARK_ENGINE_VERSION >= "3.2")) + assume( + resultFormat == "thrift" || + (resultFormat == "arrow" && SPARK_ENGINE_RUNTIME_VERSION >= "3.2")) withJdbcStatement() { statement => val resultSet = statement.executeQuery( "SELECT struct('1', '2') AS col1," + @@ -315,15 +353,4 @@ trait SparkDataTypeTests extends HiveJDBCTestHelper { assert(metaData.getScale(2) == 0) } } - - def sparkEngineMajorMinorVersion: SemanticVersion = { - var sparkRuntimeVer = "" - withJdbcStatement() { stmt => - val result = stmt.executeQuery("SELECT version()") - assert(result.next()) - sparkRuntimeVer = result.getString(1) - assert(!result.next()) - } - SemanticVersion(sparkRuntimeVer) - } } diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkMetadataTests.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkMetadataTests.scala index 4faf5bba4ff..97099ce4708 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkMetadataTests.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkMetadataTests.scala @@ -17,11 +17,6 @@ package org.apache.kyuubi.operation -import java.sql.{DatabaseMetaData, ResultSet, SQLException, SQLFeatureNotSupportedException} - -import scala.util.Random - -import org.apache.kyuubi.{KYUUBI_VERSION, KyuubiSQLException} import org.apache.kyuubi.operation.meta.ResultSetSchemaConstant._ // For both `in-memory` and `hive` external catalog @@ -292,186 +287,4 @@ trait SparkMetadataTests extends HiveJDBCTestHelper { assert(typeInfo.getInt(NUM_PREC_RADIX) === 0) } } - - test("audit Kyuubi Hive JDBC connection common MetaData") { - withJdbcStatement() { statement => - val metaData = statement.getConnection.getMetaData - Seq( - () => metaData.allProceduresAreCallable(), - () => metaData.getURL, - () => metaData.getUserName, - () => metaData.isReadOnly, - () => metaData.nullsAreSortedHigh, - () => metaData.nullsAreSortedLow, - () => metaData.nullsAreSortedAtStart(), - () => metaData.nullsAreSortedAtEnd(), - () => metaData.usesLocalFiles(), - () => metaData.usesLocalFilePerTable(), - () => metaData.supportsMixedCaseIdentifiers(), - () => metaData.supportsMixedCaseQuotedIdentifiers(), - () => metaData.storesUpperCaseIdentifiers(), - () => metaData.storesUpperCaseQuotedIdentifiers(), - () => metaData.storesLowerCaseIdentifiers(), - () => metaData.storesLowerCaseQuotedIdentifiers(), - () => metaData.storesMixedCaseIdentifiers(), - () => metaData.storesMixedCaseQuotedIdentifiers(), - () => metaData.nullPlusNonNullIsNull, - () => metaData.supportsConvert, - () => metaData.supportsTableCorrelationNames, - () => metaData.supportsDifferentTableCorrelationNames, - () => metaData.supportsExpressionsInOrderBy(), - () => metaData.supportsOrderByUnrelated, - () => metaData.supportsGroupByUnrelated, - () => metaData.supportsGroupByBeyondSelect, - () => metaData.supportsLikeEscapeClause, - () => metaData.supportsMultipleTransactions, - () => metaData.supportsMinimumSQLGrammar, - () => metaData.supportsCoreSQLGrammar, - () => metaData.supportsExtendedSQLGrammar, - () => metaData.supportsANSI92EntryLevelSQL, - () => metaData.supportsANSI92IntermediateSQL, - () => metaData.supportsANSI92FullSQL, - () => metaData.supportsIntegrityEnhancementFacility, - () => metaData.isCatalogAtStart, - () => metaData.supportsSubqueriesInComparisons, - () => metaData.supportsSubqueriesInExists, - () => metaData.supportsSubqueriesInIns, - () => metaData.supportsSubqueriesInQuantifieds, - // Spark support this, see https://issues.apache.org/jira/browse/SPARK-18455 - () => metaData.supportsCorrelatedSubqueries, - () => metaData.supportsOpenCursorsAcrossCommit, - () => metaData.supportsOpenCursorsAcrossRollback, - () => metaData.supportsOpenStatementsAcrossCommit, - () => metaData.supportsOpenStatementsAcrossRollback, - () => metaData.getMaxBinaryLiteralLength, - () => metaData.getMaxCharLiteralLength, - () => metaData.getMaxColumnsInGroupBy, - () => metaData.getMaxColumnsInIndex, - () => metaData.getMaxColumnsInOrderBy, - () => metaData.getMaxColumnsInSelect, - () => metaData.getMaxColumnsInTable, - () => metaData.getMaxConnections, - () => metaData.getMaxCursorNameLength, - () => metaData.getMaxIndexLength, - () => metaData.getMaxSchemaNameLength, - () => metaData.getMaxProcedureNameLength, - () => metaData.getMaxCatalogNameLength, - () => metaData.getMaxRowSize, - () => metaData.doesMaxRowSizeIncludeBlobs, - () => metaData.getMaxStatementLength, - () => metaData.getMaxStatements, - () => metaData.getMaxTableNameLength, - () => metaData.getMaxTablesInSelect, - () => metaData.getMaxUserNameLength, - () => metaData.supportsTransactionIsolationLevel(1), - () => metaData.supportsDataDefinitionAndDataManipulationTransactions, - () => metaData.supportsDataManipulationTransactionsOnly, - () => metaData.dataDefinitionCausesTransactionCommit, - () => metaData.dataDefinitionIgnoredInTransactions, - () => metaData.getColumnPrivileges("", "%", "%", "%"), - () => metaData.getTablePrivileges("", "%", "%"), - () => metaData.getBestRowIdentifier("", "%", "%", 0, true), - () => metaData.getVersionColumns("", "%", "%"), - () => metaData.getExportedKeys("", "default", ""), - () => metaData.supportsResultSetConcurrency(ResultSet.TYPE_FORWARD_ONLY, 2), - () => metaData.ownUpdatesAreVisible(ResultSet.TYPE_FORWARD_ONLY), - () => metaData.ownDeletesAreVisible(ResultSet.TYPE_FORWARD_ONLY), - () => metaData.ownInsertsAreVisible(ResultSet.TYPE_FORWARD_ONLY), - () => metaData.othersUpdatesAreVisible(ResultSet.TYPE_FORWARD_ONLY), - () => metaData.othersDeletesAreVisible(ResultSet.TYPE_FORWARD_ONLY), - () => metaData.othersInsertsAreVisible(ResultSet.TYPE_FORWARD_ONLY), - () => metaData.updatesAreDetected(ResultSet.TYPE_FORWARD_ONLY), - () => metaData.deletesAreDetected(ResultSet.TYPE_FORWARD_ONLY), - () => metaData.insertsAreDetected(ResultSet.TYPE_FORWARD_ONLY), - () => metaData.supportsNamedParameters(), - () => metaData.supportsMultipleOpenResults, - () => metaData.supportsGetGeneratedKeys, - () => metaData.getSuperTypes("", "%", "%"), - () => metaData.getSuperTables("", "%", "%"), - () => metaData.getAttributes("", "%", "%", "%"), - () => metaData.getResultSetHoldability, - () => metaData.locatorsUpdateCopy, - () => metaData.supportsStatementPooling, - () => metaData.getRowIdLifetime, - () => metaData.supportsStoredFunctionsUsingCallSyntax, - () => metaData.autoCommitFailureClosesAllResultSets, - () => metaData.getFunctionColumns("", "%", "%", "%"), - () => metaData.getPseudoColumns("", "%", "%", "%"), - () => metaData.generatedKeyAlwaysReturned).foreach { func => - val e = intercept[SQLFeatureNotSupportedException](func()) - assert(e.getMessage === "Method not supported") - } - - assert(metaData.allTablesAreSelectable) - assert(metaData.getClientInfoProperties.next) - assert(metaData.getDriverName === "Kyuubi Project Hive JDBC Client" || - metaData.getDriverName === "Kyuubi Project Hive JDBC Shaded Client") - assert(metaData.getDriverVersion === KYUUBI_VERSION) - assert( - metaData.getIdentifierQuoteString === " ", - "This method returns a space \" \" if identifier quoting is not supported") - assert(metaData.getNumericFunctions === "") - assert(metaData.getStringFunctions === "") - assert(metaData.getSystemFunctions === "") - assert(metaData.getTimeDateFunctions === "") - assert(metaData.getSearchStringEscape === "\\") - assert(metaData.getExtraNameCharacters === "") - assert(metaData.supportsAlterTableWithAddColumn()) - assert(!metaData.supportsAlterTableWithDropColumn()) - assert(metaData.supportsColumnAliasing()) - assert(metaData.supportsGroupBy) - assert(!metaData.supportsMultipleResultSets) - assert(!metaData.supportsNonNullableColumns) - assert(metaData.supportsOuterJoins) - assert(metaData.supportsFullOuterJoins) - assert(metaData.supportsLimitedOuterJoins) - assert(metaData.getSchemaTerm === "database") - assert(metaData.getProcedureTerm === "UDF") - assert(metaData.getCatalogTerm === "catalog") - assert(metaData.getCatalogSeparator === ".") - assert(metaData.supportsSchemasInDataManipulation) - assert(!metaData.supportsSchemasInProcedureCalls) - assert(metaData.supportsSchemasInTableDefinitions) - assert(!metaData.supportsSchemasInIndexDefinitions) - assert(!metaData.supportsSchemasInPrivilegeDefinitions) - assert(metaData.supportsCatalogsInDataManipulation) - assert(metaData.supportsCatalogsInProcedureCalls) - assert(metaData.supportsCatalogsInTableDefinitions) - assert(metaData.supportsCatalogsInIndexDefinitions) - assert(metaData.supportsCatalogsInPrivilegeDefinitions) - assert(!metaData.supportsPositionedDelete) - assert(!metaData.supportsPositionedUpdate) - assert(!metaData.supportsSelectForUpdate) - assert(!metaData.supportsStoredProcedures) - // This is actually supported, but hive jdbc package return false - assert(!metaData.supportsUnion) - assert(metaData.supportsUnionAll) - assert(metaData.getMaxColumnNameLength === 128) - assert(metaData.getDefaultTransactionIsolation === java.sql.Connection.TRANSACTION_NONE) - assert(!metaData.supportsTransactions) - assert(!metaData.getProcedureColumns("", "%", "%", "%").next()) - val e1 = intercept[SQLException] { - metaData.getPrimaryKeys("", "default", "src").next() - } - assert(e1.getMessage.contains(KyuubiSQLException.featureNotSupported().getMessage)) - assert(!metaData.getImportedKeys("", "default", "").next()) - - val e2 = intercept[SQLException] { - metaData.getCrossReference("", "default", "src", "", "default", "src2").next() - } - assert(e2.getMessage.contains(KyuubiSQLException.featureNotSupported().getMessage)) - assert(!metaData.getIndexInfo("", "default", "src", true, true).next()) - - assert(metaData.supportsResultSetType(new Random().nextInt())) - assert(!metaData.supportsBatchUpdates) - assert(!metaData.getUDTs(",", "%", "%", null).next()) - assert(!metaData.supportsSavepoints) - assert(!metaData.supportsResultSetHoldability(ResultSet.HOLD_CURSORS_OVER_COMMIT)) - assert(metaData.getJDBCMajorVersion === 3) - assert(metaData.getJDBCMinorVersion === 0) - assert(metaData.getSQLStateType === DatabaseMetaData.sqlStateSQL) - assert(metaData.getMaxLogicalLobSize === 0) - assert(!metaData.supportsRefCursors) - } - } } diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkQueryTests.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkQueryTests.scala index e297e6281ae..ff8b124813c 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkQueryTests.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/SparkQueryTests.scala @@ -28,7 +28,6 @@ import org.apache.hive.service.rpc.thrift.{TExecuteStatementReq, TFetchResultsRe import org.apache.kyuubi.{KYUUBI_VERSION, Utils} import org.apache.kyuubi.config.KyuubiConf -import org.apache.kyuubi.util.SparkVersionUtil.isSparkVersionAtLeast trait SparkQueryTests extends SparkDataTypeTests with HiveJDBCTestHelper { @@ -187,7 +186,7 @@ trait SparkQueryTests extends SparkDataTypeTests with HiveJDBCTestHelper { withJdbcStatement("t") { statement => try { val assertTableOrViewNotfound: (Exception, String) => Unit = (e, tableName) => { - if (isSparkVersionAtLeast("3.4")) { + if (SPARK_ENGINE_RUNTIME_VERSION >= "3.4") { assert(e.getMessage.contains("[TABLE_OR_VIEW_NOT_FOUND]")) assert(e.getMessage.contains(s"The table or view `$tableName` cannot be found.")) } else { @@ -433,13 +432,13 @@ trait SparkQueryTests extends SparkDataTypeTests with HiveJDBCTestHelper { expectedFormat = "thrift") checkStatusAndResultSetFormatHint( sql = "set kyuubi.operation.result.format=arrow", - expectedFormat = "arrow") + expectedFormat = "thrift") checkStatusAndResultSetFormatHint( sql = "SELECT 1", expectedFormat = "arrow") checkStatusAndResultSetFormatHint( sql = "set kyuubi.operation.result.format=thrift", - expectedFormat = "thrift") + expectedFormat = "arrow") checkStatusAndResultSetFormatHint( sql = "set kyuubi.operation.result.format", expectedFormat = "thrift") diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/log/OperationLogSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/log/OperationLogSuite.scala index 758eeeeafaa..fe3cbc7fc75 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/log/OperationLogSuite.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/operation/log/OperationLogSuite.scala @@ -61,10 +61,10 @@ class OperationLogSuite extends KyuubiFunSuite { assert(!Files.exists(logFile)) OperationLog.setCurrentOperationLog(operationLog) - assert(OperationLog.getCurrentOperationLog === operationLog) + assert(OperationLog.getCurrentOperationLog === Some(operationLog)) OperationLog.removeCurrentOperationLog() - assert(OperationLog.getCurrentOperationLog === null) + assert(OperationLog.getCurrentOperationLog.isEmpty) operationLog.write(msg1 + "\n") assert(Files.exists(logFile)) diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/InternalSecurityAccessorSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/InternalSecurityAccessorSuite.scala index e6c4c850690..e92ac7e6185 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/InternalSecurityAccessorSuite.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/InternalSecurityAccessorSuite.scala @@ -22,9 +22,8 @@ import org.apache.kyuubi.config.KyuubiConf class InternalSecurityAccessorSuite extends KyuubiFunSuite { private val conf = KyuubiConf() - conf.set( - KyuubiConf.ENGINE_SECURITY_SECRET_PROVIDER, - classOf[UserDefinedEngineSecuritySecretProvider].getCanonicalName) + .set(KyuubiConf.ENGINE_SECURITY_SECRET_PROVIDER, "simple") + .set(KyuubiConf.SIMPLE_SECURITY_SECRET_PROVIDER_PROVIDER_SECRET, "ENGINE____SECRET") test("test encrypt/decrypt, issue token/auth token") { Seq("AES/CBC/PKCS5PADDING", "AES/CTR/NoPadding").foreach { cipher => diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAtnProviderSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAtnProviderSuite.scala new file mode 100644 index 00000000000..c3c67e42115 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAtnProviderSuite.scala @@ -0,0 +1,493 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication + +import com.unboundid.ldap.sdk.Entry + +import org.apache.kyuubi.service.authentication.ldap.{LdapAuthenticationTestCase, User} + +class LdapAtnProviderSuite extends WithLdapServer { + + override protected val ldapBaseDn: Array[String] = Array( + "dc=example,dc=com", + "cn=microsoft,ou=schema") + + private val GROUP1_NAME = "group1" + private val GROUP2_NAME = "group2" + private val GROUP3_NAME = "group3" + private val GROUP4_NAME = "group4" + + private val GROUP_ADMINS_NAME = "admins" + private val GROUP_TEAM1_NAME = "team1" + private val GROUP_TEAM2_NAME = "team2" + private val GROUP_RESOURCE1_NAME = "resource1" + private val GROUP_RESOURCE2_NAME = "resource2" + + private val USER1 = + User.useIdForPassword(id = "user1", dn = "uid=user1,ou=People,dc=example,dc=com") + + private val USER2 = + User.useIdForPassword(id = "user2", dn = "uid=user2,ou=People,dc=example,dc=com") + + private val USER3 = + User.useIdForPassword(id = "user3", dn = "cn=user3,ou=People,dc=example,dc=com") + + private val USER4 = + User.useIdForPassword(id = "user4", dn = "cn=user4,ou=People,dc=example,dc=com") + + private val ENGINEER_1 = User( + id = "engineer1", + dn = "sAMAccountName=engineer1,ou=Engineering,dc=ad,dc=example,dc=com", + password = "engineer1-password") + + private val ENGINEER_2 = User( + id = "engineer2", + dn = "sAMAccountName=engineer2,ou=Engineering,dc=ad,dc=example,dc=com", + password = "engineer2-password") + + private val MANAGER_1 = User( + id = "manager1", + dn = "sAMAccountName=manager1,ou=Management,dc=ad,dc=example,dc=com", + password = "manager1-password") + + private val MANAGER_2 = User( + id = "manager2", + dn = "sAMAccountName=manager2,ou=Management,dc=ad,dc=example,dc=com", + password = "manager2-password") + + private val ADMIN_1 = User( + id = "admin1", + dn = "sAMAccountName=admin1,ou=Administration,dc=ad,dc=example,dc=com", + password = "admin1-password") + + private var testCase: LdapAuthenticationTestCase = _ + + private def defaultBuilder = LdapAuthenticationTestCase.builder.ldapUrl(ldapUrl) + + override def beforeAll(): Unit = { + super.beforeAll() + ldapServer.add(new Entry( + "dn: dc=example,dc=com", + "dc: example", + "objectClass: top", + "objectClass: domain")) + + applyLDIF("ldap/example.com.ldif") + applyLDIF("ldap/microsoft.schema.ldif") + applyLDIF("ldap/ad.example.com.ldif") + } + + test("In-Memory LDAP server is started") { + assert(ldapServer.getListenPort > 0) + } + + test("UserBindPositiveWithShortname") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER2.credentialsWithId) + } + + test("UserBindPositiveWithShortnameOldConfig") { + testCase = defaultBuilder + .baseDN("ou=People,dc=example,dc=com") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER2.credentialsWithId) + } + + test("UserBindNegativeWithShortname") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .build + testCase.assertAuthenticateFailsUsingWrongPassword(USER1.credentialsWithId) + testCase.assertAuthenticateFailsUsingWrongPassword(USER2.credentialsWithId) + } + + test("UserBindNegativeWithShortnameOldConfig") { + testCase = defaultBuilder + .baseDN("ou=People,dc=example,dc=com") + .build + testCase.assertAuthenticateFailsUsingWrongPassword(USER1.credentialsWithId) + testCase.assertAuthenticateFails(USER1.dn, USER2.password) + testCase.assertAuthenticateFailsUsingWrongPassword(USER2.credentialsWithId) + } + + test("UserBindPositiveWithDN") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + } + + test("UserBindPositiveWithDNOldConfig") { + testCase = defaultBuilder + .baseDN("ou=People,dc=example,dc=com") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + } + + test("UserBindPositiveWithDNWrongOldConfig") { + testCase = defaultBuilder + .baseDN("ou=DummyPeople,dc=example,dc=com") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + } + + test("UserBindPositiveWithDNWrongConfig") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=DummyPeople,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=DummyGroups,dc=example,dc=com") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + } + + test("UserBindPositiveWithDNBlankConfig") { + testCase = defaultBuilder + .userDNPatterns(" ") + .groupDNPatterns(" ") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + } + + test("UserBindPositiveWithDNBlankOldConfig") { + testCase = defaultBuilder.baseDN("").build + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + } + + test("UserBindNegativeWithDN") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .build + testCase.assertAuthenticateFailsUsingWrongPassword(USER1.credentialsWithDn) + testCase.assertAuthenticateFails(USER1.dn, USER2.password) + testCase.assertAuthenticateFailsUsingWrongPassword(USER2.credentialsWithDn) + } + + test("UserBindNegativeWithDNOldConfig") { + testCase = defaultBuilder + .baseDN("ou=People,dc=example,dc=com") + .build + testCase.assertAuthenticateFailsUsingWrongPassword(USER1.credentialsWithDn) + testCase.assertAuthenticateFails(USER1.dn, USER2.password) + testCase.assertAuthenticateFailsUsingWrongPassword(USER2.credentialsWithDn) + } + + test("UserFilterPositive") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .userFilters(USER1.id) + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .userFilters(USER2.id) + .build + testCase.assertAuthenticatePasses(USER2.credentialsWithId) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .userFilters(USER1.id, USER2.id) + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithId) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + } + + test("UserFilterNegative") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .userFilters(USER2.id) + .build + testCase.assertAuthenticateFails(USER1.credentialsWithId) + testCase.assertAuthenticateFails(USER1.credentialsWithDn) + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .userFilters(USER1.id) + .build + testCase.assertAuthenticateFails(USER2.credentialsWithId) + testCase.assertAuthenticateFails(USER2.credentialsWithDn) + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .userFilters(USER3.id) + .build + testCase.assertAuthenticateFails(USER1.credentialsWithId) + testCase.assertAuthenticateFails(USER2.credentialsWithId) + } + + test("GroupFilterPositive") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .groupFilters(GROUP1_NAME, GROUP2_NAME) + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithId) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .groupFilters(GROUP2_NAME) + .build + testCase.assertAuthenticatePasses(USER2.credentialsWithId) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + } + + test("GroupFilterNegative") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .groupFilters(GROUP2_NAME) + .build + testCase.assertAuthenticateFails(USER1.credentialsWithId) + testCase.assertAuthenticateFails(USER1.credentialsWithDn) + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .groupFilters(GROUP1_NAME) + .build + testCase.assertAuthenticateFails(USER2.credentialsWithId) + testCase.assertAuthenticateFails(USER2.credentialsWithDn) + } + + test("UserAndGroupFilterPositive") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .userFilters(USER1.id, USER2.id) + .groupFilters(GROUP1_NAME, GROUP2_NAME) + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithId) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + } + + test("UserAndGroupFilterNegative") { + testCase = defaultBuilder + .userDNPatterns("uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("uid=%s,ou=Groups,dc=example,dc=com") + .userFilters(USER1.id, USER2.id) + .groupFilters(GROUP3_NAME, GROUP3_NAME) + .build + testCase.assertAuthenticateFails(USER2.credentialsWithDn) + testCase.assertAuthenticateFails(USER2.credentialsWithId) + testCase.assertAuthenticateFails(USER3.credentialsWithDn) + testCase.assertAuthenticateFails(USER3.credentialsWithId) + } + + test("CustomQueryPositive") { + testCase = defaultBuilder + .baseDN("ou=People,dc=example,dc=com") + .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("cn=%s,ou=People,dc=example,dc=com") + .customQuery(String.format("(&(objectClass=person)(|(uid=%s)(uid=%s)))", USER1.id, USER4.id)) + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER4.credentialsWithId) + testCase.assertAuthenticatePasses(USER4.credentialsWithDn) + testCase = defaultBuilder + .baseDN("ou=People,dc=example,dc=com") + .customQuery("(&(objectClass=person)(uid=%s))") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER2.credentialsWithId) + } + + test("CustomQueryNegative") { + testCase = defaultBuilder + .baseDN("ou=People,dc=example,dc=com") + .customQuery("(&(objectClass=person)(cn=%s))") + .build + testCase.assertAuthenticateFails(USER2.credentialsWithDn) + testCase.assertAuthenticateFails(USER2.credentialsWithId) + } + + /** + * Test to test the LDAP Atn to use a custom LDAP query that returns + * a) A set of group DNs + * b) A combination of group(s) DN and user DN + * LDAP atn is expected to extract the members of the group using the attribute value for + * `kyuubi.authentication.ldap.userMembershipKey` + */ + test("CustomQueryWithGroupsPositive") { + testCase = defaultBuilder + .baseDN("dc=example,dc=com") + .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com") + .customQuery(s"(&(objectClass=groupOfNames)(|(cn=$GROUP1_NAME)(cn=$GROUP2_NAME)))") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER2.credentialsWithId) + testCase.assertAuthenticatePasses(USER2.credentialsWithDn) + // the following test uses a query that returns a group and a user entry. + // the ldap atn should use the groupMembershipKey to identify the users for the returned group + // and the authentication should succeed for the users of that group as well as the lone user4 + // in this case + testCase = defaultBuilder + .baseDN("dc=example,dc=com") + .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com") + .customQuery( + s"(|(&(objectClass=groupOfNames)(cn=$GROUP1_NAME))(&(objectClass=person)(sn=${USER4.id})))") + .build + testCase.assertAuthenticatePasses(USER1.credentialsWithId) + testCase.assertAuthenticatePasses(USER1.credentialsWithDn) + testCase.assertAuthenticatePasses(USER4.credentialsWithId) + testCase.assertAuthenticatePasses(USER4.credentialsWithDn) + testCase = defaultBuilder + .baseDN("dc=example,dc=com") + .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com") + .groupMembershipKey("uniqueMember") + .customQuery(s"(&(objectClass=groupOfUniqueNames)(cn=$GROUP4_NAME))") + .build + testCase.assertAuthenticatePasses(USER4.credentialsWithId) + testCase.assertAuthenticatePasses(USER4.credentialsWithDn) + } + + test("CustomQueryWithGroupsNegative") { + testCase = defaultBuilder + .baseDN("dc=example,dc=com") + .userDNPatterns("cn=%s,ou=People,dc=example,dc=com", "uid=%s,ou=People,dc=example,dc=com") + .customQuery(s"(&(objectClass=groupOfNames)(|(cn=$GROUP1_NAME)(cn=$GROUP2_NAME)))") + .build + testCase.assertAuthenticateFails(USER3.credentialsWithDn) + testCase.assertAuthenticateFails(USER3.credentialsWithId) + } + + test("GroupFilterPositiveWithCustomGUID") { + testCase = defaultBuilder + .userDNPatterns("cn=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("cn=%s,ou=Groups,dc=example,dc=com") + .groupFilters(GROUP3_NAME) + .guidKey("cn") + .build + testCase.assertAuthenticatePasses(USER3.credentialsWithId) + testCase.assertAuthenticatePasses(USER3.credentialsWithDn) + } + + test("GroupFilterPositiveWithCustomAttributes") { + testCase = defaultBuilder + .userDNPatterns("cn=%s,ou=People,dc=example,dc=com") + .groupDNPatterns("cn=%s,ou=Groups,dc=example,dc=com") + .groupFilters(GROUP4_NAME) + .guidKey("cn") + .groupMembershipKey("uniqueMember") + .groupClassKey("groupOfUniqueNames") + .build + testCase.assertAuthenticatePasses(USER4.credentialsWithId) + testCase.assertAuthenticatePasses(USER4.credentialsWithDn) + } + + test("DirectUserMembershipGroupFilterPositive") { + testCase = defaultBuilder + .userDNPatterns( + "sAMAccountName=%s,ou=Engineering,dc=ad,dc=example,dc=com", + "sAMAccountName=%s,ou=Management,dc=ad,dc=example,dc=com") + .groupDNPatterns( + "sAMAccountName=%s,ou=Teams,dc=ad,dc=example,dc=com", + "sAMAccountName=%s,ou=Resources,dc=ad,dc=example,dc=com") + .groupFilters(GROUP_TEAM1_NAME, GROUP_TEAM2_NAME, GROUP_RESOURCE1_NAME, GROUP_RESOURCE2_NAME) + .guidKey("sAMAccountName") + .userMembershipKey("memberOf") + .build + testCase.assertAuthenticatePasses(ENGINEER_1.credentialsWithId) + testCase.assertAuthenticatePasses(ENGINEER_2.credentialsWithId) + testCase.assertAuthenticatePasses(MANAGER_1.credentialsWithId) + testCase.assertAuthenticatePasses(MANAGER_2.credentialsWithId) + } + + test("DirectUserMembershipGroupFilterNegative") { + testCase = defaultBuilder + .userDNPatterns( + "sAMAccountName=%s,ou=Engineering,dc=ad,dc=example,dc=com", + "sAMAccountName=%s,ou=Management,dc=ad,dc=example,dc=com") + .groupDNPatterns("cn=%s,ou=Teams,dc=ad,dc=example,dc=com") + .groupFilters(GROUP_TEAM1_NAME) + .guidKey("sAMAccountName") + .userMembershipKey("memberOf") + .build + testCase.assertAuthenticateFails(ENGINEER_2.credentialsWithId) + testCase.assertAuthenticateFails(MANAGER_2.credentialsWithId) + } + + test("DirectUserMembershipGroupFilterNegativeWithoutUserBases") { + testCase = defaultBuilder + .groupDNPatterns("cn=%s,ou=Teams,dc=ad,dc=example,dc=com") + .groupFilters(GROUP_TEAM1_NAME) + .guidKey("sAMAccountName") + .userMembershipKey("memberOf") + .build + testCase.assertAuthenticateFails(ENGINEER_1.credentialsWithId) + testCase.assertAuthenticateFails(ENGINEER_2.credentialsWithId) + testCase.assertAuthenticateFails(MANAGER_1.credentialsWithId) + testCase.assertAuthenticateFails(MANAGER_2.credentialsWithId) + } + + test("DirectUserMembershipGroupFilterWithDNCredentials") { + testCase = defaultBuilder + .userDNPatterns("sAMAccountName=%s,ou=Engineering,dc=ad,dc=example,dc=com") + .groupDNPatterns("cn=%s,ou=Teams,dc=ad,dc=example,dc=com") + .groupFilters(GROUP_TEAM1_NAME) + .guidKey("sAMAccountName") + .userMembershipKey("memberOf") + .build + testCase.assertAuthenticatePasses(ENGINEER_1.credentialsWithDn) + testCase.assertAuthenticateFails(MANAGER_1.credentialsWithDn) + } + + test("DirectUserMembershipGroupFilterWithDifferentGroupClassKey") { + testCase = defaultBuilder + .userDNPatterns("sAMAccountName=%s,ou=Administration,dc=ad,dc=example,dc=com") + .groupDNPatterns("cn=%s,ou=Administration,dc=ad,dc=example,dc=com") + .groupFilters(GROUP_ADMINS_NAME).guidKey("sAMAccountName") + .userMembershipKey("memberOf") + .groupClassKey("groupOfUniqueNames") + .build + testCase.assertAuthenticatePasses(ADMIN_1.credentialsWithId) + testCase.assertAuthenticateFails(ENGINEER_1.credentialsWithId) + testCase.assertAuthenticateFails(MANAGER_1.credentialsWithDn) + } + + test("DirectUserMembershipGroupFilterNegativeWithWrongGroupClassKey") { + testCase = defaultBuilder + .userDNPatterns("sAMAccountName=%s,ou=Administration,dc=ad,dc=example,dc=com") + .groupDNPatterns("cn=%s,ou=Administration,dc=ad,dc=example,dc=com") + .groupFilters(GROUP_ADMINS_NAME).guidKey("sAMAccountName") + .userMembershipKey("memberOf") + .groupClassKey("wrongClass") + .build + testCase.assertAuthenticateFails(ADMIN_1.credentialsWithId) + testCase.assertAuthenticateFails(ENGINEER_1.credentialsWithId) + testCase.assertAuthenticateFails(MANAGER_1.credentialsWithDn) + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImplSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImplSuite.scala index 63941162865..718fc6f6ebd 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImplSuite.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/LdapAuthenticationProviderImplSuite.scala @@ -17,63 +17,357 @@ package org.apache.kyuubi.service.authentication -import javax.naming.CommunicationException +import javax.naming.NamingException import javax.security.sasl.AuthenticationException +import org.mockito.ArgumentMatchers.{any, anyString, eq => mockEq, isA} +import org.mockito.Mockito._ +import org.scalatestplus.mockito.MockitoSugar.mock + import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf._ +import org.apache.kyuubi.service.authentication.ldap.{DirSearch, DirSearchFactory, LdapSearchFactory} class LdapAuthenticationProviderImplSuite extends WithLdapServer { - override protected val ldapUser: String = "kentyao" - override protected val ldapUserPasswd: String = "kentyao" - private val conf = new KyuubiConf() + private var conf: KyuubiConf = _ + private var factory: DirSearchFactory = _ + private var search: DirSearch = _ + private var auth: LdapAuthenticationProviderImpl = _ - override def beforeAll(): Unit = { - super.beforeAll() + override protected def beforeEach(): Unit = { + super.beforeEach() + conf = new KyuubiConf() conf.set(AUTHENTICATION_LDAP_URL, ldapUrl) + factory = mock[DirSearchFactory] + search = mock[DirSearch] + when(factory.getInstance(any(classOf[KyuubiConf]), anyString, anyString)) + .thenReturn(search) + } + + test("authenticateGivenBlankOrNullPassword") { + Seq("", "\0", null).foreach { pwd => + auth = new LdapAuthenticationProviderImpl(conf, new LdapSearchFactory) + val thrown = intercept[AuthenticationException] { + auth.authenticate("user", pwd) + } + assert(thrown.getMessage.contains("is null or contains blank space")) + } + } + + test("AuthenticateNoUserOrGroupFilter") { + conf.set( + AUTHENTICATION_LDAP_USER_DN_PATTERN, + "cn=%s,ou=Users,dc=mycorp,dc=com:cn=%s,ou=PowerUsers,dc=mycorp,dc=com") + val factory = mock[DirSearchFactory] + lenient + .when(search.findUserDn("user1")) + .thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + when(factory.getInstance(conf, "cn=user1,ou=PowerUsers,dc=mycorp,dc=com", "Blah")) + .thenReturn(search) + when(factory.getInstance(conf, "cn=user1,ou=Users,dc=mycorp,dc=com", "Blah")) + .thenThrow(classOf[AuthenticationException]) + + auth = new LdapAuthenticationProviderImpl(conf, factory) + auth.authenticate("user1", "Blah") + + verify(factory, times(2)).getInstance(isA(classOf[KyuubiConf]), anyString, mockEq("Blah")) + verify(search, atLeastOnce).close() + } + + test("AuthenticateWhenUserFilterPasses") { + conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2") + + when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + when(search.findUserDn("user2")).thenReturn("cn=user2,ou=PowerUsers,dc=mycorp,dc=com") + + authenticateUserAndCheckSearchIsClosed("user1") + authenticateUserAndCheckSearchIsClosed("user2") + } + + test("AuthenticateWhenLoginWithDomainAndUserFilterPasses") { + conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1") + + when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + + authenticateUserAndCheckSearchIsClosed("user1@mydomain.com") + } + + test("AuthenticateWhenLoginWithDnAndUserFilterPasses") { + conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1") + + when(search.findUserDn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")) + .thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + + authenticateUserAndCheckSearchIsClosed("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") } - override def afterAll(): Unit = { - super.afterAll() + test("AuthenticateWhenUserSearchFails") { + conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2") + intercept[AuthenticationException] { + when(search.findUserDn("user1")).thenReturn(null) + authenticateUserAndCheckSearchIsClosed("user1") + } } - test("ldap server is started") { - assert(ldapServer.getListenPort > 0) + test("AuthenticateWhenUserFilterFails") { + conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2") + intercept[AuthenticationException] { + when(search.findUserDn("user3")).thenReturn("cn=user3,ou=PowerUsers,dc=mycorp,dc=com") + authenticateUserAndCheckSearchIsClosed("user3") + } } - test("authenticate tests") { - val providerImpl = new LdapAuthenticationProviderImpl(conf) - val e1 = intercept[AuthenticationException](providerImpl.authenticate("", "")) - assert(e1.getMessage.contains("user is null")) - val e2 = intercept[AuthenticationException](providerImpl.authenticate("kyuubi", "")) - assert(e2.getMessage.contains("password is null")) + test("AuthenticateWhenGroupMembershipKeyFilterPasses") { + conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "group1,group2") + + when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + when(search.findUserDn("user2")).thenReturn("cn=user2,ou=PowerUsers,dc=mycorp,dc=com") - val user = "uid=kentyao,ou=users" - providerImpl.authenticate(user, "kentyao") - val e3 = intercept[AuthenticationException]( - providerImpl.authenticate(user, "kent")) - assert(e3.getMessage.contains(user)) - assert(e3.getCause.isInstanceOf[javax.naming.AuthenticationException]) + when(search.findGroupsForUser("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")) + .thenReturn(Array( + "cn=testGroup,ou=Groups,dc=mycorp,dc=com", + "cn=group1,ou=Groups,dc=mycorp,dc=com")) + when(search.findGroupsForUser("cn=user2,ou=PowerUsers,dc=mycorp,dc=com")) + .thenReturn(Array( + "cn=testGroup,ou=Groups,dc=mycorp,dc=com", + "cn=group2,ou=Groups,dc=mycorp,dc=com")) + + authenticateUserAndCheckSearchIsClosed("user1") + authenticateUserAndCheckSearchIsClosed("user2") + } - conf.set(AUTHENTICATION_LDAP_BASEDN, ldapBaseDn) - val providerImpl2 = new LdapAuthenticationProviderImpl(conf) - providerImpl2.authenticate("kentyao", "kentyao") + test("AuthenticateWhenUserAndGroupMembershipKeyFiltersPass") { + conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "group1,group2") + conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2") - val e4 = intercept[AuthenticationException]( - providerImpl.authenticate("kentyao", "kent")) - assert(e4.getMessage.contains(user)) + when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + when(search.findUserDn("user2")).thenReturn("cn=user2,ou=PowerUsers,dc=mycorp,dc=com") - conf.unset(AUTHENTICATION_LDAP_URL) - val providerImpl3 = new LdapAuthenticationProviderImpl(conf) - val e5 = intercept[AuthenticationException]( - providerImpl3.authenticate("kentyao", "kentyao")) + when(search.findGroupsForUser("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")) + .thenReturn(Array( + "cn=testGroup,ou=Groups,dc=mycorp,dc=com", + "cn=group1,ou=Groups,dc=mycorp,dc=com")) + when(search.findGroupsForUser("cn=user2,ou=PowerUsers,dc=mycorp,dc=com")) + .thenReturn(Array( + "cn=testGroup,ou=Groups,dc=mycorp,dc=com", + "cn=group2,ou=Groups,dc=mycorp,dc=com")) + + authenticateUserAndCheckSearchIsClosed("user1") + authenticateUserAndCheckSearchIsClosed("user2") + } + + test("AuthenticateWhenUserFilterPassesAndGroupMembershipKeyFilterFails") { + conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "group1,group2") + conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2") + intercept[AuthenticationException] { + when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + when(search.findGroupsForUser("cn=user1,ou=PowerUsers,dc=mycorp,dc=com")) + .thenReturn(Array( + "cn=testGroup,ou=Groups,dc=mycorp,dc=com", + "cn=OtherGroup,ou=Groups,dc=mycorp,dc=com")) + authenticateUserAndCheckSearchIsClosed("user1") + } + } + + test("AuthenticateWhenUserFilterFailsAndGroupMembershipKeyFilterPasses") { + conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "group3") + conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user1,user2") + intercept[AuthenticationException] { + when(search.findUserDn("user3")).thenReturn("cn=user3,ou=PowerUsers,dc=mycorp,dc=com") + lenient.when(search.findGroupsForUser("cn=user3,ou=PowerUsers,dc=mycorp,dc=com")) + .thenReturn(Array( + "cn=testGroup,ou=Groups,dc=mycorp,dc=com", + "cn=group3,ou=Groups,dc=mycorp,dc=com")) + authenticateUserAndCheckSearchIsClosed("user3") + } + } + + test("AuthenticateWhenCustomQueryFilterPasses") { + conf.set(AUTHENTICATION_LDAP_BASE_DN, "dc=mycorp,dc=com") + conf.set( + AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, + "(&(objectClass=person)(|(memberOf=CN=Domain Admins,CN=Users,DC=apache,DC=org)" + + "(memberOf=CN=Administrators,CN=Builtin,DC=apache,DC=org)))") + + when(search.executeCustomQuery(anyString)) + .thenReturn(Array( + "cn=user1,ou=PowerUsers,dc=mycorp,dc=com", + "cn=user2,ou=PowerUsers,dc=mycorp,dc=com")) + + authenticateUserAndCheckSearchIsClosed("user1") + } + + test("AuthenticateWhenCustomQueryFilterFailsAndUserFilterPasses") { + conf.set(AUTHENTICATION_LDAP_BASE_DN, "dc=mycorp,dc=com") + conf.set( + AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, + "(&(objectClass=person)(|(memberOf=CN=Domain Admins,CN=Users,DC=apache,DC=org)" + + "(memberOf=CN=Administrators,CN=Builtin,DC=apache,DC=org)))") + conf.set(AUTHENTICATION_LDAP_USER_FILTER.key, "user3") + intercept[AuthenticationException] { + lenient.when(search.findUserDn("user3")).thenReturn("cn=user3,ou=PowerUsers,dc=mycorp,dc=com") + when(search.executeCustomQuery(anyString)) + .thenReturn(Array( + "cn=user1,ou=PowerUsers,dc=mycorp,dc=com", + "cn=user2,ou=PowerUsers,dc=mycorp,dc=com")) + authenticateUserAndCheckSearchIsClosed("user3") + } + } - assert(e5.getMessage.contains(user)) - assert(e5.getCause.isInstanceOf[CommunicationException]) + test("AuthenticateWhenUserMembershipKeyFilterPasses") { + conf.set(AUTHENTICATION_LDAP_BASE_DN, "dc=mycorp,dc=com") + conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "HIVE-USERS") + conf.set(AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberOf") + + when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + + val groupDn = "cn=HIVE-USERS,ou=Groups,dc=mycorp,dc=com" + when(search.findGroupDn("HIVE-USERS")).thenReturn(groupDn) + when(search.isUserMemberOfGroup("user1", groupDn)).thenReturn(true) + + auth = new LdapAuthenticationProviderImpl(conf, factory) + auth.authenticate("user1", "Blah") + + verify(factory, times(1)).getInstance(isA(classOf[KyuubiConf]), anyString, mockEq("Blah")) + verify(search, times(1)).findGroupDn(anyString) + verify(search, times(1)).isUserMemberOfGroup(anyString, anyString) + verify(search, atLeastOnce).close() + } + + test("AuthenticateWhenUserMembershipKeyFilterFails") { + conf.set(AUTHENTICATION_LDAP_BASE_DN, "dc=mycorp,dc=com") + conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "HIVE-USERS") + conf.set(AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberOf") + intercept[AuthenticationException] { + when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + + val groupDn = "cn=HIVE-USERS,ou=Groups,dc=mycorp,dc=com" + when(search.findGroupDn("HIVE-USERS")).thenReturn(groupDn) + when(search.isUserMemberOfGroup("user1", groupDn)).thenReturn(false) + + auth = new LdapAuthenticationProviderImpl(conf, factory) + auth.authenticate("user1", "Blah") + } + } + + test("AuthenticateWhenUserMembershipKeyFilter2x2PatternsPasses") { + conf.set(AUTHENTICATION_LDAP_GROUP_FILTER.key, "HIVE-USERS1,HIVE-USERS2") + conf.set(AUTHENTICATION_LDAP_GROUP_DN_PATTERN, "cn=%s,ou=Groups,ou=branch1,dc=mycorp,dc=com") + conf.set(AUTHENTICATION_LDAP_USER_DN_PATTERN, "cn=%s,ou=Userss,ou=branch1,dc=mycorp,dc=com") + conf.set(AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberOf") + + when(search.findUserDn("user1")).thenReturn("cn=user1,ou=PowerUsers,dc=mycorp,dc=com") + + when(search.findGroupDn("HIVE-USERS1")) + .thenReturn("cn=HIVE-USERS1,ou=Groups,ou=branch1,dc=mycorp,dc=com") + when(search.findGroupDn("HIVE-USERS2")) + .thenReturn("cn=HIVE-USERS2,ou=Groups,ou=branch1,dc=mycorp,dc=com") + + when(search.isUserMemberOfGroup( + "user1", + "cn=HIVE-USERS1,ou=Groups,ou=branch1,dc=mycorp,dc=com")) + .thenThrow(classOf[NamingException]) + when(search.isUserMemberOfGroup( + "user1", + "cn=HIVE-USERS2,ou=Groups,ou=branch1,dc=mycorp,dc=com")) + .thenReturn(true) + + auth = new LdapAuthenticationProviderImpl(conf, factory) + auth.authenticate("user1", "Blah") + + verify(factory, times(1)).getInstance(isA(classOf[KyuubiConf]), anyString, mockEq("Blah")) + verify(search, times(2)).findGroupDn(anyString) + verify(search, times(2)).isUserMemberOfGroup(anyString, anyString) + verify(search, atLeastOnce).close() + } + + // Kyuubi does not implement it + // test("AuthenticateWithBindInCredentialFilePasses") + // test("testAuthenticateWithBindInMissingCredentialFilePasses") + + test("AuthenticateWithBindUserPasses") { + val bindUser = "cn=BindUser,ou=Users,ou=branch1,dc=mycorp,dc=com" + val bindPass = "Blah" + val authFullUser = "cn=user1,ou=Users,ou=branch1,dc=mycorp,dc=com" + val authUser = "user1" + val authPass = "Blah2" + conf.set(AUTHENTICATION_LDAP_BIND_USER, bindUser) + conf.set(AUTHENTICATION_LDAP_BIND_PASSWORD, bindPass) + + when(search.findUserDn(mockEq(authUser))).thenReturn(authFullUser) + + auth = new LdapAuthenticationProviderImpl(conf, factory) + auth.authenticate(authUser, authPass) + + verify(factory, times(1)).getInstance( + isA(classOf[KyuubiConf]), + mockEq(bindUser), + mockEq(bindPass)) + verify(factory, times(1)).getInstance( + isA(classOf[KyuubiConf]), + mockEq(authFullUser), + mockEq(authPass)) + verify(search, times(1)).findUserDn(mockEq(authUser)) + } + + test("AuthenticateWithBindUserFailsOnAuthentication") { + val bindUser = "cn=BindUser,ou=Users,ou=branch1,dc=mycorp,dc=com" + val bindPass = "Blah" + val authFullUser = "cn=user1,ou=Users,ou=branch1,dc=mycorp,dc=com" + val authUser = "user1" + val authPass = "Blah2" + conf.set(AUTHENTICATION_LDAP_BIND_USER, bindUser) + conf.set(AUTHENTICATION_LDAP_BIND_PASSWORD, bindPass) + + intercept[AuthenticationException] { + when( + factory.getInstance( + any(classOf[KyuubiConf]), + mockEq(authFullUser), + mockEq(authPass))).thenThrow(classOf[AuthenticationException]) + when(search.findUserDn(mockEq(authUser))).thenReturn(authFullUser) + + auth = new LdapAuthenticationProviderImpl(conf, factory) + auth.authenticate(authUser, authPass) + } + } + + test("AuthenticateWithBindUserFailsOnGettingDn") { + val bindUser = "cn=BindUser,ou=Users,ou=branch1,dc=mycorp,dc=com" + val bindPass = "Blah" + val authUser = "user1" + val authPass = "Blah2" + conf.set(AUTHENTICATION_LDAP_BIND_USER, bindUser) + conf.set(AUTHENTICATION_LDAP_BIND_PASSWORD, bindPass) + + intercept[AuthenticationException] { + when(search.findUserDn(mockEq(authUser))).thenThrow(classOf[NamingException]) + auth = new LdapAuthenticationProviderImpl(conf, factory) + auth.authenticate(authUser, authPass) + } + } + + test("AuthenticateWithBindUserFailsOnBinding") { + val bindUser = "cn=BindUser,ou=Users,ou=branch1,dc=mycorp,dc=com" + val bindPass = "Blah" + val authUser = "user1" + val authPass = "Blah2" + conf.set(AUTHENTICATION_LDAP_BIND_USER, bindUser) + conf.set(AUTHENTICATION_LDAP_BIND_PASSWORD, bindPass) + + intercept[AuthenticationException] { + when(factory.getInstance(any(classOf[KyuubiConf]), mockEq(bindUser), mockEq(bindPass))) + .thenThrow(classOf[AuthenticationException]) + + auth = new LdapAuthenticationProviderImpl(conf, factory) + auth.authenticate(authUser, authPass) + } + } - conf.set(AUTHENTICATION_LDAP_DOMAIN, "kyuubi.com") - val providerImpl4 = new LdapAuthenticationProviderImpl(conf) - intercept[AuthenticationException](providerImpl4.authenticate("kentyao", "kentyao")) + private def authenticateUserAndCheckSearchIsClosed(user: String): Unit = { + auth = new LdapAuthenticationProviderImpl(conf, factory) + try auth.authenticate(user, "password doesn't matter") + finally verify(search, atLeastOnce).close() } } diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/WithLdapServer.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/WithLdapServer.scala index 0bb38684e0b..b31a06f209f 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/WithLdapServer.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/WithLdapServer.scala @@ -17,20 +17,36 @@ package org.apache.kyuubi.service.authentication +import scala.util.Random + import com.unboundid.ldap.listener.{InMemoryDirectoryServer, InMemoryDirectoryServerConfig} +import com.unboundid.ldif.LDIFReader import org.apache.kyuubi.{KyuubiFunSuite, Utils} trait WithLdapServer extends KyuubiFunSuite { protected var ldapServer: InMemoryDirectoryServer = _ - protected val ldapBaseDn = "ou=users" - protected val ldapUser = Utils.currentUser - protected val ldapUserPasswd = "ldapPassword" + protected val ldapBaseDn: Array[String] = Array("ou=users") + protected val ldapUser: String = Utils.currentUser + protected val ldapUserPasswd: String = Random.alphanumeric.take(16).mkString protected def ldapUrl = s"ldap://localhost:${ldapServer.getListenPort}" + /** + * Apply LDIF files + * @param resource the LDIF file under classpath + */ + def applyLDIF(resource: String): Unit = { + ldapServer.applyChangesFromLDIF( + new LDIFReader(Utils.getContextOrKyuubiClassLoader.getResource(resource).openStream())) + } + override def beforeAll(): Unit = { - val config = new InMemoryDirectoryServerConfig(ldapBaseDn) + val config = new InMemoryDirectoryServerConfig(ldapBaseDn: _*) + // disable the schema so that we can apply LDIF which contains Microsoft's Active Directory + // specific definitions. + // https://myshittycode.com/2017/03/28/ + config.setSchema(null) config.addAdditionalBindCredentials(s"uid=$ldapUser,ou=users", ldapUserPasswd) ldapServer = new InMemoryDirectoryServer(config) ldapServer.startListening() diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterSuite.scala new file mode 100644 index 00000000000..d76611b6e11 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/ChainFilterSuite.scala @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.security.sasl.AuthenticationException + +import org.mockito.ArgumentMatchers.{any, anyString} +import org.mockito.Mockito.{doThrow, times, verify, when} +import org.scalatestplus.mockito.MockitoSugar.mock + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.config.KyuubiConf + +class ChainFilterSuite extends KyuubiFunSuite { + private var conf: KyuubiConf = _ + private var filter1: Filter = _ + private var filter2: Filter = _ + private var filter3: Filter = _ + private var factory1: FilterFactory = _ + private var factory2: FilterFactory = _ + private var factory3: FilterFactory = _ + private var factory: FilterFactory = _ + private var search: DirSearch = _ + + override def beforeEach(): Unit = { + conf = new KyuubiConf() + filter1 = mock[Filter] + filter2 = mock[Filter] + filter3 = mock[Filter] + factory1 = mock[FilterFactory] + factory2 = mock[FilterFactory] + factory3 = mock[FilterFactory] + factory = new ChainFilterFactory(factory1, factory2, factory3) + search = mock[DirSearch] + super.beforeEach() + } + + test("FactoryAllNull") { + when(factory1.getInstance(any(classOf[KyuubiConf]))).thenReturn(None) + when(factory2.getInstance(any(classOf[KyuubiConf]))).thenReturn(None) + when(factory3.getInstance(any(classOf[KyuubiConf]))).thenReturn(None) + assert(factory.getInstance(conf).isEmpty) + } + + test("FactoryAllEmpty") { + val emptyFactory = new ChainFilterFactory() + assert(emptyFactory.getInstance(conf).isEmpty) + } + + test("Factory") { + when(factory1.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter1)) + when(factory2.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter2)) + when(factory3.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter3)) + val filter = factory.getInstance(conf).get + filter.apply(search, "User") + verify(filter1, times(1)).apply(search, "User") + verify(filter2, times(1)).apply(search, "User") + verify(filter3, times(1)).apply(search, "User") + } + + test("ApplyNegative") { + intercept[AuthenticationException] { + doThrow(classOf[AuthenticationException]) + .when(filter3) + .apply(any().asInstanceOf[DirSearch], anyString) + when(factory1.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter1)) + when(factory2.getInstance(any(classOf[KyuubiConf]))).thenReturn(None) + when(factory3.getInstance(any(classOf[KyuubiConf]))).thenReturn(Some(filter3)) + val filter = factory.getInstance(conf).get + filter.apply(search, "User") + } + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterSuite.scala new file mode 100644 index 00000000000..5ece4c88cf6 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/CustomQueryFilterSuite.scala @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.security.sasl.AuthenticationException + +import org.mockito.ArgumentMatchers.{eq => mockEq} +import org.mockito.Mockito.when +import org.scalatestplus.mockito.MockitoSugar.mock + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.config.KyuubiConf + +class CustomQueryFilterSuite extends KyuubiFunSuite { + private val USER2_DN: String = "uid=user2,ou=People,dc=example,dc=com" + private val USER1_DN: String = "uid=user1,ou=People,dc=example,dc=com" + private val CUSTOM_QUERY: String = "(&(objectClass=person)(|(uid=user1)(uid=user2)))" + + private val factory: FilterFactory = CustomQueryFilterFactory + private var conf: KyuubiConf = _ + private var search: DirSearch = _ + + override def beforeEach(): Unit = { + conf = new KyuubiConf() + search = mock[DirSearch] + super.beforeEach() + } + + test("Factory") { + conf.unset(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY) + assert(factory.getInstance(conf).isEmpty) + conf.set(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, CUSTOM_QUERY) + assert(factory.getInstance(conf).isDefined) + } + + test("ApplyPositive") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, CUSTOM_QUERY) + when(search.executeCustomQuery(mockEq(CUSTOM_QUERY))) + .thenReturn(Array(USER1_DN, USER2_DN)) + val filter: Filter = factory.getInstance(conf).get + filter.apply(search, "user1") + filter.apply(search, "user2") + } + + test("ApplyNegative") { + intercept[AuthenticationException] { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, CUSTOM_QUERY) + when(search.executeCustomQuery(mockEq(CUSTOM_QUERY))) + .thenReturn(Array(USER1_DN, USER2_DN)) + val filter: Filter = factory.getInstance(conf).get + filter.apply(search, "user3") + } + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterSuite.scala new file mode 100644 index 00000000000..f1e3c3581e9 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/GroupFilterSuite.scala @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.security.sasl.AuthenticationException + +import org.mockito.ArgumentMatchers.{eq => mockEq} +import org.mockito.Mockito.{lenient, when} +import org.scalatestplus.mockito.MockitoSugar.mock + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.config.KyuubiConf + +class GroupFilterSuite extends KyuubiFunSuite { + private val factory: FilterFactory = GroupFilterFactory + private var conf: KyuubiConf = _ + private var search: DirSearch = _ + + override def beforeEach(): Unit = { + conf = new KyuubiConf + search = mock[DirSearch] + super.beforeEach() + } + + test("GetInstanceWhenGroupFilterIsEmpty") { + conf.unset(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER) + assert(factory.getInstance(conf).isEmpty) + } + + test("GetInstanceOfGroupMembershipKeyFilter") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "G1") + val instance: Filter = factory.getInstance(conf).get + assert(instance.isInstanceOf[GroupMembershipKeyFilter]) + } + + test("GetInstanceOfUserMembershipKeyFilter") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "G1") + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberof") + val instance: Filter = factory.getInstance(conf).get + assert(instance.isInstanceOf[UserMembershipKeyFilter]) + } + + test("GroupMembershipKeyFilterApplyPositive") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "HiveUsers") + when(search.findUserDn(mockEq("user1"))) + .thenReturn("cn=user1,ou=People,dc=example,dc=com") + when(search.findUserDn(mockEq("cn=user2,dc=example,dc=com"))) + .thenReturn("cn=user2,ou=People,dc=example,dc=com") + when(search.findUserDn(mockEq("user3@mydomain.com"))) + .thenReturn("cn=user3,ou=People,dc=example,dc=com") + when(search.findGroupsForUser(mockEq("cn=user1,ou=People,dc=example,dc=com"))) + .thenReturn(Array( + "cn=SuperUsers,ou=Groups,dc=example,dc=com", + "cn=Office1,ou=Groups,dc=example,dc=com", + "cn=HiveUsers,ou=Groups,dc=example,dc=com", + "cn=G1,ou=Groups,dc=example,dc=com")) + when(search.findGroupsForUser(mockEq("cn=user2,ou=People,dc=example,dc=com"))) + .thenReturn(Array("cn=HiveUsers,ou=Groups,dc=example,dc=com")) + when(search.findGroupsForUser(mockEq("cn=user3,ou=People,dc=example,dc=com"))) + .thenReturn(Array( + "cn=HiveUsers,ou=Groups,dc=example,dc=com", + "cn=G1,ou=Groups,dc=example,dc=com", + "cn=G2,ou=Groups,dc=example,dc=com")) + val filter: Filter = factory.getInstance(conf).get + filter.apply(search, "user1") + filter.apply(search, "cn=user2,dc=example,dc=com") + filter.apply(search, "user3@mydomain.com") + } + + test("GroupMembershipKeyCaseInsensitiveFilterApplyPositive") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "hiveusers,g1") + when(search.findUserDn(mockEq("user1"))) + .thenReturn("cn=user1,ou=People,dc=example,dc=com") + when(search.findUserDn(mockEq("cn=user2,dc=example,dc=com"))) + .thenReturn("cn=user2,ou=People,dc=example,dc=com") + when(search.findUserDn(mockEq("user3@mydomain.com"))) + .thenReturn("cn=user3,ou=People,dc=example,dc=com") + when(search.findGroupsForUser(mockEq("cn=user1,ou=People,dc=example,dc=com"))) + .thenReturn(Array( + "cn=SuperUsers,ou=Groups,dc=example,dc=com", + "cn=Office1,ou=Groups,dc=example,dc=com", + "cn=HiveUsers,ou=Groups,dc=example,dc=com", + "cn=G1,ou=Groups,dc=example,dc=com")) + when(search.findGroupsForUser(mockEq("cn=user2,ou=People,dc=example,dc=com"))) + .thenReturn(Array("cn=HiveUsers,ou=Groups,dc=example,dc=com")) + when(search.findGroupsForUser(mockEq("cn=user3,ou=People,dc=example,dc=com"))) + .thenReturn(Array( + "cn=G1,ou=Groups,dc=example,dc=com", + "cn=G2,ou=Groups,dc=example,dc=com")) + val filter: Filter = factory.getInstance(conf).get + filter.apply(search, "user1") + filter.apply(search, "cn=user2,dc=example,dc=com") + filter.apply(search, "user3@mydomain.com") + } + + test("GroupMembershipKeyCaseInsensitiveFilterApplyNegative") { + intercept[AuthenticationException] { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "hiveusers,containsg1") + lenient.when(search.findGroupsForUser(mockEq("user1"))) + .thenReturn(Array("SuperUsers", "Office1", "G1", "G2")) + val filter: Filter = factory.getInstance(conf).get + filter.apply(search, "user1") + } + } + + test("GroupMembershipKeyFilterApplyNegative") { + intercept[AuthenticationException] { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "HiveUsers") + lenient.when(search.findGroupsForUser(mockEq("user1"))) + .thenReturn(Array("SuperUsers", "Office1", "G1", "G2")) + val filter: Filter = factory.getInstance(conf).get + filter.apply(search, "user1") + } + } + + test("UserMembershipKeyFilterApplyPositiveWithUserId") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY.key, "memberOf") + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "Group1,Group2") + when(search.findGroupDn("Group1")).thenReturn("cn=Group1,dc=a,dc=b") + when(search.findGroupDn("Group2")).thenReturn("cn=Group2,dc=a,dc=b") + when(search.isUserMemberOfGroup("User1", "cn=Group2,dc=a,dc=b")).thenReturn(true) + val filter: Filter = factory.getInstance(conf).get + filter.apply(search, "User1") + } + + test("UserMembershipKeyFilterApplyPositiveWithUserDn") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY.key, "memberOf") + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "Group1,Group2") + when(search.findGroupDn("Group1")).thenReturn("cn=Group1,dc=a,dc=b") + when(search.findGroupDn("Group2")).thenReturn("cn=Group2,dc=a,dc=b") + when(search.isUserMemberOfGroup("cn=User1,dc=a,dc=b", "cn=Group2,dc=a,dc=b")).thenReturn(true) + val filter: Filter = factory.getInstance(conf).get + filter.apply(search, "cn=User1,dc=a,dc=b") + } + + test("UserMembershipKeyFilterApplyNegative") { + intercept[AuthenticationException] { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY.key, "memberOf") + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "Group1,Group2") + when(search.findGroupDn("Group1")).thenReturn("cn=Group1,dc=a,dc=b") + when(search.findGroupDn("Group2")).thenReturn("cn=Group2,dc=a,dc=b") + val filter: Filter = factory.getInstance(conf).get + filter.apply(search, "User1") + } + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapAuthenticationTestCase.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapAuthenticationTestCase.scala new file mode 100644 index 00000000000..e8b92ebc0ec --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapAuthenticationTestCase.scala @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.security.sasl.AuthenticationException + +import scala.collection.mutable + +import org.scalatest.Assertions.{fail, intercept} + +import org.apache.kyuubi.config.{ConfigEntry, KyuubiConf} +import org.apache.kyuubi.service.authentication.LdapAuthenticationProviderImpl + +object LdapAuthenticationTestCase { + def builder: LdapAuthenticationTestCase.Builder = new LdapAuthenticationTestCase.Builder + + class Builder { + private val overrides: mutable.Map[ConfigEntry[_], String] = new mutable.HashMap + + var conf: KyuubiConf = _ + + def baseDN(baseDN: String): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, baseDN) + + def guidKey(guidKey: String): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY, guidKey) + + def userDNPatterns(userDNPatterns: String*): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, userDNPatterns.mkString(":")) + + def userFilters(userFilters: String*): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER, userFilters.mkString(",")) + + def groupDNPatterns(groupDNPatterns: String*): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN, groupDNPatterns.mkString(":")) + + def groupFilters(groupFilters: String*): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER, groupFilters.mkString(",")) + + def groupClassKey(groupClassKey: String): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GROUP_CLASS_KEY, groupClassKey) + + def ldapUrl(ldapUrl: String): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_URL, ldapUrl) + + def customQuery(customQuery: String): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_CUSTOM_LDAP_QUERY, customQuery) + + def groupMembershipKey(groupMembershipKey: String): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_GROUP_MEMBERSHIP_KEY, groupMembershipKey) + + def userMembershipKey(userMembershipKey: String): LdapAuthenticationTestCase.Builder = + setVarOnce(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, userMembershipKey) + + private def setVarOnce( + confVar: ConfigEntry[_], + value: String): LdapAuthenticationTestCase.Builder = { + require(!overrides.contains(confVar), s"Property $confVar has been set already") + overrides.put(confVar, value) + this + } + + def build: LdapAuthenticationTestCase = { + require(conf == null, "Test Case Builder should not be reused. Please create a new instance.") + conf = new KyuubiConf() + overrides.foreach { case (k, v) => conf.set(k.key, v) } + new LdapAuthenticationTestCase(this) + } + } +} + +final class LdapAuthenticationTestCase(builder: LdapAuthenticationTestCase.Builder) { + + private val ldapProvider = new LdapAuthenticationProviderImpl(builder.conf) + + def assertAuthenticatePasses(credentials: Credentials): Unit = + try { + ldapProvider.authenticate(credentials.user, credentials.password) + } catch { + case e: AuthenticationException => + throw new AssertionError( + s"Authentication failed for user '${credentials.user}' " + + s"with password '${credentials.password}'", + e) + } + + def assertAuthenticateFails(credentials: Credentials): Unit = { + assertAuthenticateFails(credentials.user, credentials.password) + } + + def assertAuthenticateFailsUsingWrongPassword(credentials: Credentials): Unit = { + assertAuthenticateFails(credentials.user, "not" + credentials.password) + } + + def assertAuthenticateFails(user: String, password: String): Unit = { + val e = intercept[AuthenticationException] { + ldapProvider.authenticate(user, password) + fail(s"Expected authentication to fail for $user") + } + assert(e != null) + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchSuite.scala new file mode 100644 index 00000000000..3bf27127ba3 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapSearchSuite.scala @@ -0,0 +1,298 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.naming.{NamingEnumeration, NamingException} +import javax.naming.directory.{DirContext, SearchControls, SearchResult} + +import org.mockito.ArgumentMatchers.{any, anyString, contains, eq => mockEq} +import org.mockito.Mockito.{atLeastOnce, verify, when} +import org.scalatestplus.mockito.MockitoSugar.mock + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.service.authentication.ldap.LdapTestUtils._ + +class LdapSearchSuite extends KyuubiFunSuite { + private var conf: KyuubiConf = _ + private var ctx: DirContext = _ + private var search: LdapSearch = _ + + override protected def beforeEach(): Unit = { + conf = new KyuubiConf() + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "memberOf") + ctx = mock[DirContext] + super.beforeEach() + } + + test("close") { + search = new LdapSearch(conf, ctx) + search.close() + verify(ctx, atLeastOnce).close() + } + + test("FindUserDnWhenUserDnPositive") { + val searchResult: NamingEnumeration[SearchResult] = + mockNamingEnumeration("CN=User1,OU=org1,DC=foo,DC=bar") + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(searchResult) + .thenThrow(classOf[NamingException]) + search = new LdapSearch(conf, ctx) + val expected: String = "CN=User1,OU=org1,DC=foo,DC=bar" + val actual: String = search.findUserDn("CN=User1,OU=org1") + assert(expected === actual) + } + + test("FindUserDnWhenUserDnNegativeDuplicates") { + val searchResult: NamingEnumeration[SearchResult] = + mockNamingEnumeration("CN=User1,OU=org1,DC=foo,DC=bar", "CN=User1,OU=org2,DC=foo,DC=bar") + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(searchResult) + search = new LdapSearch(conf, ctx) + assert(search.findUserDn("CN=User1,DC=foo,DC=bar") === null) + } + + test("FindUserDnWhenUserDnNegativeNone") { + val searchResult: NamingEnumeration[SearchResult] = mockEmptyNamingEnumeration + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(searchResult) + search = new LdapSearch(conf, ctx) + assert(search.findUserDn("CN=User1,DC=foo,DC=bar") === null) + } + + test("FindUserDnWhenUserPatternFoundBySecondPattern") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar:CN=%s,OU=org2,DC=foo,DC=bar") + val emptyResult: NamingEnumeration[SearchResult] = mockEmptyNamingEnumeration + val validResult: NamingEnumeration[SearchResult] = + mockNamingEnumeration("CN=User1,OU=org2,DC=foo,DC=bar") + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(emptyResult) + .thenReturn(validResult) + search = new LdapSearch(conf, ctx) + val expected: String = "CN=User1,OU=org2,DC=foo,DC=bar" + val actual: String = search.findUserDn("User1") + assert(expected === actual) + verify(ctx).search( + mockEq("OU=org1,DC=foo,DC=bar"), + contains("CN=User1"), + any(classOf[SearchControls])) + verify(ctx).search( + mockEq("OU=org2,DC=foo,DC=bar"), + contains("CN=User1"), + any(classOf[SearchControls])) + } + + test("FindUserDnWhenUserPatternFoundByFirstPattern") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar:CN=%s,OU=org2,DC=foo,DC=bar") + val emptyResult: NamingEnumeration[SearchResult] = mockEmptyNamingEnumeration + val validResult: NamingEnumeration[SearchResult] = + mockNamingEnumeration("CN=User1,OU=org2,DC=foo,DC=bar") + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(validResult) + .thenReturn(emptyResult) + search = new LdapSearch(conf, ctx) + val expected: String = "CN=User1,OU=org2,DC=foo,DC=bar" + val actual: String = search.findUserDn("User1") + assert(expected === actual) + verify(ctx).search( + mockEq("OU=org1,DC=foo,DC=bar"), + contains("CN=User1"), + any(classOf[SearchControls])) + } + + test("FindUserDnWhenUserPatternFoundByUniqueIdentifier") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar") + val validResult: NamingEnumeration[SearchResult] = + mockNamingEnumeration("CN=User1,OU=org1,DC=foo,DC=bar") + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(null) + .thenReturn(validResult) + search = new LdapSearch(conf, ctx) + val expected: String = "CN=User1,OU=org1,DC=foo,DC=bar" + val actual: String = search.findUserDn("User1") + assert(expected === actual) + verify(ctx).search( + mockEq("OU=org1,DC=foo,DC=bar"), + contains("CN=User1"), + any(classOf[SearchControls])) + verify(ctx).search( + mockEq("OU=org1,DC=foo,DC=bar"), + contains("uid=User1"), + any(classOf[SearchControls])) + } + + test("FindUserDnWhenUserPatternFoundByUniqueIdentifierNegativeNone") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar") + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(null) + .thenReturn(null) + search = new LdapSearch(conf, ctx) + assert(search.findUserDn("User1") === null) + } + + test("FindUserDnWhenUserPatternFoundByUniqueIdentifierNegativeMany") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar") + val manyResult: NamingEnumeration[SearchResult] = + mockNamingEnumeration("CN=User1,OU=org1,DC=foo,DC=bar", "CN=User12,OU=org1,DC=foo,DC=bar") + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(null) + .thenReturn(manyResult) + search = new LdapSearch(conf, ctx) + assert(search.findUserDn("User1") === null) + } + + test("FindGroupsForUser") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar") + val groupsResult: NamingEnumeration[SearchResult] = + mockNamingEnumeration("CN=Group1,OU=org1,DC=foo,DC=bar") + when( + ctx.search( + mockEq("OU=org1,DC=foo,DC=bar"), + contains("User1"), + any(classOf[SearchControls]))).thenReturn(groupsResult) + search = new LdapSearch(conf, ctx) + val expected = Array("CN=Group1,OU=org1,DC=foo,DC=bar") + val actual = search.findGroupsForUser("CN=User1,OU=org1,DC=foo,DC=bar") + assert(expected === actual) + } + + test("ExecuteCustomQuery") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, "dc=example,dc=com") + val customQueryResult: NamingEnumeration[SearchResult] = mockNamingEnumeration(Array( + mockSearchResult( + "uid=group1,ou=Groups,dc=example,dc=com", + mockAttributes("member", "uid=user1,ou=People,dc=example,dc=com")), + mockSearchResult( + "uid=group2,ou=Groups,dc=example,dc=com", + mockAttributes("member", "uid=user2,ou=People,dc=example,dc=com")))) + when( + ctx.search( + mockEq("dc=example,dc=com"), + anyString, + any(classOf[SearchControls]))) + .thenReturn(customQueryResult) + search = new LdapSearch(conf, ctx) + val expected = Array( + "uid=group1,ou=Groups,dc=example,dc=com", + "uid=user1,ou=People,dc=example,dc=com", + "uid=group2,ou=Groups,dc=example,dc=com", + "uid=user2,ou=People,dc=example,dc=com") + val actual = search.executeCustomQuery("(&(objectClass=groupOfNames)(|(cn=group1)(cn=group2)))") + assert(expected.sorted === actual.sorted) + } + + test("FindGroupDnPositive") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar") + val groupDn: String = "CN=Group1" + val result: NamingEnumeration[SearchResult] = mockNamingEnumeration(groupDn) + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(result) + search = new LdapSearch(conf, ctx) + val expected: String = groupDn + val actual: String = search.findGroupDn("grp1") + assert(expected === actual) + } + + test("FindGroupDNNoResults") { + intercept[NamingException] { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar") + val result: NamingEnumeration[SearchResult] = mockEmptyNamingEnumeration + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(result) + search = new LdapSearch(conf, ctx) + search.findGroupDn("anyGroup") + } + } + + test("FindGroupDNTooManyResults") { + intercept[NamingException] { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar") + val result: NamingEnumeration[SearchResult] = + LdapTestUtils.mockNamingEnumeration("Result1", "Result2", "Result3") + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(result) + search = new LdapSearch(conf, ctx) + search.findGroupDn("anyGroup") + } + + } + + test("FindGroupDNWhenExceptionInSearch") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_GROUP_DN_PATTERN, + Array("CN=%s,OU=org1,DC=foo,DC=bar", "CN=%s,OU=org2,DC=foo,DC=bar").mkString(":")) + val result: NamingEnumeration[SearchResult] = LdapTestUtils.mockNamingEnumeration("CN=Group1") + when(ctx.search(anyString, anyString, any(classOf[SearchControls]))) + .thenReturn(result) + .thenThrow(classOf[NamingException]) + search = new LdapSearch(conf, ctx) + val expected: String = "CN=Group1" + val actual: String = search.findGroupDn("grp1") + assert(expected === actual) + } + + test("IsUserMemberOfGroupWhenUserId") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar") + val validResult: NamingEnumeration[SearchResult] = + LdapTestUtils.mockNamingEnumeration("CN=User1") + val emptyResult: NamingEnumeration[SearchResult] = LdapTestUtils.mockEmptyNamingEnumeration + when(ctx.search(anyString, contains("(uid=usr1)"), any(classOf[SearchControls]))) + .thenReturn(validResult) + when(ctx.search(anyString, contains("(uid=usr2)"), any(classOf[SearchControls]))) + .thenReturn(emptyResult) + search = new LdapSearch(conf, ctx) + assert(search.isUserMemberOfGroup("usr1", "grp1")) + assert(!search.isUserMemberOfGroup("usr2", "grp2")) + } + + test("IsUserMemberOfGroupWhenUserDn") { + conf.set( + KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, + "CN=%s,OU=org1,DC=foo,DC=bar") + val validResult: NamingEnumeration[SearchResult] = + LdapTestUtils.mockNamingEnumeration("CN=User1") + val emptyResult: NamingEnumeration[SearchResult] = LdapTestUtils.mockEmptyNamingEnumeration + when(ctx.search(anyString, contains("(uid=User1)"), any(classOf[SearchControls]))) + .thenReturn(validResult) + when(ctx.search(anyString, contains("(uid=User2)"), any(classOf[SearchControls]))) + .thenReturn(emptyResult) + search = new LdapSearch(conf, ctx) + assert(search.isUserMemberOfGroup("CN=User1,OU=org1,DC=foo,DC=bar", "grp1")) + assert(!search.isUserMemberOfGroup("CN=User2,OU=org1,DC=foo,DC=bar", "grp2")) + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapTestUtils.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapTestUtils.scala new file mode 100644 index 00000000000..49340f2c493 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapTestUtils.scala @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.naming.{NamingEnumeration, NamingException} +import javax.naming.directory._ + +import org.mockito.Mockito.when +import org.mockito.stubbing.OngoingStubbing +import org.scalatestplus.mockito.MockitoSugar + +case class NameValues(name: String, values: String*) +case class Credentials(user: String, password: String) + +case class User(dn: String, id: String, password: String) { + + def credentialsWithDn: Credentials = Credentials(dn, password) + + def credentialsWithId: Credentials = Credentials(id, password) +} + +object User { + def useIdForPassword(dn: String, id: String): User = User(dn, id, id) +} + +object LdapTestUtils extends MockitoSugar { + @throws[NamingException] + def mockEmptyNamingEnumeration: NamingEnumeration[SearchResult] = + mockNamingEnumeration(new Array[SearchResult](0)) + + @throws[NamingException] + def mockNamingEnumeration(dns: String*): NamingEnumeration[SearchResult] = + mockNamingEnumeration(mockSearchResults(dns.toArray)) + + @throws[NamingException] + def mockNamingEnumeration(searchResults: Array[SearchResult]): NamingEnumeration[SearchResult] = { + val ne = mock[NamingEnumeration[SearchResult]] + mockHasMoreMethod(ne, searchResults.length) + if (searchResults.nonEmpty) { + val mockedResults = Array(searchResults: _*) + mockNextMethod(ne, mockedResults) + } + ne + } + + @throws[NamingException] + def mockHasMoreMethod(ne: NamingEnumeration[SearchResult], length: Int): Unit = { + var hasMoreStub: OngoingStubbing[Boolean] = when(ne.hasMore) + (0 until length).foreach(_ => hasMoreStub = hasMoreStub.thenReturn(true)) + hasMoreStub.thenReturn(false) + } + + @throws[NamingException] + def mockNextMethod( + ne: NamingEnumeration[SearchResult], + searchResults: Array[SearchResult]): Unit = { + var nextStub: OngoingStubbing[SearchResult] = when(ne.next) + searchResults.foreach { searchResult => + nextStub = nextStub.thenReturn(searchResult) + } + } + + def mockSearchResults(dns: Array[String]): Array[SearchResult] = { + dns.map(mockSearchResult(_, null)) + } + + def mockSearchResult(dn: String, attributes: Attributes): SearchResult = { + val searchResult = mock[SearchResult] + when(searchResult.getNameInNamespace).thenReturn(dn) + when(searchResult.getAttributes).thenReturn(attributes) + searchResult + } + + @throws[NamingException] + def mockEmptyAttributes(): Attributes = mockAttributes() + + @throws[NamingException] + def mockAttributes(name: String, value: String): Attributes = + mockAttributes(NameValues(name, value)) + + @throws[NamingException] + def mockAttributes(name1: String, value1: String, name2: String, value2: String): Attributes = + if (name1 == name2) { + mockAttributes(NameValues(name1, value1, value2)) + } else { + mockAttributes( + NameValues(name1, value1), + NameValues(name2, value2)) + } + + @throws[NamingException] + private def mockAttributes(namedValues: NameValues*): Attributes = { + val attributes = new BasicAttributes + namedValues.foreach { namedValue => + val attr = new BasicAttribute(namedValue.name) + namedValue.values.foreach(attr.add) + attributes.put(attr) + } + attributes + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtilsSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtilsSuite.scala new file mode 100644 index 00000000000..1ef371051e4 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/LdapUtilsSuite.scala @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.config.KyuubiConf + +class LdapUtilsSuite extends KyuubiFunSuite { + test("CreateCandidatePrincipalsForUserDn") { + val conf = new KyuubiConf() + val userDn = "cn=user1,ou=CORP,dc=mycompany,dc=com" + val expected = Array(userDn) + val actual = LdapUtils.createCandidatePrincipals(conf, userDn) + assert(actual === expected) + } + + test("CreateCandidatePrincipalsForUserWithDomain") { + val conf = new KyuubiConf() + val userWithDomain: String = "user1@mycompany.com" + val expected = Array(userWithDomain) + val actual = LdapUtils.createCandidatePrincipals(conf, userWithDomain) + assert(actual === expected) + } + + test("CreateCandidatePrincipalsLdapDomain") { + val conf = new KyuubiConf() + .set(KyuubiConf.AUTHENTICATION_LDAP_DOMAIN, "mycompany.com") + val expected = Array("user1@mycompany.com") + val actual = LdapUtils.createCandidatePrincipals(conf, "user1") + assert(actual === expected) + } + + test("CreateCandidatePrincipalsUserPatternsDefaultBaseDn") { + val conf = new KyuubiConf() + .set(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY, "sAMAccountName") + .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, "dc=mycompany,dc=com") + val expected = Array("sAMAccountName=user1,dc=mycompany,dc=com") + val actual = LdapUtils.createCandidatePrincipals(conf, "user1") + assert(actual === expected) + } + + test("CreateCandidatePrincipals") { + val conf = new KyuubiConf() + .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, "dc=mycompany,dc=com") + .set( + KyuubiConf.AUTHENTICATION_LDAP_USER_DN_PATTERN, + "cn=%s,ou=CORP1,dc=mycompany,dc=com:cn=%s,ou=CORP2,dc=mycompany,dc=com") + val expected = Array( + "cn=user1,ou=CORP1,dc=mycompany,dc=com", + "cn=user1,ou=CORP2,dc=mycompany,dc=com") + val actual = LdapUtils.createCandidatePrincipals(conf, "user1") + assert(actual.sorted === expected.sorted) + } + + test("ExtractFirstRdn") { + val dn = "cn=user1,ou=CORP1,dc=mycompany,dc=com" + val expected = "cn=user1" + val actual = LdapUtils.extractFirstRdn(dn) + assert(actual === expected) + } + + test("ExtractBaseDn") { + val dn: String = "cn=user1,ou=CORP1,dc=mycompany,dc=com" + val expected = "ou=CORP1,dc=mycompany,dc=com" + val actual = LdapUtils.extractBaseDn(dn) + assert(actual === expected) + } + + test("ExtractBaseDnNegative") { + val dn: String = "cn=user1" + assert(LdapUtils.extractBaseDn(dn) === null) + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactorySuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactorySuite.scala new file mode 100644 index 00000000000..56800968000 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QueryFactorySuite.scala @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.config.KyuubiConf + +class QueryFactorySuite extends KyuubiFunSuite { + private var conf: KyuubiConf = _ + private var queries: QueryFactory = _ + + override def beforeEach(): Unit = { + conf = new KyuubiConf() + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GUID_KEY, "guid") + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_CLASS_KEY, "superGroups") + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_MEMBERSHIP_KEY, "member") + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_MEMBERSHIP_KEY, "partOf") + queries = new QueryFactory(conf) + super.beforeEach() + } + + test("FindGroupDnById") { + val q = queries.findGroupDnById("unique_group_id") + val expected = "(&(objectClass=superGroups)(guid=unique_group_id))" + val actual = q.filter + assert(expected === actual) + } + + test("FindUserDnByRdn") { + val q = queries.findUserDnByRdn("cn=User1") + val expected = + "(&(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))(cn=User1))" + val actual = q.filter + assert(expected === actual) + } + + test("FindDnByPattern") { + val q = queries.findDnByPattern("cn=User1") + val expected = "(cn=User1)" + val actual = q.filter + assert(expected === actual) + } + + test("FindUserDnByName") { + val q = queries.findUserDnByName("unique_user_id") + val expected = + "(&(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" + + "(|(uid=unique_user_id)(sAMAccountName=unique_user_id)))" + val actual = q.filter + assert(expected === actual) + } + + test("FindGroupsForUser") { + val q = queries.findGroupsForUser("user_name", "user_Dn") + val expected = "(&(objectClass=superGroups)(|(member=user_Dn)(member=user_name)))" + val actual = q.filter + assert(expected === actual) + } + + test("IsUserMemberOfGroup") { + val q = queries.isUserMemberOfGroup("unique_user", "cn=MyGroup,ou=Groups,dc=mycompany,dc=com") + val expected = + "(&(|(objectClass=person)(objectClass=user)(objectClass=inetOrgPerson))" + + "(partOf=cn=MyGroup,ou=Groups,dc=mycompany,dc=com)(guid=unique_user))" + val actual = q.filter + assert(expected === actual) + } + + test("IsUserMemberOfGroupWhenMisconfigured") { + intercept[IllegalArgumentException] { + val misconfiguredQueryFactory = new QueryFactory(new KyuubiConf()) + misconfiguredQueryFactory.isUserMemberOfGroup("user", "cn=MyGroup") + } + } + + test("FindGroupDNByID") { + val q = queries.findGroupDnById("unique_group_id") + val expected = "(&(objectClass=superGroups)(guid=unique_group_id))" + val actual = q.filter + assert(expected === actual) + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QuerySuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QuerySuite.scala new file mode 100644 index 00000000000..ffe330cce8b --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/QuerySuite.scala @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import org.apache.kyuubi.KyuubiFunSuite + +class QuerySuite extends KyuubiFunSuite { + + test("QueryBuilderFilter") { + val q = Query.builder + .filter("test = query") + .map("uid_attr", "uid") + .map("value", "Hello!") + .build + assert("test uid=Hello! query" === q.filter) + assert(0 === q.controls.getCountLimit) + } + + test("QueryBuilderLimit") { + val q = Query.builder + .filter(",") + .map("key1", "value1") + .map("key2", "value2") + .limit(8) + .build + assert("value1,value2" === q.filter) + assert(8 === q.controls.getCountLimit) + } + + test("QueryBuilderReturningAttributes") { + val q = Query.builder + .filter("(query)") + .returnAttribute("attr1") + .returnAttribute("attr2") + .build + assert("(query)" === q.filter) + assert(Array("attr1", "attr2") === q.controls.getReturningAttributes) + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandlerSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandlerSuite.scala new file mode 100644 index 00000000000..4e92f7f5feb --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/SearchResultHandlerSuite.scala @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import java.util +import javax.naming.{NamingEnumeration, NamingException} +import javax.naming.directory.SearchResult + +import scala.collection.mutable.ArrayBuffer + +import org.mockito.Mockito.{atLeastOnce, doThrow, verify} + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.service.authentication.ldap.LdapTestUtils._ + +class SearchResultHandlerSuite extends KyuubiFunSuite { + private var handler: SearchResultHandler = _ + + test("handle") { + val resultCollection = new MockResultCollectionBuilder() + .addSearchResultWithDns("1") + .addSearchResultWithDns("2", "3") + .build + handler = new SearchResultHandler(resultCollection) + val expected: util.List[String] = util.Arrays.asList("1", "2") + val actual: util.List[String] = new util.ArrayList[String] + handler.handle { record => + actual.add(record.getNameInNamespace) + actual.size < 2 + } + assert(expected === actual) + assertAllNamingEnumerationsClosed(resultCollection) + } + + test("GetAllLdapNamesNoRecords") { + val resultCollection = new MockResultCollectionBuilder() + .addEmptySearchResult() + .build + handler = new SearchResultHandler(resultCollection) + val actual = handler.getAllLdapNames + assert(actual.isEmpty, "ResultSet size") + assertAllNamingEnumerationsClosed(resultCollection) + } + + test("GetAllLdapNamesWithExceptionInNamingEnumerationClose") { + val resultCollection = new MockResultCollectionBuilder() + .addSearchResultWithDns("1") + .addSearchResultWithDns("2") + .build + doThrow(classOf[NamingException]).when(resultCollection.iterator.next).close() + handler = new SearchResultHandler(resultCollection) + val actual = handler.getAllLdapNames + assert(actual.length === 2, "ResultSet size") + assertAllNamingEnumerationsClosed(resultCollection) + } + + test("GetAllLdapNames") { + val objectDn1: String = "cn=a1,dc=b,dc=c" + val objectDn2: String = "cn=a2,dc=b,dc=c" + val objectDn3: String = "cn=a3,dc=b,dc=c" + val resultCollection = new MockResultCollectionBuilder() + .addSearchResultWithDns(objectDn1) + .addSearchResultWithDns(objectDn2, objectDn3) + .build + handler = new SearchResultHandler(resultCollection) + val expected = Array(objectDn1, objectDn2, objectDn3) + val actual = handler.getAllLdapNames + assert(expected.sorted === actual.sorted) + assertAllNamingEnumerationsClosed(resultCollection) + } + + test("GetAllLdapNamesAndAttributes") { + val searchResult1 = mockSearchResult( + "cn=a1,dc=b,dc=c", + mockAttributes("attr1", "attr1value1")) + val searchResult2 = mockSearchResult( + "cn=a2,dc=b,dc=c", + mockAttributes("attr1", "attr1value2", "attr2", "attr2value1")) + val searchResult3 = mockSearchResult( + "cn=a3,dc=b,dc=c", + mockAttributes("attr1", "attr1value3", "attr1", "attr1value4")) + val searchResult4 = mockSearchResult( + "cn=a4,dc=b,dc=c", + mockEmptyAttributes()) + val resultCollection = new MockResultCollectionBuilder() + .addSearchResults(searchResult1) + .addSearchResults(searchResult2, searchResult3) + .addSearchResults(searchResult4) + .build + handler = new SearchResultHandler(resultCollection) + val expected = Array( + "cn=a1,dc=b,dc=c", + "attr1value1", + "cn=a2,dc=b,dc=c", + "attr1value2", + "attr2value1", + "cn=a3,dc=b,dc=c", + "attr1value3", + "attr1value4", + "cn=a4,dc=b,dc=c") + val actual = handler.getAllLdapNamesAndAttributes + assert(expected.sorted === actual.sorted) + assertAllNamingEnumerationsClosed(resultCollection) + } + + test("HasSingleResultNoRecords") { + val resultCollection = new MockResultCollectionBuilder() + .addEmptySearchResult() + .build + handler = new SearchResultHandler(resultCollection) + assert(!handler.hasSingleResult) + assertAllNamingEnumerationsClosed(resultCollection) + } + + test("HasSingleResult") { + val resultCollection = new MockResultCollectionBuilder() + .addSearchResultWithDns("1") + .build + handler = new SearchResultHandler(resultCollection) + assert(handler.hasSingleResult) + assertAllNamingEnumerationsClosed(resultCollection) + } + + test("HasSingleResultManyRecords") { + val resultCollection = new MockResultCollectionBuilder() + .addSearchResultWithDns("1") + .addSearchResultWithDns("2") + .build + handler = new SearchResultHandler(resultCollection) + assert(!handler.hasSingleResult) + assertAllNamingEnumerationsClosed(resultCollection) + } + + test("GetSingleLdapNameNoRecords") { + intercept[NamingException] { + val resultCollection = new MockResultCollectionBuilder() + .addEmptySearchResult() + .build + handler = new SearchResultHandler(resultCollection) + try handler.getSingleLdapName + finally { + assertAllNamingEnumerationsClosed(resultCollection) + } + } + } + + test("GetSingleLdapName") { + val objectDn: String = "cn=a,dc=b,dc=c" + val resultCollection = new MockResultCollectionBuilder() + .addEmptySearchResult() + .addSearchResultWithDns(objectDn) + .build + handler = new SearchResultHandler(resultCollection) + val expected: String = objectDn + val actual: String = handler.getSingleLdapName + assert(expected === actual) + assertAllNamingEnumerationsClosed(resultCollection) + } + + private def assertAllNamingEnumerationsClosed( + resultCollection: Array[NamingEnumeration[SearchResult]]): Unit = { + for (namingEnumeration <- resultCollection) { + verify(namingEnumeration, atLeastOnce).close() + } + } +} + +class MockResultCollectionBuilder { + + val results = new ArrayBuffer[NamingEnumeration[SearchResult]] + + def addSearchResultWithDns(dns: String*): MockResultCollectionBuilder = { + results += mockNamingEnumeration(dns: _*) + this + } + + def addSearchResults(dns: SearchResult*): MockResultCollectionBuilder = { + results += mockNamingEnumeration(dns.toArray) + this + } + + def addEmptySearchResult(): MockResultCollectionBuilder = { + addSearchResults() + this + } + + def build: Array[NamingEnumeration[SearchResult]] = results.toArray +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterSuite.scala new file mode 100644 index 00000000000..4fc6cba49b8 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserFilterSuite.scala @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.security.sasl.AuthenticationException + +import org.scalatestplus.mockito.MockitoSugar.mock + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.config.KyuubiConf + +class UserFilterSuite extends KyuubiFunSuite { + private val factory: FilterFactory = UserFilterFactory + private var conf: KyuubiConf = _ + private var search: DirSearch = _ + + override def beforeEach(): Unit = { + conf = new KyuubiConf() + search = mock[DirSearch] + super.beforeEach() + } + + test("Factory") { + conf.unset(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER) + assert(factory.getInstance(conf).isEmpty) + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1") + assert(factory.getInstance(conf).isDefined) + } + + test("ApplyPositive") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1,User2,uSeR3") + val filter = factory.getInstance(conf).get + filter.apply(search, "User1") + filter.apply(search, "uid=user2,ou=People,dc=example,dc=com") + filter.apply(search, "User3@mydomain.com") + } + + test("ApplyNegative") { + intercept[AuthenticationException] { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1,User2") + val filter = factory.getInstance(conf).get + filter.apply(search, "User3") + } + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterSuite.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterSuite.scala new file mode 100644 index 00000000000..1a711a6d9c9 --- /dev/null +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/service/authentication/ldap/UserSearchFilterSuite.scala @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.service.authentication.ldap + +import javax.naming.NamingException +import javax.security.sasl.AuthenticationException + +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.when +import org.scalatestplus.mockito.MockitoSugar.mock + +import org.apache.kyuubi.KyuubiFunSuite +import org.apache.kyuubi.config.KyuubiConf + +class UserSearchFilterSuite extends KyuubiFunSuite { + private val factory: FilterFactory = UserSearchFilterFactory + private var conf: KyuubiConf = _ + private var search: DirSearch = _ + + override def beforeEach(): Unit = { + conf = new KyuubiConf() + search = mock[DirSearch] + super.beforeEach() + } + + test("FactoryWhenNoGroupOrUserFilters") { + assert(factory.getInstance(conf).isEmpty) + } + + test("FactoryWhenGroupFilter") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_GROUP_FILTER.key, "Grp1,Grp2") + assert(factory.getInstance(conf).isDefined) + } + + test("FactoryWhenUserFilter") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1,User2") + assert(factory.getInstance(conf).isDefined) + } + + test("ApplyPositive") { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1") + val filter = factory.getInstance(conf).get + when(search.findUserDn(anyString)).thenReturn("cn=User1,ou=People,dc=example,dc=com") + filter.apply(search, "User1") + } + + test("ApplyWhenNamingException") { + intercept[AuthenticationException] { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1") + val filter = factory.getInstance(conf).get + when(search.findUserDn(anyString)).thenThrow(classOf[NamingException]) + filter.apply(search, "User3") + } + } + + test("ApplyWhenNotFound") { + intercept[AuthenticationException] { + conf.set(KyuubiConf.AUTHENTICATION_LDAP_USER_FILTER.key, "User1") + val filter = factory.getInstance(conf).get + when(search.findUserDn(anyString)).thenReturn(null) + filter.apply(search, "User3") + } + } +} diff --git a/kyuubi-common/src/test/scala/org/apache/kyuubi/util/SparkVersionUtil.scala b/kyuubi-common/src/test/scala/org/apache/kyuubi/util/SparkVersionUtil.scala index cd8409d10db..785015cc377 100644 --- a/kyuubi-common/src/test/scala/org/apache/kyuubi/util/SparkVersionUtil.scala +++ b/kyuubi-common/src/test/scala/org/apache/kyuubi/util/SparkVersionUtil.scala @@ -17,13 +17,22 @@ package org.apache.kyuubi.util -import org.apache.kyuubi.SPARK_COMPILE_VERSION import org.apache.kyuubi.engine.SemanticVersion +import org.apache.kyuubi.operation.HiveJDBCTestHelper -object SparkVersionUtil { - lazy val sparkSemanticVersion: SemanticVersion = SemanticVersion(SPARK_COMPILE_VERSION) +trait SparkVersionUtil { + this: HiveJDBCTestHelper => - def isSparkVersionAtLeast(ver: String): Boolean = { - sparkSemanticVersion.isVersionAtLeast(ver) + protected lazy val SPARK_ENGINE_RUNTIME_VERSION = sparkEngineMajorMinorVersion + + def sparkEngineMajorMinorVersion: SemanticVersion = { + var sparkRuntimeVer = "" + withJdbcStatement() { stmt => + val result = stmt.executeQuery("SELECT version()") + assert(result.next()) + sparkRuntimeVer = result.getString(1) + assert(!result.next()) + } + SemanticVersion(sparkRuntimeVer) } } diff --git a/kyuubi-ctl/pom.xml b/kyuubi-ctl/pom.xml index aa1e8f2e476..eb4060ffdd5 100644 --- a/kyuubi-ctl/pom.xml +++ b/kyuubi-ctl/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/CtlConf.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/CtlConf.scala index 08fbd7342c9..58b65582a22 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/CtlConf.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/CtlConf.scala @@ -19,16 +19,15 @@ package org.apache.kyuubi.ctl import java.time.Duration -import org.apache.kyuubi.config.{ConfigBuilder, ConfigEntry, KyuubiConf, OptionalConfigEntry} +import org.apache.kyuubi.config.{ConfigEntry, OptionalConfigEntry} +import org.apache.kyuubi.config.KyuubiConf.buildConf object CtlConf { - private def buildConf(key: String): ConfigBuilder = KyuubiConf.buildConf(key) - val CTL_REST_CLIENT_BASE_URL: OptionalConfigEntry[String] = buildConf("kyuubi.ctl.rest.base.url") .doc("The REST API base URL, " + - "which contains the scheme (http:// or https://), host name, port number") + "which contains the scheme (http:// or https://), hostname, port number") .version("1.6.0") .stringConf .createOptional @@ -49,7 +48,7 @@ object CtlConf { val CTL_REST_CLIENT_CONNECT_TIMEOUT: ConfigEntry[Long] = buildConf("kyuubi.ctl.rest.connect.timeout") - .doc("The timeout[ms] for establishing the connection with the kyuubi server." + + .doc("The timeout[ms] for establishing the connection with the kyuubi server. " + "A timeout value of zero is interpreted as an infinite timeout.") .version("1.6.0") .timeConf @@ -58,7 +57,7 @@ object CtlConf { val CTL_REST_CLIENT_SOCKET_TIMEOUT: ConfigEntry[Long] = buildConf("kyuubi.ctl.rest.socket.timeout") - .doc("The timeout[ms] for waiting for data packets after connection is established." + + .doc("The timeout[ms] for waiting for data packets after connection is established. " + "A timeout value of zero is interpreted as an infinite timeout.") .version("1.6.0") .timeConf diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/create/CreateServerCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/create/CreateServerCommand.scala index 66f75fc5f67..f4d4ce2ea9a 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/create/CreateServerCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/create/CreateServerCommand.scala @@ -56,7 +56,7 @@ class CreateServerCommand(cliConfig: CliConfig) extends Command[Seq[ServiceNodeI withDiscoveryClient(kyuubiConf) { discoveryClient => val fromNamespace = DiscoveryPaths.makePath(null, kyuubiConf.get(HA_NAMESPACE)) - val toNamespace = CtlUtils.getZkNamespace(kyuubiConf, normalizedCliConfig) + val toNamespace = CtlUtils.getZkServerNamespace(kyuubiConf, normalizedCliConfig) val currentServerNodes = discoveryClient.getServiceNodesInfo(fromNamespace) val exposedServiceNodes = ListBuffer[ServiceNodeInfo]() diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteCommand.scala index 69479259a6f..ddbe083ce2c 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteCommand.scala @@ -16,15 +16,13 @@ */ package org.apache.kyuubi.ctl.cmd.delete -import scala.collection.mutable.ListBuffer - import org.apache.kyuubi.ctl.cmd.Command import org.apache.kyuubi.ctl.opt.CliConfig -import org.apache.kyuubi.ctl.util.{CtlUtils, Render, Validator} -import org.apache.kyuubi.ha.client.DiscoveryClientProvider.withDiscoveryClient +import org.apache.kyuubi.ctl.util.{Render, Validator} import org.apache.kyuubi.ha.client.ServiceNodeInfo -class DeleteCommand(cliConfig: CliConfig) extends Command[Seq[ServiceNodeInfo]](cliConfig) { +abstract class DeleteCommand(cliConfig: CliConfig) + extends Command[Seq[ServiceNodeInfo]](cliConfig) { def validate(): Unit = { Validator.validateZkArguments(normalizedCliConfig) @@ -35,28 +33,7 @@ class DeleteCommand(cliConfig: CliConfig) extends Command[Seq[ServiceNodeInfo]]( /** * Delete zookeeper service node with specified host port. */ - def doRun(): Seq[ServiceNodeInfo] = { - withDiscoveryClient(conf) { discoveryClient => - val znodeRoot = CtlUtils.getZkNamespace(conf, normalizedCliConfig) - val hostPortOpt = - Some((normalizedCliConfig.zkOpts.host, normalizedCliConfig.zkOpts.port.toInt)) - val nodesToDelete = CtlUtils.getServiceNodes(discoveryClient, znodeRoot, hostPortOpt) - - val deletedNodes = ListBuffer[ServiceNodeInfo]() - nodesToDelete.foreach { node => - val nodePath = s"$znodeRoot/${node.nodeName}" - info(s"Deleting zookeeper service node:$nodePath") - try { - discoveryClient.delete(nodePath) - deletedNodes += node - } catch { - case e: Exception => - error(s"Failed to delete zookeeper service node:$nodePath", e) - } - } - deletedNodes - } - } + def doRun(): Seq[ServiceNodeInfo] def render(nodes: Seq[ServiceNodeInfo]): Unit = { val title = "Deleted zookeeper service nodes" diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteEngineCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteEngineCommand.scala index 7be60746785..ab6e81e2440 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteEngineCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteEngineCommand.scala @@ -16,7 +16,12 @@ */ package org.apache.kyuubi.ctl.cmd.delete +import scala.collection.mutable.ListBuffer + import org.apache.kyuubi.ctl.opt.CliConfig +import org.apache.kyuubi.ctl.util.CtlUtils +import org.apache.kyuubi.ha.client.DiscoveryClientProvider.withDiscoveryClient +import org.apache.kyuubi.ha.client.ServiceNodeInfo class DeleteEngineCommand(cliConfig: CliConfig) extends DeleteCommand(cliConfig) { @@ -28,4 +33,29 @@ class DeleteEngineCommand(cliConfig: CliConfig) extends DeleteCommand(cliConfig) fail("Must specify user name for engine, please use -u or --user.") } } + + def doRun(): Seq[ServiceNodeInfo] = { + withDiscoveryClient(conf) { discoveryClient => + val hostPortOpt = + Some((cliConfig.zkOpts.host, cliConfig.zkOpts.port.toInt)) + val candidateNodes = CtlUtils.listZkEngineNodes(conf, normalizedCliConfig, hostPortOpt) + hostPortOpt.map { case (host, port) => + candidateNodes.filter { cn => cn.host == host && cn.port == port } + }.getOrElse(candidateNodes) + val deletedNodes = ListBuffer[ServiceNodeInfo]() + candidateNodes.foreach { node => + val engineNode = discoveryClient.getChildren(node.namespace)(0) + val nodePath = s"${node.namespace}/$engineNode" + info(s"Deleting zookeeper service node:$nodePath") + try { + discoveryClient.delete(nodePath) + deletedNodes += node + } catch { + case e: Exception => + error(s"Failed to delete zookeeper service node:$nodePath", e) + } + } + deletedNodes + } + } } diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteServerCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteServerCommand.scala index 6debba4d56f..197b786459a 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteServerCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/delete/DeleteServerCommand.scala @@ -16,6 +16,34 @@ */ package org.apache.kyuubi.ctl.cmd.delete +import scala.collection.mutable.ListBuffer + import org.apache.kyuubi.ctl.opt.CliConfig +import org.apache.kyuubi.ctl.util.CtlUtils +import org.apache.kyuubi.ha.client.DiscoveryClientProvider.withDiscoveryClient +import org.apache.kyuubi.ha.client.ServiceNodeInfo + +class DeleteServerCommand(cliConfig: CliConfig) extends DeleteCommand(cliConfig) { + override def doRun(): Seq[ServiceNodeInfo] = { + withDiscoveryClient(conf) { discoveryClient => + val znodeRoot = CtlUtils.getZkServerNamespace(conf, normalizedCliConfig) + val hostPortOpt = + Some((normalizedCliConfig.zkOpts.host, normalizedCliConfig.zkOpts.port.toInt)) + val nodesToDelete = CtlUtils.getServiceNodes(discoveryClient, znodeRoot, hostPortOpt) -class DeleteServerCommand(cliConfig: CliConfig) extends DeleteCommand(cliConfig) {} + val deletedNodes = ListBuffer[ServiceNodeInfo]() + nodesToDelete.foreach { node => + val nodePath = s"$znodeRoot/${node.nodeName}" + info(s"Deleting zookeeper service node:$nodePath") + try { + discoveryClient.delete(nodePath) + deletedNodes += node + } catch { + case e: Exception => + error(s"Failed to delete zookeeper service node:$nodePath", e) + } + } + deletedNodes + } + } +} diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetCommand.scala index d78f0b995bb..af8285105c8 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetCommand.scala @@ -18,10 +18,10 @@ package org.apache.kyuubi.ctl.cmd.get import org.apache.kyuubi.ctl.cmd.Command import org.apache.kyuubi.ctl.opt.CliConfig -import org.apache.kyuubi.ctl.util.{CtlUtils, Render, Validator} +import org.apache.kyuubi.ctl.util.{Render, Validator} import org.apache.kyuubi.ha.client.ServiceNodeInfo -class GetCommand(cliConfig: CliConfig) extends Command[Seq[ServiceNodeInfo]](cliConfig) { +abstract class GetCommand(cliConfig: CliConfig) extends Command[Seq[ServiceNodeInfo]](cliConfig) { def validate(): Unit = { Validator.validateZkArguments(normalizedCliConfig) @@ -29,9 +29,7 @@ class GetCommand(cliConfig: CliConfig) extends Command[Seq[ServiceNodeInfo]](cli mergeArgsIntoKyuubiConf() } - def doRun(): Seq[ServiceNodeInfo] = { - CtlUtils.listZkServerNodes(conf, normalizedCliConfig, filterHostPort = true) - } + def doRun(): Seq[ServiceNodeInfo] def render(nodes: Seq[ServiceNodeInfo]): Unit = { val title = "Zookeeper service nodes" diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetEngineCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetEngineCommand.scala index 4d9101625fb..13f4d00c8fa 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetEngineCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetEngineCommand.scala @@ -17,6 +17,8 @@ package org.apache.kyuubi.ctl.cmd.get import org.apache.kyuubi.ctl.opt.CliConfig +import org.apache.kyuubi.ctl.util.CtlUtils +import org.apache.kyuubi.ha.client.ServiceNodeInfo class GetEngineCommand(cliConfig: CliConfig) extends GetCommand(cliConfig) { @@ -28,4 +30,12 @@ class GetEngineCommand(cliConfig: CliConfig) extends GetCommand(cliConfig) { fail("Must specify user name for engine, please use -u or --user.") } } + + override def doRun(): Seq[ServiceNodeInfo] = { + CtlUtils.listZkEngineNodes( + conf, + normalizedCliConfig, + Some((cliConfig.zkOpts.host, cliConfig.zkOpts.port.toInt))) + } + } diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetServerCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetServerCommand.scala index 71b8684532d..faa76b219c4 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetServerCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/get/GetServerCommand.scala @@ -17,5 +17,14 @@ package org.apache.kyuubi.ctl.cmd.get import org.apache.kyuubi.ctl.opt.CliConfig +import org.apache.kyuubi.ctl.util.CtlUtils +import org.apache.kyuubi.ha.client.ServiceNodeInfo -class GetServerCommand(cliConfig: CliConfig) extends GetCommand(cliConfig) {} +class GetServerCommand(cliConfig: CliConfig) extends GetCommand(cliConfig) { + override def doRun(): Seq[ServiceNodeInfo] = { + CtlUtils.listZkServerNodes( + conf, + normalizedCliConfig, + Some((cliConfig.zkOpts.host, cliConfig.zkOpts.port.toInt))) + } +} diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListCommand.scala index 0cfeb8e4ea0..e5a3a688216 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListCommand.scala @@ -18,19 +18,17 @@ package org.apache.kyuubi.ctl.cmd.list import org.apache.kyuubi.ctl.cmd.Command import org.apache.kyuubi.ctl.opt.CliConfig -import org.apache.kyuubi.ctl.util.{CtlUtils, Render, Validator} +import org.apache.kyuubi.ctl.util.{Render, Validator} import org.apache.kyuubi.ha.client.ServiceNodeInfo -class ListCommand(cliConfig: CliConfig) extends Command[Seq[ServiceNodeInfo]](cliConfig) { +abstract class ListCommand(cliConfig: CliConfig) extends Command[Seq[ServiceNodeInfo]](cliConfig) { def validate(): Unit = { Validator.validateZkArguments(normalizedCliConfig) mergeArgsIntoKyuubiConf() } - def doRun(): Seq[ServiceNodeInfo] = { - CtlUtils.listZkServerNodes(conf, normalizedCliConfig, filterHostPort = false) - } + def doRun(): Seq[ServiceNodeInfo] def render(nodes: Seq[ServiceNodeInfo]): Unit = { val title = "Zookeeper service nodes" diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListEngineCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListEngineCommand.scala index 6a78a9e97c3..8a26b4cc973 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListEngineCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListEngineCommand.scala @@ -17,6 +17,8 @@ package org.apache.kyuubi.ctl.cmd.list import org.apache.kyuubi.ctl.opt.CliConfig +import org.apache.kyuubi.ctl.util.CtlUtils +import org.apache.kyuubi.ha.client.ServiceNodeInfo class ListEngineCommand(cliConfig: CliConfig) extends ListCommand(cliConfig) { @@ -28,4 +30,7 @@ class ListEngineCommand(cliConfig: CliConfig) extends ListCommand(cliConfig) { fail("Must specify user name for engine, please use -u or --user.") } } + + override def doRun(): Seq[ServiceNodeInfo] = + CtlUtils.listZkEngineNodes(conf, normalizedCliConfig, None) } diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListServerCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListServerCommand.scala index 8c3219ecea6..56e8f4695cf 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListServerCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/list/ListServerCommand.scala @@ -17,5 +17,11 @@ package org.apache.kyuubi.ctl.cmd.list import org.apache.kyuubi.ctl.opt.CliConfig +import org.apache.kyuubi.ctl.util.CtlUtils +import org.apache.kyuubi.ha.client.ServiceNodeInfo -class ListServerCommand(cliConfig: CliConfig) extends ListCommand(cliConfig) {} +class ListServerCommand(cliConfig: CliConfig) extends ListCommand(cliConfig) { + override def doRun(): Seq[ServiceNodeInfo] = { + CtlUtils.listZkServerNodes(conf, normalizedCliConfig, None) + } +} diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/refresh/RefreshConfigCommand.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/refresh/RefreshConfigCommand.scala index b658c0e45e6..69aa0c3d0f1 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/refresh/RefreshConfigCommand.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/cmd/refresh/RefreshConfigCommand.scala @@ -21,7 +21,7 @@ import org.apache.kyuubi.KyuubiException import org.apache.kyuubi.client.AdminRestApi import org.apache.kyuubi.ctl.RestClientFactory.withKyuubiRestClient import org.apache.kyuubi.ctl.cmd.AdminCtlCommand -import org.apache.kyuubi.ctl.cmd.refresh.RefreshConfigCommandConfigType.{HADOOP_CONF, USER_DEFAULTS_CONF} +import org.apache.kyuubi.ctl.cmd.refresh.RefreshConfigCommandConfigType.{HADOOP_CONF, UNLIMITED_USERS, USER_DEFAULTS_CONF} import org.apache.kyuubi.ctl.opt.CliConfig import org.apache.kyuubi.ctl.util.{Tabulator, Validator} @@ -36,6 +36,7 @@ class RefreshConfigCommand(cliConfig: CliConfig) extends AdminCtlCommand[String] normalizedCliConfig.adminConfigOpts.configType match { case HADOOP_CONF => adminRestApi.refreshHadoopConf() case USER_DEFAULTS_CONF => adminRestApi.refreshUserDefaultsConf() + case UNLIMITED_USERS => adminRestApi.refreshUnlimitedUsers() case configType => throw new KyuubiException(s"Invalid config type:$configType") } } @@ -48,4 +49,5 @@ class RefreshConfigCommand(cliConfig: CliConfig) extends AdminCtlCommand[String] object RefreshConfigCommandConfigType { final val HADOOP_CONF = "hadoopConf" final val USER_DEFAULTS_CONF = "userDefaultsConf" + final val UNLIMITED_USERS = "unlimitedUsers" } diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/opt/AdminCommandLine.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/opt/AdminCommandLine.scala index 59ad7f5fc4c..b1a70935b0d 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/opt/AdminCommandLine.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/opt/AdminCommandLine.scala @@ -102,6 +102,6 @@ object AdminCommandLine extends CommonCommandLine { .optional() .action((v, c) => c.copy(adminConfigOpts = c.adminConfigOpts.copy(configType = v))) .text("The valid config type can be one of the following: " + - s"$HADOOP_CONF, $USER_DEFAULTS_CONF.")) + s"$HADOOP_CONF, $USER_DEFAULTS_CONF, $UNLIMITED_USERS.")) } } diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/util/CtlUtils.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/util/CtlUtils.scala index fdcc127f16a..8ce1d611a5a 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/util/CtlUtils.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/util/CtlUtils.scala @@ -25,48 +25,35 @@ import org.yaml.snakeyaml.Yaml import org.apache.kyuubi.KyuubiException import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf.{ENGINE_SHARE_LEVEL, ENGINE_SHARE_LEVEL_SUBDOMAIN, ENGINE_TYPE} -import org.apache.kyuubi.ctl.opt.{CliConfig, ControlObject} +import org.apache.kyuubi.ctl.opt.CliConfig import org.apache.kyuubi.ha.client.{DiscoveryClient, DiscoveryPaths, ServiceNodeInfo} import org.apache.kyuubi.ha.client.DiscoveryClientProvider.withDiscoveryClient object CtlUtils { - private[ctl] def getZkNamespace(conf: KyuubiConf, cliConfig: CliConfig): String = { - cliConfig.resource match { - case ControlObject.SERVER => - DiscoveryPaths.makePath(null, cliConfig.zkOpts.namespace) - case ControlObject.ENGINE => - val engineType = Some(cliConfig.engineOpts.engineType) - .filter(_ != null).filter(_.nonEmpty) - .getOrElse(conf.get(ENGINE_TYPE)) - val engineSubdomain = Some(cliConfig.engineOpts.engineSubdomain) - .filter(_ != null).filter(_.nonEmpty) - .getOrElse(conf.get(ENGINE_SHARE_LEVEL_SUBDOMAIN).getOrElse("default")) - val engineShareLevel = Some(cliConfig.engineOpts.engineShareLevel) - .filter(_ != null).filter(_.nonEmpty) - .getOrElse(conf.get(ENGINE_SHARE_LEVEL)) - // The path of the engine defined in zookeeper comes from - // org.apache.kyuubi.engine.EngineRef#engineSpace - DiscoveryPaths.makePath( - s"${cliConfig.zkOpts.namespace}_" + - s"${cliConfig.zkOpts.version}_" + - s"${engineShareLevel}_${engineType}", - cliConfig.engineOpts.user, - engineSubdomain) - } + private[ctl] def getZkServerNamespace(conf: KyuubiConf, cliConfig: CliConfig): String = { + DiscoveryPaths.makePath(null, cliConfig.zkOpts.namespace) } - private[ctl] def getServiceNodes( - discoveryClient: DiscoveryClient, - znodeRoot: String, - hostPortOpt: Option[(String, Int)]): Seq[ServiceNodeInfo] = { - val serviceNodes = discoveryClient.getServiceNodesInfo(znodeRoot) - hostPortOpt match { - case Some((host, port)) => serviceNodes.filter { sn => - sn.host == host && sn.port == port - } - case _ => serviceNodes - } + private[ctl] def getZkEngineNamespaceAndSubdomain( + conf: KyuubiConf, + cliConfig: CliConfig): (String, Option[String]) = { + val engineType = Some(cliConfig.engineOpts.engineType) + .filter(_ != null).filter(_.nonEmpty) + .getOrElse(conf.get(ENGINE_TYPE)) + val engineShareLevel = Some(cliConfig.engineOpts.engineShareLevel) + .filter(_ != null).filter(_.nonEmpty) + .getOrElse(conf.get(ENGINE_SHARE_LEVEL)) + val engineSubdomain = Option(cliConfig.engineOpts.engineSubdomain) + .filter(_.nonEmpty).orElse(conf.get(ENGINE_SHARE_LEVEL_SUBDOMAIN)) + // The path of the engine defined in zookeeper comes from + // org.apache.kyuubi.engine.EngineRef#engineSpace + val rootPath = DiscoveryPaths.makePath( + s"${cliConfig.zkOpts.namespace}_" + + s"${cliConfig.zkOpts.version}_" + + s"${engineShareLevel}_${engineType}", + cliConfig.engineOpts.user) + (rootPath, engineSubdomain) } /** @@ -75,17 +62,41 @@ object CtlUtils { private[ctl] def listZkServerNodes( conf: KyuubiConf, cliConfig: CliConfig, - filterHostPort: Boolean): Seq[ServiceNodeInfo] = { - var nodes = Seq.empty[ServiceNodeInfo] + hostPortOpt: Option[(String, Int)]): Seq[ServiceNodeInfo] = { withDiscoveryClient(conf) { discoveryClient => - val znodeRoot = getZkNamespace(conf, cliConfig) - val hostPortOpt = - if (filterHostPort) { - Some((cliConfig.zkOpts.host, cliConfig.zkOpts.port.toInt)) - } else None - nodes = getServiceNodes(discoveryClient, znodeRoot, hostPortOpt) + val znodeRoot = getZkServerNamespace(conf, cliConfig) + getServiceNodes(discoveryClient, znodeRoot, hostPortOpt) } - nodes + } + + /** + * List Kyuubi engine nodes info. + */ + private[ctl] def listZkEngineNodes( + conf: KyuubiConf, + cliConfig: CliConfig, + hostPortOpt: Option[(String, Int)]): Seq[ServiceNodeInfo] = { + withDiscoveryClient(conf) { discoveryClient => + val (znodeRoot, subdomainOpt) = getZkEngineNamespaceAndSubdomain(conf, cliConfig) + val candidates = discoveryClient.getChildren(znodeRoot) + val matched = subdomainOpt match { + case Some(subdomain) => candidates.filter(_ == subdomain) + case None => candidates + } + matched.flatMap { subdomain => + getServiceNodes(discoveryClient, s"$znodeRoot/$subdomain", hostPortOpt) + } + } + } + + private[ctl] def getServiceNodes( + discoveryClient: DiscoveryClient, + znodeRoot: String, + hostPortOpt: Option[(String, Int)]): Seq[ServiceNodeInfo] = { + val serviceNodes = discoveryClient.getServiceNodesInfo(znodeRoot) + hostPortOpt.map { case (host, port) => + serviceNodes.filter { sn => sn.host == host && sn.port == port } + }.getOrElse(serviceNodes) } private[ctl] def loadYamlAsMap(cliConfig: CliConfig): JMap[String, Object] = { diff --git a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/util/Render.scala b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/util/Render.scala index aba6df35a4b..2d4879e42ad 100644 --- a/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/util/Render.scala +++ b/kyuubi-ctl/src/main/scala/org/apache/kyuubi/ctl/util/Render.scala @@ -111,6 +111,9 @@ private[ctl] object Render { private def buildBatchAppInfo(batch: Batch, showDiagnostic: Boolean = true): List[String] = { val batchAppInfo = ListBuffer[String]() + batch.getBatchInfo.asScala.foreach { case (key, value) => + batchAppInfo += s"$key: $value" + } if (batch.getAppStartTime > 0) { batchAppInfo += s"App Start Time:" + s" ${millisToDateString(batch.getAppStartTime, "yyyy-MM-dd HH:mm:ss")}" diff --git a/kyuubi-ctl/src/test/scala/org/apache/kyuubi/ctl/AdminControlCliArgumentsSuite.scala b/kyuubi-ctl/src/test/scala/org/apache/kyuubi/ctl/AdminControlCliArgumentsSuite.scala index afb946e9285..dab796127e3 100644 --- a/kyuubi-ctl/src/test/scala/org/apache/kyuubi/ctl/AdminControlCliArgumentsSuite.scala +++ b/kyuubi-ctl/src/test/scala/org/apache/kyuubi/ctl/AdminControlCliArgumentsSuite.scala @@ -63,7 +63,7 @@ class AdminControlCliArgumentsSuite extends KyuubiFunSuite with TestPrematureExi val opArgs = new AdminControlCliArguments(args) assert(opArgs.cliConfig.action === ControlAction.REFRESH) assert(opArgs.cliConfig.resource === ControlObject.CONFIG) - assert(opArgs.cliConfig.adminConfigOpts.configType === "hadoopConf") + assert(opArgs.cliConfig.adminConfigOpts.configType === HADOOP_CONF) args = Array( "refresh", @@ -72,7 +72,16 @@ class AdminControlCliArgumentsSuite extends KyuubiFunSuite with TestPrematureExi val opArgs2 = new AdminControlCliArguments(args) assert(opArgs2.cliConfig.action === ControlAction.REFRESH) assert(opArgs2.cliConfig.resource === ControlObject.CONFIG) - assert(opArgs2.cliConfig.adminConfigOpts.configType === "userDefaultsConf") + assert(opArgs2.cliConfig.adminConfigOpts.configType === USER_DEFAULTS_CONF) + + args = Array( + "refresh", + "config", + "unlimitedUsers") + val opArgs3 = new AdminControlCliArguments(args) + assert(opArgs3.cliConfig.action === ControlAction.REFRESH) + assert(opArgs3.cliConfig.resource === ControlObject.CONFIG) + assert(opArgs3.cliConfig.adminConfigOpts.configType === UNLIMITED_USERS) args = Array( "refresh", @@ -147,7 +156,7 @@ class AdminControlCliArgumentsSuite extends KyuubiFunSuite with TestPrematureExi | Refresh the resource. |Command: refresh config [] | Refresh the config with specified type. - | The valid config type can be one of the following: $HADOOP_CONF, $USER_DEFAULTS_CONF. + | The valid config type can be one of the following: $HADOOP_CONF, $USER_DEFAULTS_CONF, $UNLIMITED_USERS. | | -h, --help Show help message and exit.""".stripMargin // scalastyle:on diff --git a/kyuubi-ctl/src/test/scala/org/apache/kyuubi/ctl/ControlCliSuite.scala b/kyuubi-ctl/src/test/scala/org/apache/kyuubi/ctl/ControlCliSuite.scala index d27f3ec2a19..43a694a081a 100644 --- a/kyuubi-ctl/src/test/scala/org/apache/kyuubi/ctl/ControlCliSuite.scala +++ b/kyuubi-ctl/src/test/scala/org/apache/kyuubi/ctl/ControlCliSuite.scala @@ -199,20 +199,23 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { } } - test("test get zk namespace for different service type") { - val arg1 = Array( + test("test get zk server namespace") { + val args = Array( "list", "server", "--zk-quorum", zkServer.getConnectString, "--namespace", namespace) - val scArgs1 = new ControlCliArguments(arg1) - assert(CtlUtils.getZkNamespace( - scArgs1.command.conf, - scArgs1.command.normalizedCliConfig) == s"/$namespace") + val scArgs = new ControlCliArguments(args) + assert( + CtlUtils.getZkServerNamespace( + scArgs.command.conf, + scArgs.command.normalizedCliConfig) === s"/$namespace") + } - val arg2 = Array( + test("test get zk engine namespace") { + val args = Array( "list", "engine", "--zk-quorum", @@ -221,9 +224,11 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { namespace, "--user", user) - val scArgs2 = new ControlCliArguments(arg2) - assert(CtlUtils.getZkNamespace(scArgs2.command.conf, scArgs2.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user/default") + val scArgs = new ControlCliArguments(args) + val expected = (s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user", None) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs.command.conf, + scArgs.command.normalizedCliConfig) === expected) } test("test list zk service nodes info") { @@ -364,8 +369,10 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--user", user) val scArgs1 = new ControlCliArguments(arg1) - assert(CtlUtils.getZkNamespace(scArgs1.command.conf, scArgs1.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user/default") + val expected1 = (s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user", None) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs1.command.conf, + scArgs1.command.normalizedCliConfig) === expected1) val arg2 = Array( "list", @@ -379,8 +386,10 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--engine-type", "FLINK_SQL") val scArgs2 = new ControlCliArguments(arg2) - assert(CtlUtils.getZkNamespace(scArgs2.command.conf, scArgs2.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_USER_FLINK_SQL/$user/default") + val expected2 = (s"/${namespace}_${KYUUBI_VERSION}_USER_FLINK_SQL/$user", None) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs2.command.conf, + scArgs2.command.normalizedCliConfig) === expected2) val arg3 = Array( "list", @@ -394,8 +403,10 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--engine-type", "TRINO") val scArgs3 = new ControlCliArguments(arg3) - assert(CtlUtils.getZkNamespace(scArgs3.command.conf, scArgs3.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_USER_TRINO/$user/default") + val expected3 = (s"/${namespace}_${KYUUBI_VERSION}_USER_TRINO/$user", None) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs3.command.conf, + scArgs3.command.normalizedCliConfig) === expected3) val arg4 = Array( "list", @@ -411,8 +422,10 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--engine-subdomain", "sub_1") val scArgs4 = new ControlCliArguments(arg4) - assert(CtlUtils.getZkNamespace(scArgs4.command.conf, scArgs4.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user/sub_1") + val expected4 = (s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user", Some("sub_1")) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs4.command.conf, + scArgs4.command.normalizedCliConfig) === expected4) val arg5 = Array( "list", @@ -430,8 +443,10 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--engine-subdomain", "sub_1") val scArgs5 = new ControlCliArguments(arg5) - assert(CtlUtils.getZkNamespace(scArgs5.command.conf, scArgs5.command.normalizedCliConfig) == - s"/${namespace}_1.5.0_USER_SPARK_SQL/$user/sub_1") + val expected5 = (s"/${namespace}_1.5.0_USER_SPARK_SQL/$user", Some("sub_1")) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs5.command.conf, + scArgs5.command.normalizedCliConfig) === expected5) } test("test get zk namespace for different share level engines") { @@ -445,8 +460,10 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--user", user) val scArgs1 = new ControlCliArguments(arg1) - assert(CtlUtils.getZkNamespace(scArgs1.command.conf, scArgs1.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user/default") + val expected1 = (s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user", None) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs1.command.conf, + scArgs1.command.normalizedCliConfig) === expected1) val arg2 = Array( "list", @@ -460,8 +477,10 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--engine-share-level", "CONNECTION") val scArgs2 = new ControlCliArguments(arg2) - assert(CtlUtils.getZkNamespace(scArgs2.command.conf, scArgs2.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_CONNECTION_SPARK_SQL/$user/default") + val expected2 = (s"/${namespace}_${KYUUBI_VERSION}_CONNECTION_SPARK_SQL/$user", None) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs2.command.conf, + scArgs2.command.normalizedCliConfig) === expected2) val arg3 = Array( "list", @@ -475,8 +494,10 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--engine-share-level", "USER") val scArgs3 = new ControlCliArguments(arg3) - assert(CtlUtils.getZkNamespace(scArgs3.command.conf, scArgs3.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user/default") + val expected3 = (s"/${namespace}_${KYUUBI_VERSION}_USER_SPARK_SQL/$user", None) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs3.command.conf, + scArgs3.command.normalizedCliConfig) === expected3) val arg4 = Array( "list", @@ -490,8 +511,10 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--engine-share-level", "GROUP") val scArgs4 = new ControlCliArguments(arg4) - assert(CtlUtils.getZkNamespace(scArgs4.command.conf, scArgs4.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_GROUP_SPARK_SQL/$user/default") + val expected4 = (s"/${namespace}_${KYUUBI_VERSION}_GROUP_SPARK_SQL/$user", None) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs4.command.conf, + scArgs4.command.normalizedCliConfig) === expected4) val arg5 = Array( "list", @@ -505,7 +528,9 @@ class ControlCliSuite extends KyuubiFunSuite with TestPrematureExit { "--engine-share-level", "SERVER") val scArgs5 = new ControlCliArguments(arg5) - assert(CtlUtils.getZkNamespace(scArgs5.command.conf, scArgs5.command.normalizedCliConfig) == - s"/${namespace}_${KYUUBI_VERSION}_SERVER_SPARK_SQL/$user/default") + val expected5 = (s"/${namespace}_${KYUUBI_VERSION}_SERVER_SPARK_SQL/$user", None) + assert(CtlUtils.getZkEngineNamespaceAndSubdomain( + scArgs5.command.conf, + scArgs5.command.normalizedCliConfig) === expected5) } } diff --git a/kyuubi-events/pom.xml b/kyuubi-events/pom.xml index a8030eb83d3..b97e9dffbb5 100644 --- a/kyuubi-events/pom.xml +++ b/kyuubi-events/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml diff --git a/kyuubi-ha/pom.xml b/kyuubi-ha/pom.xml index 8d7246effb4..b4605b6a187 100644 --- a/kyuubi-ha/pom.xml +++ b/kyuubi-ha/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT ../pom.xml diff --git a/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/HighAvailabilityConf.scala b/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/HighAvailabilityConf.scala index d33dccf982f..148a21e4dd3 100644 --- a/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/HighAvailabilityConf.scala +++ b/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/HighAvailabilityConf.scala @@ -21,17 +21,16 @@ import java.time.Duration import org.apache.hadoop.security.UserGroupInformation -import org.apache.kyuubi.config.{ConfigBuilder, ConfigEntry, KyuubiConf, OptionalConfigEntry} +import org.apache.kyuubi.config.{ConfigEntry, KyuubiConf, OptionalConfigEntry} +import org.apache.kyuubi.config.KyuubiConf.buildConf import org.apache.kyuubi.ha.client.AuthTypes import org.apache.kyuubi.ha.client.RetryPolicies object HighAvailabilityConf { - private def buildConf(key: String): ConfigBuilder = KyuubiConf.buildConf(key) - @deprecated("using kyuubi.ha.addresses instead", "1.6.0") val HA_ZK_QUORUM: ConfigEntry[String] = buildConf("kyuubi.ha.zookeeper.quorum") - .doc("(deprecated) The connection string for the zookeeper ensemble") + .doc("(deprecated) The connection string for the ZooKeeper ensemble") .version("1.0.0") .stringConf .createWithDefault("") @@ -69,14 +68,14 @@ object HighAvailabilityConf { "1.3.2") val HA_ZK_ACL_ENABLED: ConfigEntry[Boolean] = buildConf("kyuubi.ha.zookeeper.acl.enabled") - .doc("Set to true if the zookeeper ensemble is kerberized") + .doc("Set to true if the ZooKeeper ensemble is kerberized") .version("1.0.0") .booleanConf .createWithDefault(UserGroupInformation.isSecurityEnabled) val HA_ZK_AUTH_TYPE: ConfigEntry[String] = buildConf("kyuubi.ha.zookeeper.auth.type") - .doc("The type of zookeeper authentication, all candidates are " + + .doc("The type of ZooKeeper authentication, all candidates are " + s"${AuthTypes.values.mkString("
              • ", "
              • ", "
              ")}") .version("1.3.2") .stringConf @@ -85,7 +84,7 @@ object HighAvailabilityConf { val HA_ZK_ENGINE_AUTH_TYPE: ConfigEntry[String] = buildConf("kyuubi.ha.zookeeper.engine.auth.type") - .doc("The type of zookeeper authentication for engine, all candidates are " + + .doc("The type of ZooKeeper authentication for the engine, all candidates are " + s"${AuthTypes.values.mkString("
              • ", "
              • ", "
              ")}") .version("1.3.2") .stringConf @@ -94,31 +93,31 @@ object HighAvailabilityConf { val HA_ZK_AUTH_PRINCIPAL: ConfigEntry[Option[String]] = buildConf("kyuubi.ha.zookeeper.auth.principal") - .doc("Name of the Kerberos principal is used for zookeeper authentication.") + .doc("Name of the Kerberos principal is used for ZooKeeper authentication.") .version("1.3.2") .fallbackConf(KyuubiConf.SERVER_PRINCIPAL) val HA_ZK_AUTH_KEYTAB: ConfigEntry[Option[String]] = buildConf("kyuubi.ha.zookeeper.auth.keytab") - .doc("Location of Kyuubi server's keytab is used for zookeeper authentication.") + .doc("Location of the Kyuubi server's keytab is used for ZooKeeper authentication.") .version("1.3.2") .fallbackConf(KyuubiConf.SERVER_KEYTAB) val HA_ZK_AUTH_DIGEST: OptionalConfigEntry[String] = buildConf("kyuubi.ha.zookeeper.auth.digest") - .doc("The digest auth string is used for zookeeper authentication, like: username:password.") + .doc("The digest auth string is used for ZooKeeper authentication, like: username:password.") .version("1.3.2") .stringConf .createOptional val HA_ZK_CONN_MAX_RETRIES: ConfigEntry[Int] = buildConf("kyuubi.ha.zookeeper.connection.max.retries") - .doc("Max retry times for connecting to the zookeeper ensemble") + .doc("Max retry times for connecting to the ZooKeeper ensemble") .version("1.0.0") .intConf .createWithDefault(3) val HA_ZK_CONN_BASE_RETRY_WAIT: ConfigEntry[Int] = buildConf("kyuubi.ha.zookeeper.connection.base.retry.wait") - .doc("Initial amount of time to wait between retries to the zookeeper ensemble") + .doc("Initial amount of time to wait between retries to the ZooKeeper ensemble") .version("1.0.0") .intConf .createWithDefault(1000) @@ -133,7 +132,7 @@ object HighAvailabilityConf { .createWithDefault(30 * 1000) val HA_ZK_CONN_TIMEOUT: ConfigEntry[Int] = buildConf("kyuubi.ha.zookeeper.connection.timeout") - .doc("The timeout(ms) of creating the connection to the zookeeper ensemble") + .doc("The timeout(ms) of creating the connection to the ZooKeeper ensemble") .version("1.0.0") .intConf .createWithDefault(15 * 1000) @@ -146,7 +145,7 @@ object HighAvailabilityConf { val HA_ZK_CONN_RETRY_POLICY: ConfigEntry[String] = buildConf("kyuubi.ha.zookeeper.connection.retry.policy") - .doc("The retry policy for connecting to the zookeeper ensemble, all candidates are:" + + .doc("The retry policy for connecting to the ZooKeeper ensemble, all candidates are:" + s" ${RetryPolicies.values.mkString("
              • ", "
              • ", "
              ")}") .version("1.0.0") .stringConf @@ -155,7 +154,7 @@ object HighAvailabilityConf { val HA_ZK_NODE_TIMEOUT: ConfigEntry[Long] = buildConf("kyuubi.ha.zookeeper.node.creation.timeout") - .doc("Timeout for creating zookeeper node") + .doc("Timeout for creating ZooKeeper node") .version("1.2.0") .timeConf .checkValue(_ > 0, "Must be positive") @@ -163,7 +162,7 @@ object HighAvailabilityConf { val HA_ENGINE_REF_ID: OptionalConfigEntry[String] = buildConf("kyuubi.ha.engine.ref.id") - .doc("The engine reference id will be attached to zookeeper node when engine started, " + + .doc("The engine reference id will be attached to ZooKeeper node when engine started, " + "and the kyuubi server will check it cyclically.") .internal .version("1.3.2") @@ -172,7 +171,7 @@ object HighAvailabilityConf { val HA_ZK_PUBLISH_CONFIGS: ConfigEntry[Boolean] = buildConf("kyuubi.ha.zookeeper.publish.configs") - .doc("When set to true, publish Kerberos configs to Zookeeper." + + .doc("When set to true, publish Kerberos configs to Zookeeper. " + "Note that the Hive driver needs to be greater than 1.3 or 2.0 or apply HIVE-11581 patch.") .version("1.4.0") .booleanConf @@ -189,8 +188,8 @@ object HighAvailabilityConf { val HA_ETCD_LEASE_TIMEOUT: ConfigEntry[Long] = buildConf("kyuubi.ha.etcd.lease.timeout") - .doc("Timeout for etcd keep alive lease. The kyuubi server will known " + - "unexpected loss of engine after up to this seconds.") + .doc("Timeout for etcd keep alive lease. The kyuubi server will know " + + "the unexpected loss of engine after up to this seconds.") .version("1.6.0") .timeConf .checkValue(_ > 0, "Must be positive") @@ -198,7 +197,7 @@ object HighAvailabilityConf { val HA_ETCD_SSL_ENABLED: ConfigEntry[Boolean] = buildConf("kyuubi.ha.etcd.ssl.enabled") - .doc("When set to true, will build a ssl secured etcd client.") + .doc("When set to true, will build an SSL secured etcd client.") .version("1.6.0") .booleanConf .createWithDefault(false) diff --git a/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/client/etcd/EtcdDiscoveryClient.scala b/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/client/etcd/EtcdDiscoveryClient.scala index ad3a0550c4a..80a70f2f218 100644 --- a/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/client/etcd/EtcdDiscoveryClient.scala +++ b/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/client/etcd/EtcdDiscoveryClient.scala @@ -90,7 +90,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def createClient(): Unit = { + override def createClient(): Unit = { client = buildClient() kvClient = client.getKVClient() lockClient = client.getLockClient() @@ -99,13 +99,13 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { leaseTTL = conf.get(HighAvailabilityConf.HA_ETCD_LEASE_TIMEOUT) / 1000 } - def closeClient(): Unit = { + override def closeClient(): Unit = { if (client != null) { client.close() } } - def create(path: String, mode: String, createParent: Boolean = true): String = { + override def create(path: String, mode: String, createParent: Boolean = true): String = { // createParent can not effect here mode match { case "PERSISTENT" => kvClient.put( @@ -116,7 +116,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { path } - def getData(path: String): Array[Byte] = { + override def getData(path: String): Array[Byte] = { val response = kvClient.get(ByteSequence.from(path.getBytes())).get() if (response.getKvs.isEmpty) { throw new KyuubiException(s"Key[$path] not exists in ETCD, please check it.") @@ -125,12 +125,12 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def setData(path: String, data: Array[Byte]): Boolean = { + override def setData(path: String, data: Array[Byte]): Boolean = { val response = kvClient.put(ByteSequence.from(path.getBytes), ByteSequence.from(data)).get() response != null } - def getChildren(path: String): List[String] = { + override def getChildren(path: String): List[String] = { val kvs = kvClient.get( ByteSequence.from(path.getBytes()), GetOption.newBuilder().isPrefix(true).build()).get().getKvs @@ -142,25 +142,25 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def pathExists(path: String): Boolean = { + override def pathExists(path: String): Boolean = { !pathNonExists(path) } - def pathNonExists(path: String): Boolean = { + override def pathNonExists(path: String): Boolean = { kvClient.get(ByteSequence.from(path.getBytes())).get().getKvs.isEmpty } - def delete(path: String, deleteChildren: Boolean = false): Unit = { + override def delete(path: String, deleteChildren: Boolean = false): Unit = { kvClient.delete( ByteSequence.from(path.getBytes()), DeleteOption.newBuilder().isPrefix(deleteChildren).build()).get() } - def monitorState(serviceDiscovery: ServiceDiscovery): Unit = { + override def monitorState(serviceDiscovery: ServiceDiscovery): Unit = { // not need with etcd } - def tryWithLock[T]( + override def tryWithLock[T]( lockPath: String, timeout: Long)(f: => T): T = { // the default unit is millis, covert to seconds. @@ -195,7 +195,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def getServerHost(namespace: String): Option[(String, Int)] = { + override def getServerHost(namespace: String): Option[(String, Int)] = { // TODO: use last one because to avoid touching some maybe-crashed engines // We need a big improvement here. getServiceNodesInfo(namespace, Some(1), silent = true) match { @@ -204,7 +204,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def getEngineByRefId( + override def getEngineByRefId( namespace: String, engineRefId: String): Option[(String, Int)] = { getServiceNodesInfo(namespace, silent = true) @@ -212,7 +212,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { .map(data => (data.host, data.port)) } - def getServiceNodesInfo( + override def getServiceNodesInfo( namespace: String, sizeOpt: Option[Int] = None, silent: Boolean = false): Seq[ServiceNodeInfo] = { @@ -241,7 +241,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def registerService( + override def registerService( conf: KyuubiConf, namespace: String, serviceDiscovery: ServiceDiscovery, @@ -267,7 +267,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def deregisterService(): Unit = { + override def deregisterService(): Unit = { // close the EPHEMERAL_SEQUENTIAL node in etcd if (serviceNode != null) { if (serviceNode.lease != LEASE_NULL_VALUE) { @@ -278,7 +278,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def postDeregisterService(namespace: String): Boolean = { + override def postDeregisterService(namespace: String): Boolean = { if (namespace != null) { delete(DiscoveryPaths.makePath(null, namespace), true) true @@ -287,7 +287,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def createAndGetServiceNode( + override def createAndGetServiceNode( conf: KyuubiConf, namespace: String, instance: String, @@ -297,7 +297,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } @VisibleForTesting - def startSecretNode( + override def startSecretNode( createMode: String, basePath: String, initData: String, @@ -307,7 +307,7 @@ class EtcdDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { ByteSequence.from(initData.getBytes())).get() } - def getAndIncrement(path: String, delta: Int = 1): Int = { + override def getAndIncrement(path: String, delta: Int = 1): Int = { val lockPath = s"${path}_tmp_for_lock" tryWithLock(lockPath, 60 * 1000) { if (pathNonExists(path)) { diff --git a/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/client/zookeeper/ZookeeperDiscoveryClient.scala b/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/client/zookeeper/ZookeeperDiscoveryClient.scala index 1315cf02957..daa27047eb9 100644 --- a/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/client/zookeeper/ZookeeperDiscoveryClient.scala +++ b/kyuubi-ha/src/main/scala/org/apache/kyuubi/ha/client/zookeeper/ZookeeperDiscoveryClient.scala @@ -66,17 +66,17 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { @volatile private var serviceNode: PersistentNode = _ private var watcher: DeRegisterWatcher = _ - def createClient(): Unit = { + override def createClient(): Unit = { zkClient.start() } - def closeClient(): Unit = { + override def closeClient(): Unit = { if (zkClient != null) { zkClient.close() } } - def create(path: String, mode: String, createParent: Boolean = true): String = { + override def create(path: String, mode: String, createParent: Boolean = true): String = { val builder = if (createParent) zkClient.create().creatingParentsIfNeeded() else zkClient.create() builder @@ -84,27 +84,27 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { .forPath(path) } - def getData(path: String): Array[Byte] = { + override def getData(path: String): Array[Byte] = { zkClient.getData.forPath(path) } - def setData(path: String, data: Array[Byte]): Boolean = { + override def setData(path: String, data: Array[Byte]): Boolean = { zkClient.setData().forPath(path, data) != null } - def getChildren(path: String): List[String] = { + override def getChildren(path: String): List[String] = { zkClient.getChildren.forPath(path).asScala.toList } - def pathExists(path: String): Boolean = { + override def pathExists(path: String): Boolean = { zkClient.checkExists().forPath(path) != null } - def pathNonExists(path: String): Boolean = { + override def pathNonExists(path: String): Boolean = { zkClient.checkExists().forPath(path) == null } - def delete(path: String, deleteChildren: Boolean = false): Unit = { + override def delete(path: String, deleteChildren: Boolean = false): Unit = { if (deleteChildren) { zkClient.delete().deletingChildrenIfNeeded().forPath(path) } else { @@ -112,7 +112,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def monitorState(serviceDiscovery: ServiceDiscovery): Unit = { + override def monitorState(serviceDiscovery: ServiceDiscovery): Unit = { zkClient .getConnectionStateListenable.addListener(new ConnectionStateListener { private val isConnected = new AtomicBoolean(false) @@ -141,7 +141,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { }) } - def tryWithLock[T](lockPath: String, timeout: Long)(f: => T): T = { + override def tryWithLock[T](lockPath: String, timeout: Long)(f: => T): T = { var lock: InterProcessSemaphoreMutex = null try { try { @@ -189,7 +189,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def getServerHost(namespace: String): Option[(String, Int)] = { + override def getServerHost(namespace: String): Option[(String, Int)] = { // TODO: use last one because to avoid touching some maybe-crashed engines // We need a big improvement here. getServiceNodesInfo(namespace, Some(1), silent = true) match { @@ -198,7 +198,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def getEngineByRefId( + override def getEngineByRefId( namespace: String, engineRefId: String): Option[(String, Int)] = { getServiceNodesInfo(namespace, silent = true) @@ -206,7 +206,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { .map(data => (data.host, data.port)) } - def getServiceNodesInfo( + override def getServiceNodesInfo( namespace: String, sizeOpt: Option[Int] = None, silent: Boolean = false): Seq[ServiceNodeInfo] = { @@ -235,7 +235,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def registerService( + override def registerService( conf: KyuubiConf, namespace: String, serviceDiscovery: ServiceDiscovery, @@ -254,7 +254,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { watchNode() } - def deregisterService(): Unit = { + override def deregisterService(): Unit = { // close the EPHEMERAL_SEQUENTIAL node in zk if (serviceNode != null) { try { @@ -268,7 +268,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def postDeregisterService(namespace: String): Boolean = { + override def postDeregisterService(namespace: String): Boolean = { if (namespace != null) { try { delete(namespace, true) @@ -283,7 +283,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } } - def createAndGetServiceNode( + override def createAndGetServiceNode( conf: KyuubiConf, namespace: String, instance: String, @@ -293,7 +293,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { } @VisibleForTesting - def startSecretNode( + override def startSecretNode( createMode: String, basePath: String, initData: String, @@ -307,7 +307,7 @@ class ZookeeperDiscoveryClient(conf: KyuubiConf) extends DiscoveryClient { secretNode.start() } - def getAndIncrement(path: String, delta: Int = 1): Int = { + override def getAndIncrement(path: String, delta: Int = 1): Int = { val dai = new DistributedAtomicInteger(zkClient, path, new RetryForever(1000)) var atomicVal: AtomicValue[Integer] = null do { diff --git a/kyuubi-ha/src/test/scala/org/apache/kyuubi/ha/client/etcd/EtcdDiscoveryClientSuite.scala b/kyuubi-ha/src/test/scala/org/apache/kyuubi/ha/client/etcd/EtcdDiscoveryClientSuite.scala index 5b8855c1ee9..de48a3495db 100644 --- a/kyuubi-ha/src/test/scala/org/apache/kyuubi/ha/client/etcd/EtcdDiscoveryClientSuite.scala +++ b/kyuubi-ha/src/test/scala/org/apache/kyuubi/ha/client/etcd/EtcdDiscoveryClientSuite.scala @@ -22,6 +22,9 @@ import java.nio.charset.StandardCharsets import scala.collection.JavaConverters._ import io.etcd.jetcd.launcher.{Etcd, EtcdCluster} +import org.scalactic.source.Position +import org.scalatest.Tag +import org.testcontainers.DockerClientFactory import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.ha.HighAvailabilityConf.{HA_ADDRESSES, HA_CLIENT_CLASS} @@ -41,25 +44,38 @@ class EtcdDiscoveryClientSuite extends DiscoveryClientTests { var conf: KyuubiConf = KyuubiConf() .set(HA_CLIENT_CLASS, classOf[EtcdDiscoveryClient].getName) + private val hasDockerEnv = DockerClientFactory.instance().isDockerAvailable + override def beforeAll(): Unit = { - etcdCluster = new Etcd.Builder() - .withNodes(2) - .build() - etcdCluster.start() - conf = new KyuubiConf() - .set(HA_CLIENT_CLASS, classOf[EtcdDiscoveryClient].getName) - .set(HA_ADDRESSES, getConnectString) + if (hasDockerEnv) { + etcdCluster = new Etcd.Builder() + .withNodes(2) + .build() + etcdCluster.start() + conf = new KyuubiConf() + .set(HA_CLIENT_CLASS, classOf[EtcdDiscoveryClient].getName) + .set(HA_ADDRESSES, getConnectString) + } super.beforeAll() } override def afterAll(): Unit = { super.afterAll() - if (etcdCluster != null) { + if (hasDockerEnv && etcdCluster != null) { etcdCluster.close() etcdCluster = null } } + override protected def test( + testName: String, + testTags: Tag*)(testFun: => Any)(implicit pos: Position): Unit = { + if (hasDockerEnv) { + super.test(testName, testTags: _*)(testFun) + } + // skip test + } + test("etcd test: set, get and delete") { withDiscoveryClient(conf) { discoveryClient => val path = "/kyuubi" diff --git a/kyuubi-hive-beeline/README.md b/kyuubi-hive-beeline/README.md index ec4f86fd769..161acb99b64 100644 --- a/kyuubi-hive-beeline/README.md +++ b/kyuubi-hive-beeline/README.md @@ -3,3 +3,4 @@ Aiming to make a better supported beeline for Kyuubi - Support to show launch engine log when getting KyuubiConnection(Done, available since v1.4.0-incubating) + diff --git a/kyuubi-hive-beeline/pom.xml b/kyuubi-hive-beeline/pom.xml index 76753b38d60..beacba438c2 100644 --- a/kyuubi-hive-beeline/pom.xml +++ b/kyuubi-hive-beeline/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT kyuubi-hive-beeline @@ -115,6 +115,12 @@ commons-io + + org.mockito + mockito-core + test + + commons-lang commons-lang diff --git a/kyuubi-hive-beeline/src/main/java/org/apache/hive/beeline/KyuubiBeeLine.java b/kyuubi-hive-beeline/src/main/java/org/apache/hive/beeline/KyuubiBeeLine.java index c88ceb5a86e..7ca7671486b 100644 --- a/kyuubi-hive-beeline/src/main/java/org/apache/hive/beeline/KyuubiBeeLine.java +++ b/kyuubi-hive-beeline/src/main/java/org/apache/hive/beeline/KyuubiBeeLine.java @@ -40,6 +40,7 @@ public KyuubiBeeLine() { this(true); } + @SuppressWarnings("deprecation") public KyuubiBeeLine(boolean isBeeLine) { super(isBeeLine); try { diff --git a/kyuubi-hive-beeline/src/main/java/org/apache/hive/beeline/KyuubiCommands.java b/kyuubi-hive-beeline/src/main/java/org/apache/hive/beeline/KyuubiCommands.java index 57241784761..311cb6a9538 100644 --- a/kyuubi-hive-beeline/src/main/java/org/apache/hive/beeline/KyuubiCommands.java +++ b/kyuubi-hive-beeline/src/main/java/org/apache/hive/beeline/KyuubiCommands.java @@ -19,6 +19,7 @@ import static org.apache.kyuubi.jdbc.hive.JdbcConnectionParams.*; +import com.google.common.annotations.VisibleForTesting; import java.io.*; import java.sql.*; import java.util.*; @@ -93,8 +94,9 @@ private boolean sourceFileInternal(File sourceFile) throws IOException { lines += "\n" + extra; } } - String[] cmds = lines.split(";"); + String[] cmds = lines.split(beeLine.getOpts().getDelimiter()); for (String c : cmds) { + c = c.trim(); if (!executeInternal(c, false)) { return false; } @@ -276,7 +278,8 @@ private boolean execute(String line, boolean call, boolean entireLineAsCommand) * quotations. It iterates through each character in the line and checks to see if it is a ;, ', * or " */ - private List getCmdList(String line, boolean entireLineAsCommand) { + @VisibleForTesting + public List getCmdList(String line, boolean entireLineAsCommand) { List cmdList = new ArrayList(); if (entireLineAsCommand) { cmdList.add(line); @@ -405,7 +408,7 @@ private String getProperty(Properties props, String[] keys) { } } - for (Iterator i = props.keySet().iterator(); i.hasNext(); ) { + for (Iterator i = props.keySet().iterator(); i.hasNext(); ) { String key = (String) i.next(); for (int j = 0; j < keys.length; j++) { if (key.endsWith(keys[j])) { @@ -470,9 +473,7 @@ public boolean connect(Properties props) throws IOException { props.setProperty(AUTH_USER, username); if (password == null) { password = - beeLine - .getConsoleReader() - .readLine("Enter password for " + urlForPrompt + ": ", new Character('*')); + beeLine.getConsoleReader().readLine("Enter password for " + urlForPrompt + ": ", '*'); } props.setProperty(AUTH_PASSWD, password); } @@ -487,6 +488,9 @@ public boolean connect(Properties props) throws IOException { beeLine.updateOptsForCli(); } beeLine.runInit(); + if (beeLine.getOpts().getInitFiles() != null) { + beeLine.initializeConsoleReader(null); + } beeLine.setCompletions(); beeLine.getOpts().setLastConnectedUrl(url); diff --git a/kyuubi-hive-beeline/src/test/java/org/apache/hive/beeline/KyuubiCommandsTest.java b/kyuubi-hive-beeline/src/test/java/org/apache/hive/beeline/KyuubiCommandsTest.java new file mode 100644 index 00000000000..ecb8d65f502 --- /dev/null +++ b/kyuubi-hive-beeline/src/test/java/org/apache/hive/beeline/KyuubiCommandsTest.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hive.beeline; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.util.List; +import jline.console.ConsoleReader; +import org.junit.Test; +import org.mockito.Mockito; + +public class KyuubiCommandsTest { + @Test + public void testParsePythonSnippets() throws IOException { + ConsoleReader reader = Mockito.mock(ConsoleReader.class); + String pythonSnippets = "for i in [1, 2, 3]:\n" + " print(i)\n"; + Mockito.when(reader.readLine()).thenReturn(pythonSnippets); + + KyuubiBeeLine beeline = new KyuubiBeeLine(); + beeline.setConsoleReader(reader); + KyuubiCommands commands = new KyuubiCommands(beeline); + String line = commands.handleMultiLineCmd(pythonSnippets); + + List cmdList = commands.getCmdList(line, false); + assertEquals(cmdList.size(), 1); + assertEquals(cmdList.get(0), pythonSnippets); + } +} diff --git a/kyuubi-hive-jdbc-shaded/pom.xml b/kyuubi-hive-jdbc-shaded/pom.xml index 0bfe88922da..1a6f258b02f 100644 --- a/kyuubi-hive-jdbc-shaded/pom.xml +++ b/kyuubi-hive-jdbc-shaded/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT kyuubi-hive-jdbc-shaded diff --git a/kyuubi-hive-jdbc/README.md b/kyuubi-hive-jdbc/README.md index 3210e76ac56..10a0522dc38 100644 --- a/kyuubi-hive-jdbc/README.md +++ b/kyuubi-hive-jdbc/README.md @@ -1,9 +1,9 @@ # Kyuubi Hive JDBC Module - Aiming to make a better supported client for Kyuubi and Spark - Add catalog to getTables meta function for DataLakes (DONE, broken in v1.3.0-incubating, fixed in v1.3.1-incubating) - Deploy to maven central (DONE, available since v1.3.0-incubating) - Create shaded jar (DONE, available since v1.4.0-incubating) - Remove Hive dependencies (DONE, available since v1.6.0-incubating) + diff --git a/kyuubi-hive-jdbc/pom.xml b/kyuubi-hive-jdbc/pom.xml index 4d9648e75f6..36ea7acc274 100644 --- a/kyuubi-hive-jdbc/pom.xml +++ b/kyuubi-hive-jdbc/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT kyuubi-hive-jdbc @@ -171,6 +171,14 @@ + + + + true + src/main/resources + + + org.apache.maven.plugins diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/JdbcColumnAttributes.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/JdbcColumnAttributes.java index 06fb398999a..b0257cfff09 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/JdbcColumnAttributes.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/JdbcColumnAttributes.java @@ -20,7 +20,7 @@ public class JdbcColumnAttributes { public int precision = 0; public int scale = 0; - public String timeZone = ""; + public String timeZone = null; public JdbcColumnAttributes() {} diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiArrowBasedResultSet.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiArrowBasedResultSet.java index e45b6545471..ef5008503aa 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiArrowBasedResultSet.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiArrowBasedResultSet.java @@ -34,6 +34,7 @@ import org.apache.kyuubi.jdbc.hive.arrow.ArrowUtils; /** Data independent base class which implements the common part of all Kyuubi result sets. */ +@SuppressWarnings("deprecation") public abstract class KyuubiArrowBasedResultSet implements SQLResultSet { protected Statement statement = null; @@ -49,6 +50,7 @@ public abstract class KyuubiArrowBasedResultSet implements SQLResultSet { protected Schema arrowSchema; protected VectorSchemaRoot root; protected ArrowColumnarBatchRow row; + protected boolean timestampAsString = true; protected BufferAllocator allocator; @@ -311,11 +313,18 @@ private Object getColumnValue(int columnIndex) throws SQLException { if (wasNull) { return null; } else { - return row.get(columnIndex - 1, columnType); + JdbcColumnAttributes attributes = columnAttributes.get(columnIndex - 1); + return row.get( + columnIndex - 1, + columnType, + attributes == null ? null : attributes.timeZone, + timestampAsString); } } catch (Exception e) { - e.printStackTrace(); - throw new KyuubiSQLException("Unrecognized column type:", e); + throw new KyuubiSQLException( + String.format( + "Error getting row of type %s at column index %d", columnType, columnIndex - 1), + e); } } diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiArrowQueryResultSet.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiArrowQueryResultSet.java index 1f2af29dc16..fda70f463e9 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiArrowQueryResultSet.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiArrowQueryResultSet.java @@ -58,9 +58,6 @@ public class KyuubiArrowQueryResultSet extends KyuubiArrowBasedResultSet { private boolean isScrollable = false; private boolean fetchFirst = false; - // TODO:(fchen) make this configurable - protected boolean convertComplexTypeToString = true; - private final TProtocolVersion protocol; public static class Builder { @@ -87,6 +84,8 @@ public static class Builder { private boolean isScrollable = false; private ReentrantLock transportLock = null; + private boolean timestampAsString = true; + public Builder(Statement statement) throws SQLException { this.statement = statement; this.connection = statement.getConnection(); @@ -153,6 +152,11 @@ public Builder setScrollable(boolean setScrollable) { return this; } + public Builder setTimestampAsString(boolean timestampAsString) { + this.timestampAsString = timestampAsString; + return this; + } + public Builder setTransportLock(ReentrantLock transportLock) { this.transportLock = transportLock; return this; @@ -189,10 +193,10 @@ protected KyuubiArrowQueryResultSet(Builder builder) throws SQLException { this.maxRows = builder.maxRows; } this.isScrollable = builder.isScrollable; + this.timestampAsString = builder.timestampAsString; this.protocol = builder.getProtocolVersion(); arrowSchema = - ArrowUtils.toArrowSchema( - columnNames, convertComplexTypeToStringType(columnTypes), columnAttributes); + ArrowUtils.toArrowSchema(columnNames, convertToStringType(columnTypes), columnAttributes); if (allocator == null) { initArrowSchemaAndAllocator(); } @@ -271,8 +275,7 @@ private void retrieveSchema() throws SQLException { columnAttributes.add(getColumnAttributes(primitiveTypeEntry)); } arrowSchema = - ArrowUtils.toArrowSchema( - columnNames, convertComplexTypeToStringType(columnTypes), columnAttributes); + ArrowUtils.toArrowSchema(columnNames, convertToStringType(columnTypes), columnAttributes); } catch (SQLException eS) { throw eS; // rethrow the SQLException as is } catch (Exception ex) { @@ -480,22 +483,25 @@ public boolean isClosed() { return isClosed; } - private List convertComplexTypeToStringType(List colTypes) { - if (convertComplexTypeToString) { - return colTypes.stream() - .map( - type -> { - if (type == TTypeId.ARRAY_TYPE - || type == TTypeId.MAP_TYPE - || type == TTypeId.STRUCT_TYPE) { - return TTypeId.STRING_TYPE; - } else { - return type; - } - }) - .collect(Collectors.toList()); - } else { - return colTypes; - } + /** + * 1. the complex types (map/array/struct) are always converted to string type to transport 2. if + * the user set `timestampAsString = true`, then the timestamp type will be converted to string + * type too. + */ + private List convertToStringType(List colTypes) { + return colTypes.stream() + .map( + type -> { + if ((type == TTypeId.ARRAY_TYPE + || type == TTypeId.MAP_TYPE + || type == TTypeId.STRUCT_TYPE) // complex type (map/array/struct) + // timestamp type + || (type == TTypeId.TIMESTAMP_TYPE && timestampAsString)) { + return TTypeId.STRING_TYPE; + } else { + return type; + } + }) + .collect(Collectors.toList()); } } diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiBaseResultSet.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiBaseResultSet.java index 5fe889346e6..a9d32e8cafb 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiBaseResultSet.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiBaseResultSet.java @@ -32,6 +32,7 @@ import org.apache.kyuubi.jdbc.hive.common.TimestampTZUtil; /** Data independent base class which implements the common part of all Kyuubi result sets. */ +@SuppressWarnings("deprecation") public abstract class KyuubiBaseResultSet implements SQLResultSet { protected Statement statement = null; diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiConnection.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiConnection.java index 1d7755b1ef9..f9935d23e19 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiConnection.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiConnection.java @@ -30,10 +30,7 @@ import java.net.UnknownHostException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; -import java.security.AccessControlContext; -import java.security.AccessController; -import java.security.KeyStore; -import java.security.SecureRandom; +import java.security.*; import java.sql.*; import java.util.*; import java.util.Map.Entry; @@ -43,6 +40,7 @@ import javax.net.ssl.TrustManagerFactory; import javax.security.auth.Subject; import javax.security.sasl.Sasl; +import org.apache.commons.lang3.ClassUtils; import org.apache.commons.lang3.StringUtils; import org.apache.hive.service.rpc.thrift.*; import org.apache.http.HttpRequestInterceptor; @@ -109,6 +107,7 @@ public class KyuubiConnection implements SQLConnection, KyuubiLoggable { private String engineId = ""; private String engineName = ""; private String engineUrl = ""; + private String engineRefId = ""; private boolean isBeeLineMode; @@ -738,6 +737,7 @@ private void openSession() throws SQLException { } catch (UnknownHostException e) { LOG.debug("Error getting Kyuubi session local client ip address", e); } + openConf.put(Utils.KYUUBI_CLIENT_VERSION_KEY, Utils.getVersion()); openReq.setConfiguration(openConf); // Store the user name in the open request in case no non-sasl authentication @@ -812,11 +812,16 @@ private boolean isSaslAuthMode() { return !AUTH_SIMPLE.equalsIgnoreCase(sessConfMap.get(AUTH_TYPE)); } - private boolean isFromSubjectAuthMode() { - return isSaslAuthMode() - && hasSessionValue(AUTH_PRINCIPAL) - && AUTH_KERBEROS_AUTH_TYPE_FROM_SUBJECT.equalsIgnoreCase( - sessConfMap.get(AUTH_KERBEROS_AUTH_TYPE)); + private boolean isHadoopUserGroupInformationDoAs() { + try { + @SuppressWarnings("unchecked") + Class HadoopUserClz = + (Class) ClassUtils.getClass("org.apache.hadoop.security.User"); + Subject subject = Subject.getSubject(AccessController.getContext()); + return subject != null && !subject.getPrincipals(HadoopUserClz).isEmpty(); + } catch (ClassNotFoundException e) { + return false; + } } private boolean isKeytabAuthMode() { @@ -826,6 +831,16 @@ && hasSessionValue(AUTH_KYUUBI_CLIENT_PRINCIPAL) && hasSessionValue(AUTH_KYUUBI_CLIENT_KEYTAB); } + private boolean isFromSubjectAuthMode() { + return isSaslAuthMode() + && hasSessionValue(AUTH_PRINCIPAL) + && !hasSessionValue(AUTH_KYUUBI_CLIENT_PRINCIPAL) + && !hasSessionValue(AUTH_KYUUBI_CLIENT_KEYTAB) + && (AUTH_KERBEROS_AUTH_TYPE_FROM_SUBJECT.equalsIgnoreCase( + sessConfMap.get(AUTH_KERBEROS_AUTH_TYPE)) + || isHadoopUserGroupInformationDoAs()); + } + private boolean isTgtCacheAuthMode() { return isSaslAuthMode() && hasSessionValue(AUTH_PRINCIPAL) @@ -842,15 +857,15 @@ private boolean isKerberosAuthMode() { } private Subject createSubject() { - if (isFromSubjectAuthMode()) { + if (isKeytabAuthMode()) { + String principal = sessConfMap.get(AUTH_KYUUBI_CLIENT_PRINCIPAL); + String keytab = sessConfMap.get(AUTH_KYUUBI_CLIENT_KEYTAB); + return KerberosAuthenticationManager.getKeytabAuthentication(principal, keytab).getSubject(); + } else if (isFromSubjectAuthMode()) { AccessControlContext context = AccessController.getContext(); return Subject.getSubject(context); } else if (isTgtCacheAuthMode()) { return KerberosAuthenticationManager.getTgtCacheAuthentication().getSubject(); - } else if (isKeytabAuthMode()) { - String principal = sessConfMap.get(AUTH_KYUUBI_CLIENT_PRINCIPAL); - String keytab = sessConfMap.get(AUTH_KYUUBI_CLIENT_KEYTAB); - return KerberosAuthenticationManager.getKeytabAuthentication(principal, keytab).getSubject(); } else { // This should never happen throw new IllegalArgumentException("Unsupported auth mode"); @@ -1248,6 +1263,7 @@ public TProtocolVersion getProtocol() { return protocol; } + @SuppressWarnings("rawtypes") public static TCLIService.Iface newSynchronizedClient(TCLIService.Iface client) { return (TCLIService.Iface) Proxy.newProxyInstance( @@ -1286,6 +1302,7 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl } } + @SuppressWarnings("fallthrough") public void waitLaunchEngineToComplete() throws SQLException { if (launchEngineOpHandle == null) return; @@ -1354,6 +1371,8 @@ private void fetchLaunchEngineResult() { engineName = value; } else if ("url".equals(key)) { engineUrl = value; + } else if ("refId".equals(key)) { + engineRefId = value; } } } catch (Exception e) { @@ -1372,4 +1391,8 @@ public String getEngineName() { public String getEngineUrl() { return engineUrl; } + + public String getEngineRefId() { + return engineRefId; + } } diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiPreparedStatement.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiPreparedStatement.java index 43c2a030bc8..a0d4f3bfd25 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiPreparedStatement.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiPreparedStatement.java @@ -26,9 +26,7 @@ import java.sql.Timestamp; import java.sql.Types; import java.text.MessageFormat; -import java.util.ArrayList; import java.util.HashMap; -import java.util.List; import java.util.Scanner; import org.apache.hive.service.rpc.thrift.TCLIService; import org.apache.hive.service.rpc.thrift.TSessionHandle; @@ -81,57 +79,7 @@ public int executeUpdate() throws SQLException { /** update the SQL string with parameters set by setXXX methods of {@link PreparedStatement} */ private String updateSql(final String sql, HashMap parameters) throws SQLException { - List parts = splitSqlStatement(sql); - - StringBuilder newSql = new StringBuilder(parts.get(0)); - for (int i = 1; i < parts.size(); i++) { - if (!parameters.containsKey(i)) { - throw new KyuubiSQLException("Parameter #" + i + " is unset"); - } - newSql.append(parameters.get(i)); - newSql.append(parts.get(i)); - } - return newSql.toString(); - } - - /** - * Splits the parametered sql statement at parameter boundaries. - * - *

              taking into account ' and \ escaping. - * - *

              output for: 'select 1 from ? where a = ?' ['select 1 from ',' where a = ',''] - */ - private List splitSqlStatement(String sql) { - List parts = new ArrayList<>(); - int apCount = 0; - int off = 0; - boolean skip = false; - - for (int i = 0; i < sql.length(); i++) { - char c = sql.charAt(i); - if (skip) { - skip = false; - continue; - } - switch (c) { - case '\'': - apCount++; - break; - case '\\': - skip = true; - break; - case '?': - if ((apCount & 1) == 0) { - parts.add(sql.substring(off, i)); - off = i + 1; - } - break; - default: - break; - } - } - parts.add(sql.substring(off, sql.length())); - return parts; + return Utils.updateSql(sql, parameters); } @Override diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiStatement.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiStatement.java index ee3dc71a97d..cbe32eca65e 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiStatement.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/KyuubiStatement.java @@ -37,6 +37,7 @@ public class KyuubiStatement implements SQLStatement, KyuubiLoggable { public static final Logger LOG = LoggerFactory.getLogger(KyuubiStatement.class.getName()); public static final int DEFAULT_FETCH_SIZE = 1000; public static final String DEFAULT_RESULT_FORMAT = "thrift"; + public static final String DEFAULT_ARROW_TIMESTAMP_AS_STRING = "false"; private final KyuubiConnection connection; private TCLIService.Iface client; private TOperationHandle stmtHandle = null; @@ -45,7 +46,8 @@ public class KyuubiStatement implements SQLStatement, KyuubiLoggable { private int fetchSize = DEFAULT_FETCH_SIZE; private boolean isScrollableResultset = false; private boolean isOperationComplete = false; - private Map properties = new HashMap<>(); + + private Map properties = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); /** * We need to keep a reference to the result set to support the following: * statement.execute(String sql); @@ -210,9 +212,14 @@ private boolean executeWithConfOverlay(String sql, Map confOverl String resultFormat = properties.getOrDefault("__kyuubi_operation_result_format__", DEFAULT_RESULT_FORMAT); - LOG.info("kyuubi.operation.result.format: " + resultFormat); + LOG.debug("kyuubi.operation.result.format: {}", resultFormat); switch (resultFormat) { case "arrow": + boolean timestampAsString = + Boolean.parseBoolean( + properties.getOrDefault( + "__kyuubi_operation_result_arrow_timestampAsString__", + DEFAULT_ARROW_TIMESTAMP_AS_STRING)); resultSet = new KyuubiArrowQueryResultSet.Builder(this) .setClient(client) @@ -222,6 +229,7 @@ private boolean executeWithConfOverlay(String sql, Map confOverl .setFetchSize(fetchSize) .setScrollable(isScrollableResultset) .setSchema(columnNames, columnTypes, columnAttributes) + .setTimestampAsString(timestampAsString) .build(); break; default: @@ -267,9 +275,14 @@ public boolean executeAsync(String sql) throws SQLException { String resultFormat = properties.getOrDefault("__kyuubi_operation_result_format__", DEFAULT_RESULT_FORMAT); - LOG.info("kyuubi.operation.result.format: " + resultFormat); + LOG.debug("kyuubi.operation.result.format: {}", resultFormat); switch (resultFormat) { case "arrow": + boolean timestampAsString = + Boolean.parseBoolean( + properties.getOrDefault( + "__kyuubi_operation_result_arrow_timestampAsString__", + DEFAULT_ARROW_TIMESTAMP_AS_STRING)); resultSet = new KyuubiArrowQueryResultSet.Builder(this) .setClient(client) @@ -279,7 +292,9 @@ public boolean executeAsync(String sql) throws SQLException { .setFetchSize(fetchSize) .setScrollable(isScrollableResultset) .setSchema(columnNames, columnTypes, columnAttributes) + .setTimestampAsString(timestampAsString) .build(); + break; default: resultSet = new KyuubiQueryResultSet.Builder(this) diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/Utils.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/Utils.java index c5b197f13df..ac9b29664c0 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/Utils.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/Utils.java @@ -22,10 +22,12 @@ import java.net.InetAddress; import java.net.URI; import java.net.UnknownHostException; +import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; import org.apache.hive.service.rpc.thrift.TStatus; import org.apache.hive.service.rpc.thrift.TStatusCode; import org.slf4j.Logger; @@ -88,6 +90,62 @@ static void verifySuccess(TStatus status, boolean withInfo) throws SQLException throw new KyuubiSQLException(status); } + /** + * Splits the parametered sql statement at parameter boundaries. + * + *

              taking into account ' and \ escaping. + * + *

              output for: 'select 1 from ? where a = ?' ['select 1 from ',' where a = ',''] + */ + static List splitSqlStatement(String sql) { + List parts = new ArrayList<>(); + int apCount = 0; + int off = 0; + boolean skip = false; + + for (int i = 0; i < sql.length(); i++) { + char c = sql.charAt(i); + if (skip) { + skip = false; + continue; + } + switch (c) { + case '\'': + apCount++; + break; + case '\\': + skip = true; + break; + case '?': + if ((apCount & 1) == 0) { + parts.add(sql.substring(off, i)); + off = i + 1; + } + break; + default: + break; + } + } + parts.add(sql.substring(off, sql.length())); + return parts; + } + + /** update the SQL string with parameters set by setXXX methods of {@link PreparedStatement} */ + public static String updateSql(final String sql, HashMap parameters) + throws SQLException { + List parts = splitSqlStatement(sql); + + StringBuilder newSql = new StringBuilder(parts.get(0)); + for (int i = 1; i < parts.size(); i++) { + if (!parameters.containsKey(i)) { + throw new KyuubiSQLException("Parameter #" + i + " is unset"); + } + newSql.append(parameters.get(i)); + newSql.append(parts.get(i)); + } + return newSql.toString(); + } + public static JdbcConnectionParams parseURL(String uri) throws JdbcUriParseException, SQLException, ZooKeeperHiveClientException { return parseURL(uri, new Properties()); @@ -193,12 +251,20 @@ public static JdbcConnectionParams extractURLComponents(String uri, Properties i } } + Pattern confPattern = Pattern.compile("([^;]*)([^;]*);?"); + // parse hive conf settings String confStr = jdbcURI.getQuery(); if (confStr != null) { - Matcher confMatcher = pattern.matcher(confStr); + Matcher confMatcher = confPattern.matcher(confStr); while (confMatcher.find()) { - connParams.getHiveConfs().put(confMatcher.group(1), confMatcher.group(2)); + String connParam = confMatcher.group(1); + if (StringUtils.isNotBlank(connParam) && connParam.contains("=")) { + int symbolIndex = connParam.indexOf('='); + connParams + .getHiveConfs() + .put(connParam.substring(0, symbolIndex), connParam.substring(symbolIndex + 1)); + } } } @@ -477,4 +543,21 @@ public static String getCanonicalHostName(String hostName) { public static boolean isKyuubiOperationHint(String hint) { return KYUUBI_OPERATION_HINT_PATTERN.matcher(hint).matches(); } + + public static final String KYUUBI_CLIENT_VERSION_KEY = "kyuubi.client.version"; + private static String KYUUBI_CLIENT_VERSION; + + public static synchronized String getVersion() { + if (KYUUBI_CLIENT_VERSION == null) { + try { + Properties prop = new Properties(); + prop.load(Utils.class.getClassLoader().getResourceAsStream("version.properties")); + KYUUBI_CLIENT_VERSION = prop.getProperty(KYUUBI_CLIENT_VERSION_KEY, "unknown"); + } catch (Exception e) { + LOG.error("Error getting kyuubi client version", e); + KYUUBI_CLIENT_VERSION = "unknown"; + } + } + return KYUUBI_CLIENT_VERSION; + } } diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/ZooKeeperHiveClientHelper.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/ZooKeeperHiveClientHelper.java index 349fc8dfb6b..41fadfa2f68 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/ZooKeeperHiveClientHelper.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/ZooKeeperHiveClientHelper.java @@ -17,6 +17,7 @@ package org.apache.kyuubi.jdbc.hive; +import com.google.common.annotations.VisibleForTesting; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -32,12 +33,14 @@ class ZooKeeperHiveClientHelper { // Pattern for key1=value1;key2=value2 private static final Pattern kvPattern = Pattern.compile("([^=;]*)=([^;]*);?"); - private static String getZooKeeperNamespace(JdbcConnectionParams connParams) { + @VisibleForTesting + protected static String getZooKeeperNamespace(JdbcConnectionParams connParams) { String zooKeeperNamespace = connParams.getSessionVars().get(JdbcConnectionParams.ZOOKEEPER_NAMESPACE); if ((zooKeeperNamespace == null) || (zooKeeperNamespace.isEmpty())) { zooKeeperNamespace = JdbcConnectionParams.ZOOKEEPER_DEFAULT_NAMESPACE; } + zooKeeperNamespace = zooKeeperNamespace.replaceAll("^/+", "").replaceAll("/+$", ""); return zooKeeperNamespace; } diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLCallableStatement.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLCallableStatement.java index 9ebe07011f7..4e62a3b00a3 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLCallableStatement.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLCallableStatement.java @@ -25,6 +25,7 @@ import java.util.Calendar; import java.util.Map; +@SuppressWarnings("deprecation") public interface SQLCallableStatement extends CallableStatement { @Override diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLPreparedStatement.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLPreparedStatement.java index 6bc9d383a1b..cbcaf2788e1 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLPreparedStatement.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLPreparedStatement.java @@ -23,6 +23,7 @@ import java.sql.*; import java.util.Calendar; +@SuppressWarnings("deprecation") public interface SQLPreparedStatement extends PreparedStatement { @Override diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLResultSet.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLResultSet.java index 8523e4b8d64..70c8ff4fe57 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLResultSet.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/adapter/SQLResultSet.java @@ -25,6 +25,7 @@ import java.util.Calendar; import java.util.Map; +@SuppressWarnings("deprecation") public interface SQLResultSet extends ResultSet { @Override diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/arrow/ArrowColumnarBatchRow.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/arrow/ArrowColumnarBatchRow.java index 20ed55a1d62..373867069b4 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/arrow/ArrowColumnarBatchRow.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/arrow/ArrowColumnarBatchRow.java @@ -19,6 +19,8 @@ import java.math.BigDecimal; import java.sql.Timestamp; +import java.time.LocalDateTime; +import org.apache.arrow.vector.util.DateUtility; import org.apache.hive.service.rpc.thrift.TTypeId; import org.apache.kyuubi.jdbc.hive.common.DateUtils; import org.apache.kyuubi.jdbc.hive.common.HiveIntervalDayTime; @@ -104,7 +106,11 @@ public Object getMap(int ordinal) { throw new UnsupportedOperationException(); } - public Object get(int ordinal, TTypeId dataType) { + public Object get(int ordinal, TTypeId dataType, String timeZone, boolean timestampAsString) { + long seconds; + long milliseconds; + long microseconds; + int nanos; switch (dataType) { case BOOLEAN_TYPE: return getBoolean(ordinal); @@ -127,13 +133,19 @@ public Object get(int ordinal, TTypeId dataType) { case STRING_TYPE: return getString(ordinal); case TIMESTAMP_TYPE: - return new Timestamp(getLong(ordinal) / 1000); + if (timestampAsString) { + return Timestamp.valueOf(getString(ordinal)); + } else { + LocalDateTime localDateTime = + DateUtility.getLocalDateTimeFromEpochMicro(getLong(ordinal), timeZone); + return Timestamp.valueOf(localDateTime); + } case DATE_TYPE: return DateUtils.internalToDate(getInt(ordinal)); case INTERVAL_DAY_TIME_TYPE: - long microseconds = getLong(ordinal); - long seconds = microseconds / 1000000; - int nanos = (int) (microseconds % 1000000) * 1000; + microseconds = getLong(ordinal); + seconds = microseconds / 1_000_000; + nanos = (int) (microseconds % 1_000_000) * 1_000; return new HiveIntervalDayTime(seconds, nanos); case INTERVAL_YEAR_MONTH_TYPE: return new HiveIntervalYearMonth(getInt(ordinal)); diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/arrow/ArrowUtils.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/arrow/ArrowUtils.java index 1d1587b5444..9a777d4c240 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/arrow/ArrowUtils.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/arrow/ArrowUtils.java @@ -79,6 +79,8 @@ public static ArrowType toArrowType(TTypeId ttype, JdbcColumnAttributes jdbcColu if (jdbcColumnAttributes != null) { return ArrowType.Decimal.createDecimal( jdbcColumnAttributes.precision, jdbcColumnAttributes.scale, null); + } else { + throw new IllegalStateException("Missing precision and scale where it is mandatory."); } case DATE_TYPE: return new ArrowType.Date(DateUnit.DAY); diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/cli/ColumnBuffer.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/cli/ColumnBuffer.java index 0cdbb9a5e5d..e703cb1f00c 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/cli/ColumnBuffer.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/cli/ColumnBuffer.java @@ -89,6 +89,7 @@ public ColumnBuffer(TColumn colValues) { } } + @SuppressWarnings("unchecked") public ColumnBuffer(TTypeId type, BitSet nulls, Object values) { this.type = type; this.nulls = nulls; diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/FastHiveDecimal.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/FastHiveDecimal.java index 09b46fe4911..41fb9f840b9 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/FastHiveDecimal.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/FastHiveDecimal.java @@ -457,6 +457,7 @@ protected boolean fastScaleByPowerOfTen(int n, FastHiveDecimal fastResult) { fastSignum, fast0, fast1, fast2, fastIntegerDigitCount, fastScale, n, fastResult); } + @SuppressWarnings("deprecation") protected static String fastRoundingModeToString(int roundingMode) { String roundingModeString; switch (roundingMode) { diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/FastHiveDecimalImpl.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/FastHiveDecimalImpl.java index 619371cbfb4..d3dba0f7b7a 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/FastHiveDecimalImpl.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/FastHiveDecimalImpl.java @@ -32,6 +32,7 @@ * vectorization to implement decimals by storing the fast0, fast1, and fast2 longs and the * fastSignum, fastScale, etc ints in the DecimalColumnVector class. */ +@SuppressWarnings("deprecation") public class FastHiveDecimalImpl extends FastHiveDecimal { /** @@ -9369,7 +9370,7 @@ public static String getStackTraceAsSingleLine(StackTraceElement[] stackTrace) { public static String displayBytes(byte[] bytes, int start, int length) { StringBuilder sb = new StringBuilder(); for (int i = start; i < start + length; i++) { - sb.append(String.format("\\%03d", (int) (bytes[i] & 0xff))); + sb.append(String.format("\\%03d", bytes[i] & 0xff)); } return sb.toString(); } diff --git a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/HiveDecimal.java b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/HiveDecimal.java index bd4906ec7df..b8faa2305ca 100644 --- a/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/HiveDecimal.java +++ b/kyuubi-hive-jdbc/src/main/java/org/apache/kyuubi/jdbc/hive/common/HiveDecimal.java @@ -80,6 +80,7 @@ *

              The original V1 public methods and fields are annotated with @HiveDecimalVersionV1; new public * methods and fields are annotated with @HiveDecimalVersionV2. */ +@SuppressWarnings("deprecation") public final class HiveDecimal extends FastHiveDecimal implements Comparable { /* diff --git a/kyuubi-hive-jdbc/src/main/resources/version.properties b/kyuubi-hive-jdbc/src/main/resources/version.properties new file mode 100644 index 00000000000..82ae50cfbf6 --- /dev/null +++ b/kyuubi-hive-jdbc/src/main/resources/version.properties @@ -0,0 +1,18 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +kyuubi.client.version = ${project.version} diff --git a/kyuubi-hive-jdbc/src/test/java/org/apache/kyuubi/jdbc/hive/UtilsTest.java b/kyuubi-hive-jdbc/src/test/java/org/apache/kyuubi/jdbc/hive/UtilsTest.java index c890c873190..b01957b3e43 100644 --- a/kyuubi-hive-jdbc/src/test/java/org/apache/kyuubi/jdbc/hive/UtilsTest.java +++ b/kyuubi-hive-jdbc/src/test/java/org/apache/kyuubi/jdbc/hive/UtilsTest.java @@ -21,9 +21,15 @@ import static org.apache.kyuubi.jdbc.hive.Utils.extractURLComponents; import static org.junit.Assert.assertEquals; +import com.google.common.collect.ImmutableMap; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collection; +import java.util.Map; import java.util.Properties; +import java.util.regex.Pattern; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; @@ -35,23 +41,76 @@ public class UtilsTest { private String expectedPort; private String expectedCatalog; private String expectedDb; + private Map expectedHiveConf; private String uri; @Parameterized.Parameters - public static Collection data() { + public static Collection data() throws UnsupportedEncodingException { return Arrays.asList( - new String[][] { - {"localhost", "10009", null, "db", "jdbc:hive2:///db;k1=v1?k2=v2#k3=v3"}, - {"localhost", "10009", null, "default", "jdbc:hive2:///"}, - {"localhost", "10009", null, "default", "jdbc:kyuubi://"}, - {"localhost", "10009", null, "default", "jdbc:hive2://"}, - {"hostname", "10018", null, "db", "jdbc:hive2://hostname:10018/db;k1=v1?k2=v2#k3=v3"}, + new Object[][] { + { + "localhost", + "10009", + null, + "db", + new ImmutableMap.Builder().put("k2", "v2").build(), + "jdbc:hive2:///db;k1=v1?k2=v2#k3=v3" + }, + { + "localhost", + "10009", + null, + "default", + new ImmutableMap.Builder().build(), + "jdbc:hive2:///" + }, + { + "localhost", + "10009", + null, + "default", + new ImmutableMap.Builder().build(), + "jdbc:kyuubi://" + }, + { + "localhost", + "10009", + null, + "default", + new ImmutableMap.Builder().build(), + "jdbc:hive2://" + }, + { + "hostname", + "10018", + null, + "db", + new ImmutableMap.Builder().put("k2", "v2").build(), + "jdbc:hive2://hostname:10018/db;k1=v1?k2=v2#k3=v3" + }, { "hostname", "10018", "catalog", "db", + new ImmutableMap.Builder().put("k2", "v2").build(), "jdbc:hive2://hostname:10018/catalog/db;k1=v1?k2=v2#k3=v3" + }, + { + "hostname", + "10018", + "catalog", + "db", + new ImmutableMap.Builder() + .put("k2", "v2") + .put("k3", "-Xmx2g -XX:+PrintGCDetails -XX:HeapDumpPath=/heap.hprof") + .build(), + "jdbc:hive2://hostname:10018/catalog/db;k1=v1?" + + URLEncoder.encode( + "k2=v2;k3=-Xmx2g -XX:+PrintGCDetails -XX:HeapDumpPath=/heap.hprof", + StandardCharsets.UTF_8.toString()) + .replaceAll("\\+", "%20") + + "#k4=v4" } }); } @@ -61,11 +120,13 @@ public UtilsTest( String expectedPort, String expectedCatalog, String expectedDb, + Map expectedHiveConf, String uri) { this.expectedHost = expectedHost; this.expectedPort = expectedPort; this.expectedCatalog = expectedCatalog; this.expectedDb = expectedDb; + this.expectedHiveConf = expectedHiveConf; this.uri = uri; } @@ -76,5 +137,12 @@ public void testExtractURLComponents() throws JdbcUriParseException { assertEquals(Integer.parseInt(expectedPort), jdbcConnectionParams1.getPort()); assertEquals(expectedCatalog, jdbcConnectionParams1.getCatalogName()); assertEquals(expectedDb, jdbcConnectionParams1.getDbName()); + assertEquals(expectedHiveConf, jdbcConnectionParams1.getHiveConfs()); + } + + @Test + public void testGetVersion() { + Pattern pattern = Pattern.compile("^\\d+\\.\\d+\\.\\d+.*"); + assert pattern.matcher(Utils.getVersion()).matches(); } } diff --git a/kyuubi-hive-jdbc/src/test/java/org/apache/kyuubi/jdbc/hive/ZooKeeperHiveClientHelperTest.java b/kyuubi-hive-jdbc/src/test/java/org/apache/kyuubi/jdbc/hive/ZooKeeperHiveClientHelperTest.java new file mode 100644 index 00000000000..d1fd78f473e --- /dev/null +++ b/kyuubi-hive-jdbc/src/test/java/org/apache/kyuubi/jdbc/hive/ZooKeeperHiveClientHelperTest.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.jdbc.hive; + +import static org.apache.kyuubi.jdbc.hive.Utils.extractURLComponents; +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Properties; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +@RunWith(Parameterized.class) +public class ZooKeeperHiveClientHelperTest { + + private String uri; + + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList( + new String[][] { + {"jdbc:hive2://hostname:10018/db;zooKeeperNamespace=zookeeper/namespace"}, + {"jdbc:hive2://hostname:10018/db;zooKeeperNamespace=/zookeeper/namespace"}, + {"jdbc:hive2://hostname:10018/db;zooKeeperNamespace=zookeeper/namespace/"}, + {"jdbc:hive2://hostname:10018/db;zooKeeperNamespace=/zookeeper/namespace/"}, + {"jdbc:hive2://hostname:10018/db;zooKeeperNamespace=///zookeeper/namespace///"} + }); + } + + public ZooKeeperHiveClientHelperTest(String uri) { + this.uri = uri; + } + + @Test + public void testGetZooKeeperNamespace() throws JdbcUriParseException { + JdbcConnectionParams jdbcConnectionParams = extractURLComponents(uri, new Properties()); + assertEquals( + "zookeeper/namespace", + ZooKeeperHiveClientHelper.getZooKeeperNamespace(jdbcConnectionParams)); + } +} diff --git a/kyuubi-metrics/pom.xml b/kyuubi-metrics/pom.xml index b8ba40f4762..2edeb73c7ce 100644 --- a/kyuubi-metrics/pom.xml +++ b/kyuubi-metrics/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT kyuubi-metrics_2.12 diff --git a/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/JsonReporterService.scala b/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/JsonReporterService.scala index cb0ef740431..7b172fc1eb9 100644 --- a/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/JsonReporterService.scala +++ b/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/JsonReporterService.scala @@ -65,7 +65,7 @@ class JsonReporterService(registry: MetricRegistry) Files.setPosixFilePermissions(tmpPath, PosixFilePermissions.fromString("rwxr--r--")) Files.move(tmpPath, reportPath, StandardCopyOption.REPLACE_EXISTING) } catch { - case NonFatal(e) => error("Error writing metrics to json file" + reportPath, e) + case NonFatal(e) => error(s"Error writing metrics to json file: $reportPath", e) } finally { if (writer != null) writer.close() } diff --git a/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsConf.scala b/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsConf.scala index cacc15b185e..ad734ced5d7 100644 --- a/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsConf.scala +++ b/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsConf.scala @@ -19,13 +19,12 @@ package org.apache.kyuubi.metrics import java.time.Duration -import org.apache.kyuubi.config.{ConfigBuilder, ConfigEntry, KyuubiConf} +import org.apache.kyuubi.config.ConfigEntry +import org.apache.kyuubi.config.KyuubiConf.buildConf import org.apache.kyuubi.metrics.ReporterType._ object MetricsConf { - private def buildConf(key: String): ConfigBuilder = KyuubiConf.buildConf(key) - val METRICS_ENABLED: ConfigEntry[Boolean] = buildConf("kyuubi.metrics.enabled") .doc("Set to true to enable kyuubi metrics system") @@ -34,12 +33,12 @@ object MetricsConf { .createWithDefault(true) val METRICS_REPORTERS: ConfigEntry[Seq[String]] = buildConf("kyuubi.metrics.reporters") - .doc("A comma separated list for all metrics reporters" + + .doc("A comma-separated list for all metrics reporters" + "

                " + "
              • CONSOLE - ConsoleReporter which outputs measurements to CONSOLE periodically.
              • " + "
              • JMX - JmxReporter which listens for new metrics and exposes them as MBeans.
              • " + "
              • JSON - JsonReporter which outputs measurements to json file periodically.
              • " + - "
              • PROMETHEUS - PrometheusReporter which exposes metrics in prometheus format.
              • " + + "
              • PROMETHEUS - PrometheusReporter which exposes metrics in Prometheus format.
              • " + "
              • SLF4J - Slf4jReporter which outputs measurements to system log periodically.
              • " + "
              ") .version("1.2.0") @@ -58,13 +57,13 @@ object MetricsConf { .createWithDefault(Duration.ofSeconds(5).toMillis) val METRICS_JSON_LOCATION: ConfigEntry[String] = buildConf("kyuubi.metrics.json.location") - .doc("Where the json metrics file located") + .doc("Where the JSON metrics file located") .version("1.2.0") .stringConf .createWithDefault("metrics") val METRICS_JSON_INTERVAL: ConfigEntry[Long] = buildConf("kyuubi.metrics.json.interval") - .doc("How often should report metrics to json file") + .doc("How often should report metrics to JSON file") .version("1.2.0") .timeConf .createWithDefault(Duration.ofSeconds(5).toMillis) diff --git a/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsConstants.scala b/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsConstants.scala index 3f967abe6e6..e97fd28ea25 100644 --- a/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsConstants.scala +++ b/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsConstants.scala @@ -29,6 +29,7 @@ object MetricsConstants { final val EXEC_POOL_ALIVE: String = KYUUBI + "exec.pool.threads.alive" final val EXEC_POOL_ACTIVE: String = KYUUBI + "exec.pool.threads.active" + final val EXEC_POOL_WORK_QUEUE_SIZE: String = KYUUBI + "exec.pool.work_queue.size" final private val CONN = KYUUBI + "connection." final private val THRIFT_HTTP_CONN = KYUUBI + "thrift.http.connection." @@ -61,6 +62,7 @@ object MetricsConstants { final val OPERATION_FAIL: String = OPERATION + "failed" final val OPERATION_TOTAL: String = OPERATION + "total" final val OPERATION_STATE: String = OPERATION + "state" + final val OPERATION_EXEC_TIME: String = OPERATION + "exec_time" final private val BACKEND_SERVICE = KYUUBI + "backend_service." final val BS_FETCH_LOG_ROWS_RATE = BACKEND_SERVICE + "fetch_log_rows_rate" diff --git a/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsSystem.scala b/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsSystem.scala index 2507eb77387..99da1f1b06e 100644 --- a/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsSystem.scala +++ b/kyuubi-metrics/src/main/scala/org/apache/kyuubi/metrics/MetricsSystem.scala @@ -20,7 +20,7 @@ package org.apache.kyuubi.metrics import java.lang.management.ManagementFactory import java.util.concurrent.TimeUnit -import com.codahale.metrics.{Gauge, MetricRegistry} +import com.codahale.metrics.{Gauge, MetricRegistry, Snapshot} import com.codahale.metrics.jvm._ import org.apache.kyuubi.config.KyuubiConf @@ -121,4 +121,8 @@ object MetricsSystem { def meterValue(name: String): Option[Long] = { maybeSystem.map(_.registry.meter(name).getCount) } + + def histogramSnapshot(name: String): Option[Snapshot] = { + maybeSystem.map(_.registry.histogram(name).getSnapshot) + } } diff --git a/kyuubi-rest-client/pom.xml b/kyuubi-rest-client/pom.xml index 6e07b6267c4..a9ceb9bb3cb 100644 --- a/kyuubi-rest-client/pom.xml +++ b/kyuubi-rest-client/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT kyuubi-rest-client @@ -59,6 +59,11 @@ httpclient + + org.apache.httpcomponents + httpmime + + org.apache.hadoop hadoop-client-api @@ -77,6 +82,11 @@ slf4j-api + + org.slf4j + jcl-over-slf4j + + org.apache.logging.log4j log4j-slf4j-impl @@ -110,6 +120,13 @@ + + + true + src/main/resources + + + net.alchim31.maven diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/AdminRestApi.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/AdminRestApi.java index da9782df5b1..c81af593ae4 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/AdminRestApi.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/AdminRestApi.java @@ -22,6 +22,8 @@ import java.util.List; import java.util.Map; import org.apache.kyuubi.client.api.v1.dto.Engine; +import org.apache.kyuubi.client.api.v1.dto.OperationData; +import org.apache.kyuubi.client.api.v1.dto.SessionData; public class AdminRestApi { private KyuubiRestClient client; @@ -44,6 +46,11 @@ public String refreshUserDefaultsConf() { return this.getClient().post(path, null, client.getAuthHeader()); } + public String refreshUnlimitedUsers() { + String path = String.format("%s/%s", API_BASE_PATH, "refresh/unlimited_users"); + return this.getClient().post(path, null, client.getAuthHeader()); + } + public String deleteEngine( String engineType, String shareLevel, String subdomain, String hs2ProxyUser) { Map params = new HashMap<>(); @@ -67,6 +74,31 @@ public List listEngines( return Arrays.asList(result); } + public List listSessions() { + SessionData[] result = + this.getClient() + .get(API_BASE_PATH + "/sessions", null, SessionData[].class, client.getAuthHeader()); + return Arrays.asList(result); + } + + public String closeSession(String sessionHandleStr) { + String url = String.format("%s/sessions/%s", API_BASE_PATH, sessionHandleStr); + return this.getClient().delete(url, null, client.getAuthHeader()); + } + + public List listOperations() { + OperationData[] result = + this.getClient() + .get( + API_BASE_PATH + "/operations", null, OperationData[].class, client.getAuthHeader()); + return Arrays.asList(result); + } + + public String closeOperation(String operationHandleStr) { + String url = String.format("%s/operations/%s", API_BASE_PATH, operationHandleStr); + return this.getClient().delete(url, null, client.getAuthHeader()); + } + private IRestClient getClient() { return this.client.getHttpClient(); } diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/BatchRestApi.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/BatchRestApi.java index a5f27590eb6..f5099568b21 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/BatchRestApi.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/BatchRestApi.java @@ -17,14 +17,12 @@ package org.apache.kyuubi.client; +import java.io.File; import java.util.HashMap; import java.util.Map; -import org.apache.kyuubi.client.api.v1.dto.Batch; -import org.apache.kyuubi.client.api.v1.dto.BatchRequest; -import org.apache.kyuubi.client.api.v1.dto.CloseBatchResponse; -import org.apache.kyuubi.client.api.v1.dto.GetBatchesResponse; -import org.apache.kyuubi.client.api.v1.dto.OperationLog; +import org.apache.kyuubi.client.api.v1.dto.*; import org.apache.kyuubi.client.util.JsonUtils; +import org.apache.kyuubi.client.util.VersionUtils; public class BatchRestApi { @@ -39,10 +37,19 @@ public BatchRestApi(KyuubiRestClient client) { } public Batch createBatch(BatchRequest request) { + setClientVersion(request); String requestBody = JsonUtils.toJson(request); return this.getClient().post(API_BASE_PATH, requestBody, Batch.class, client.getAuthHeader()); } + public Batch createBatch(BatchRequest request, File resourceFile) { + setClientVersion(request); + Map multiPartMap = new HashMap<>(); + multiPartMap.put("batchRequest", new MultiPart(MultiPart.MultiPartType.JSON, request)); + multiPartMap.put("resourceFile", new MultiPart(MultiPart.MultiPartType.FILE, resourceFile)); + return this.getClient().post(API_BASE_PATH, multiPartMap, Batch.class, client.getAuthHeader()); + } + public Batch getBatchById(String batchId) { String path = String.format("%s/%s", API_BASE_PATH, batchId); return this.getClient().get(path, null, Batch.class, client.getAuthHeader()); @@ -92,4 +99,13 @@ public CloseBatchResponse deleteBatch(String batchId, String hs2ProxyUser) { private IRestClient getClient() { return this.client.getHttpClient(); } + + private void setClientVersion(BatchRequest request) { + if (request != null) { + Map newConf = new HashMap<>(); + newConf.putAll(request.getConf()); + newConf.put(VersionUtils.KYUUBI_CLIENT_VERSION_KEY, VersionUtils.getVersion()); + request.setConf(newConf); + } + } } diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/IRestClient.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/IRestClient.java index 50436ef736b..0eaffebd246 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/IRestClient.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/IRestClient.java @@ -18,6 +18,7 @@ package org.apache.kyuubi.client; import java.util.Map; +import org.apache.kyuubi.client.api.v1.dto.MultiPart; /** A underlying http client interface for common rest request. */ public interface IRestClient extends AutoCloseable { @@ -27,8 +28,14 @@ public interface IRestClient extends AutoCloseable { T post(String path, String body, Class type, String authHeader); + T post(String path, Map multiPartMap, Class type, String authHeader); + String post(String path, String body, String authHeader); + T put(String path, String body, Class type, String authHeader); + + String put(String path, String body, String authHeader); + T delete(String path, Map params, Class type, String authHeader); String delete(String path, Map params, String authHeader); diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/KyuubiRestClient.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/KyuubiRestClient.java index a6079e9e0fe..dbcc89b16d3 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/KyuubiRestClient.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/KyuubiRestClient.java @@ -165,13 +165,16 @@ public static class Builder { private String password; - private int socketTimeout = 3000; + // 2 minutes + private int socketTimeout = 2 * 60 * 1000; - private int connectTimeout = 3000; + // 30s + private int connectTimeout = 30 * 1000; private int maxAttempts = 3; - private int attemptWaitTime = 3000; + // 3s + private int attemptWaitTime = 3 * 1000; public Builder(String hostUrl) { if (StringUtils.isBlank(hostUrl)) { diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClient.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClient.java index 7b93f559ed7..6447d547765 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClient.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RestClient.java @@ -17,6 +17,7 @@ package org.apache.kyuubi.client; +import java.io.File; import java.net.ConnectException; import java.net.URI; import java.net.URISyntaxException; @@ -26,15 +27,22 @@ import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpEntity; import org.apache.http.HttpHeaders; +import org.apache.http.NoHttpResponseException; import org.apache.http.client.HttpResponseException; import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.client.methods.RequestBuilder; import org.apache.http.client.utils.URIBuilder; import org.apache.http.conn.ConnectTimeoutException; +import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.entity.mime.content.ContentBody; +import org.apache.http.entity.mime.content.FileBody; +import org.apache.http.entity.mime.content.StringBody; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.util.EntityUtils; +import org.apache.kyuubi.client.api.v1.dto.MultiPart; import org.apache.kyuubi.client.exception.KyuubiRestException; import org.apache.kyuubi.client.exception.RetryableKyuubiRestException; import org.apache.kyuubi.client.util.JsonUtils; @@ -87,6 +95,52 @@ public String post(String path, String body, String authHeader) { return doRequest(buildURI(path), authHeader, postRequestBuilder); } + @Override + public T post( + String path, Map multiPartMap, Class type, String authHeader) { + MultipartEntityBuilder entityBuilder = + MultipartEntityBuilder.create().setCharset(StandardCharsets.UTF_8); + multiPartMap.forEach( + (s, multiPart) -> { + ContentBody contentBody; + Object payload = multiPart.getPayload(); + switch (multiPart.getType()) { + case JSON: + String string = + (payload instanceof String) ? (String) payload : JsonUtils.toJson(payload); + contentBody = new StringBody(string, ContentType.APPLICATION_JSON); + break; + case FILE: + contentBody = new FileBody((File) payload); + break; + default: + throw new RuntimeException("Unsupported multi part type:" + multiPart); + } + entityBuilder.addPart(s, contentBody); + }); + HttpEntity httpEntity = entityBuilder.build(); + RequestBuilder postRequestBuilder = RequestBuilder.post(buildURI(path)); + postRequestBuilder.setHeader(httpEntity.getContentType()); + postRequestBuilder.setEntity(httpEntity); + String responseJson = doRequest(buildURI(path), authHeader, postRequestBuilder); + return JsonUtils.fromJson(responseJson, type); + } + + @Override + public T put(String path, String body, Class type, String authHeader) { + String responseJson = put(path, body, authHeader); + return JsonUtils.fromJson(responseJson, type); + } + + @Override + public String put(String path, String body, String authHeader) { + RequestBuilder putRequestBuilder = RequestBuilder.put(); + if (body != null) { + putRequestBuilder.setEntity(new StringEntity(body, StandardCharsets.UTF_8)); + } + return doRequest(buildURI(path), authHeader, putRequestBuilder); + } + @Override public T delete(String path, Map params, Class type, String authHeader) { String responseJson = delete(path, params, authHeader); @@ -101,14 +155,14 @@ public String delete(String path, Map params, String authHeader) private String doRequest(URI uri, String authHeader, RequestBuilder requestBuilder) { String response; try { + if (requestBuilder.getFirstHeader(HttpHeaders.CONTENT_TYPE) == null) { + requestBuilder.setHeader( + HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); + } if (StringUtils.isNotBlank(authHeader)) { requestBuilder.setHeader(HttpHeaders.AUTHORIZATION, authHeader); } - HttpUriRequest httpRequest = - requestBuilder - .setUri(uri) - .setHeader(HttpHeaders.CONTENT_TYPE, "application/json") - .build(); + HttpUriRequest httpRequest = requestBuilder.setUri(uri).build(); LOG.debug("Executing {} request: {}", httpRequest.getMethod(), uri); @@ -126,7 +180,7 @@ private String doRequest(URI uri, String authHeader, RequestBuilder requestBuild response = httpclient.execute(httpRequest, responseHandler); LOG.debug("Response: {}", response); - } catch (ConnectException | ConnectTimeoutException e) { + } catch (ConnectException | ConnectTimeoutException | NoHttpResponseException e) { // net exception can be retried by connecting to other Kyuubi server throw new RetryableKyuubiRestException("Api request failed for " + uri.toString(), e); } catch (KyuubiRestException rethrow) { diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RetryableRestClient.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RetryableRestClient.java index 6dd378a9ab0..dcd052acae4 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RetryableRestClient.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/RetryableRestClient.java @@ -48,6 +48,7 @@ private RetryableRestClient(List uris, RestClientConf conf) { newRestClient(); } + @SuppressWarnings("rawtypes") public static IRestClient getRestClient(List uris, RestClientConf conf) { RetryableRestClient client = new RetryableRestClient(uris, conf); return (IRestClient) diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/SessionRestApi.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/SessionRestApi.java index fbb424102db..a4c3bb7ab24 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/SessionRestApi.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/SessionRestApi.java @@ -20,7 +20,8 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; -import org.apache.kyuubi.client.api.v1.dto.SessionData; +import org.apache.kyuubi.client.api.v1.dto.*; +import org.apache.kyuubi.client.util.JsonUtils; public class SessionRestApi { @@ -41,6 +42,102 @@ public List listSessions() { return Arrays.asList(result); } + public SessionHandle openSession(SessionOpenRequest sessionOpenRequest) { + return this.getClient() + .post( + API_BASE_PATH, + JsonUtils.toJson(sessionOpenRequest), + SessionHandle.class, + client.getAuthHeader()); + } + + public String closeSession(String sessionHandleStr) { + String path = String.format("%s/%s", API_BASE_PATH, sessionHandleStr); + return this.getClient().delete(path, new HashMap<>(), client.getAuthHeader()); + } + + public KyuubiSessionEvent getSessionEvent(String sessionHandleStr) { + String path = String.format("%s/%s", API_BASE_PATH, sessionHandleStr); + return this.getClient() + .get(path, new HashMap<>(), KyuubiSessionEvent.class, client.getAuthHeader()); + } + + public InfoDetail getSessionInfo(String sessionHandleStr, int infoType) { + String path = String.format("%s/%s/info/%s", API_BASE_PATH, sessionHandleStr, infoType); + return this.getClient().get(path, new HashMap<>(), InfoDetail.class, client.getAuthHeader()); + } + + public int getOpenSessionCount() { + String path = String.format("%s/count", API_BASE_PATH); + return this.getClient() + .get(path, new HashMap<>(), SessionOpenCount.class, client.getAuthHeader()) + .getOpenSessionCount(); + } + + public ExecPoolStatistic getExecPoolStatistic() { + String path = String.format("%s/execPool/statistic", API_BASE_PATH); + return this.getClient() + .get(path, new HashMap<>(), ExecPoolStatistic.class, client.getAuthHeader()); + } + + public OperationHandle executeStatement(String sessionHandleStr, StatementRequest request) { + String path = String.format("%s/%s/operations/statement", API_BASE_PATH, sessionHandleStr); + return this.getClient() + .post(path, JsonUtils.toJson(request), OperationHandle.class, client.getAuthHeader()); + } + + public OperationHandle getTypeInfo(String sessionHandleStr) { + String path = String.format("%s/%s/operations/typeInfo", API_BASE_PATH, sessionHandleStr); + return this.getClient().post(path, "", OperationHandle.class, client.getAuthHeader()); + } + + public OperationHandle getCatalogs(String sessionHandleStr) { + String path = String.format("%s/%s/operations/catalogs", API_BASE_PATH, sessionHandleStr); + return this.getClient().post(path, "", OperationHandle.class, client.getAuthHeader()); + } + + public OperationHandle getSchemas(String sessionHandleStr, GetSchemasRequest request) { + String path = String.format("%s/%s/operations/schemas", API_BASE_PATH, sessionHandleStr); + return this.getClient() + .post(path, JsonUtils.toJson(request), OperationHandle.class, client.getAuthHeader()); + } + + public OperationHandle getTables(String sessionHandleStr, GetTablesRequest request) { + String path = String.format("%s/%s/operations/tables", API_BASE_PATH, sessionHandleStr); + return this.getClient() + .post(path, JsonUtils.toJson(request), OperationHandle.class, client.getAuthHeader()); + } + + public OperationHandle getTableTypes(String sessionHandleStr) { + String path = String.format("%s/%s/operations/tableTypes", API_BASE_PATH, sessionHandleStr); + return this.getClient().post(path, "", OperationHandle.class, client.getAuthHeader()); + } + + public OperationHandle getColumns(String sessionHandleStr, GetColumnsRequest request) { + String path = String.format("%s/%s/operations/columns", API_BASE_PATH, sessionHandleStr); + return this.getClient() + .post(path, JsonUtils.toJson(request), OperationHandle.class, client.getAuthHeader()); + } + + public OperationHandle getFunctions(String sessionHandleStr, GetFunctionsRequest request) { + String path = String.format("%s/%s/operations/functions", API_BASE_PATH, sessionHandleStr); + return this.getClient() + .post(path, JsonUtils.toJson(request), OperationHandle.class, client.getAuthHeader()); + } + + public OperationHandle getPrimaryKeys(String sessionHandleStr, GetPrimaryKeysRequest request) { + String path = String.format("%s/%s/operations/primaryKeys", API_BASE_PATH, sessionHandleStr); + return this.getClient() + .post(path, JsonUtils.toJson(request), OperationHandle.class, client.getAuthHeader()); + } + + public OperationHandle getCrossReference( + String sessionHandleStr, GetCrossReferenceRequest request) { + String path = String.format("%s/%s/operations/crossReference", API_BASE_PATH, sessionHandleStr); + return this.getClient() + .post(path, JsonUtils.toJson(request), OperationHandle.class, client.getAuthHeader()); + } + private IRestClient getClient() { return this.client.getHttpClient(); } diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/Batch.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/Batch.java index 43fbf10af58..b318b709d5e 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/Batch.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/Batch.java @@ -17,6 +17,8 @@ package org.apache.kyuubi.client.api.v1.dto; +import java.util.Collections; +import java.util.Map; import java.util.Objects; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; @@ -35,6 +37,7 @@ public class Batch { private String state; private long createTime; private long endTime; + private Map batchInfo = Collections.emptyMap(); public Batch() {} @@ -51,7 +54,8 @@ public Batch( String kyuubiInstance, String state, long createTime, - long endTime) { + long endTime, + Map batchInfo) { this.id = id; this.user = user; this.batchType = batchType; @@ -65,6 +69,7 @@ public Batch( this.state = state; this.createTime = createTime; this.endTime = endTime; + this.batchInfo = batchInfo; } public String getId() { @@ -171,6 +176,17 @@ public void setEndTime(long endTime) { this.endTime = endTime; } + public Map getBatchInfo() { + if (batchInfo == null) { + return Collections.emptyMap(); + } + return batchInfo; + } + + public void setBatchInfo(Map batchInfo) { + this.batchInfo = batchInfo; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/BatchRequest.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/BatchRequest.java index f10a8fdb5f2..f45821fc232 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/BatchRequest.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/BatchRequest.java @@ -29,8 +29,8 @@ public class BatchRequest { private String resource; private String className; private String name; - private Map conf; - private List args; + private Map conf = Collections.emptyMap(); + private List args = Collections.emptyList(); public BatchRequest() {} @@ -54,8 +54,6 @@ public BatchRequest(String batchType, String resource, String className, String this.resource = resource; this.className = className; this.name = name; - this.conf = Collections.emptyMap(); - this.args = Collections.emptyList(); } public String getBatchType() { diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/ExecPoolStatistic.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/ExecPoolStatistic.java index ee8a9f0072c..a40811f92bf 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/ExecPoolStatistic.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/ExecPoolStatistic.java @@ -24,12 +24,14 @@ public class ExecPoolStatistic { private int execPoolSize; private int execPoolActiveCount; + private int execPoolWorkQueueSize; public ExecPoolStatistic() {} - public ExecPoolStatistic(int execPoolSize, int execPoolActiveCount) { + public ExecPoolStatistic(int execPoolSize, int execPoolActiveCount, int execPoolWorkQueueSize) { this.execPoolSize = execPoolSize; this.execPoolActiveCount = execPoolActiveCount; + this.execPoolWorkQueueSize = execPoolWorkQueueSize; } public int getExecPoolSize() { @@ -48,18 +50,27 @@ public void setExecPoolActiveCount(int execPoolActiveCount) { this.execPoolActiveCount = execPoolActiveCount; } + public int getExecPoolWorkQueueSize() { + return execPoolWorkQueueSize; + } + + public void setExecPoolWorkQueueSize(int execPoolWorkQueueSize) { + this.execPoolWorkQueueSize = execPoolWorkQueueSize; + } + @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ExecPoolStatistic that = (ExecPoolStatistic) o; return getExecPoolSize() == that.getExecPoolSize() - && getExecPoolActiveCount() == that.getExecPoolActiveCount(); + && getExecPoolActiveCount() == that.getExecPoolActiveCount() + && getExecPoolWorkQueueSize() == that.getExecPoolWorkQueueSize(); } @Override public int hashCode() { - return Objects.hash(getExecPoolSize(), getExecPoolActiveCount()); + return Objects.hash(getExecPoolSize(), getExecPoolActiveCount(), getExecPoolWorkQueueSize()); } @Override diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/KyuubiEvent.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/KyuubiEvent.java new file mode 100644 index 00000000000..8de12508914 --- /dev/null +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/KyuubiEvent.java @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.client.api.v1.dto; + +public interface KyuubiEvent {} diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/KyuubiSessionEvent.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/KyuubiSessionEvent.java new file mode 100644 index 00000000000..4c3cbcfd540 --- /dev/null +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/KyuubiSessionEvent.java @@ -0,0 +1,361 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.client.api.v1.dto; + +import java.util.Map; + +public class KyuubiSessionEvent implements KyuubiEvent { + + private String sessionId; + + private int clientVersion; + + private String sessionType; + + private String sessionName; + + private String remoteSessionId; + + private String engineId; + + private String user; + + private String clientIp; + + private String serverIp; + + private Map conf; + + private long eventTime; + + private long openedTime; + + private long startTime; + + private long endTime; + + private int totalOperations; + + private Throwable exception; + + public KyuubiSessionEvent() {} + + public KyuubiSessionEvent( + String sessionId, + int clientVersion, + String sessionType, + String sessionName, + String remoteSessionId, + String engineId, + String user, + String clientIp, + String serverIp, + Map conf, + long eventTime, + long openedTime, + long startTime, + long endTime, + int totalOperations, + Throwable exception) { + this.sessionId = sessionId; + this.clientVersion = clientVersion; + this.sessionType = sessionType; + this.sessionName = sessionName; + this.remoteSessionId = remoteSessionId; + this.engineId = engineId; + this.user = user; + this.clientIp = clientIp; + this.serverIp = serverIp; + this.conf = conf; + this.eventTime = eventTime; + this.openedTime = openedTime; + this.startTime = startTime; + this.endTime = endTime; + this.totalOperations = totalOperations; + this.exception = exception; + } + + public static KyuubiSessionEvent.KyuubiSessionEventBuilder builder() { + return new KyuubiSessionEvent.KyuubiSessionEventBuilder(); + } + + public static class KyuubiSessionEventBuilder { + private String sessionId; + + private int clientVersion; + + private String sessionType; + + private String sessionName; + + private String remoteSessionId; + + private String engineId; + + private String user; + + private String clientIp; + + private String serverIp; + + private Map conf; + + private long eventTime; + + private long openedTime; + + private long startTime; + + private long endTime; + + private int totalOperations; + + private Throwable exception; + + public KyuubiSessionEventBuilder() {} + + public KyuubiSessionEvent.KyuubiSessionEventBuilder sessionId(final String sessionId) { + this.sessionId = sessionId; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder remoteSessionId( + final String remoteSessionId) { + this.remoteSessionId = remoteSessionId; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder clientVersion(final int clientVersion) { + this.clientVersion = clientVersion; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder sessionType(final String sessionType) { + this.sessionType = sessionType; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder sessionName(final String sessionName) { + this.sessionName = sessionName; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder engineId(final String engineId) { + this.engineId = engineId; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder user(final String user) { + this.user = user; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder clientIp(final String clientIp) { + this.clientIp = clientIp; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder serverIp(final String serverIp) { + this.serverIp = serverIp; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder conf(final Map conf) { + this.conf = conf; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder eventTime(final long eventTime) { + this.eventTime = eventTime; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder openedTime(final long openedTime) { + this.openedTime = openedTime; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder startTime(final long startTime) { + this.startTime = startTime; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder endTime(final long endTime) { + this.endTime = endTime; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder totalOperations(final int totalOperations) { + this.totalOperations = totalOperations; + return this; + } + + public KyuubiSessionEvent.KyuubiSessionEventBuilder exception(final Throwable exception) { + this.exception = exception; + return this; + } + + public KyuubiSessionEvent build() { + return new KyuubiSessionEvent( + sessionId, + clientVersion, + sessionType, + sessionName, + remoteSessionId, + engineId, + user, + clientIp, + serverIp, + conf, + eventTime, + openedTime, + startTime, + endTime, + totalOperations, + exception); + } + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public int getClientVersion() { + return clientVersion; + } + + public void setClientVersion(int clientVersion) { + this.clientVersion = clientVersion; + } + + public String getSessionType() { + return sessionType; + } + + public void setSessionType(String sessionType) { + this.sessionType = sessionType; + } + + public String getSessionName() { + return sessionName; + } + + public void setSessionName(String sessionName) { + this.sessionName = sessionName; + } + + public String getRemoteSessionId() { + return remoteSessionId; + } + + public void setRemoteSessionId(String remoteSessionId) { + this.remoteSessionId = remoteSessionId; + } + + public String getEngineId() { + return engineId; + } + + public void setEngineId(String engineId) { + this.engineId = engineId; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public String getClientIp() { + return clientIp; + } + + public void setClientIp(String clientIp) { + this.clientIp = clientIp; + } + + public String getServerIp() { + return serverIp; + } + + public void setServerIp(String serverIp) { + this.serverIp = serverIp; + } + + public Map getConf() { + return conf; + } + + public void setConf(Map conf) { + this.conf = conf; + } + + public long getEventTime() { + return eventTime; + } + + public void setEventTime(long eventTime) { + this.eventTime = eventTime; + } + + public long getOpenedTime() { + return openedTime; + } + + public void setOpenedTime(long openedTime) { + this.openedTime = openedTime; + } + + public long getStartTime() { + return startTime; + } + + public void setStartTime(long startTime) { + this.startTime = startTime; + } + + public long getEndTime() { + return endTime; + } + + public void setEndTime(long endTime) { + this.endTime = endTime; + } + + public int getTotalOperations() { + return totalOperations; + } + + public void setTotalOperations(int totalOperations) { + this.totalOperations = totalOperations; + } + + public Throwable getException() { + return exception; + } + + public void setException(Throwable exception) { + this.exception = exception; + } +} diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/MultiPart.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/MultiPart.java new file mode 100644 index 00000000000..997a629fec1 --- /dev/null +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/MultiPart.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.client.api.v1.dto; + +public class MultiPart { + private MultiPartType type; + private Object payload; + + public enum MultiPartType { + FILE, + JSON + } + + public MultiPart(MultiPartType type, Object obj) { + this.type = type; + this.payload = obj; + } + + public Object getPayload() { + return payload; + } + + public void setPayload(Object payload) { + this.payload = payload; + } + + public MultiPartType getType() { + return type; + } + + public void setType(MultiPartType type) { + this.type = type; + } +} diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/OperationData.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/OperationData.java new file mode 100644 index 00000000000..1b99bb2c690 --- /dev/null +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/OperationData.java @@ -0,0 +1,169 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.client.api.v1.dto; + +import java.util.Objects; +import org.apache.commons.lang3.builder.ReflectionToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +public class OperationData { + private String identifier; + private String statement; + private String state; + private Long createTime; + private Long startTime; + private Long completeTime; + private String exception; + private String sessionId; + private String sessionUser; + private String sessionType; + private String kyuubiInstance; + + public OperationData() {} + + public OperationData( + String identifier, + String statement, + String state, + Long createTime, + Long startTime, + Long completeTime, + String exception, + String sessionId, + String sessionUser, + String sessionType, + String kyuubiInstance) { + this.identifier = identifier; + this.statement = statement; + this.state = state; + this.createTime = createTime; + this.startTime = startTime; + this.completeTime = completeTime; + this.exception = exception; + this.sessionId = sessionId; + this.sessionUser = sessionUser; + this.sessionType = sessionType; + this.kyuubiInstance = kyuubiInstance; + } + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = identifier; + } + + public String getStatement() { + return statement; + } + + public void setStatement(String statement) { + this.statement = statement; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public Long getCreateTime() { + return createTime; + } + + public void setCreateTime(Long createTime) { + this.createTime = createTime; + } + + public Long getStartTime() { + return startTime; + } + + public void setStartTime(Long startTime) { + this.startTime = startTime; + } + + public Long getCompleteTime() { + return completeTime; + } + + public void setCompleteTime(Long completeTime) { + this.completeTime = completeTime; + } + + public String getException() { + return exception; + } + + public void setException(String exception) { + this.exception = exception; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getSessionUser() { + return sessionUser; + } + + public void setSessionUser(String sessionUser) { + this.sessionUser = sessionUser; + } + + public String getSessionType() { + return sessionType; + } + + public void setSessionType(String sessionType) { + this.sessionType = sessionType; + } + + public String getKyuubiInstance() { + return kyuubiInstance; + } + + public void setKyuubiInstance(String kyuubiInstance) { + this.kyuubiInstance = kyuubiInstance; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SessionData that = (SessionData) o; + return Objects.equals(getIdentifier(), that.getIdentifier()); + } + + @Override + public int hashCode() { + return Objects.hash(getIdentifier()); + } + + @Override + public String toString() { + return ReflectionToStringBuilder.toString(this, ToStringStyle.JSON_STYLE); + } +} diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/OperationHandle.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/OperationHandle.java new file mode 100644 index 00000000000..394e6c157c7 --- /dev/null +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/OperationHandle.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.client.api.v1.dto; + +import java.util.Objects; +import java.util.UUID; +import org.apache.commons.lang3.builder.ReflectionToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +public class OperationHandle { + + private UUID identifier; + + public OperationHandle() {} + + public OperationHandle(UUID identifier) { + this.identifier = identifier; + } + + public UUID getIdentifier() { + return identifier; + } + + public void setIdentifier(UUID identifier) { + this.identifier = identifier; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OperationHandle that = (OperationHandle) o; + return Objects.equals(identifier, that.identifier); + } + + @Override + public int hashCode() { + return Objects.hash(identifier); + } + + @Override + public String toString() { + return ReflectionToStringBuilder.toString(this, ToStringStyle.JSON_STYLE); + } +} diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/SessionData.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/SessionData.java index bae6f39dabd..ae7dfdec984 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/SessionData.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/SessionData.java @@ -31,6 +31,10 @@ public class SessionData { private Long createTime; private Long duration; private Long idleTime; + private String exception; + private String sessionType; + private String kyuubiInstance; + private String engineId; public SessionData() {} @@ -41,7 +45,11 @@ public SessionData( Map conf, Long createTime, Long duration, - Long idleTime) { + Long idleTime, + String exception, + String sessionType, + String kyuubiInstance, + String engineId) { this.identifier = identifier; this.user = user; this.ipAddr = ipAddr; @@ -49,6 +57,10 @@ public SessionData( this.createTime = createTime; this.duration = duration; this.idleTime = idleTime; + this.exception = exception; + this.sessionType = sessionType; + this.kyuubiInstance = kyuubiInstance; + this.engineId = engineId; } public String getIdentifier() { @@ -110,6 +122,38 @@ public void setIdleTime(Long idleTime) { this.idleTime = idleTime; } + public String getException() { + return exception; + } + + public void setException(String exception) { + this.exception = exception; + } + + public String getSessionType() { + return sessionType; + } + + public void setSessionType(String sessionType) { + this.sessionType = sessionType; + } + + public String getKyuubiInstance() { + return kyuubiInstance; + } + + public void setKyuubiInstance(String kyuubiInstance) { + this.kyuubiInstance = kyuubiInstance; + } + + public String getEngineId() { + return engineId; + } + + public void setEngineId(String engineId) { + this.engineId = engineId; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/SessionOpenRequest.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/SessionOpenRequest.java index 2d23aac5717..06eb29e9723 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/SessionOpenRequest.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/SessionOpenRequest.java @@ -24,24 +24,14 @@ import org.apache.commons.lang3.builder.ToStringStyle; public class SessionOpenRequest { - private int protocolVersion; private Map configs; public SessionOpenRequest() {} - public SessionOpenRequest(int protocolVersion, Map configs) { - this.protocolVersion = protocolVersion; + public SessionOpenRequest(Map configs) { this.configs = configs; } - public int getProtocolVersion() { - return protocolVersion; - } - - public void setProtocolVersion(int protocolVersion) { - this.protocolVersion = protocolVersion; - } - public Map getConfigs() { if (null == configs) { return Collections.emptyMap(); @@ -58,13 +48,12 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SessionOpenRequest that = (SessionOpenRequest) o; - return getProtocolVersion() == that.getProtocolVersion() - && Objects.equals(getConfigs(), that.getConfigs()); + return Objects.equals(getConfigs(), that.getConfigs()); } @Override public int hashCode() { - return Objects.hash(getProtocolVersion(), getConfigs()); + return Objects.hash(getConfigs()); } @Override diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/StatementRequest.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/StatementRequest.java index 436017f3c1e..f2dc060d5ec 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/StatementRequest.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/StatementRequest.java @@ -17,6 +17,8 @@ package org.apache.kyuubi.client.api.v1.dto; +import java.util.Collections; +import java.util.Map; import java.util.Objects; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; @@ -25,13 +27,20 @@ public class StatementRequest { private String statement; private boolean runAsync; private Long queryTimeout; + private Map confOverlay; public StatementRequest() {} public StatementRequest(String statement, boolean runAsync, Long queryTimeout) { + this(statement, runAsync, queryTimeout, Collections.emptyMap()); + } + + public StatementRequest( + String statement, boolean runAsync, Long queryTimeout, Map confOverlay) { this.statement = statement; this.runAsync = runAsync; this.queryTimeout = queryTimeout; + this.confOverlay = confOverlay; } public String getStatement() { @@ -58,6 +67,17 @@ public void setQueryTimeout(Long queryTimeout) { this.queryTimeout = queryTimeout; } + public Map getConfOverlay() { + if (confOverlay == null) { + return Collections.emptyMap(); + } + return confOverlay; + } + + public void setConfOverlay(Map confOverlay) { + this.confOverlay = confOverlay; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/VersionInfo.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/VersionInfo.java index 427272f4195..5749c4e32ad 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/VersionInfo.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/api/v1/dto/VersionInfo.java @@ -17,6 +17,8 @@ package org.apache.kyuubi.client.api.v1.dto; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Objects; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; @@ -26,10 +28,13 @@ public class VersionInfo { public VersionInfo() {} - public VersionInfo(String version) { + // Explicitly specifies JsonProperty to be compatible if disable auto detect feature + @JsonCreator + public VersionInfo(@JsonProperty("version") String version) { this.version = version; } + @JsonProperty public String getVersion() { return version; } diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/BatchUtils.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/BatchUtils.java index 59f5967a0a6..f7efaad9dc3 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/BatchUtils.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/BatchUtils.java @@ -20,6 +20,7 @@ import java.util.Arrays; import java.util.List; import java.util.Locale; +import org.apache.kyuubi.client.api.v1.dto.Batch; public final class BatchUtils { /** The batch has not been submitted to resource manager yet. */ @@ -40,6 +41,10 @@ public final class BatchUtils { public static List terminalBatchStates = Arrays.asList(FINISHED_STATE, ERROR_STATE, CANCELED_STATE); + public static String KYUUBI_BATCH_ID_KEY = "kyuubi.batch.id"; + + public static String KYUUBI_BATCH_DUPLICATED_KEY = "kyuubi.batch.duplicated"; + public static boolean isPendingState(String state) { return PENDING_STATE.equalsIgnoreCase(state); } @@ -55,4 +60,8 @@ public static boolean isFinishedState(String state) { public static boolean isTerminalState(String state) { return state != null && terminalBatchStates.contains(state.toUpperCase(Locale.ROOT)); } + + public static boolean isDuplicatedSubmission(Batch batch) { + return "true".equalsIgnoreCase(batch.getBatchInfo().get(KYUUBI_BATCH_DUPLICATED_KEY)); + } } diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/JsonUtils.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/JsonUtils.java index f42849166d3..855a152803f 100644 --- a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/JsonUtils.java +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/JsonUtils.java @@ -17,12 +17,14 @@ package org.apache.kyuubi.client.util; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kyuubi.client.exception.KyuubiRestException; public final class JsonUtils { - private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final ObjectMapper MAPPER = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); public static String toJson(Object object) { try { diff --git a/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/VersionUtils.java b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/VersionUtils.java new file mode 100644 index 00000000000..bcabca5b9f8 --- /dev/null +++ b/kyuubi-rest-client/src/main/java/org/apache/kyuubi/client/util/VersionUtils.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.client.util; + +import java.util.Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class VersionUtils { + static final Logger LOG = LoggerFactory.getLogger(VersionUtils.class); + + public static final String KYUUBI_CLIENT_VERSION_KEY = "kyuubi.client.version"; + private static String KYUUBI_CLIENT_VERSION; + + public static synchronized String getVersion() { + if (KYUUBI_CLIENT_VERSION == null) { + try { + Properties prop = new Properties(); + prop.load(VersionUtils.class.getClassLoader().getResourceAsStream("version.properties")); + KYUUBI_CLIENT_VERSION = prop.getProperty(KYUUBI_CLIENT_VERSION_KEY, "unknown"); + } catch (Exception e) { + LOG.error("Error getting kyuubi client version", e); + KYUUBI_CLIENT_VERSION = "unknown"; + } + } + return KYUUBI_CLIENT_VERSION; + } +} diff --git a/kyuubi-rest-client/src/main/resources/version.properties b/kyuubi-rest-client/src/main/resources/version.properties new file mode 100644 index 00000000000..82ae50cfbf6 --- /dev/null +++ b/kyuubi-rest-client/src/main/resources/version.properties @@ -0,0 +1,18 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +kyuubi.client.version = ${project.version} diff --git a/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/RestClientTestUtils.java b/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/RestClientTestUtils.java index 82413e2a40e..1ac0278bf08 100644 --- a/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/RestClientTestUtils.java +++ b/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/RestClientTestUtils.java @@ -45,35 +45,31 @@ public static CloseBatchResponse generateTestCloseBatchResp() { } public static Batch generateTestBatch(String id) { - Batch batch = - new Batch( - id, - TEST_USERNAME, - "spark", - "batch_name", - 0, - id, - null, - "RUNNING", - null, - "192.168.31.130:64573", - "RUNNING", - BATCH_CREATE_TIME, - 0); - - return batch; + return new Batch( + id, + TEST_USERNAME, + "spark", + "batch_name", + 0, + id, + null, + "RUNNING", + null, + "192.168.31.130:64573", + "RUNNING", + BATCH_CREATE_TIME, + 0, + Collections.emptyMap()); } public static BatchRequest generateTestBatchRequest() { - BatchRequest batchRequest = - new BatchRequest( - "spark", - "/MySpace/kyuubi-spark-sql-engine_2.12-1.6.0-SNAPSHOT.jar", - "org.apache.kyuubi.engine.spark.SparkSQLEngine", - "test_batch", - Collections.singletonMap("spark.driver.memory", "16m"), - Collections.emptyList()); - return batchRequest; + return new BatchRequest( + "spark", + "/MySpace/kyuubi-spark-sql-engine_2.12-1.6.0-SNAPSHOT.jar", + "org.apache.kyuubi.engine.spark.SparkSQLEngine", + "test_batch", + Collections.singletonMap("spark.driver.memory", "16m"), + Collections.emptyList()); } public static GetBatchesResponse generateTestBatchesResponse() { @@ -87,9 +83,8 @@ public static GetBatchesResponse generateTestBatchesResponse() { public static OperationLog generateTestOperationLog() { List logs = Arrays.asList( - "13:15:13.523 INFO org.apache.curator.framework.state." - + "ConnectionStateManager: State change: CONNECTED", - "13:15:13.528 INFO org.apache.kyuubi." + "engine.EngineRef: Launching engine:"); + "13:15:13.523 INFO ConnectionStateManager: State change: CONNECTED", + "13:15:13.528 INFO EngineRef: Launching engine:"); return new OperationLog(logs, 2); } } diff --git a/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/util/VersionUtilsTest.java b/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/util/VersionUtilsTest.java new file mode 100644 index 00000000000..d4675f34039 --- /dev/null +++ b/kyuubi-rest-client/src/test/java/org/apache/kyuubi/client/util/VersionUtilsTest.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.client.util; + +import java.util.regex.Pattern; +import org.junit.Test; + +public class VersionUtilsTest { + + @Test + public void testGetClientVersion() { + Pattern pattern = Pattern.compile("^\\d+\\.\\d+\\.\\d+.*"); + assert pattern.matcher(VersionUtils.getVersion()).matches(); + } +} diff --git a/kyuubi-server/pom.xml b/kyuubi-server/pom.xml index 748d8d02819..7408ac5dd00 100644 --- a/kyuubi-server/pom.xml +++ b/kyuubi-server/pom.xml @@ -21,7 +21,7 @@ org.apache.kyuubi kyuubi-parent - 1.7.0-SNAPSHOT + 1.8.0-SNAPSHOT kyuubi-server_2.12 @@ -36,6 +36,12 @@ ${project.version} + + org.apache.kyuubi + kyuubi-hive-jdbc + ${project.version} + + org.apache.kyuubi kyuubi-events_${scala.binary.version} @@ -92,6 +98,11 @@ kubernetes-client + + io.fabric8 + kubernetes-httpclient-okhttp + + org.apache.hive hive-metastore @@ -216,6 +227,21 @@ jersey-media-json-jackson + + org.glassfish.jersey.media + jersey-media-multipart + + + + com.fasterxml.jackson.core + jackson-databind + + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + + com.zaxxer HikariCP @@ -490,6 +516,12 @@ jetcd-launcher test + + + com.vladsch.flexmark + flexmark-all + test + @@ -522,6 +554,47 @@
              + + + com.github.eirslett + frontend-maven-plugin + + web-ui + + + + install node and pnpm + + install-node-and-pnpm + + + ${webui.skip} + + + + pnpm install + + pnpm + + generate-resources + + ${webui.skip} + install + + + + pnpm run build + + pnpm + + package + + ${webui.skip} + run build + + + +
              target/scala-${scala.binary.version}/classes target/scala-${scala.binary.version}/test-classes diff --git a/kyuubi-server/src/main/antlr4/org/apache/kyuubi/sql/KyuubiTrinoFeBaseLexer.g4 b/kyuubi-server/src/main/antlr4/org/apache/kyuubi/sql/KyuubiTrinoFeBaseLexer.g4 index 0b9543a430c..83810a073c7 100644 --- a/kyuubi-server/src/main/antlr4/org/apache/kyuubi/sql/KyuubiTrinoFeBaseLexer.g4 +++ b/kyuubi-server/src/main/antlr4/org/apache/kyuubi/sql/KyuubiTrinoFeBaseLexer.g4 @@ -43,6 +43,10 @@ FALSE: 'FALSE'; LIKE: 'LIKE'; IN: 'IN'; WHERE: 'WHERE'; +EXECUTE: 'EXECUTE'; +PREPARE: 'PREPARE'; +DEALLOCATE: 'DEALLOCATE'; +USING: 'USING'; ESCAPE: 'ESCAPE'; AUTO_INCREMENT: 'AUTO_INCREMENT'; @@ -97,6 +101,21 @@ SCOPE_TABLE: 'SCOPE_TABLE'; SOURCE_DATA_TYPE: 'SOURCE_DATA_TYPE'; IS_AUTOINCREMENT: 'IS_AUTOINCREMENT'; IS_GENERATEDCOLUMN: 'IS_GENERATEDCOLUMN'; +VARCHAR: 'VARCHAR'; +TINYINT: 'TINYINT'; +SMALLINT: 'SMALLINT'; +INTEGER: 'INTEGER'; +BIGINT: 'BIGINT'; +REAL: 'REAL'; +DOUBLE: 'DOUBLE'; +DECIMAL: 'DECIMAL'; +DATE: 'DATE'; +TIME: 'TIME'; +TIMESTAMP: 'TIMESTAMP'; +CAST: 'CAST'; +AS: 'AS'; +KEY_SEQ: 'KEY_SEQ'; +PK_NAME: 'PK_NAME'; fragment SEARCH_STRING_ESCAPE: '\'' '\\' '\''; @@ -108,6 +127,10 @@ STRING : '\'' ( ~'\'' | '\'\'' )* '\'' ; +STRING_MARK + : '\'' + ; + SIMPLE_COMMENT : '--' ~[\r\n]* '\r'? '\n'? -> channel(HIDDEN) ; @@ -119,6 +142,10 @@ BRACKETED_COMMENT WS : [ \r\n\t]+ -> channel(HIDDEN) ; +IDENTIFIER + : [A-Za-z_$0-9\u0080-\uFFFF]*?[A-Za-z_$\u0080-\uFFFF]+?[A-Za-z_$0-9\u0080-\uFFFF]* + ; + // Catch-all for anything we can't recognize. // We use this to be able to ignore and recover all the text // when splitting statements with DelimiterLexer diff --git a/kyuubi-server/src/main/antlr4/org/apache/kyuubi/sql/KyuubiTrinoFeBaseParser.g4 b/kyuubi-server/src/main/antlr4/org/apache/kyuubi/sql/KyuubiTrinoFeBaseParser.g4 index 590c4378d52..72811e59231 100644 --- a/kyuubi-server/src/main/antlr4/org/apache/kyuubi/sql/KyuubiTrinoFeBaseParser.g4 +++ b/kyuubi-server/src/main/antlr4/org/apache/kyuubi/sql/KyuubiTrinoFeBaseParser.g4 @@ -47,9 +47,27 @@ statement SOURCE_DATA_TYPE COMMA IS_AUTOINCREMENT COMMA IS_GENERATEDCOLUMN FROM SYSTEM_JDBC_COLUMNS (WHERE tableCatalogFilter? AND? tableSchemaFilter? AND? tableNameFilter? AND? colNameFilter?)? ORDER BY TABLE_CAT COMMA TABLE_SCHEM COMMA TABLE_NAME COMMA ORDINAL_POSITION #getColumns + | SELECT CAST LEFT_PAREN NULL AS VARCHAR RIGHT_PAREN TABLE_CAT COMMA + CAST LEFT_PAREN NULL AS VARCHAR RIGHT_PAREN TABLE_SCHEM COMMA + CAST LEFT_PAREN NULL AS VARCHAR RIGHT_PAREN TABLE_NAME COMMA + CAST LEFT_PAREN NULL AS VARCHAR RIGHT_PAREN COLUMN_NAME COMMA + CAST LEFT_PAREN NULL AS SMALLINT RIGHT_PAREN KEY_SEQ COMMA + CAST LEFT_PAREN NULL AS VARCHAR RIGHT_PAREN PK_NAME + WHERE FALSE #getPrimaryKeys + | EXECUTE IDENTIFIER (USING parameterList)? #execute + | PREPARE IDENTIFIER FROM statement #prepare + | DEALLOCATE PREPARE IDENTIFIER #deallocate | .*? #passThrough ; +anyStr + : ( ~',' )* + ; + +parameterList + : (TINYINT|SMALLINT|INTEGER|BIGINT|DOUBLE|REAL|DECIMAL|DATE|TIME|TIMESTAMP)? anyStr (',' (TINYINT|SMALLINT|INTEGER|BIGINT|DOUBLE|REAL|DECIMAL|DATE|TIME|TIMESTAMP)? anyStr)* + ; + tableCatalogFilter : (TABLE_CAT | TABLE_CATALOG) IS NULL #nullCatalog | (TABLE_CAT | TABLE_CATALOG) EQ catalog=STRING+ #catalogFilter diff --git a/kyuubi-server/src/main/resources/dist/index.html b/kyuubi-server/src/main/resources/dist/index.html new file mode 100644 index 00000000000..ab54fc14a6a --- /dev/null +++ b/kyuubi-server/src/main/resources/dist/index.html @@ -0,0 +1,28 @@ + + + + + + + Apache Kyuubi Dashboard + + +
              This is a dummy page for development.
              + + diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/client/KyuubiSyncThriftClient.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/client/KyuubiSyncThriftClient.scala index babe737456c..8b8561fa99f 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/client/KyuubiSyncThriftClient.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/client/KyuubiSyncThriftClient.scala @@ -17,6 +17,7 @@ package org.apache.kyuubi.client +import java.util.UUID import java.util.concurrent.{ExecutorService, ScheduledExecutorService, TimeUnit} import java.util.concurrent.locks.ReentrantLock @@ -53,8 +54,12 @@ class KyuubiSyncThriftClient private ( private val lock = new ReentrantLock() + // Visible for testing. + private[kyuubi] def remoteSessionHandle: TSessionHandle = _remoteSessionHandle + @volatile private var _aliveProbeSessionHandle: TSessionHandle = _ @volatile private var remoteEngineBroken: Boolean = false + @volatile private var clientClosedOnEngineBroken: Boolean = false private val engineAliveProbeClient = engineAliveProbeProtocol.map(new TCLIService.Client(_)) private var engineAliveThreadPool: ScheduledExecutorService = _ @volatile private var engineLastAlive: Long = _ @@ -105,6 +110,18 @@ class KyuubiSyncThriftClient private ( } } else { shutdownAsyncRequestExecutor() + warn(s"Removing Clients for ${_remoteSessionHandle}") + Seq(protocol).union(engineAliveProbeProtocol.toSeq).foreach { tProtocol => + Utils.tryLogNonFatalError { + if (tProtocol.getTransport.isOpen) { + tProtocol.getTransport.close() + } + } + clientClosedOnEngineBroken = true + Option(engineAliveThreadPool).foreach { pool => + ThreadUtils.shutdown(pool, Duration(engineAliveProbeInterval, TimeUnit.MILLISECONDS)) + } + } } } } @@ -180,7 +197,10 @@ class KyuubiSyncThriftClient private ( engineAliveProbeClient.foreach { aliveProbeClient => val sessionName = SessionHandle.apply(_remoteSessionHandle).identifier + "_aliveness_probe" Utils.tryLogNonFatalError { - req.setConfiguration((configs ++ Map(KyuubiConf.SESSION_NAME.key -> sessionName)).asJava) + req.setConfiguration((configs ++ Map( + KyuubiConf.SESSION_NAME.key -> sessionName, + KYUUBI_SESSION_HANDLE_KEY -> UUID.randomUUID().toString, + KyuubiConf.ENGINE_SESSION_INITIALIZE_SQL.key -> "")).asJava) val resp = aliveProbeClient.OpenSession(req) ThriftUtils.verifyTStatus(resp.getStatus) _aliveProbeSessionHandle = resp.getSessionHandle @@ -192,6 +212,7 @@ class KyuubiSyncThriftClient private ( } def closeSession(): Unit = { + if (clientClosedOnEngineBroken) return try { if (_remoteSessionHandle != null) { val req = new TCloseSessionReq(_remoteSessionHandle) diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/ApplicationOperation.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/ApplicationOperation.scala index 93d495895ad..a2b3d0f7616 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/ApplicationOperation.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/ApplicationOperation.scala @@ -56,9 +56,10 @@ trait ApplicationOperation { * Get the engine/application status by the unique application tag * * @param tag the unique application tag for engine instance. + * @param submitTime engine submit to resourceManager time * @return [[ApplicationInfo]] */ - def getApplicationInfoByTag(tag: String): ApplicationInfo + def getApplicationInfoByTag(tag: String, submitTime: Option[Long] = None): ApplicationInfo } object ApplicationState extends Enumeration { @@ -99,6 +100,11 @@ case class ApplicationInfo( } } +object ApplicationInfo { + val NOT_FOUND: ApplicationInfo = ApplicationInfo(null, null, ApplicationState.NOT_FOUND) + val UNKNOWN: ApplicationInfo = ApplicationInfo(null, null, ApplicationState.UNKNOWN) +} + object ApplicationOperation { val NOT_FOUND = "APPLICATION_NOT_FOUND" } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/EngineRef.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/EngineRef.scala index 565f41ff295..63b37f1c5d8 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/EngineRef.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/EngineRef.scala @@ -19,6 +19,7 @@ package org.apache.kyuubi.engine import java.util.concurrent.TimeUnit +import scala.collection.JavaConverters._ import scala.util.Random import com.codahale.metrics.MetricRegistry @@ -28,8 +29,9 @@ import org.apache.kyuubi.{KYUUBI_VERSION, KyuubiSQLException, Logging, Utils} import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf._ import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_ENGINE_SUBMIT_TIME_KEY -import org.apache.kyuubi.engine.EngineType.{EngineType, FLINK_SQL, HIVE_SQL, JDBC, SPARK_SQL, TRINO} +import org.apache.kyuubi.engine.EngineType._ import org.apache.kyuubi.engine.ShareLevel.{CONNECTION, GROUP, SERVER, ShareLevel} +import org.apache.kyuubi.engine.chat.ChatProcessBuilder import org.apache.kyuubi.engine.flink.FlinkProcessBuilder import org.apache.kyuubi.engine.hive.HiveProcessBuilder import org.apache.kyuubi.engine.jdbc.JdbcProcessBuilder @@ -40,6 +42,7 @@ import org.apache.kyuubi.ha.client.{DiscoveryClient, DiscoveryClientProvider, Di import org.apache.kyuubi.metrics.MetricsConstants.{ENGINE_FAIL, ENGINE_TIMEOUT, ENGINE_TOTAL} import org.apache.kyuubi.metrics.MetricsSystem import org.apache.kyuubi.operation.log.OperationLog +import org.apache.kyuubi.plugin.GroupProvider /** * The description and functionality of an engine at server side @@ -51,7 +54,7 @@ import org.apache.kyuubi.operation.log.OperationLog private[kyuubi] class EngineRef( conf: KyuubiConf, user: String, - primaryGroup: String, + groupProvider: GroupProvider, engineRefId: String, engineManager: KyuubiApplicationManager) extends Logging { @@ -74,7 +77,7 @@ private[kyuubi] class EngineRef( private val enginePoolIgnoreSubdomain: Boolean = conf.get(ENGINE_POOL_IGNORE_SUBDOMAIN) - private val enginePoolBalancePolicy: String = conf.get(ENGINE_POOL_BALANCE_POLICY) + private val enginePoolSelectPolicy: String = conf.get(ENGINE_POOL_SELECT_POLICY) // In case the multi kyuubi instances have the small gap of timeout, here we add // a small amount of time for timeout @@ -82,10 +85,12 @@ private[kyuubi] class EngineRef( private var builder: ProcBuilder = _ + private[kyuubi] def getEngineRefId(): String = engineRefId + // Launcher of the engine private[kyuubi] val appUser: String = shareLevel match { case SERVER => Utils.currentUser - case GROUP => primaryGroup + case GROUP => groupProvider.primaryGroup(user, conf.getAll.asJava) case _ => user } @@ -97,12 +102,11 @@ private[kyuubi] class EngineRef( warn(s"Request engine pool size($clientPoolSize) exceeds, fallback to " + s"system threshold $poolThreshold") } - val seqNum = enginePoolBalancePolicy match { + val seqNum = enginePoolSelectPolicy match { case "POLLING" => val snPath = DiscoveryPaths.makePath( - s"${serverSpace}_${KYUUBI_VERSION}_${shareLevel}_$engineType", - "seq_num", + s"${serverSpace}_${KYUUBI_VERSION}_${shareLevel}_${engineType}_seqNum", appUser, clientPoolName) DiscoveryClientProvider.withDiscoveryClient(conf) { client => @@ -159,8 +163,7 @@ private[kyuubi] class EngineRef( case _ => val lockPath = DiscoveryPaths.makePath( - s"${serverSpace}_${shareLevel}_$engineType", - "lock", + s"${serverSpace}_${KYUUBI_VERSION}_${shareLevel}_${engineType}_lock", appUser, subdomain) discoveryClient.tryWithLock( @@ -192,6 +195,8 @@ private[kyuubi] class EngineRef( new HiveProcessBuilder(appUser, conf, engineRefId, extraEngineLog) case JDBC => new JdbcProcessBuilder(appUser, conf, engineRefId, extraEngineLog) + case CHAT => + new ChatProcessBuilder(appUser, conf, engineRefId, extraEngineLog) } MetricsSystem.tracing(_.incCount(ENGINE_TOTAL)) @@ -218,7 +223,10 @@ private[kyuubi] class EngineRef( // check the engine application state from engine manager and fast fail on engine terminate if (exitValue == Some(0)) { Option(engineManager).foreach { engineMgr => - engineMgr.getApplicationInfo(builder.clusterManager(), engineRefId).foreach { appInfo => + engineMgr.getApplicationInfo( + builder.clusterManager(), + engineRefId, + Some(started)).foreach { appInfo => if (ApplicationState.isTerminated(appInfo.state)) { MetricsSystem.tracing { ms => ms.incCount(MetricRegistry.name(ENGINE_FAIL, appUser)) diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/JpsApplicationOperation.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/JpsApplicationOperation.scala index bd482b86bf5..ce2e054617a 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/JpsApplicationOperation.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/JpsApplicationOperation.scala @@ -84,7 +84,7 @@ class JpsApplicationOperation extends ApplicationOperation { killJpsApplicationByTag(tag, true) } - override def getApplicationInfoByTag(tag: String): ApplicationInfo = { + override def getApplicationInfoByTag(tag: String, submitTime: Option[Long]): ApplicationInfo = { val commandOption = getEngine(tag) if (commandOption.nonEmpty) { val idAndCmd = commandOption.get diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/KubernetesApplicationOperation.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/KubernetesApplicationOperation.scala index bee69b11762..83792f52f79 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/KubernetesApplicationOperation.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/KubernetesApplicationOperation.scala @@ -17,30 +17,54 @@ package org.apache.kyuubi.engine -import io.fabric8.kubernetes.api.model.{Pod, PodList} +import java.util.concurrent.{ConcurrentHashMap, TimeUnit} + +import com.google.common.cache.{Cache, CacheBuilder, RemovalNotification} +import io.fabric8.kubernetes.api.model.Pod import io.fabric8.kubernetes.client.KubernetesClient -import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable +import io.fabric8.kubernetes.client.informers.{ResourceEventHandler, SharedIndexInformer} -import org.apache.kyuubi.Logging +import org.apache.kyuubi.{Logging, Utils} import org.apache.kyuubi.config.KyuubiConf -import org.apache.kyuubi.engine.ApplicationState.{ApplicationState, FAILED, FINISHED, PENDING, RUNNING, UNKNOWN} -import org.apache.kyuubi.engine.KubernetesApplicationOperation.{toApplicationState, SPARK_APP_ID_LABEL} +import org.apache.kyuubi.engine.ApplicationState.{isTerminated, ApplicationState, FAILED, FINISHED, NOT_FOUND, PENDING, RUNNING, UNKNOWN} +import org.apache.kyuubi.engine.KubernetesApplicationOperation.{toApplicationState, LABEL_KYUUBI_UNIQUE_KEY, SPARK_APP_ID_LABEL} import org.apache.kyuubi.util.KubernetesUtils class KubernetesApplicationOperation extends ApplicationOperation with Logging { @volatile private var kubernetesClient: KubernetesClient = _ - private var jpsOperation: JpsApplicationOperation = _ + private var enginePodInformer: SharedIndexInformer[Pod] = _ + private var submitTimeout: Long = _ - override def initialize(conf: KyuubiConf): Unit = { - jpsOperation = new JpsApplicationOperation - jpsOperation.initialize(conf) + // key is kyuubi_unique_key + private val appInfoStore: ConcurrentHashMap[String, ApplicationInfo] = + new ConcurrentHashMap[String, ApplicationInfo] + // key is kyuubi_unique_key + private var cleanupTerminatedAppInfoTrigger: Cache[String, ApplicationState] = _ + override def initialize(conf: KyuubiConf): Unit = { info("Start initializing Kubernetes Client.") kubernetesClient = KubernetesUtils.buildKubernetesClient(conf) match { case Some(client) => info(s"Initialized Kubernetes Client connect to: ${client.getMasterUrl}") + submitTimeout = conf.get(KyuubiConf.ENGINE_SUBMIT_TIMEOUT) + // Disable resync, see https://github.com/fabric8io/kubernetes-client/discussions/5015 + enginePodInformer = client.pods() + .withLabel(LABEL_KYUUBI_UNIQUE_KEY) + .inform(new SparkEnginePodEventHandler) + info("Start Kubernetes Client Informer.") + // Defer cleaning terminated application information + val retainPeriod = conf.get(KyuubiConf.KUBERNETES_TERMINATED_APPLICATION_RETAIN_PERIOD) + cleanupTerminatedAppInfoTrigger = CacheBuilder.newBuilder() + .expireAfterWrite(retainPeriod, TimeUnit.MILLISECONDS) + .removalListener((notification: RemovalNotification[String, ApplicationState]) => { + Option(appInfoStore.remove(notification.getKey)).foreach { removed => + info(s"Remove terminated application ${removed.id} with " + + s"tag ${notification.getKey} and state ${removed.state}") + } + }) + .build() client case None => warn("Fail to init Kubernetes Client for Kubernetes Application Operation") @@ -49,89 +73,136 @@ class KubernetesApplicationOperation extends ApplicationOperation with Logging { } override def isSupported(clusterManager: Option[String]): Boolean = { + // TODO add deploy mode to check whether is supported kubernetesClient != null && clusterManager.nonEmpty && clusterManager.get.toLowerCase.startsWith("k8s") } override def killApplicationByTag(tag: String): KillResponse = { - if (kubernetesClient != null) { - debug(s"Deleting application info from Kubernetes cluster by $tag tag") - try { - // Need driver only - val operation = findDriverPodByTag(tag) - val podList = operation.list().getItems - if (podList.size() != 0) { - toApplicationState(podList.get(0).getStatus.getPhase) match { - case FAILED | UNKNOWN => - ( - false, - s"Target Pod ${podList.get(0).getMetadata.getName} is in FAILED or UNKNOWN status") - case _ => - ( - operation.delete(), - s"Operation of deleted appId: ${podList.get(0).getMetadata.getName} is completed") - } - } else { - // client mode - jpsOperation.killApplicationByTag(tag) - } - } catch { - case e: Exception => - (false, s"Failed to terminate application with $tag, due to ${e.getMessage}") - } - } else { + if (kubernetesClient == null) { throw new IllegalStateException("Methods initialize and isSupported must be called ahead") } - } - - override def getApplicationInfoByTag(tag: String): ApplicationInfo = { - if (kubernetesClient != null) { - debug(s"Getting application info from Kubernetes cluster by $tag tag") - try { - val operation = findDriverPodByTag(tag) - val podList = operation.list().getItems - if (podList.size() != 0) { - val pod = podList.get(0) - val info = ApplicationInfo( - // spark pods always tag label `spark-app-selector:` - id = pod.getMetadata.getLabels.get(SPARK_APP_ID_LABEL), - name = pod.getMetadata.getName, - state = KubernetesApplicationOperation.toApplicationState(pod.getStatus.getPhase), - error = Option(pod.getStatus.getReason)) - debug(s"Successfully got application info by $tag: $info") - info - } else { - // client mode - jpsOperation.getApplicationInfoByTag(tag) - } - } catch { - case e: Exception => - error(s"Failed to get application with $tag, due to ${e.getMessage}") - ApplicationInfo(id = null, name = null, ApplicationState.NOT_FOUND) + debug(s"Deleting application info from Kubernetes cluster by $tag tag") + try { + val info = appInfoStore.getOrDefault(tag, ApplicationInfo.NOT_FOUND) + debug(s"Application info[tag: $tag] is in ${info.state}") + info.state match { + case NOT_FOUND | FAILED | UNKNOWN => + ( + false, + s"Target application[tag: $tag] is in ${info.state} status") + case _ => + ( + !kubernetesClient.pods.withName(info.name).delete().isEmpty, + s"Operation of deleted application[appId: ${info.id} ,tag: $tag] is completed") } - } else { - throw new IllegalStateException("Methods initialize and isSupported must be called ahead") + } catch { + case e: Exception => + (false, s"Failed to terminate application with $tag, due to ${e.getMessage}") } } - private def findDriverPodByTag(tag: String): FilterWatchListDeletable[Pod, PodList] = { - val operation = kubernetesClient.pods() - .withLabel(KubernetesApplicationOperation.LABEL_KYUUBI_UNIQUE_KEY, tag) - val size = operation.list().getItems.size() - if (size != 1) { - warn(s"Get Tag: ${tag} Driver Pod In Kubernetes size: ${size}, we expect 1") + override def getApplicationInfoByTag(tag: String, submitTime: Option[Long]): ApplicationInfo = { + if (kubernetesClient == null) { + throw new IllegalStateException("Methods initialize and isSupported must be called ahead") + } + debug(s"Getting application info from Kubernetes cluster by $tag tag") + try { + val appInfo = appInfoStore.getOrDefault(tag, ApplicationInfo.NOT_FOUND) + (appInfo.state, submitTime) match { + // Kyuubi should wait second if pod is not be created + case (NOT_FOUND, Some(_submitTime)) => + val elapsedTime = System.currentTimeMillis - _submitTime + if (elapsedTime > submitTimeout) { + error(s"Can't find target driver pod by tag: $tag, " + + s"elapsed time: ${elapsedTime}ms exceeds ${submitTimeout}ms.") + ApplicationInfo.NOT_FOUND + } else { + warn("Wait for driver pod to be created, " + + s"elapsed time: ${elapsedTime}ms, return UNKNOWN status") + ApplicationInfo.UNKNOWN + } + case (NOT_FOUND, None) => + ApplicationInfo.NOT_FOUND + case _ => + debug(s"Successfully got application info by $tag: $appInfo") + appInfo + } + } catch { + case e: Exception => + error(s"Failed to get application with $tag, due to ${e.getMessage}") + ApplicationInfo.NOT_FOUND } - operation } override def stop(): Unit = { - if (kubernetesClient != null) { - try { + Utils.tryLogNonFatalError { + if (enginePodInformer != null) { + enginePodInformer.stop() + enginePodInformer = null + } + } + + Utils.tryLogNonFatalError { + if (kubernetesClient != null) { kubernetesClient.close() - } catch { - case e: Exception => error(e.getMessage) + kubernetesClient = null } } + + if (cleanupTerminatedAppInfoTrigger != null) { + cleanupTerminatedAppInfoTrigger.cleanUp() + cleanupTerminatedAppInfoTrigger = null + } + } + + private class SparkEnginePodEventHandler extends ResourceEventHandler[Pod] { + + override def onAdd(pod: Pod): Unit = { + if (isSparkEnginePod(pod)) { + updateApplicationState(pod) + } + } + + override def onUpdate(oldPod: Pod, newPod: Pod): Unit = { + if (isSparkEnginePod(newPod)) { + updateApplicationState(newPod) + val appState = toApplicationState(newPod.getStatus.getPhase) + if (isTerminated(appState)) { + markApplicationTerminated(newPod) + } + } + } + + override def onDelete(pod: Pod, deletedFinalStateUnknown: Boolean): Unit = { + if (isSparkEnginePod(pod)) { + updateApplicationState(pod) + markApplicationTerminated(pod) + } + } + } + + private def isSparkEnginePod(pod: Pod): Boolean = { + val labels = pod.getMetadata.getLabels + labels.containsKey(LABEL_KYUUBI_UNIQUE_KEY) && labels.containsKey(SPARK_APP_ID_LABEL) + } + + private def updateApplicationState(pod: Pod): Unit = { + val appState = toApplicationState(pod.getStatus.getPhase) + debug(s"Driver Informer changes pod: ${pod.getMetadata.getName} to state: $appState") + appInfoStore.put( + pod.getMetadata.getLabels.get(LABEL_KYUUBI_UNIQUE_KEY), + ApplicationInfo( + id = pod.getMetadata.getLabels.get(SPARK_APP_ID_LABEL), + name = pod.getMetadata.getName, + state = appState, + error = Option(pod.getStatus.getReason))) + } + + private def markApplicationTerminated(pod: Pod): Unit = { + cleanupTerminatedAppInfoTrigger.put( + pod.getMetadata.getLabels.get(LABEL_KYUUBI_UNIQUE_KEY), + toApplicationState(pod.getStatus.getPhase)) } } @@ -148,10 +219,10 @@ object KubernetesApplicationOperation extends Logging { case "Running" => RUNNING case "Succeeded" => FINISHED case "Failed" | "Error" => FAILED - case "Unknown" => ApplicationState.UNKNOWN + case "Unknown" => UNKNOWN case _ => warn(s"The kubernetes driver pod state: $state is not supported, " + "mark the application state as UNKNOWN.") - ApplicationState.UNKNOWN + UNKNOWN } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/KyuubiApplicationManager.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/KyuubiApplicationManager.scala index 481d7a2f17c..9b23e550d07 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/KyuubiApplicationManager.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/KyuubiApplicationManager.scala @@ -19,6 +19,7 @@ package org.apache.kyuubi.engine import java.io.File import java.net.{URI, URISyntaxException} +import java.nio.file.{Files, Path} import java.util.{Locale, ServiceLoader} import scala.collection.JavaConverters._ @@ -83,10 +84,11 @@ class KyuubiApplicationManager extends AbstractService("KyuubiApplicationManager def getApplicationInfo( clusterManager: Option[String], - tag: String): Option[ApplicationInfo] = { + tag: String, + submitTime: Option[Long] = None): Option[ApplicationInfo] = { val operation = operations.find(_.isSupported(clusterManager)) operation match { - case Some(op) => Some(op.getApplicationInfoByTag(tag)) + case Some(op) => Some(op.getApplicationInfoByTag(tag, submitTime)) case None => None } } @@ -109,6 +111,15 @@ object KyuubiApplicationManager { conf.set(FlinkProcessBuilder.TAG_KEY, newTag) } + val uploadWorkDir: Path = { + val path = Utils.getAbsolutePathFromWork("upload") + val pathFile = path.toFile + if (!pathFile.exists()) { + Files.createDirectories(path) + } + path + } + private[kyuubi] def checkApplicationAccessPath(path: String, conf: KyuubiConf): Unit = { val localDirAllowList = conf.get(KyuubiConf.SESSION_LOCAL_DIR_ALLOW_LIST) if (localDirAllowList.nonEmpty) { diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/ProcBuilder.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/ProcBuilder.scala index 5b69b02f54d..4c7330b4dd5 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/ProcBuilder.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/ProcBuilder.scala @@ -118,7 +118,7 @@ trait ProcBuilder { env.get("KYUUBI_WORK_DIR_ROOT").map { root => val workingRoot = Paths.get(root).toAbsolutePath if (!Files.exists(workingRoot)) { - debug(s"Creating KYUUBI_WORK_DIR_ROOT at $workingRoot") + info(s"Creating KYUUBI_WORK_DIR_ROOT at $workingRoot") Files.createDirectories(workingRoot) } if (Files.isDirectory(workingRoot)) { @@ -127,7 +127,7 @@ trait ProcBuilder { }.map { rootAbs => val working = Paths.get(rootAbs, proxyUser) if (!Files.exists(working)) { - debug(s"Creating $proxyUser's working directory at $working") + info(s"Creating $proxyUser's working directory at $working") Files.createDirectories(working) } if (Files.isDirectory(working)) { @@ -335,7 +335,7 @@ trait ProcBuilder { protected def validateEnv(requiredEnv: String): Throwable = { KyuubiSQLException(s"$requiredEnv is not set! For more information on installing and " + - s"configuring $requiredEnv, please visit https://kyuubi.apache.org/docs/latest/" + + s"configuring $requiredEnv, please visit https://kyuubi.readthedocs.io/en/master/" + s"deployment/settings.html#environments") } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/YarnApplicationOperation.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/YarnApplicationOperation.scala index b38b1daa222..e836e65da99 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/YarnApplicationOperation.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/YarnApplicationOperation.scala @@ -75,7 +75,7 @@ class YarnApplicationOperation extends ApplicationOperation with Logging { } } - override def getApplicationInfoByTag(tag: String): ApplicationInfo = { + override def getApplicationInfoByTag(tag: String, submitTime: Option[Long]): ApplicationInfo = { if (yarnClient != null) { debug(s"Getting application info from Yarn cluster by $tag tag") val reports = yarnClient.getApplications(null, null, Set(tag).asJava) diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/chat/ChatProcessBuilder.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/chat/ChatProcessBuilder.scala new file mode 100644 index 00000000000..3e4a20de373 --- /dev/null +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/chat/ChatProcessBuilder.scala @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.engine.chat + +import java.io.File +import java.nio.file.{Files, Paths} +import java.util + +import scala.collection.JavaConverters._ +import scala.collection.mutable.ArrayBuffer + +import com.google.common.annotations.VisibleForTesting + +import org.apache.kyuubi.{Logging, SCALA_COMPILE_VERSION, Utils} +import org.apache.kyuubi.Utils.REDACTION_REPLACEMENT_TEXT +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.config.KyuubiConf._ +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_USER_KEY +import org.apache.kyuubi.engine.ProcBuilder +import org.apache.kyuubi.operation.log.OperationLog + +class ChatProcessBuilder( + override val proxyUser: String, + override val conf: KyuubiConf, + val engineRefId: String, + val extraEngineLog: Option[OperationLog] = None) + extends ProcBuilder with Logging { + + @VisibleForTesting + def this(proxyUser: String, conf: KyuubiConf) { + this(proxyUser, conf, "") + } + + /** + * The short name of the engine process builder, we use this for form the engine jar paths now + * see `mainResource` + */ + override def shortName: String = "chat" + + override protected def module: String = "kyuubi-chat-engine" + + /** + * The class containing the main method + */ + override protected def mainClass: String = "org.apache.kyuubi.engine.chat.ChatEngine" + + override protected val commands: Array[String] = { + val buffer = new ArrayBuffer[String]() + buffer += executable + + val memory = conf.get(ENGINE_CHAT_MEMORY) + buffer += s"-Xmx$memory" + + val javaOptions = conf.get(ENGINE_CHAT_JAVA_OPTIONS) + javaOptions.foreach(buffer += _) + + buffer += "-cp" + val classpathEntries = new util.LinkedHashSet[String] + mainResource.foreach(classpathEntries.add) + mainResource.foreach { path => + val parent = Paths.get(path).getParent + val chatDevDepDir = parent + .resolve(s"scala-$SCALA_COMPILE_VERSION") + .resolve("jars") + if (Files.exists(chatDevDepDir)) { + // add dev classpath + classpathEntries.add(s"$chatDevDepDir${File.separator}*") + } else { + // add prod classpath + classpathEntries.add(s"$parent${File.separator}*") + } + } + + val extraCp = conf.get(ENGINE_CHAT_EXTRA_CLASSPATH) + extraCp.foreach(classpathEntries.add) + buffer += classpathEntries.asScala.mkString(File.pathSeparator) + buffer += mainClass + + buffer += "--conf" + buffer += s"$KYUUBI_SESSION_USER_KEY=$proxyUser" + + conf.getAll.foreach { case (k, v) => + buffer += "--conf" + buffer += s"$k=$v" + } + buffer.toArray + } + + override def toString: String = { + if (commands == null) { + super.toString() + } else { + Utils.redactCommandLineArgs(conf, commands).map { + case arg if arg.startsWith("-") || arg == mainClass => s"\\\n\t$arg" + case arg if arg.contains(ENGINE_CHAT_GPT_API_KEY.key) => + s"${ENGINE_CHAT_GPT_API_KEY.key}=$REDACTION_REPLACEMENT_TEXT" + case arg => arg + }.mkString(" ") + } + } +} diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/spark/SparkBatchProcessBuilder.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/spark/SparkBatchProcessBuilder.scala index 98f9ea5a335..4a613278dcb 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/spark/SparkBatchProcessBuilder.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/spark/SparkBatchProcessBuilder.scala @@ -56,9 +56,7 @@ class SparkBatchProcessBuilder( buffer += s"${convertConfigKey(k)}=$v" } - setSparkUserName(proxyUser, buffer) - buffer += PROXY_USER - buffer += proxyUser + setupKerberos(buffer) assert(mainResource.isDefined) buffer += mainResource.get @@ -77,6 +75,6 @@ class SparkBatchProcessBuilder( override protected def module: String = "kyuubi-spark-batch-submit" override def clusterManager(): Option[String] = { - batchConf.get(MASTER_KEY).orElse(defaultMaster) + batchConf.get(MASTER_KEY).orElse(super.clusterManager()) } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/spark/SparkProcessBuilder.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/spark/SparkProcessBuilder.scala index 874a36c0016..b74eab77d05 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/spark/SparkProcessBuilder.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/engine/spark/SparkProcessBuilder.scala @@ -121,6 +121,16 @@ class SparkProcessBuilder( buffer += s"${convertConfigKey(k)}=$v" } + setupKerberos(buffer) + + mainResource.foreach { r => buffer += r } + + buffer.toArray + } + + override protected def module: String = "kyuubi-spark-sql-engine" + + protected def setupKerberos(buffer: ArrayBuffer[String]): Unit = { // if the keytab is specified, PROXY_USER is not supported tryKeytab() match { case None => @@ -130,14 +140,8 @@ class SparkProcessBuilder( case Some(name) => setSparkUserName(name, buffer) } - - mainResource.foreach { r => buffer += r } - - buffer.toArray } - override protected def module: String = "kyuubi-spark-sql-engine" - private def tryKeytab(): Option[String] = { val principal = conf.getOption(PRINCIPAL) val keytab = conf.getOption(KEYTAB) diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/events/KyuubiOperationEvent.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/events/KyuubiOperationEvent.scala index 74a3a3fad39..7147cb42450 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/events/KyuubiOperationEvent.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/events/KyuubiOperationEvent.scala @@ -42,6 +42,7 @@ import org.apache.kyuubi.session.KyuubiSession * @param sessionId the identifier of the parent session * @param sessionUser the authenticated client user * @param sessionType the type of the parent session + * @param kyuubiInstance the parent session connection url */ case class KyuubiOperationEvent private ( statementId: String, @@ -56,7 +57,8 @@ case class KyuubiOperationEvent private ( exception: Option[Throwable], sessionId: String, sessionUser: String, - sessionType: String) extends KyuubiEvent { + sessionType: String, + kyuubiInstance: String) extends KyuubiEvent { // operation events are partitioned by the date when the corresponding operations are // created. @@ -85,6 +87,7 @@ object KyuubiOperationEvent { status.exception, session.handle.identifier.toString, session.user, - session.sessionType.toString) + session.sessionType.toString, + session.connectionUrl) } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/BatchJobSubmission.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/BatchJobSubmission.scala index e99b3292c36..3cbb16907bc 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/BatchJobSubmission.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/BatchJobSubmission.scala @@ -18,6 +18,7 @@ package org.apache.kyuubi.operation import java.io.IOException +import java.nio.file.{Files, Paths} import java.util.Locale import java.util.concurrent.TimeUnit @@ -32,7 +33,7 @@ import org.apache.kyuubi.engine.spark.SparkBatchProcessBuilder import org.apache.kyuubi.metrics.MetricsConstants.OPERATION_OPEN import org.apache.kyuubi.metrics.MetricsSystem import org.apache.kyuubi.operation.FetchOrientation.FetchOrientation -import org.apache.kyuubi.operation.OperationState.{CANCELED, OperationState, RUNNING} +import org.apache.kyuubi.operation.OperationState.{isTerminal, CANCELED, OperationState, RUNNING} import org.apache.kyuubi.operation.log.OperationLog import org.apache.kyuubi.server.metadata.api.Metadata import org.apache.kyuubi.session.KyuubiBatchSessionImpl @@ -69,7 +70,11 @@ class BatchJobSubmission( private[kyuubi] val batchId: String = session.handle.identifier.toString - private var applicationInfo: Option[ApplicationInfo] = None + @volatile private var _applicationInfo: Option[ApplicationInfo] = None + def getOrFetchCurrentApplicationInfo: Option[ApplicationInfo] = _applicationInfo match { + case Some(_) => _applicationInfo + case None => currentApplicationInfo + } private var killMessage: KillResponse = (false, "UNKNOWN") def getKillMessage: KillResponse = killMessage @@ -97,10 +102,19 @@ class BatchJobSubmission( } } - override private[kyuubi] def currentApplicationInfo: Option[ApplicationInfo] = { + override protected def currentApplicationInfo: Option[ApplicationInfo] = { + if (isTerminal(state) && _applicationInfo.nonEmpty) return _applicationInfo // only the ApplicationInfo with non-empty id is valid for the operation + val submitTime = if (_appStartTime <= 0) { + System.currentTimeMillis() + } else { + _appStartTime + } val applicationInfo = - applicationManager.getApplicationInfo(builder.clusterManager(), batchId).filter(_.id != null) + applicationManager.getApplicationInfo( + builder.clusterManager(), + batchId, + Some(submitTime)).filter(_.id != null) applicationInfo.foreach { _ => if (_appStartTime <= 0) { _appStartTime = System.currentTimeMillis() @@ -127,13 +141,13 @@ class BatchJobSubmission( } if (isTerminalState(state)) { - if (applicationInfo.isEmpty) { - applicationInfo = + if (_applicationInfo.isEmpty) { + _applicationInfo = Option(ApplicationInfo(id = null, name = null, state = ApplicationState.NOT_FOUND)) } } - applicationInfo.foreach { status => + _applicationInfo.foreach { status => val metadataToUpdate = Metadata( identifier = batchId, state = state.toString, @@ -154,7 +168,7 @@ class BatchJobSubmission( private def setStateIfNotCanceled(newState: OperationState): Unit = state.synchronized { if (state != CANCELED) { setState(newState) - applicationInfo.filter(_.id != null).foreach { ai => + _applicationInfo.filter(_.id != null).foreach { ai => session.getSessionEvent.foreach(_.engineId = ai.id) } if (newState == RUNNING) { @@ -184,8 +198,8 @@ class BatchJobSubmission( // submitted batch application. recoveryMetadata.map { metadata => if (metadata.state == OperationState.PENDING.toString) { - applicationInfo = currentApplicationInfo - applicationInfo.map(_.id) match { + _applicationInfo = currentApplicationInfo + _applicationInfo.map(_.id) match { case Some(null) => submitAndMonitorBatchJob() case Some(appId) => @@ -226,10 +240,10 @@ class BatchJobSubmission( try { info(s"Submitting $batchType batch[$batchId] job:\n$builder") val process = builder.start - applicationInfo = currentApplicationInfo - while (!applicationFailed(applicationInfo) && process.isAlive) { + _applicationInfo = currentApplicationInfo + while (!applicationFailed(_applicationInfo) && process.isAlive) { if (!appStatusFirstUpdated) { - if (applicationInfo.isDefined) { + if (_applicationInfo.isDefined) { setStateIfNotCanceled(OperationState.RUNNING) updateBatchMetadata() appStatusFirstUpdated = true @@ -243,54 +257,56 @@ class BatchJobSubmission( } } process.waitFor(applicationCheckInterval, TimeUnit.MILLISECONDS) - applicationInfo = currentApplicationInfo + _applicationInfo = currentApplicationInfo } - if (applicationFailed(applicationInfo)) { + if (applicationFailed(_applicationInfo)) { process.destroyForcibly() - throw new RuntimeException(s"Batch job failed: $applicationInfo") + throw new RuntimeException(s"Batch job failed: ${_applicationInfo}") } else { process.waitFor() if (process.exitValue() != 0) { throw new KyuubiException(s"Process exit with value ${process.exitValue()}") } - Option(applicationInfo.map(_.id)).foreach { + Option(_applicationInfo.map(_.id)).foreach { case Some(appId) => monitorBatchJob(appId) case _ => } } } finally { builder.close() + cleanupUploadedResourceIfNeeded() } } private def monitorBatchJob(appId: String): Unit = { info(s"Monitoring submitted $batchType batch[$batchId] job: $appId") - if (applicationInfo.isEmpty) { - applicationInfo = currentApplicationInfo + if (_applicationInfo.isEmpty) { + _applicationInfo = currentApplicationInfo } if (state == OperationState.PENDING) { setStateIfNotCanceled(OperationState.RUNNING) } - if (applicationInfo.isEmpty) { + if (_applicationInfo.isEmpty) { info(s"The $batchType batch[$batchId] job: $appId not found, assume that it has finished.") - } else if (applicationFailed(applicationInfo)) { - throw new RuntimeException(s"$batchType batch[$batchId] job failed: $applicationInfo") + } else if (applicationFailed(_applicationInfo)) { + throw new RuntimeException(s"$batchType batch[$batchId] job failed: ${_applicationInfo}") } else { updateBatchMetadata() // TODO: add limit for max batch job submission lifetime - while (applicationInfo.isDefined && !applicationTerminated(applicationInfo)) { + while (_applicationInfo.isDefined && !applicationTerminated(_applicationInfo)) { Thread.sleep(applicationCheckInterval) val newApplicationStatus = currentApplicationInfo - if (newApplicationStatus.map(_.state) != applicationInfo.map(_.state)) { - applicationInfo = newApplicationStatus - info(s"Batch report for $batchId, $applicationInfo") + if (newApplicationStatus.map(_.state) != _applicationInfo.map(_.state)) { + _applicationInfo = newApplicationStatus + updateBatchMetadata() + info(s"Batch report for $batchId, ${_applicationInfo}") } } - if (applicationFailed(applicationInfo)) { - throw new RuntimeException(s"$batchType batch[$batchId] job failed: $applicationInfo") + if (applicationFailed(_applicationInfo)) { + throw new RuntimeException(s"$batchType batch[$batchId] job failed: ${_applicationInfo}") } } } @@ -319,12 +335,14 @@ class BatchJobSubmission( if (isTerminalState(state)) { killMessage = (false, s"batch $batchId is already terminal so can not kill it.") builder.close() + cleanupUploadedResourceIfNeeded() return } try { killMessage = killBatchApplication() builder.close() + cleanupUploadedResourceIfNeeded() } finally { if (state == OperationState.INITIALIZED) { // if state is INITIALIZED, it means that the batch submission has not started to run, set @@ -355,6 +373,16 @@ class BatchJobSubmission( override def isTimedOut: Boolean = false override protected def eventEnabled: Boolean = true + + private def cleanupUploadedResourceIfNeeded(): Unit = { + if (session.isResourceUploaded) { + try { + Files.deleteIfExists(Paths.get(resource)) + } catch { + case e: Throwable => error(s"Error deleting the uploaded resource: $resource", e) + } + } + } } object BatchJobSubmission { diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/ExecuteStatement.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/ExecuteStatement.scala index 4e818355ec6..4767cbf121b 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/ExecuteStatement.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/ExecuteStatement.scala @@ -19,11 +19,13 @@ package org.apache.kyuubi.operation import scala.collection.JavaConverters._ +import com.codahale.metrics.MetricRegistry import org.apache.hive.service.rpc.thrift.{TGetOperationStatusResp, TOperationState, TProtocolVersion} import org.apache.hive.service.rpc.thrift.TOperationState._ import org.apache.kyuubi.KyuubiSQLException import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.metrics.{MetricsConstants, MetricsSystem} import org.apache.kyuubi.operation.FetchOrientation.FETCH_NEXT import org.apache.kyuubi.operation.log.OperationLog import org.apache.kyuubi.session.Session @@ -61,7 +63,8 @@ class ExecuteStatement( // We need to avoid executing query in sync mode, because there is no heartbeat mechanism // in thrift protocol, in sync mode, we cannot distinguish between long-run query and // engine crash without response before socket read timeout. - _remoteOpHandle = client.executeStatement(statement, confOverlay, true, queryTimeout) + _remoteOpHandle = + client.executeStatement(statement, confOverlay ++ operationHandleConf, true, queryTimeout) setHasResultSet(_remoteOpHandle.isHasResultSet) } catch onError() } @@ -131,6 +134,12 @@ class ExecuteStatement( } sendCredentialsIfNeeded() } + MetricsSystem.tracing { ms => + val execTime = System.currentTimeMillis() - startTime + ms.updateHistogram( + MetricRegistry.name(MetricsConstants.OPERATION_EXEC_TIME, opType), + execTime) + } // see if anymore log could be fetched fetchQueryLog() } catch onError() diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/KyuubiApplicationOperation.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/KyuubiApplicationOperation.scala index cf10b2da41a..605c4cca6b8 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/KyuubiApplicationOperation.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/KyuubiApplicationOperation.scala @@ -31,7 +31,11 @@ import org.apache.kyuubi.util.ThriftUtils abstract class KyuubiApplicationOperation(session: Session) extends KyuubiOperation(session) { - private[kyuubi] def currentApplicationInfo: Option[ApplicationInfo] + protected def currentApplicationInfo: Option[ApplicationInfo] + + protected def applicationInfoMap: Option[Map[String, String]] = { + currentApplicationInfo.map(_.toMap) + } override def getResultSetMetadata: TGetResultSetMetadataResp = { val schema = new TTableSchema() @@ -51,7 +55,7 @@ abstract class KyuubiApplicationOperation(session: Session) extends KyuubiOperat } override def getNextRowSet(order: FetchOrientation, rowSetSize: Int): TRowSet = { - currentApplicationInfo.map(_.toMap).map { state => + applicationInfoMap.map { state => val tRow = new TRowSet(0, new JArrayList[TRow](state.size)) Seq(state.keys, state.values.map(Option(_).getOrElse(""))).map(_.toSeq.asJava).foreach { col => diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/KyuubiOperation.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/KyuubiOperation.scala index 638985ea12b..106a11e4b25 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/KyuubiOperation.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/KyuubiOperation.scala @@ -26,6 +26,7 @@ import org.apache.thrift.TException import org.apache.thrift.transport.TTransportException import org.apache.kyuubi.{KyuubiSQLException, Utils} +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_OPERATION_HANDLE_KEY import org.apache.kyuubi.events.{EventBus, KyuubiOperationEvent} import org.apache.kyuubi.metrics.MetricsConstants.{OPERATION_FAIL, OPERATION_OPEN, OPERATION_STATE, OPERATION_TOTAL} import org.apache.kyuubi.metrics.MetricsSystem @@ -46,6 +47,8 @@ abstract class KyuubiOperation(session: Session) extends AbstractOperation(sessi protected[operation] lazy val client = session.asInstanceOf[KyuubiSessionImpl].client + protected val operationHandleConf = Map(KYUUBI_OPERATION_HANDLE_KEY -> handle.identifier.toString) + @volatile protected var _remoteOpHandle: TOperationHandle = _ def remoteOpHandle(): TOperationHandle = _remoteOpHandle @@ -176,7 +179,9 @@ abstract class KyuubiOperation(session: Session) extends AbstractOperation(sessi override def setState(newState: OperationState): Unit = { MetricsSystem.tracing { ms => - ms.markMeter(MetricRegistry.name(OPERATION_STATE, opType, state.toString.toLowerCase), -1) + if (!OperationState.isTerminal(state)) { + ms.markMeter(MetricRegistry.name(OPERATION_STATE, opType, state.toString.toLowerCase), -1) + } ms.markMeter(MetricRegistry.name(OPERATION_STATE, opType, newState.toString.toLowerCase)) ms.markMeter(MetricRegistry.name(OPERATION_STATE, newState.toString.toLowerCase)) } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/LaunchEngine.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/LaunchEngine.scala index 0444b92fd81..fb4f39e262b 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/LaunchEngine.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/operation/LaunchEngine.scala @@ -33,7 +33,7 @@ class LaunchEngine(session: KyuubiSessionImpl, override val shouldRunAsync: Bool } override def getOperationLog: Option[OperationLog] = Option(_operationLog) - override private[kyuubi] def currentApplicationInfo: Option[ApplicationInfo] = { + override protected def currentApplicationInfo: Option[ApplicationInfo] = { Option(client).map { cli => ApplicationInfo( cli.engineId.orNull, @@ -68,4 +68,9 @@ class LaunchEngine(session: KyuubiSessionImpl, override val shouldRunAsync: Bool if (!shouldRunAsync) getBackgroundHandle.get() } + + override protected def applicationInfoMap: Option[Map[String, String]] = { + super.applicationInfoMap.map { _ + ("refId" -> session.engine.getEngineRefId()) } + } + } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/BackendServiceMetric.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/BackendServiceMetric.scala index d8b66416375..68bf11d7f99 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/BackendServiceMetric.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/BackendServiceMetric.scala @@ -152,9 +152,11 @@ trait BackendServiceMetric extends BackendService { } } - abstract override def getOperationStatus(operationHandle: OperationHandle): OperationStatus = { + abstract override def getOperationStatus( + operationHandle: OperationHandle, + maxWait: Option[Long] = None): OperationStatus = { MetricsSystem.timerTracing(MetricsConstants.BS_GET_OPERATION_STATUS) { - super.getOperationStatus(operationHandle) + super.getOperationStatus(operationHandle, maxWait) } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiRestFrontendService.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiRestFrontendService.scala index 29f4cf30419..cd191afe834 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiRestFrontendService.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiRestFrontendService.scala @@ -26,14 +26,14 @@ import javax.ws.rs.core.Response.Status import com.google.common.annotations.VisibleForTesting import org.apache.hadoop.conf.Configuration -import org.eclipse.jetty.servlet.FilterHolder +import org.eclipse.jetty.servlet.{ErrorPageErrorHandler, FilterHolder} import org.apache.kyuubi.{KyuubiException, Utils} import org.apache.kyuubi.config.KyuubiConf -import org.apache.kyuubi.config.KyuubiConf.{FRONTEND_REST_BIND_HOST, FRONTEND_REST_BIND_PORT, FRONTEND_REST_MAX_WORKER_THREADS, METADATA_RECOVERY_THREADS} +import org.apache.kyuubi.config.KyuubiConf._ import org.apache.kyuubi.server.api.v1.ApiRootResource import org.apache.kyuubi.server.http.authentication.{AuthenticationFilter, KyuubiHttpAuthenticationFactory} -import org.apache.kyuubi.server.ui.JettyServer +import org.apache.kyuubi.server.ui.{JettyServer, JettyUtils} import org.apache.kyuubi.service.{AbstractFrontendService, Serverable, Service, ServiceUtils} import org.apache.kyuubi.service.authentication.KyuubiAuthenticationFactory import org.apache.kyuubi.session.{KyuubiSessionManager, SessionHandle} @@ -58,7 +58,10 @@ class KyuubiRestFrontendService(override val serverable: Serverable) lazy val host: String = conf.get(FRONTEND_REST_BIND_HOST) .getOrElse { - if (conf.get(KyuubiConf.FRONTEND_CONNECTION_URL_USE_HOSTNAME)) { + if (Utils.isWindows || Utils.isMac) { + warn(s"Kyuubi Server run in Windows or Mac environment, binding $getName to 0.0.0.0") + "0.0.0.0" + } else if (conf.get(KyuubiConf.FRONTEND_CONNECTION_URL_USE_HOSTNAME)) { Utils.findLocalInetAddress.getCanonicalHostName } else { Utils.findLocalInetAddress.getHostAddress @@ -95,6 +98,18 @@ class KyuubiRestFrontendService(override val serverable: Serverable) server.addRedirectHandler("/docs", "/swagger/") server.addRedirectHandler("/docs/", "/swagger/") server.addRedirectHandler("/swagger", "/swagger/") + + installWebUI() + } + + private def installWebUI(): Unit = { + val servletHandler = JettyUtils.createStaticHandler("dist", "/ui") + // HTML5 Web History Mode requires redirect any url path under Web UI Servlet to the main page. + // See more details at https://router.vuejs.org/guide/essentials/history-mode.html#html5-mode + val errorHandler = new ErrorPageErrorHandler + errorHandler.addErrorPage(404, "/") + servletHandler.setErrorHandler(errorHandler) + server.addHandler(servletHandler) } private def startBatchChecker(): Unit = { @@ -162,7 +177,6 @@ class KyuubiRestFrontendService(override val serverable: Serverable) server.start() recoverBatchSessions() isStarted.set(true) - info(s"$getName has started at ${server.getServerUri}") startBatchChecker() startInternal() } catch { @@ -170,6 +184,7 @@ class KyuubiRestFrontendService(override val serverable: Serverable) } } super.start() + info(s"Exposing REST endpoint at: http://${server.getServerUri}") } override def stop(): Unit = synchronized { diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiServer.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiServer.scala index 731ad5df629..a7f2e817837 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiServer.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiServer.scala @@ -33,6 +33,7 @@ import org.apache.kyuubi.ha.client.{AuthTypes, ServiceDiscovery} import org.apache.kyuubi.metrics.{MetricsConf, MetricsSystem} import org.apache.kyuubi.server.metadata.jdbc.JDBCMetadataStoreConf import org.apache.kyuubi.service.{AbstractBackendService, AbstractFrontendService, Serverable, ServiceState} +import org.apache.kyuubi.session.KyuubiSessionManager import org.apache.kyuubi.util.{KyuubiHadoopUtils, SignalRegister} import org.apache.kyuubi.zookeeper.EmbeddedZookeeper @@ -82,7 +83,7 @@ object KyuubiServer extends Logging { | /\___/ | \/__/ """.stripMargin) - info(s"Version: $KYUUBI_VERSION, Revision: $REVISION, Branch: $BRANCH," + + info(s"Version: $KYUUBI_VERSION, Revision: $REVISION ($REVISION_TIME), Branch: $BRANCH," + s" Java: $JAVA_COMPILE_VERSION, Scala: $SCALA_COMPILE_VERSION," + s" Spark: $SPARK_COMPILE_VERSION, Hadoop: $HADOOP_COMPILE_VERSION," + s" Hive: $HIVE_COMPILE_VERSION, Flink: $FLINK_COMPILE_VERSION," + @@ -128,6 +129,14 @@ object KyuubiServer extends Logging { info(s"Refreshed user defaults configs with changes of " + s"unset: $unsetCount, updated: $updatedCount, added: $addedCount") } + + private[kyuubi] def refreshUnlimitedUsers(): Unit = synchronized { + val sessionMgr = kyuubiServer.backendService.sessionManager.asInstanceOf[KyuubiSessionManager] + val existingUnlimitedUsers = sessionMgr.getUnlimitedUsers() + sessionMgr.refreshUnlimitedUsers(KyuubiConf().loadFileDefaults()) + val refreshedUnlimitedUsers = sessionMgr.getUnlimitedUsers() + info(s"Refreshed unlimited users from $existingUnlimitedUsers to $refreshedUnlimitedUsers") + } } class KyuubiServer(name: String) extends Serverable(name) { @@ -148,7 +157,7 @@ class KyuubiServer(name: String) extends Serverable(name) { warn("MYSQL frontend protocol is experimental.") new KyuubiMySQLFrontendService(this) case TRINO => - warn("Trio frontend protocol is experimental.") + warn("Trino frontend protocol is experimental.") new KyuubiTrinoFrontendService(this) case other => throw new UnsupportedOperationException(s"Frontend protocol $other is not supported yet.") @@ -160,6 +169,9 @@ class KyuubiServer(name: String) extends Serverable(name) { val kinit = new KinitAuxiliaryService() addService(kinit) + val periodicGCService = new PeriodicGCService + addService(periodicGCService) + if (conf.get(MetricsConf.METRICS_ENABLED)) { addService(new MetricsSystem) } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiTrinoFrontendService.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiTrinoFrontendService.scala index fca8b8a8787..573bb948f90 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiTrinoFrontendService.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/KyuubiTrinoFrontendService.scala @@ -64,15 +64,6 @@ class KyuubiTrinoFrontendService(override val serverable: Serverable) private def startInternal(): Unit = { val contextHandler = ApiRootResource.getServletHandler(this) server.addHandler(contextHandler) - - server.addStaticHandler("org/apache/kyuubi/ui/static", "/static/") - server.addRedirectHandler("/", "/static/") - server.addRedirectHandler("/static", "/static/") - server.addStaticHandler("META-INF/resources/webjars/swagger-ui/4.9.1/", "/swagger-static/") - server.addStaticHandler("org/apache/kyuubi/ui/swagger", "/swagger/") - server.addRedirectHandler("/docs", "/swagger/") - server.addRedirectHandler("/docs/", "/swagger/") - server.addRedirectHandler("/swagger", "/swagger/") } override def start(): Unit = synchronized { diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/PeriodicGCService.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/PeriodicGCService.scala new file mode 100644 index 00000000000..a4035b689d5 --- /dev/null +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/PeriodicGCService.scala @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.server + +import java.util.concurrent.TimeUnit + +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.service.AbstractService +import org.apache.kyuubi.util.ThreadUtils + +class PeriodicGCService(name: String) extends AbstractService(name) { + def this() = this(classOf[PeriodicGCService].getSimpleName) + + private val gcTrigger = ThreadUtils.newDaemonSingleThreadScheduledExecutor("periodic-gc-trigger") + + override def start(): Unit = { + startGcTrigger() + super.start() + } + + override def stop(): Unit = { + super.stop() + ThreadUtils.shutdown(gcTrigger) + } + + private def startGcTrigger(): Unit = { + val interval = conf.get(KyuubiConf.SERVER_PERIODIC_GC_INTERVAL) + gcTrigger.scheduleWithFixedDelay(() => System.gc(), interval, interval, TimeUnit.MILLISECONDS) + } +} diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/ApiUtils.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/ApiUtils.scala new file mode 100644 index 00000000000..ebbf04c9073 --- /dev/null +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/ApiUtils.scala @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.server.api + +import scala.collection.JavaConverters._ + +import org.apache.kyuubi.Utils +import org.apache.kyuubi.client.api.v1.dto.{OperationData, SessionData} +import org.apache.kyuubi.events.KyuubiOperationEvent +import org.apache.kyuubi.operation.KyuubiOperation +import org.apache.kyuubi.session.KyuubiSession + +object ApiUtils { + + def sessionData(session: KyuubiSession): SessionData = { + val sessionEvent = session.getSessionEvent + new SessionData( + session.handle.identifier.toString, + session.user, + session.ipAddress, + session.conf.asJava, + session.createTime, + session.lastAccessTime - session.createTime, + session.getNoOperationTime, + sessionEvent.flatMap(_.exception).map(Utils.prettyPrint).getOrElse(""), + session.sessionType.toString, + session.connectionUrl, + sessionEvent.map(_.engineId).getOrElse("")) + } + + def operationData(operation: KyuubiOperation): OperationData = { + val opEvent = KyuubiOperationEvent(operation) + new OperationData( + opEvent.statementId, + opEvent.statement, + opEvent.state, + opEvent.createTime, + opEvent.startTime, + opEvent.completeTime, + opEvent.exception.map(Utils.prettyPrint).getOrElse(""), + opEvent.sessionId, + opEvent.sessionUser, + opEvent.sessionType, + operation.getSession.asInstanceOf[KyuubiSession].connectionUrl) + } +} diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/OpenAPIConfig.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/OpenAPIConfig.scala index c4733a0b0e4..d8b48965638 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/OpenAPIConfig.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/OpenAPIConfig.scala @@ -17,6 +17,7 @@ package org.apache.kyuubi.server.api +import org.glassfish.jersey.media.multipart.MultiPartFeature import org.glassfish.jersey.server.ResourceConfig import org.apache.kyuubi.server.api.v1.KyuubiOpenApiResource @@ -26,4 +27,5 @@ class OpenAPIConfig extends ResourceConfig { register(classOf[KyuubiOpenApiResource]) register(classOf[KyuubiScalaObjectMapper]) register(classOf[RestExceptionMapper]) + register(classOf[MultiPartFeature]) } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/AdminResource.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/AdminResource.scala index a92992e66f4..0d8b31b2c65 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/AdminResource.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/AdminResource.scala @@ -24,29 +24,33 @@ import javax.ws.rs.core.{MediaType, Response} import scala.collection.JavaConverters._ import scala.collection.mutable.ListBuffer -import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema} import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag +import org.apache.commons.lang3.StringUtils +import org.apache.zookeeper.KeeperException.NoNodeException import org.apache.kyuubi.{KYUUBI_VERSION, Logging, Utils} -import org.apache.kyuubi.client.api.v1.dto.Engine +import org.apache.kyuubi.client.api.v1.dto.{Engine, OperationData, SessionData} import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf._ import org.apache.kyuubi.ha.HighAvailabilityConf.HA_NAMESPACE import org.apache.kyuubi.ha.client.{DiscoveryPaths, ServiceNodeInfo} import org.apache.kyuubi.ha.client.DiscoveryClientProvider.withDiscoveryClient +import org.apache.kyuubi.operation.{KyuubiOperation, OperationHandle} import org.apache.kyuubi.server.KyuubiServer -import org.apache.kyuubi.server.api.ApiRequestContext +import org.apache.kyuubi.server.api.{ApiRequestContext, ApiUtils} +import org.apache.kyuubi.session.{KyuubiSession, SessionHandle} @Tag(name = "Admin") @Produces(Array(MediaType.APPLICATION_JSON)) private[v1] class AdminResource extends ApiRequestContext with Logging { - private lazy val administrator = Utils.currentUser + private lazy val administrators = fe.getConf.get(KyuubiConf.SERVER_ADMINISTRATORS).toSet + + Utils.currentUser @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "refresh the Kyuubi server hadoop conf, note that, " + "it only takes affect for frontend services now") @POST @@ -55,7 +59,7 @@ private[v1] class AdminResource extends ApiRequestContext with Logging { val userName = fe.getSessionUser(Map.empty[String, String]) val ipAddress = fe.getIpAddress info(s"Receive refresh Kyuubi server hadoop conf request from $userName/$ipAddress") - if (!userName.equals(administrator)) { + if (!isAdministrator(userName)) { throw new NotAllowedException( s"$userName is not allowed to refresh the Kyuubi server hadoop conf") } @@ -66,8 +70,7 @@ private[v1] class AdminResource extends ApiRequestContext with Logging { @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "refresh the user defaults configs") @POST @Path("refresh/user_defaults_conf") @@ -75,7 +78,7 @@ private[v1] class AdminResource extends ApiRequestContext with Logging { val userName = fe.getSessionUser(Map.empty[String, String]) val ipAddress = fe.getIpAddress info(s"Receive refresh user defaults conf request from $userName/$ipAddress") - if (!userName.equals(administrator)) { + if (!isAdministrator(userName)) { throw new NotAllowedException( s"$userName is not allowed to refresh the user defaults conf") } @@ -84,10 +87,124 @@ private[v1] class AdminResource extends ApiRequestContext with Logging { Response.ok(s"Refresh the user defaults conf successfully.").build() } + @ApiResponse( + responseCode = "200", + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), + description = "refresh the unlimited users") + @POST + @Path("refresh/unlimited_users") + def refreshUnlimitedUser(): Response = { + val userName = fe.getSessionUser(Map.empty[String, String]) + val ipAddress = fe.getIpAddress + info(s"Receive refresh unlimited users request from $userName/$ipAddress") + if (!isAdministrator(userName)) { + throw new NotAllowedException( + s"$userName is not allowed to refresh the unlimited users") + } + info(s"Reloading unlimited users") + KyuubiServer.refreshUnlimitedUsers() + Response.ok(s"Refresh the unlimited users successfully.").build() + } + + @ApiResponse( + responseCode = "200", + content = Array(new Content( + mediaType = MediaType.APPLICATION_JSON, + array = new ArraySchema(schema = new Schema(implementation = classOf[SessionData])))), + description = "get the list of all live sessions") + @GET + @Path("sessions") + def sessions(@QueryParam("users") users: String): Seq[SessionData] = { + val userName = fe.getSessionUser(Map.empty[String, String]) + val ipAddress = fe.getIpAddress + info(s"Received listing all live sessions request from $userName/$ipAddress") + if (!isAdministrator(userName)) { + throw new NotAllowedException( + s"$userName is not allowed to list all live sessions") + } + var sessions = fe.be.sessionManager.allSessions() + if (StringUtils.isNotBlank(users)) { + val usersSet = users.split(",").toSet + sessions = sessions.filter(session => usersSet.contains(session.user)) + } + sessions.map { case session => + ApiUtils.sessionData(session.asInstanceOf[KyuubiSession]) + }.toSeq + } + + @ApiResponse( + responseCode = "200", + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), + description = "Close a session") + @DELETE + @Path("sessions/{sessionHandle}") + def closeSession(@PathParam("sessionHandle") sessionHandleStr: String): Response = { + val userName = fe.getSessionUser(Map.empty[String, String]) + val ipAddress = fe.getIpAddress + info(s"Received closing a session request from $userName/$ipAddress") + if (!isAdministrator(userName)) { + throw new NotAllowedException( + s"$userName is not allowed to close the session $sessionHandleStr") + } + fe.be.closeSession(SessionHandle.fromUUID(sessionHandleStr)) + Response.ok(s"Session $sessionHandleStr is closed successfully.").build() + } + @ApiResponse( responseCode = "200", content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + mediaType = MediaType.APPLICATION_JSON, + array = new ArraySchema(schema = new Schema(implementation = + classOf[OperationData])))), + description = + "get the list of all active operations") + @GET + @Path("operations") + def listOperations( + @QueryParam("users") users: String, + @QueryParam("sessionHandle") sessionHandle: String): Seq[OperationData] = { + val userName = fe.getSessionUser(Map.empty[String, String]) + val ipAddress = fe.getIpAddress + info(s"Received listing all of the active operations request from $userName/$ipAddress") + if (!isAdministrator(userName)) { + throw new NotAllowedException( + s"$userName is not allowed to list all the operations") + } + var operations = fe.be.sessionManager.operationManager.allOperations() + if (StringUtils.isNotBlank(users)) { + val usersSet = users.split(",").toSet + operations = operations.filter(operation => usersSet.contains(operation.getSession.user)) + } + if (StringUtils.isNotBlank(sessionHandle)) { + operations = operations.filter(operation => + operation.getSession.handle.equals(SessionHandle.fromUUID(sessionHandle))) + } + operations + .map(operation => ApiUtils.operationData(operation.asInstanceOf[KyuubiOperation])).toSeq + } + + @ApiResponse( + responseCode = "200", + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), + description = "close an operation") + @DELETE + @Path("operations/{operationHandle}") + def closeOperation(@PathParam("operationHandle") operationHandleStr: String): Response = { + val userName = fe.getSessionUser(Map.empty[String, String]) + val ipAddress = fe.getIpAddress + info(s"Received close an operation request from $userName/$ipAddress") + if (!isAdministrator(userName)) { + throw new NotAllowedException( + s"$userName is not allowed to close the operation $operationHandleStr") + } + val operationHandle = OperationHandle(operationHandleStr) + fe.be.closeOperation(operationHandle) + Response.ok(s"Operation $operationHandleStr is closed successfully.").build() + } + + @ApiResponse( + responseCode = "200", + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "delete kyuubi engine") @DELETE @Path("engine") @@ -96,7 +213,11 @@ private[v1] class AdminResource extends ApiRequestContext with Logging { @QueryParam("sharelevel") shareLevel: String, @QueryParam("subdomain") subdomain: String, @QueryParam("hive.server2.proxy.user") hs2ProxyUser: String): Response = { - val userName = fe.getSessionUser(hs2ProxyUser) + val userName = if (isAdministrator(fe.getRealUser())) { + Option(hs2ProxyUser).getOrElse(fe.getRealUser()) + } else { + fe.getSessionUser(hs2ProxyUser) + } val engine = getEngine(userName, engineType, shareLevel, subdomain, "default") val engineSpace = getEngineSpace(engine) @@ -121,8 +242,7 @@ private[v1] class AdminResource extends ApiRequestContext with Logging { @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "list kyuubi engines") @GET @Path("engine") @@ -131,7 +251,11 @@ private[v1] class AdminResource extends ApiRequestContext with Logging { @QueryParam("sharelevel") shareLevel: String, @QueryParam("subdomain") subdomain: String, @QueryParam("hive.server2.proxy.user") hs2ProxyUser: String): Seq[Engine] = { - val userName = fe.getSessionUser(hs2ProxyUser) + val userName = if (isAdministrator(fe.getRealUser())) { + Option(hs2ProxyUser).getOrElse(fe.getRealUser()) + } else { + fe.getSessionUser(hs2ProxyUser) + } val engine = getEngine(userName, engineType, shareLevel, subdomain, "") val engineSpace = getEngineSpace(engine) @@ -144,9 +268,19 @@ private[v1] class AdminResource extends ApiRequestContext with Logging { } case None => withDiscoveryClient(fe.getConf) { discoveryClient => - discoveryClient.getChildren(engineSpace).map { child => - info(s"Listing engine nodes for $engineSpace/$child") - engineNodes ++= discoveryClient.getServiceNodesInfo(s"$engineSpace/$child") + try { + discoveryClient.getChildren(engineSpace).map { child => + info(s"Listing engine nodes for $engineSpace/$child") + engineNodes ++= discoveryClient.getServiceNodesInfo(s"$engineSpace/$child") + } + } catch { + case nne: NoNodeException => + error( + s"No such engine for user: $userName, " + + s"engine type: $engineType, share level: $shareLevel, subdomain: $subdomain", + nne) + throw new NotFoundException(s"No such engine for user: $userName, " + + s"engine type: $engineType, share level: $shareLevel, subdomain: $subdomain") } } } @@ -197,4 +331,8 @@ private[v1] class AdminResource extends ApiRequestContext with Logging { engine.getUser, engine.getSubdomain) } + + private def isAdministrator(userName: String): Boolean = { + administrators.contains(userName); + } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/ApiRootResource.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/ApiRootResource.scala index 0d91da868af..d8b997e865c 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/ApiRootResource.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/ApiRootResource.scala @@ -37,8 +37,7 @@ private[v1] class ApiRootResource extends ApiRequestContext { @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "Get the version of Kyuubi server.") @GET @Path("version") diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/BatchesResource.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/BatchesResource.scala index 487362d96b1..4814996a4a1 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/BatchesResource.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/BatchesResource.scala @@ -17,31 +17,37 @@ package org.apache.kyuubi.server.api.v1 -import java.util.Locale +import java.io.InputStream +import java.util +import java.util.{Collections, Locale, UUID} import java.util.concurrent.ConcurrentHashMap import javax.ws.rs._ import javax.ws.rs.core.MediaType import javax.ws.rs.core.Response.Status import scala.collection.JavaConverters._ +import scala.util.{Failure, Success, Try} import scala.util.control.NonFatal import io.swagger.v3.oas.annotations.media.{Content, Schema} import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag +import org.glassfish.jersey.media.multipart.{FormDataContentDisposition, FormDataParam} import org.apache.kyuubi.{Logging, Utils} import org.apache.kyuubi.client.api.v1.dto._ import org.apache.kyuubi.client.exception.KyuubiRestException +import org.apache.kyuubi.client.util.BatchUtils._ import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiReservedKeys._ -import org.apache.kyuubi.engine.ApplicationInfo +import org.apache.kyuubi.engine.{ApplicationInfo, KyuubiApplicationManager} import org.apache.kyuubi.operation.{BatchJobSubmission, FetchOrientation, OperationState} import org.apache.kyuubi.server.api.ApiRequestContext import org.apache.kyuubi.server.api.v1.BatchesResource._ import org.apache.kyuubi.server.metadata.MetadataManager import org.apache.kyuubi.server.metadata.api.Metadata import org.apache.kyuubi.session.{KyuubiBatchSessionImpl, KyuubiSessionManager, SessionHandle} +import org.apache.kyuubi.util.JdbcUtils @Tag(name = "Batch") @Produces(Array(MediaType.APPLICATION_JSON)) @@ -68,7 +74,7 @@ private[v1] class BatchesResource extends ApiRequestContext with Logging { private def buildBatch(session: KyuubiBatchSessionImpl): Batch = { val batchOp = session.batchJobSubmissionOp val batchOpStatus = batchOp.getStatus - val batchAppStatus = batchOp.currentApplicationInfo + val batchAppStatus = batchOp.getOrFetchCurrentApplicationInfo val name = Option(batchOp.batchName).getOrElse(batchAppStatus.map(_.name).orNull) var appId: String = null @@ -102,7 +108,8 @@ private[v1] class BatchesResource extends ApiRequestContext with Logging { session.connectionUrl, batchOpStatus.state.toString, session.createTime, - batchOpStatus.completed) + batchOpStatus.completed, + Map.empty[String, String].asJava) } private def buildBatch( @@ -139,7 +146,8 @@ private[v1] class BatchesResource extends ApiRequestContext with Logging { metadata.kyuubiInstance, currentBatchState, metadata.createTime, - metadata.endTime) + metadata.endTime, + Map.empty[String, String].asJava) }.getOrElse(MetadataManager.buildBatch(metadata)) } @@ -161,6 +169,46 @@ private[v1] class BatchesResource extends ApiRequestContext with Logging { @POST @Consumes(Array(MediaType.APPLICATION_JSON)) def openBatchSession(request: BatchRequest): Batch = { + openBatchSessionInternal(request) + } + + @ApiResponse( + responseCode = "200", + content = Array(new Content( + mediaType = MediaType.APPLICATION_JSON, + schema = new Schema(implementation = classOf[Batch]))), + description = "create and open a batch session with uploading resource file") + @POST + @Consumes(Array(MediaType.MULTIPART_FORM_DATA)) + def openBatchSessionWithUpload( + @FormDataParam("batchRequest") batchRequest: BatchRequest, + @FormDataParam("resourceFile") resourceFileInputStream: InputStream, + @FormDataParam("resourceFile") resourceFileMetadata: FormDataContentDisposition): Batch = { + require( + fe.getConf.get(KyuubiConf.BATCH_RESOURCE_UPLOAD_ENABLED), + "Batch resource upload function is not enabled.") + require( + batchRequest != null, + "batchRequest is required and please check the content type" + + " of batchRequest is application/json") + val tempFile = Utils.writeToTempFile( + resourceFileInputStream, + KyuubiApplicationManager.uploadWorkDir, + resourceFileMetadata.getFileName) + batchRequest.setResource(tempFile.getPath) + openBatchSessionInternal(batchRequest, isResourceFromUpload = true) + } + + /** + * open new batch session with request + * + * @param request instance of BatchRequest + * @param isResourceFromUpload whether to clean up temporary uploaded resource file + * in local path after execution + */ + private def openBatchSessionInternal( + request: BatchRequest, + isResourceFromUpload: Boolean = false): Batch = { require( supportedBatchType(request.getBatchType), s"${request.getBatchType} is not in the supported list: $SUPPORTED_BATCH_TYPES}") @@ -170,21 +218,55 @@ private[v1] class BatchesResource extends ApiRequestContext with Logging { } request.setBatchType(request.getBatchType.toUpperCase(Locale.ROOT)) - val userName = fe.getSessionUser(request.getConf.asScala.toMap) - val ipAddress = fe.getIpAddress - request.setConf( - (request.getConf.asScala ++ Map( - KYUUBI_CLIENT_IP_KEY -> ipAddress, - KYUUBI_SERVER_IP_KEY -> fe.host, - KYUUBI_SESSION_CONNECTION_URL_KEY -> fe.connectionUrl, - KYUUBI_SESSION_REAL_USER_KEY -> fe.getRealUser())).asJava) - val sessionHandle = sessionManager.openBatchSession( - userName, - "anonymous", - ipAddress, - request.getConf.asScala.toMap, - request) - buildBatch(sessionManager.getBatchSessionImpl(sessionHandle)) + val userProvidedBatchId = request.getConf.asScala.get(KYUUBI_BATCH_ID_KEY) + userProvidedBatchId.foreach { batchId => + try UUID.fromString(batchId) + catch { + case NonFatal(e) => + throw new IllegalArgumentException(s"$KYUUBI_BATCH_ID_KEY=$batchId must be an UUID", e) + } + } + + userProvidedBatchId.flatMap { batchId => + Option(sessionManager.getBatchFromMetadataStore(batchId)) + } match { + case Some(batch) => + markDuplicated(batch) + case None => + val userName = fe.getSessionUser(request.getConf.asScala.toMap) + val ipAddress = fe.getIpAddress + val batchId = userProvidedBatchId.getOrElse(UUID.randomUUID().toString) + request.setConf( + (request.getConf.asScala ++ Map( + KYUUBI_BATCH_ID_KEY -> batchId, + KYUUBI_BATCH_RESOURCE_UPLOADED_KEY -> isResourceFromUpload.toString, + KYUUBI_CLIENT_IP_KEY -> ipAddress, + KYUUBI_SERVER_IP_KEY -> fe.host, + KYUUBI_SESSION_CONNECTION_URL_KEY -> fe.connectionUrl, + KYUUBI_SESSION_REAL_USER_KEY -> fe.getRealUser())).asJava) + + Try { + sessionManager.openBatchSession( + userName, + "anonymous", + ipAddress, + request.getConf.asScala.toMap, + request) + } match { + case Success(sessionHandle) => + buildBatch(sessionManager.getBatchSessionImpl(sessionHandle)) + case Failure(cause) if JdbcUtils.isDuplicatedKeyDBErr(cause) => + val batch = sessionManager.getBatchFromMetadataStore(batchId) + assert(batch != null, s"can not find duplicated batch $batchId from metadata store") + markDuplicated(batch) + } + } + } + + private def markDuplicated(batch: Batch): Batch = { + warn(s"duplicated submission: ${batch.getId}, ignore and return the existing batch.") + batch.setBatchInfo(Map(KYUUBI_BATCH_DUPLICATED_KEY -> "true").asJava) + batch } @ApiResponse( @@ -214,7 +296,8 @@ private[v1] class BatchesResource extends ApiRequestContext with Logging { error(s"Error redirecting get batch[$batchId] to ${metadata.kyuubiInstance}", e) val batchAppStatus = sessionManager.applicationManager.getApplicationInfo( metadata.clusterManager, - batchId) + batchId, + Some(metadata.createTime)) buildBatch(metadata, batchAppStatus) } } @@ -278,12 +361,16 @@ private[v1] class BatchesResource extends ApiRequestContext with Logging { Option(sessionManager.getBatchSessionImpl(sessionHandle)).map { batchSession => try { val submissionOp = batchSession.batchJobSubmissionOp - val rowSet = submissionOp.getOperationLogRowSet( - FetchOrientation.FETCH_NEXT, - from, - size) - val logRowSet = rowSet.getColumns.get(0).getStringVal.getValues.asScala - new OperationLog(logRowSet.asJava, logRowSet.size) + val rowSet = submissionOp.getOperationLogRowSet(FetchOrientation.FETCH_NEXT, from, size) + val columns = rowSet.getColumns + val logRowSet: util.List[String] = + if (columns == null || columns.size == 0) { + Collections.emptyList() + } else { + assert(columns.size == 1) + columns.get(0).getStringVal.getValues + } + new OperationLog(logRowSet, logRowSet.size) } catch { case NonFatal(e) => val errorMsg = s"Error getting operation log for batchId: $batchId" diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/OperationsResource.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/OperationsResource.scala index b1b84c30801..70a6d3a2848 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/OperationsResource.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/OperationsResource.scala @@ -28,8 +28,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag import org.apache.hive.service.rpc.thrift._ -import org.apache.kyuubi.KyuubiSQLException -import org.apache.kyuubi.Logging +import org.apache.kyuubi.{KyuubiSQLException, Logging} import org.apache.kyuubi.client.api.v1.dto._ import org.apache.kyuubi.events.KyuubiOperationEvent import org.apache.kyuubi.operation.{FetchOrientation, KyuubiOperation, OperationHandle} @@ -37,6 +36,7 @@ import org.apache.kyuubi.server.api.ApiRequestContext @Tag(name = "Operation") @Produces(Array(MediaType.APPLICATION_JSON)) +@Consumes(Array(MediaType.APPLICATION_JSON)) private[v1] class OperationsResource extends ApiRequestContext with Logging { @ApiResponse( @@ -64,8 +64,7 @@ private[v1] class OperationsResource extends ApiRequestContext with Logging { @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "apply an action for an operation") @PUT @@ -183,24 +182,55 @@ private[v1] class OperationsResource extends ApiRequestContext with Logging { i.getSetField.name(), i.getSetField match { case TColumnValue._Fields.STRING_VAL => - i.getStringVal.getFieldValue(TStringValue._Fields.VALUE) + if (i.getStringVal.isSetValue) { + i.getStringVal.getFieldValue(TStringValue._Fields.VALUE) + } else { + null + } case TColumnValue._Fields.BOOL_VAL => - i.getBoolVal.getFieldValue(TBoolValue._Fields.VALUE) + if (i.getBoolVal.isSetValue) { + i.getBoolVal.getFieldValue(TBoolValue._Fields.VALUE) + } else { + null + } case TColumnValue._Fields.BYTE_VAL => - i.getByteVal.getFieldValue(TByteValue._Fields.VALUE) + if (i.getByteVal.isSetValue) { + i.getByteVal.getFieldValue(TByteValue._Fields.VALUE) + } else { + null + } case TColumnValue._Fields.DOUBLE_VAL => - i.getDoubleVal.getFieldValue(TDoubleValue._Fields.VALUE) + if (i.getDoubleVal.isSetValue) { + i.getDoubleVal.getFieldValue(TDoubleValue._Fields.VALUE) + } else { + null + } case TColumnValue._Fields.I16_VAL => - i.getI16Val.getFieldValue(TI16Value._Fields.VALUE) + if (i.getI16Val.isSetValue) { + i.getI16Val.getFieldValue(TI16Value._Fields.VALUE) + } else { + null + } case TColumnValue._Fields.I32_VAL => - i.getI32Val.getFieldValue(TI32Value._Fields.VALUE) + if (i.getI32Val.isSetValue) { + i.getI32Val.getFieldValue(TI32Value._Fields.VALUE) + } else { + null + } case TColumnValue._Fields.I64_VAL => - i.getI64Val.getFieldValue(TI64Value._Fields.VALUE) + if (i.getI64Val.isSetValue) { + i.getI64Val.getFieldValue(TI64Value._Fields.VALUE) + } else { + null + } }) }).asJava) }) new ResultRowSet(rows.asJava, rows.size) } catch { + case e: IllegalArgumentException => + error(e.getMessage, e) + throw new BadRequestException(e.getMessage) case NonFatal(e) => val errorMsg = s"Error getting result row set for operation handle $operationHandleStr" error(errorMsg, e) diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/SessionsResource.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/SessionsResource.scala index 80212faf2c3..81d1a27092f 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/SessionsResource.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/api/v1/SessionsResource.scala @@ -33,14 +33,13 @@ import org.apache.kyuubi.Logging import org.apache.kyuubi.client.api.v1.dto import org.apache.kyuubi.client.api.v1.dto._ import org.apache.kyuubi.config.KyuubiReservedKeys._ -import org.apache.kyuubi.events.KyuubiEvent import org.apache.kyuubi.operation.OperationHandle -import org.apache.kyuubi.server.api.ApiRequestContext -import org.apache.kyuubi.session.KyuubiSession -import org.apache.kyuubi.session.SessionHandle +import org.apache.kyuubi.server.api.{ApiRequestContext, ApiUtils} +import org.apache.kyuubi.session.{KyuubiSession, SessionHandle} @Tag(name = "Session") @Produces(Array(MediaType.APPLICATION_JSON)) +@Consumes(Array(MediaType.APPLICATION_JSON)) private[v1] class SessionsResource extends ApiRequestContext with Logging { implicit def toSessionHandle(str: String): SessionHandle = SessionHandle.fromUUID(str) private def sessionManager = fe.be.sessionManager @@ -53,15 +52,8 @@ private[v1] class SessionsResource extends ApiRequestContext with Logging { description = "get the list of all live sessions") @GET def sessions(): Seq[SessionData] = { - sessionManager.allSessions().map { session => - new SessionData( - session.handle.identifier.toString, - session.user, - session.ipAddress, - session.conf.asJava, - session.createTime, - session.lastAccessTime - session.createTime, - session.getNoOperationTime) + sessionManager.allSessions().map { case session => + ApiUtils.sessionData(session.asInstanceOf[KyuubiSession]) }.toSeq } @@ -69,14 +61,32 @@ private[v1] class SessionsResource extends ApiRequestContext with Logging { responseCode = "200", content = Array(new Content( mediaType = MediaType.APPLICATION_JSON, - schema = new Schema(implementation = classOf[KyuubiEvent]))), + schema = new Schema(implementation = classOf[dto.KyuubiSessionEvent]))), description = "get a session event via session handle identifier") @GET @Path("{sessionHandle}") - def sessionInfo(@PathParam("sessionHandle") sessionHandleStr: String): KyuubiEvent = { + def sessionInfo(@PathParam("sessionHandle") sessionHandleStr: String): dto.KyuubiSessionEvent = { try { sessionManager.getSession(sessionHandleStr) - .asInstanceOf[KyuubiSession].getSessionEvent.get + .asInstanceOf[KyuubiSession].getSessionEvent.map(event => + dto.KyuubiSessionEvent.builder + .sessionId(event.sessionId) + .clientVersion(event.clientVersion) + .sessionType(event.sessionType) + .sessionName(event.sessionName) + .user(event.user) + .clientIp(event.clientIP) + .serverIp(event.serverIP) + .conf(event.conf.asJava) + .remoteSessionId(event.remoteSessionId) + .engineId(event.engineId) + .eventTime(event.eventTime) + .openedTime(event.openedTime) + .startTime(event.startTime) + .endTime(event.endTime) + .totalOperations(event.totalOperations) + .exception(event.exception.getOrElse(null)) + .build).get } catch { case NonFatal(e) => error(s"Invalid $sessionHandleStr", e) @@ -130,21 +140,20 @@ private[v1] class SessionsResource extends ApiRequestContext with Logging { def execPoolStatistic(): ExecPoolStatistic = { new ExecPoolStatistic( sessionManager.getExecPoolSize, - sessionManager.getActiveCount) + sessionManager.getActiveCount, + sessionManager.getWorkQueueSize) } @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "Open(create) a session") @POST - @Consumes(Array(MediaType.APPLICATION_JSON)) def openSession(request: SessionOpenRequest): dto.SessionHandle = { val userName = fe.getSessionUser(request.getConfigs.asScala.toMap) val ipAddress = fe.getIpAddress val handle = fe.be.openSession( - TProtocolVersion.findByValue(request.getProtocolVersion), + SessionsResource.SESSION_PROTOCOL_VERSION, userName, "", ipAddress, @@ -158,8 +167,7 @@ private[v1] class SessionsResource extends ApiRequestContext with Logging { @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "Close a session") @DELETE @Path("{sessionHandle}") @@ -183,7 +191,7 @@ private[v1] class SessionsResource extends ApiRequestContext with Logging { fe.be.executeStatement( sessionHandleStr, request.getStatement, - Map.empty, + request.getConfOverlay.asScala.toMap, request.isRunAsync, request.getQueryTimeout) } catch { @@ -406,3 +414,7 @@ private[v1] class SessionsResource extends ApiRequestContext with Logging { } } } + +object SessionsResource { + final val SESSION_PROTOCOL_VERSION = TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V1 +} diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/http/authentication/AuthenticationAuditLogger.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/http/authentication/AuthenticationAuditLogger.scala index ac1ee2a63a6..ac74c449bdf 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/http/authentication/AuthenticationAuditLogger.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/http/authentication/AuthenticationAuditLogger.scala @@ -35,6 +35,7 @@ object AuthenticationAuditLogger extends Logging { sb.append(s"proxyIp=${HTTP_PROXY_HEADER_CLIENT_IP_ADDRESS.get()}").append("\t") sb.append(s"method=${request.getMethod}").append("\t") sb.append(s"uri=${request.getRequestURI}").append("\t") + sb.append(s"params=${request.getQueryString}").append("\t") sb.append(s"protocol=${request.getProtocol}").append("\t") sb.append(s"status=${response.getStatus}") info(sb.toString()) diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/http/authentication/AuthenticationFilter.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/http/authentication/AuthenticationFilter.scala index 740937d8ec9..3c4065a7bdc 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/http/authentication/AuthenticationFilter.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/http/authentication/AuthenticationFilter.scala @@ -79,7 +79,6 @@ class AuthenticationFilter(conf: KyuubiConf) extends Filter with Logging { override def init(filterConfig: FilterConfig): Unit = { initAuthHandlers() - super.init(filterConfig) } private[kyuubi] def getMatchedHandler(authorization: String): Option[AuthenticationHandler] = { diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/MetadataManager.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/MetadataManager.scala index 5cecd2ab149..88a7f4e4ebd 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/MetadataManager.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/MetadataManager.scala @@ -20,6 +20,8 @@ package org.apache.kyuubi.server.metadata import java.util.concurrent.{ConcurrentHashMap, ThreadPoolExecutor, TimeUnit} import java.util.concurrent.atomic.AtomicInteger +import scala.collection.JavaConverters._ + import org.apache.kyuubi.{KyuubiException, Logging} import org.apache.kyuubi.client.api.v1.dto.Batch import org.apache.kyuubi.config.KyuubiConf @@ -29,7 +31,7 @@ import org.apache.kyuubi.operation.OperationState import org.apache.kyuubi.server.metadata.api.{Metadata, MetadataFilter} import org.apache.kyuubi.service.AbstractService import org.apache.kyuubi.session.SessionType -import org.apache.kyuubi.util.{ClassUtils, ThreadUtils} +import org.apache.kyuubi.util.{ClassUtils, JdbcUtils, ThreadUtils} class MetadataManager extends AbstractService("MetadataManager") { import MetadataManager._ @@ -37,44 +39,55 @@ class MetadataManager extends AbstractService("MetadataManager") { private var _metadataStore: MetadataStore = _ // Visible for testing. - private[metadata] val identifierRequestsRetryRefs = + private[metadata] val identifierRequestsAsyncRetryRefs = new ConcurrentHashMap[String, MetadataRequestsRetryRef]() // Visible for testing. - private[metadata] val identifierRequestsRetryingCounts = + private[metadata] val identifierRequestsAsyncRetryingCounts = new ConcurrentHashMap[String, AtomicInteger]() - private val requestsRetryTrigger = - ThreadUtils.newDaemonSingleThreadScheduledExecutor("metadata-requests-retry-trigger") + private lazy val requestsRetryInterval = + conf.get(KyuubiConf.METADATA_REQUEST_RETRY_INTERVAL) + + private lazy val requestsAsyncRetryEnabled = + conf.get(KyuubiConf.METADATA_REQUEST_ASYNC_RETRY_ENABLED) + + private lazy val requestsAsyncRetryTrigger = + ThreadUtils.newDaemonSingleThreadScheduledExecutor("metadata-requests-async-retry-trigger") - private var requestsRetryExecutor: ThreadPoolExecutor = _ + private lazy val requestsAsyncRetryExecutor: ThreadPoolExecutor = + ThreadUtils.newDaemonFixedThreadPool( + conf.get(KyuubiConf.METADATA_REQUEST_ASYNC_RETRY_THREADS), + "metadata-requests-async-retry") - private var maxMetadataRequestsRetryRefs: Int = _ + private lazy val cleanerEnabled = conf.get(KyuubiConf.METADATA_CLEANER_ENABLED) - private val metadataCleaner = + private lazy val metadataCleaner = ThreadUtils.newDaemonSingleThreadScheduledExecutor("metadata-cleaner") override def initialize(conf: KyuubiConf): Unit = { _metadataStore = MetadataManager.createMetadataStore(conf) - val retryExecutorNumThreads = - conf.get(KyuubiConf.METADATA_REQUEST_RETRY_THREADS) - requestsRetryExecutor = ThreadUtils.newDaemonFixedThreadPool( - retryExecutorNumThreads, - "metadata-requests-retry-executor") - maxMetadataRequestsRetryRefs = conf.get(KyuubiConf.METADATA_REQUEST_RETRY_QUEUE_SIZE) super.initialize(conf) } override def start(): Unit = { super.start() - startMetadataRequestsRetryTrigger() - startMetadataCleaner() + if (requestsAsyncRetryEnabled) { + startMetadataRequestsAsyncRetryTrigger() + } + if (cleanerEnabled) { + startMetadataCleaner() + } } override def stop(): Unit = { - ThreadUtils.shutdown(requestsRetryTrigger) - ThreadUtils.shutdown(requestsRetryExecutor) - ThreadUtils.shutdown(metadataCleaner) + if (requestsAsyncRetryEnabled) { + ThreadUtils.shutdown(requestsAsyncRetryTrigger) + ThreadUtils.shutdown(requestsAsyncRetryExecutor) + } + if (cleanerEnabled) { + ThreadUtils.shutdown(metadataCleaner) + } _metadataStore.close() super.stop() } @@ -93,11 +106,19 @@ class MetadataManager extends AbstractService("MetadataManager") { } } - def insertMetadata(metadata: Metadata, retryOnError: Boolean = true): Unit = { + protected def unrecoverableDBErr(cause: Throwable): Boolean = { + // cover other cases in the future + JdbcUtils.isDuplicatedKeyDBErr(cause) + } + + def insertMetadata(metadata: Metadata, asyncRetryOnError: Boolean = true): Unit = { try { withMetadataRequestMetrics(_metadataStore.insertMetadata(metadata)) } catch { - case e: Throwable if retryOnError => + // stop to retry when encounter duplicated key error. + case rethrow: Throwable if unrecoverableDBErr(rethrow) => + throw rethrow + case e: Throwable if requestsAsyncRetryEnabled && asyncRetryOnError => error(s"Error inserting metadata for session ${metadata.identifier}", e) addMetadataRetryRequest(InsertMetadata(metadata)) } @@ -156,11 +177,11 @@ class MetadataManager extends AbstractService("MetadataManager") { withMetadataRequestMetrics(_metadataStore.getMetadataList(filter, from, size, true)) } - def updateMetadata(metadata: Metadata, retryOnError: Boolean = true): Unit = { + def updateMetadata(metadata: Metadata, asyncRetryOnError: Boolean = true): Unit = { try { withMetadataRequestMetrics(_metadataStore.updateMetadata(metadata)) } catch { - case e: Throwable if retryOnError => + case e: Throwable if requestsAsyncRetryEnabled && asyncRetryOnError => error(s"Error updating metadata for session ${metadata.identifier}", e) addMetadataRetryRequest(UpdateMetadata(metadata)) } @@ -171,35 +192,33 @@ class MetadataManager extends AbstractService("MetadataManager") { } private def startMetadataCleaner(): Unit = { - val cleanerEnabled = conf.get(KyuubiConf.METADATA_CLEANER_ENABLED) val stateMaxAge = conf.get(METADATA_MAX_AGE) - - if (cleanerEnabled) { - val interval = conf.get(KyuubiConf.METADATA_CLEANER_INTERVAL) - val cleanerTask: Runnable = () => { - try { - withMetadataRequestMetrics(_metadataStore.cleanupMetadataByAge(stateMaxAge)) - } catch { - case e: Throwable => error("Error cleaning up the metadata by age", e) - } + val interval = conf.get(KyuubiConf.METADATA_CLEANER_INTERVAL) + val cleanerTask: Runnable = () => { + try { + withMetadataRequestMetrics(_metadataStore.cleanupMetadataByAge(stateMaxAge)) + } catch { + case e: Throwable => error("Error cleaning up the metadata by age", e) } - - metadataCleaner.scheduleWithFixedDelay( - cleanerTask, - interval, - interval, - TimeUnit.MILLISECONDS) } + + metadataCleaner.scheduleWithFixedDelay( + cleanerTask, + interval, + interval, + TimeUnit.MILLISECONDS) } def addMetadataRetryRequest(request: MetadataRequest): Unit = { - if (identifierRequestsRetryRefs.size() > maxMetadataRequestsRetryRefs) { + val maxRequestsAsyncRetryRefs: Int = + conf.get(KyuubiConf.METADATA_REQUEST_ASYNC_RETRY_QUEUE_SIZE) + if (identifierRequestsAsyncRetryRefs.size() > maxRequestsAsyncRetryRefs) { throw new KyuubiException( "The number of metadata requests retry instances exceeds the limitation:" + - maxMetadataRequestsRetryRefs) + maxRequestsAsyncRetryRefs) } val identifier = request.metadata.identifier - val ref = identifierRequestsRetryRefs.computeIfAbsent( + val ref = identifierRequestsAsyncRetryRefs.computeIfAbsent( identifier, identifier => { val ref = new MetadataRequestsRetryRef @@ -207,30 +226,29 @@ class MetadataManager extends AbstractService("MetadataManager") { ref }) ref.addRetryingMetadataRequest(request) - identifierRequestsRetryRefs.putIfAbsent(identifier, ref) + identifierRequestsAsyncRetryRefs.putIfAbsent(identifier, ref) MetricsSystem.tracing(_.markMeter(MetricsConstants.METADATA_REQUEST_RETRYING)) } def getMetadataRequestsRetryRef(identifier: String): MetadataRequestsRetryRef = { - identifierRequestsRetryRefs.get(identifier) + identifierRequestsAsyncRetryRefs.get(identifier) } def deRegisterRequestsRetryRef(identifier: String): Unit = { - identifierRequestsRetryRefs.remove(identifier) - identifierRequestsRetryingCounts.remove(identifier) + identifierRequestsAsyncRetryRefs.remove(identifier) + identifierRequestsAsyncRetryingCounts.remove(identifier) } - private def startMetadataRequestsRetryTrigger(): Unit = { - val interval = conf.get(KyuubiConf.METADATA_REQUEST_RETRY_INTERVAL) + private def startMetadataRequestsAsyncRetryTrigger(): Unit = { val triggerTask = new Runnable { override def run(): Unit = { - identifierRequestsRetryRefs.forEach { (id, ref) => + identifierRequestsAsyncRetryRefs.forEach { (id, ref) => if (!ref.hasRemainingRequests()) { - identifierRequestsRetryRefs.remove(id) - identifierRequestsRetryingCounts.remove(id) + identifierRequestsAsyncRetryRefs.remove(id) + identifierRequestsAsyncRetryingCounts.remove(id) } else { - val retryingCount = - identifierRequestsRetryingCounts.computeIfAbsent(id, _ => new AtomicInteger(0)) + val retryingCount = identifierRequestsAsyncRetryingCounts + .computeIfAbsent(id, _ => new AtomicInteger(0)) if (retryingCount.get() == 0) { val retryTask = new Runnable { @@ -241,12 +259,9 @@ class MetadataManager extends AbstractService("MetadataManager") { while (request != null) { request match { case insert: InsertMetadata => - insertMetadata(insert.metadata, retryOnError = false) - + insertMetadata(insert.metadata, asyncRetryOnError = false) case update: UpdateMetadata => - updateMetadata(update.metadata, retryOnError = false) - - case _ => + updateMetadata(update.metadata, asyncRetryOnError = false) } ref.metadataRequests.remove(request) MetricsSystem.tracing(_.markMeter( @@ -265,22 +280,21 @@ class MetadataManager extends AbstractService("MetadataManager") { try { retryingCount.incrementAndGet() - requestsRetryExecutor.submit(retryTask) + requestsAsyncRetryExecutor.submit(retryTask) } catch { case e: Throwable => error(s"Error submitting metadata retry requests for $id", e) retryingCount.decrementAndGet() } } - } } } } - requestsRetryTrigger.scheduleWithFixedDelay( + requestsAsyncRetryTrigger.scheduleWithFixedDelay( triggerTask, - interval, - interval, + requestsRetryInterval, + requestsRetryInterval, TimeUnit.MILLISECONDS) } } @@ -319,6 +333,7 @@ object MetadataManager extends Logging { batchMetadata.kyuubiInstance, batchState, batchMetadata.createTime, - batchMetadata.endTime) + batchMetadata.endTime, + Map.empty[String, String].asJava) } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/MetadataRequest.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/MetadataRequest.scala index dcee6466bad..2c121edfeb1 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/MetadataRequest.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/MetadataRequest.scala @@ -19,7 +19,7 @@ package org.apache.kyuubi.server.metadata import org.apache.kyuubi.server.metadata.api.Metadata -trait MetadataRequest { +sealed trait MetadataRequest { def metadata: Metadata } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStore.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStore.scala index 151d846d8ca..488039e2baa 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStore.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStore.scala @@ -39,6 +39,7 @@ import org.apache.kyuubi.server.metadata.api.{Metadata, MetadataFilter} import org.apache.kyuubi.server.metadata.jdbc.DatabaseType._ import org.apache.kyuubi.server.metadata.jdbc.JDBCMetadataStoreConf._ import org.apache.kyuubi.session.SessionType +import org.apache.kyuubi.util.JdbcUtils class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { import JDBCMetadataStore._ @@ -68,11 +69,10 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { hikariConfig.setPoolName("jdbc-metadata-store-pool") @VisibleForTesting - private[kyuubi] val hikariDataSource = new HikariDataSource(hikariConfig) + implicit private[kyuubi] val hikariDataSource = new HikariDataSource(hikariConfig) private val mapper = new ObjectMapper().registerModule(DefaultScalaModule) - private val terminalStates = - OperationState.terminalStates.map(x => s"'${x.toString}'").mkString(", ") + private val terminalStates = OperationState.terminalStates.map(x => s"'$x'").mkString(", ") if (conf.get(METADATA_STORE_JDBC_DATABASE_SCHEMA_INIT)) { initSchema() @@ -81,7 +81,7 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { private def initSchema(): Unit = { getInitSchema(dbType).foreach { schema => val ddlStatements = schema.trim.split(";") - withConnection() { connection => + JdbcUtils.withConnection { connection => Utils.tryLogNonFatalError { ddlStatements.foreach { ddlStatement => execute(connection, ddlStatement) @@ -96,37 +96,49 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { private[jdbc] def getInitSchema(dbType: DatabaseType): Option[String] = { val classLoader = Utils.getContextOrKyuubiClassLoader val schemaPackage = s"sql/${dbType.toString.toLowerCase}" - val schemaUrlPattern = """^metadata-store-schema-(\d+)\.(\d+)\.(\d+)\.(.*)\.sql$""".r - val schemaUrls = ListBuffer[String]() - Option(classLoader.getResource(schemaPackage)).map(_.toURI).foreach { uri => + Option(classLoader.getResource(schemaPackage)).map(_.toURI).flatMap { uri => val pathNames = if (uri.getScheme == "jar") { val fs = FileSystems.newFileSystem(uri, Map.empty[String, AnyRef].asJava) try { Files.walk(fs.getPath(schemaPackage), 1).iterator().asScala.map( _.getFileName.toString).filter { name => - schemaUrlPattern.findFirstMatchIn(name).isDefined + SCHEMA_URL_PATTERN.findFirstMatchIn(name).isDefined }.toArray } finally { fs.close() } } else { Paths.get(uri).toFile.listFiles((_, name) => { - schemaUrlPattern.findFirstMatchIn(name).isDefined + SCHEMA_URL_PATTERN.findFirstMatchIn(name).isDefined }).map(_.getName) } - pathNames.foreach(name => schemaUrls += s"$schemaPackage/$name") + getLatestSchemaUrl(pathNames).map(name => s"$schemaPackage/$name").map { schemaUrl => + val inputStream = classLoader.getResourceAsStream(schemaUrl) + try { + new BufferedReader(new InputStreamReader(inputStream)).lines() + .collect(Collectors.joining("\n")) + } finally { + inputStream.close() + } + } } + } - schemaUrls.sorted.lastOption.map { schemaUrl => - val inputStream = classLoader.getResourceAsStream(schemaUrl) - try { - new BufferedReader(new InputStreamReader(inputStream)).lines() - .collect(Collectors.joining("\n")) - } finally { - inputStream.close() - } + def getSchemaVersion(schemaUrl: String): (Int, Int, Int) = + SCHEMA_URL_PATTERN.findFirstMatchIn(schemaUrl) match { + case Some(m) => (m.group(1).toInt, m.group(2).toInt, m.group(3).toInt) + case _ => throw new KyuubiException(s"Invalid schema url: $schemaUrl") } + + def getLatestSchemaUrl(schemaUrls: Seq[String]): Option[String] = { + schemaUrls.sortWith { (u1, u2) => + val v1 = getSchemaVersion(u1) + val v2 = getSchemaVersion(u2) + v1._1 > v2._1 || + (v1._1 == v2._1 && v1._2 > v2._2) || + (v1._1 == v2._1 && v1._2 == v2._2 && v1._3 > v2._3) + }.headOption } override def close(): Unit = { @@ -156,7 +168,7 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { |VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |""".stripMargin - withConnection() { connection => + JdbcUtils.withConnection { connection => execute( connection, query, @@ -186,7 +198,7 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { s"SELECT $METADATA_ALL_COLUMNS FROM $METADATA_TABLE WHERE identifier = ?" } - withConnection() { connection => + JdbcUtils.withConnection { connection => withResultSet(connection, query, identifier) { rs => buildMetadata(rs, stateOnly).headOption.orNull } @@ -207,44 +219,44 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { } val whereConditions = ListBuffer[String]() Option(filter.sessionType).foreach { sessionType => - whereConditions += " session_type = ?" + whereConditions += "session_type = ?" params += sessionType.toString } Option(filter.engineType).filter(_.nonEmpty).foreach { engineType => - whereConditions += " UPPER(engine_type) = ? " + whereConditions += "UPPER(engine_type) = ?" params += engineType.toUpperCase(Locale.ROOT) } Option(filter.username).filter(_.nonEmpty).foreach { username => - whereConditions += " user_name = ? " + whereConditions += "user_name = ?" params += username } Option(filter.state).filter(_.nonEmpty).foreach { state => - whereConditions += " state = ? " + whereConditions += "state = ?" params += state.toUpperCase(Locale.ROOT) } Option(filter.kyuubiInstance).filter(_.nonEmpty).foreach { kyuubiInstance => - whereConditions += " kyuubi_instance = ? " + whereConditions += "kyuubi_instance = ?" params += kyuubiInstance } if (filter.createTime > 0) { - whereConditions += " create_time >= ? " + whereConditions += "create_time >= ?" params += filter.createTime } if (filter.endTime > 0) { - whereConditions += " end_time > 0 " - whereConditions += " end_time <= ? " + whereConditions += "end_time > 0" + whereConditions += "end_time <= ?" params += filter.endTime } if (filter.peerInstanceClosed) { - whereConditions += " peer_instance_closed = ? " + whereConditions += "peer_instance_closed = ?" params += filter.peerInstanceClosed } if (whereConditions.nonEmpty) { - queryBuilder.append(whereConditions.mkString(" WHERE ", " AND ", " ")) + queryBuilder.append(whereConditions.mkString(" WHERE ", " AND ", "")) } - queryBuilder.append(" ORDER BY key_id ") + queryBuilder.append(" ORDER BY key_id") val query = databaseAdaptor.addLimitAndOffsetToQuery(queryBuilder.toString(), size, from) - withConnection() { connection => + JdbcUtils.withConnection { connection => withResultSet(connection, query, params: _*) { rs => buildMetadata(rs, stateOnly) } @@ -258,49 +270,49 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { queryBuilder.append(s"UPDATE $METADATA_TABLE") val setClauses = ListBuffer[String]() Option(metadata.state).foreach { _ => - setClauses += " state = ? " + setClauses += "state = ?" params += metadata.state } if (metadata.endTime > 0) { - setClauses += " end_time = ? " + setClauses += "end_time = ?" params += metadata.endTime } if (metadata.engineOpenTime > 0) { - setClauses += " engine_open_time = ? " + setClauses += "engine_open_time = ?" params += metadata.engineOpenTime } Option(metadata.engineId).foreach { _ => - setClauses += " engine_id = ? " + setClauses += "engine_id = ?" params += metadata.engineId } Option(metadata.engineName).foreach { _ => - setClauses += " engine_name = ? " + setClauses += "engine_name = ?" params += metadata.engineName } Option(metadata.engineUrl).foreach { _ => - setClauses += " engine_url = ? " + setClauses += "engine_url = ?" params += metadata.engineUrl } Option(metadata.engineState).foreach { _ => - setClauses += " engine_state = ? " + setClauses += "engine_state = ?" params += metadata.engineState } metadata.engineError.foreach { error => - setClauses += " engine_error = ? " + setClauses += "engine_error = ?" params += error } if (metadata.peerInstanceClosed) { - setClauses += " peer_instance_closed = ? " + setClauses += "peer_instance_closed = ?" params += metadata.peerInstanceClosed } if (setClauses.nonEmpty) { - queryBuilder.append(setClauses.mkString(" SET ", " , ", " ")) + queryBuilder.append(setClauses.mkString(" SET ", ", ", "")) } - queryBuilder.append(" WHERE identifier = ? ") + queryBuilder.append(" WHERE identifier = ?") params += metadata.identifier val query = queryBuilder.toString() - withConnection() { connection => + JdbcUtils.withConnection { connection => withUpdateCount(connection, query, params: _*) { updateCount => if (updateCount == 0) { throw new KyuubiException( @@ -312,7 +324,7 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { override def cleanupMetadataByIdentifier(identifier: String): Unit = { val query = s"DELETE FROM $METADATA_TABLE WHERE identifier = ?" - withConnection() { connection => + JdbcUtils.withConnection { connection => execute(connection, query, identifier) } } @@ -320,7 +332,7 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { override def cleanupMetadataByAge(maxAge: Long): Unit = { val minEndTime = System.currentTimeMillis() - maxAge val query = s"DELETE FROM $METADATA_TABLE WHERE state IN ($terminalStates) AND end_time < ?" - withConnection() { connection => + JdbcUtils.withConnection { connection => execute(connection, query, minEndTime) } } @@ -391,7 +403,7 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { } private def execute(conn: Connection, sql: String, params: Any*): Unit = { - debug(s"executing sql $sql") + debug(s"execute sql: $sql, with params: ${params.mkString(", ")}") var statement: PreparedStatement = null try { statement = conn.prepareStatement(sql) @@ -399,7 +411,9 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { statement.execute() } catch { case e: SQLException => - throw new KyuubiException(s"Error executing $sql:" + e.getMessage, e) + throw new KyuubiException( + s"Error executing sql: $sql, with params: ${params.mkString(", ")}. ${e.getMessage}", + e) } finally { if (statement != null) { Utils.tryLogNonFatalError(statement.close()) @@ -411,7 +425,7 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { conn: Connection, sql: String, params: Any*)(f: ResultSet => T): T = { - debug(s"executing sql $sql with result set") + debug(s"executeQuery sql: $sql, with params: ${params.mkString(", ")}") var statement: PreparedStatement = null var resultSet: ResultSet = null try { @@ -421,7 +435,9 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { f(resultSet) } catch { case e: SQLException => - throw new KyuubiException(e.getMessage, e) + throw new KyuubiException( + s"Error executing sql: $sql, with params: ${params.mkString(", ")}. ${e.getMessage}", + e) } finally { if (resultSet != null) { Utils.tryLogNonFatalError(resultSet.close()) @@ -436,7 +452,7 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { conn: Connection, sql: String, params: Any*)(f: Int => T): T = { - debug(s"executing sql $sql with update count") + debug(s"executeUpdate sql: $sql, with params: ${params.mkString(", ")}") var statement: PreparedStatement = null try { statement = conn.prepareStatement(sql) @@ -444,7 +460,9 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { f(statement.executeUpdate()) } catch { case e: SQLException => - throw new KyuubiException(e.getMessage, e) + throw new KyuubiException( + s"Error executing sql: $sql, with params: ${params.mkString(", ")}. ${e.getMessage}", + e) } finally { if (statement != null) { Utils.tryLogNonFatalError(statement.close()) @@ -467,22 +485,6 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { } } - private def withConnection[T](autoCommit: Boolean = true)(f: Connection => T): T = { - var connection: Connection = null - try { - connection = hikariDataSource.getConnection - connection.setAutoCommit(autoCommit) - f(connection) - } catch { - case e: SQLException => - throw new KyuubiException(e.getMessage, e) - } finally { - if (connection != null) { - Utils.tryLogNonFatalError(connection.close()) - } - } - } - private def valueAsString(obj: Any): String = { mapper.writeValueAsString(obj) } @@ -505,6 +507,7 @@ class JDBCMetadataStore(conf: KyuubiConf) extends MetadataStore with Logging { } object JDBCMetadataStore { + private val SCHEMA_URL_PATTERN = """^metadata-store-schema-(\d+)\.(\d+)\.(\d+)\.(.*)\.sql$""".r private val METADATA_TABLE = "metadata" private val METADATA_STATE_ONLY_COLUMNS = Seq( "identifier", diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStoreConf.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStoreConf.scala index 27b9bc58e11..de30b6e6689 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStoreConf.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStoreConf.scala @@ -19,13 +19,12 @@ package org.apache.kyuubi.server.metadata.jdbc import java.util.{Locale, Properties} -import org.apache.kyuubi.config.{ConfigBuilder, ConfigEntry, KyuubiConf, OptionalConfigEntry} +import org.apache.kyuubi.config.{ConfigEntry, KyuubiConf, OptionalConfigEntry} +import org.apache.kyuubi.config.KyuubiConf.buildConf object JDBCMetadataStoreConf { final val METADATA_STORE_JDBC_DATASOURCE_PREFIX = "kyuubi.metadata.store.jdbc.datasource" - private def buildConf(key: String): ConfigBuilder = KyuubiConf.buildConf(key) - /** Get metadata store jdbc datasource properties. */ def getMetadataStoreJDBCDataSourceProperties(conf: KyuubiConf): Properties = { val datasourceProperties = new Properties() @@ -38,11 +37,11 @@ object JDBCMetadataStoreConf { val METADATA_STORE_JDBC_DATABASE_TYPE: ConfigEntry[String] = buildConf("kyuubi.metadata.store.jdbc.database.type") .doc("The database type for server jdbc metadata store.
                " + - "
              • DERBY: Apache Derby, jdbc driver `org.apache.derby.jdbc.AutoloadedDriver`.
              • " + - "
              • MYSQL: MySQL, jdbc driver `com.mysql.jdbc.Driver`.
              • " + - "
              • CUSTOM: User-defined database type, need to specify corresponding jdbc driver.
              • " + - " Note that: The jdbc datasource is powered by HiKariCP, for datasource properties," + - " please specify them with prefix: kyuubi.metadata.store.jdbc.datasource." + + "
              • DERBY: Apache Derby, JDBC driver `org.apache.derby.jdbc.AutoloadedDriver`.
              • " + + "
              • MYSQL: MySQL, JDBC driver `com.mysql.jdbc.Driver`.
              • " + + "
              • CUSTOM: User-defined database type, need to specify corresponding JDBC driver.
              • " + + " Note that: The JDBC datasource is powered by HiKariCP, for datasource properties," + + " please specify them with the prefix: kyuubi.metadata.store.jdbc.datasource." + " For example, kyuubi.metadata.store.jdbc.datasource.connectionTimeout=10000.") .version("1.6.0") .serverOnly @@ -52,7 +51,7 @@ object JDBCMetadataStoreConf { val METADATA_STORE_JDBC_DATABASE_SCHEMA_INIT: ConfigEntry[Boolean] = buildConf("kyuubi.metadata.store.jdbc.database.schema.init") - .doc("Whether to init the jdbc metadata store database schema.") + .doc("Whether to init the JDBC metadata store database schema.") .version("1.6.0") .serverOnly .booleanConf @@ -68,9 +67,10 @@ object JDBCMetadataStoreConf { val METADATA_STORE_JDBC_URL: ConfigEntry[String] = buildConf("kyuubi.metadata.store.jdbc.url") - .doc("The jdbc url for server jdbc metadata store. By defaults, it is a DERBY in-memory" + + .doc("The JDBC url for server JDBC metadata store. By default, it is a DERBY in-memory" + " database url, and the state information is not shared across kyuubi instances. To" + - " enable multiple kyuubi instances high available, please specify a production jdbc url.") + " enable high availability for multiple kyuubi instances," + + " please specify a production JDBC url.") .version("1.6.0") .serverOnly .stringConf @@ -78,7 +78,7 @@ object JDBCMetadataStoreConf { val METADATA_STORE_JDBC_USER: ConfigEntry[String] = buildConf("kyuubi.metadata.store.jdbc.user") - .doc("The username for server jdbc metadata store.") + .doc("The username for server JDBC metadata store.") .version("1.6.0") .serverOnly .stringConf @@ -86,7 +86,7 @@ object JDBCMetadataStoreConf { val METADATA_STORE_JDBC_PASSWORD: ConfigEntry[String] = buildConf("kyuubi.metadata.store.jdbc.password") - .doc("The password for server jdbc metadata store.") + .doc("The password for server JDBC metadata store.") .version("1.6.0") .serverOnly .stringConf diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/KyuubiTrinoOperationTranslator.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/KyuubiTrinoOperationTranslator.scala index 6ec9fc1c80e..c78cb351edf 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/KyuubiTrinoOperationTranslator.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/KyuubiTrinoOperationTranslator.scala @@ -19,30 +19,22 @@ package org.apache.kyuubi.server.trino.api import scala.collection.JavaConverters._ -import org.apache.hive.service.rpc.thrift.TProtocolVersion - import org.apache.kyuubi.operation.OperationHandle import org.apache.kyuubi.service.BackendService +import org.apache.kyuubi.session.SessionHandle import org.apache.kyuubi.sql.parser.trino.KyuubiTrinoFeParser import org.apache.kyuubi.sql.plan.PassThroughNode -import org.apache.kyuubi.sql.plan.trino.{GetCatalogs, GetColumns, GetSchemas, GetTables, GetTableTypes, GetTypeInfo} +import org.apache.kyuubi.sql.plan.trino.{GetCatalogs, GetColumns, GetPrimaryKeys, GetSchemas, GetTables, GetTableTypes, GetTypeInfo} class KyuubiTrinoOperationTranslator(backendService: BackendService) { lazy val parser = new KyuubiTrinoFeParser() def transform( statement: String, - user: String, - ipAddress: String, + sessionHandle: SessionHandle, configs: Map[String, String], runAsync: Boolean, queryTimeout: Long): OperationHandle = { - val sessionHandle = backendService.openSession( - TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V11, - user, - "", - ipAddress, - configs) parser.parsePlan(statement) match { case GetSchemas(catalogName, schemaPattern) => backendService.getSchemas(sessionHandle, catalogName, schemaPattern) @@ -68,6 +60,11 @@ class KyuubiTrinoOperationTranslator(backendService: BackendService) { schemaPattern, tableNamePattern, colNamePattern) + case GetPrimaryKeys() => + val operationHandle = backendService.getPrimaryKeys(sessionHandle, null, null, null) + // The trino implementation always returns empty. + operationHandle.setHasResultSet(false) + operationHandle case PassThroughNode() => backendService.executeStatement(sessionHandle, statement, configs, runAsync, queryTimeout) } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/Query.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/Query.scala new file mode 100644 index 00000000000..4e768b04a41 --- /dev/null +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/Query.scala @@ -0,0 +1,288 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.server.trino.api + +import java.net.URI +import java.security.SecureRandom +import java.util.Objects.requireNonNull +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong +import javax.ws.rs.WebApplicationException +import javax.ws.rs.core.{Response, UriInfo} + +import scala.collection.mutable + +import Slug.Context.{EXECUTING_QUERY, QUEUED_QUERY} +import com.google.common.hash.Hashing +import io.trino.client.QueryResults +import org.apache.hive.service.rpc.thrift.{TBoolValue, TColumnDesc, TColumnValue, TGetResultSetMetadataResp, TPrimitiveTypeEntry, TProtocolVersion, TRow, TRowSet, TTableSchema, TTypeDesc, TTypeEntry, TTypeId} + +import org.apache.kyuubi.operation.{FetchOrientation, OperationHandle, OperationState, OperationStatus} +import org.apache.kyuubi.operation.OperationState.{FINISHED, INITIALIZED, OperationState, PENDING} +import org.apache.kyuubi.server.trino.api.Query.KYUUBI_SESSION_ID +import org.apache.kyuubi.service.BackendService +import org.apache.kyuubi.service.TFrontendService.OK_STATUS +import org.apache.kyuubi.session.SessionHandle + +case class Query( + queryId: QueryId, + context: TrinoContext, + be: BackendService) { + + private val QUEUED_QUERY_PATH = "/v1/statement/queued/" + private val EXECUTING_QUERY_PATH = "/v1/statement/executing" + + private val slug: Slug = Slug.createNewWithUUID(queryId.getQueryId) + private val lastToken = new AtomicLong + + private val defaultMaxRows = 1000 + private val defaultFetchOrientation = FetchOrientation.withName("FETCH_NEXT") + + def getQueryResults(token: Long, uriInfo: UriInfo, maxWait: Long = 0): QueryResults = { + val status = + be.getOperationStatus(queryId.operationHandle, Some(maxWait)) + val nextUri = if (status.exception.isEmpty) { + getNextUri(token + 1, uriInfo, toSlugContext(status.state)) + } else null + val queryHtmlUri = uriInfo.getRequestUriBuilder + .replacePath("ui/query.html").replaceQuery(queryId.getQueryId).build() + + status.state match { + case FINISHED => + val metaData = be.getResultSetMetadata(queryId.operationHandle) + val resultSet = be.fetchResults( + queryId.operationHandle, + defaultFetchOrientation, + defaultMaxRows, + false) + TrinoContext.createQueryResults( + queryId.getQueryId, + nextUri, + queryHtmlUri, + status, + Option(metaData), + Option(resultSet)) + case _ => + TrinoContext.createQueryResults( + queryId.getQueryId, + nextUri, + queryHtmlUri, + status) + } + } + + def getPrepareQueryResults( + token: Long, + uriInfo: UriInfo, + maxWait: Long = 0): QueryResults = { + val status = OperationStatus(OperationState.FINISHED, 0, 0, 0, 0, false) + val nextUri = null + val queryHtmlUri = uriInfo.getRequestUriBuilder + .replacePath("ui/query.html").replaceQuery(queryId.getQueryId).build() + + val columns = new TGetResultSetMetadataResp() + columns.setStatus(OK_STATUS) + val tColumnDesc = new TColumnDesc() + tColumnDesc.setColumnName("result") + val desc = new TTypeDesc + desc.addToTypes(TTypeEntry.primitiveEntry(new TPrimitiveTypeEntry(TTypeId.BOOLEAN_TYPE))) + tColumnDesc.setTypeDesc(desc) + tColumnDesc.setPosition(0) + val schema = new TTableSchema() + schema.addToColumns(tColumnDesc) + columns.setSchema(schema) + + val rows = new java.util.ArrayList[TRow] + val trow = new TRow() + val value = new TBoolValue() + value.setValue(true) + trow.addToColVals(TColumnValue.boolVal(value)) + rows.add(trow) + val rowSet = new TRowSet(0, rows) + + TrinoContext.createQueryResults( + queryId.getQueryId, + nextUri, + queryHtmlUri, + status, + Option(columns), + Option(rowSet), + updateType = "PREPARE") + } + + def getLastToken: Long = this.lastToken.get() + + def getSlug: Slug = this.slug + + def cancel: Unit = clear + + private def clear = { + be.closeOperation(queryId.operationHandle) + context.session.get(KYUUBI_SESSION_ID).foreach { id => + be.closeSession(SessionHandle.fromUUID(id)) + } + } + + private def setToken(token: Long): Unit = { + val lastToken = this.lastToken.get + if (token != lastToken && token != lastToken + 1) { + throw new WebApplicationException(Response.Status.GONE) + } + this.lastToken.compareAndSet(lastToken, token) + } + + private def getNextUri(token: Long, uriInfo: UriInfo, slugContext: Slug.Context.Context): URI = { + val path = slugContext match { + case QUEUED_QUERY => QUEUED_QUERY_PATH + case EXECUTING_QUERY => EXECUTING_QUERY_PATH + } + + uriInfo.getBaseUriBuilder.replacePath(path) + .path(queryId.getQueryId) + .path(slug.makeSlug(slugContext, token)) + .path(String.valueOf(token)) + .replaceQuery("") + .build() + } + + private def toSlugContext(state: OperationState): Slug.Context.Context = { + state match { + case INITIALIZED | PENDING => Slug.Context.QUEUED_QUERY + case _ => Slug.Context.EXECUTING_QUERY + } + } + +} + +object Query { + + val KYUUBI_SESSION_ID = "kyuubi.session.id" + + def apply( + statement: String, + context: TrinoContext, + translator: KyuubiTrinoOperationTranslator, + backendService: BackendService, + queryTimeout: Long = 0): Query = { + val sessionHandle = getOrCreateSession(context, backendService) + val operationHandle = translator.transform( + statement, + sessionHandle, + context.session, + true, + queryTimeout) + val sessionWithId = + context.session + (KYUUBI_SESSION_ID -> sessionHandle.identifier.toString) + val updatedContext = context.copy(session = sessionWithId) + Query(QueryId(operationHandle), updatedContext, backendService) + } + + def apply( + statementId: String, + statement: String, + context: TrinoContext, + backendService: BackendService): Query = { + val sessionHandle = getOrCreateSession(context, backendService) + val sessionWithId = + context.session + (KYUUBI_SESSION_ID -> sessionHandle.identifier.toString) + Query( + queryId = QueryId(new OperationHandle(UUID.randomUUID())), + context.copy(preparedStatement = Map(statementId -> statement), session = sessionWithId), + backendService) + } + + def apply(id: String, context: TrinoContext, backendService: BackendService): Query = { + Query(QueryId(id), context, backendService) + } + + private def getOrCreateSession( + context: TrinoContext, + backendService: BackendService): SessionHandle = { + context.session.get(KYUUBI_SESSION_ID).map(SessionHandle.fromUUID).getOrElse { + // transform Trino information to session and engine as far as possible. + val trinoInfo = new mutable.HashMap[String, String]() + context.clientInfo.foreach { info => + trinoInfo.put("trino.client.info", info) + } + context.source.foreach { source => + trinoInfo.put("trino.request.source", source) + } + context.traceToken.foreach { traceToken => + trinoInfo.put("trino.trace.token", traceToken) + } + context.timeZone.foreach { timeZone => + trinoInfo.put("trino.time.zone", timeZone) + } + context.language.foreach { language => + trinoInfo.put("trino.language", language) + } + if (context.clientTags.nonEmpty) { + trinoInfo.put("trino.client.info", context.clientTags.mkString(",")) + } + + val newSessionConfigs = context.session ++ trinoInfo + backendService.openSession( + TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V11, + context.user, + "", + context.remoteUserAddress.getOrElse(""), + newSessionConfigs) + } + } + +} + +case class QueryId(operationHandle: OperationHandle) { + def getQueryId: String = operationHandle.identifier.toString +} + +object QueryId { + def apply(id: String): QueryId = QueryId(OperationHandle(id)) +} + +object Slug { + + object Context extends Enumeration { + type Context = Value + val QUEUED_QUERY, EXECUTING_QUERY = Value + } + + private val RANDOM = new SecureRandom + + def createNew: Slug = { + val randomBytes = new Array[Byte](16) + RANDOM.nextBytes(randomBytes) + new Slug(randomBytes) + } + + def createNewWithUUID(uuid: String): Slug = { + val uuidBytes = UUID.fromString(uuid).toString.getBytes("UTF-8") + new Slug(uuidBytes) + } +} + +case class Slug(slugKey: Array[Byte]) { + val hmac = Hashing.hmacSha1(requireNonNull(slugKey, "slugKey is null")) + + def makeSlug(context: Slug.Context.Context, token: Long): String = { + "y" + hmac.newHasher.putInt(context.id).putLong(token).hash.toString + } + + def isValid(context: Slug.Context.Context, slug: String, token: Long): Boolean = + makeSlug(context, token) == slug +} diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoContext.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoContext.scala index 8f3131f61c9..16fc0388a2c 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoContext.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoContext.scala @@ -18,34 +18,43 @@ package org.apache.kyuubi.server.trino.api import java.io.UnsupportedEncodingException -import java.net.{URLDecoder, URLEncoder} +import java.net.{URI, URLDecoder, URLEncoder} +import java.util +import java.util.Optional import javax.ws.rs.core.{HttpHeaders, Response} import scala.collection.JavaConverters._ +import com.google.common.collect.ImmutableList +import io.trino.client.{ClientStandardTypes, ClientTypeSignature, ClientTypeSignatureParameter, Column, NamedClientTypeSignature, QueryError, QueryResults, RowFieldName, StatementStats, Warning} import io.trino.client.ProtocolHeaders.TRINO_HEADERS -import io.trino.client.QueryResults +import org.apache.hive.service.rpc.thrift.{TCLIServiceConstants, TGetResultSetMetadataResp, TRowSet, TTypeEntry, TTypeId} +import org.apache.kyuubi.operation.OperationState.FINISHED +import org.apache.kyuubi.operation.OperationStatus +import org.apache.kyuubi.server.trino.api.Query.KYUUBI_SESSION_ID + +// TODO: Support replace `preparedStatement` for Trino-jdbc /** * The description and functionality of trino request * and response's context * - * @param user Specifies the session user, must be supplied with every query - * @param timeZone The timezone for query processing + * @param user Specifies the session user, must be supplied with every query + * @param timeZone The timezone for query processing * @param clientCapabilities Exclusive for trino server - * @param source This supplies the name of the software that submitted the query, - * e.g. `trino-jdbc` or `trino-cli` by default - * @param catalog The catalog context for query processing, will be set response - * @param schema The schema context for query processing - * @param language The language to use when processing the query and formatting results, - * formatted as a Java Locale string, e.g., en-US for US English - * @param traceToken Trace token for correlating requests across systems - * @param clientInfo Extra information about the client - * @param clientTags Client tags for selecting resource groups. Example: abc,xyz - * @param preparedStatement `preparedStatement` are kv pairs, where the names - * are names of previously prepared SQL statements, - * and the values are keys that identify the - * executable form of the named prepared statements + * @param source This supplies the name of the software that submitted the query, + * e.g. `trino-jdbc` or `trino-cli` by default + * @param catalog The catalog context for query processing, will be set response + * @param schema The schema context for query processing + * @param language The language to use when processing the query and formatting results, + * formatted as a Java Locale string, e.g., en-US for US English + * @param traceToken Trace token for correlating requests across systems + * @param clientInfo Extra information about the client + * @param clientTags Client tags for selecting resource groups. Example: abc,xyz + * @param preparedStatement `preparedStatement` are kv pairs, where the names + * are names of previously prepared SQL statements, + * and the values are keys that identify the + * executable form of the named prepared statements */ case class TrinoContext( user: String, @@ -54,6 +63,7 @@ case class TrinoContext( source: Option[String] = None, catalog: Option[String] = None, schema: Option[String] = None, + remoteUserAddress: Option[String] = None, language: Option[String] = None, traceToken: Option[String] = None, clientInfo: Option[String] = None, @@ -63,10 +73,16 @@ case class TrinoContext( object TrinoContext { - def apply(headers: HttpHeaders): TrinoContext = { - apply(headers.getRequestHeaders.asScala.toMap.map { + private val defaultWarning: util.List[Warning] = new util.ArrayList[Warning]() + private val GENERIC_INTERNAL_ERROR_CODE = 65536 + private val GENERIC_INTERNAL_ERROR_NAME = "GENERIC_INTERNAL_ERROR_NAME" + private val GENERIC_INTERNAL_ERROR_TYPE = "INTERNAL_ERROR" + + def apply(headers: HttpHeaders, remoteAddress: Option[String]): TrinoContext = { + val context = apply(headers.getRequestHeaders.asScala.toMap.map { case (k, v) => (k, v.asScala.toList) }) + context.copy(remoteUserAddress = remoteAddress) } def apply(headers: Map[String, List[String]]): TrinoContext = { @@ -125,19 +141,20 @@ object TrinoContext { } } - // TODO: Building response with TrinoContext and other information def buildTrinoResponse(qr: QueryResults, trinoContext: TrinoContext): Response = { val responseBuilder = Response.ok(qr) - trinoContext.catalog.foreach( - responseBuilder.header(TRINO_HEADERS.responseSetCatalog, _)) - trinoContext.schema.foreach( - responseBuilder.header(TRINO_HEADERS.responseSetSchema, _)) + // Note, We have injected kyuubi session id to session context so that the next query can find + // the previous session to restore the query context. + // It's hard to follow the Trino style that set all context to http headers. + // Because we do not know the context at server side. e.g. `set k=v`, `use database`. + // We also can not inject other session context into header before we supporting to map + // query result to session context. + require(trinoContext.session.contains(KYUUBI_SESSION_ID), s"$KYUUBI_SESSION_ID must be set.") + responseBuilder.header( + TRINO_HEADERS.responseSetSession, + s"$KYUUBI_SESSION_ID=${urlEncode(trinoContext.session(KYUUBI_SESSION_ID))}") - trinoContext.session.foreach { - case (k, v) => - responseBuilder.header(TRINO_HEADERS.responseSetSession, s"${k}=${urlEncode(v)}") - } trinoContext.preparedStatement.foreach { case (k, v) => responseBuilder.header(TRINO_HEADERS.responseAddedPrepare, s"${k}=${urlEncode(v)}") @@ -147,8 +164,6 @@ object TrinoContext { responseBuilder.header(TRINO_HEADERS.responseDeallocatedPrepare, urlEncode(v)) } - responseBuilder.header(TRINO_HEADERS.responseClearSession, s"responseClearSession") - responseBuilder.header(TRINO_HEADERS.responseClearTransactionId, "false") responseBuilder.build() } @@ -166,4 +181,285 @@ object TrinoContext { throw new AssertionError(e) } + def createQueryResults( + queryId: String, + nextUri: URI, + queryHtmlUri: URI, + queryStatus: OperationStatus, + columns: Option[TGetResultSetMetadataResp] = None, + data: Option[TRowSet] = None, + updateType: String = null): QueryResults = { + + val columnList = columns match { + case Some(value) => convertTColumn(value) + case None => null + } + val rowList = data match { + case Some(value) => + Option(updateType) match { + case Some("PREPARE") => + ImmutableList.of(ImmutableList.of(true).asInstanceOf[util.List[Object]]) + case _ => convertTRowSet(value) + } + case None => null + } + + val updatedNextUri = queryStatus.state match { + case FINISHED if rowList == null || rowList.isEmpty || rowList.get(0).isEmpty => null + case _ => nextUri + } + + new QueryResults( + queryId, + queryHtmlUri, + nextUri, + updatedNextUri, + columnList, + rowList, + StatementStats.builder.setState(queryStatus.state.name()).setQueued(false) + .setElapsedTimeMillis(0).setQueuedTimeMillis(0).build(), + toQueryError(queryStatus), + defaultWarning, + updateType, + 0L) + } + + private def convertTColumn(columns: TGetResultSetMetadataResp): util.List[Column] = { + columns.getSchema.getColumns.asScala.map(c => { + val (tp, arguments) = toClientTypeSignature(c.getTypeDesc.getTypes.get(0)) + new Column(c.getColumnName, tp, new ClientTypeSignature(tp, arguments)) + }).toList.asJava + } + + private def toClientTypeSignature( + entry: TTypeEntry): (String, util.List[ClientTypeSignatureParameter]) = { + // according to `io.trino.jdbc.ColumnInfo` + if (entry.isSetPrimitiveEntry) { + entry.getPrimitiveEntry.getType match { + case TTypeId.BOOLEAN_TYPE => + (ClientStandardTypes.BOOLEAN, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.TINYINT_TYPE => + (ClientStandardTypes.TINYINT, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.SMALLINT_TYPE => + (ClientStandardTypes.SMALLINT, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.INT_TYPE => + (ClientStandardTypes.INTEGER, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.BIGINT_TYPE => + (ClientStandardTypes.BIGINT, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.FLOAT_TYPE => + (ClientStandardTypes.DOUBLE, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.DOUBLE_TYPE => + (ClientStandardTypes.DOUBLE, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.DATE_TYPE => + (ClientStandardTypes.DATE, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.TIMESTAMP_TYPE => + (ClientStandardTypes.TIMESTAMP, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.BINARY_TYPE => + (ClientStandardTypes.VARBINARY, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.DECIMAL_TYPE => + val map = entry.getPrimitiveEntry.getTypeQualifiers.getQualifiers + val precision = Option(map.get(TCLIServiceConstants.PRECISION)).map(_.getI32Value) + .getOrElse(38) + val scale = Option(map.get(TCLIServiceConstants.SCALE)).map(_.getI32Value) + .getOrElse(18) + ( + ClientStandardTypes.DECIMAL, + ImmutableList.of( + ClientTypeSignatureParameter.ofLong(precision), + ClientTypeSignatureParameter.ofLong(scale))) + case TTypeId.STRING_TYPE => + ( + ClientStandardTypes.VARCHAR, + varcharSignatureParameter) + case TTypeId.VARCHAR_TYPE => + ( + ClientStandardTypes.VARCHAR, + varcharSignatureParameter) + case TTypeId.CHAR_TYPE => + (ClientStandardTypes.CHAR, ImmutableList.of(ClientTypeSignatureParameter.ofLong(65536))) + case TTypeId.INTERVAL_YEAR_MONTH_TYPE => + ( + ClientStandardTypes.INTERVAL_YEAR_TO_MONTH, + ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.INTERVAL_DAY_TIME_TYPE => + (ClientStandardTypes.TIME_WITH_TIME_ZONE, ImmutableList.of[ClientTypeSignatureParameter]) + case TTypeId.TIMESTAMPLOCALTZ_TYPE => + ( + ClientStandardTypes.TIMESTAMP_WITH_TIME_ZONE, + ImmutableList.of[ClientTypeSignatureParameter]) + case _ => + ( + ClientStandardTypes.VARCHAR, + varcharSignatureParameter) + } + } else if (entry.isSetArrayEntry) { + // thrift does not support nested types. + // it's quite hard to follow the hive way, so always return varchar + // TODO: make complex data type more accurate + ( + ClientStandardTypes.ARRAY, + ImmutableList.of(ClientTypeSignatureParameter.ofType( + new ClientTypeSignature(ClientStandardTypes.VARCHAR, varcharSignatureParameter)))) + } else if (entry.isSetMapEntry) { + ( + ClientStandardTypes.MAP, + ImmutableList.of( + ClientTypeSignatureParameter.ofType( + new ClientTypeSignature(ClientStandardTypes.VARCHAR, varcharSignatureParameter)), + ClientTypeSignatureParameter.ofType( + new ClientTypeSignature(ClientStandardTypes.VARCHAR, varcharSignatureParameter)))) + } else if (entry.isSetStructEntry) { + val parameters = entry.getStructEntry.getNameToTypePtr.asScala.map { case (k, v) => + ClientTypeSignatureParameter.ofNamedType( + new NamedClientTypeSignature( + Optional.of(new RowFieldName(k)), + new ClientTypeSignature(ClientStandardTypes.VARCHAR, varcharSignatureParameter))) + } + ( + ClientStandardTypes.ROW, + ImmutableList.copyOf(parameters.toArray)) + } else { + throw new UnsupportedOperationException(s"Do not support type: $entry") + } + } + + private def varcharSignatureParameter: util.List[ClientTypeSignatureParameter] = { + ImmutableList.of(ClientTypeSignatureParameter.ofLong( + ClientTypeSignature.VARCHAR_UNBOUNDED_LENGTH)) + } + + def convertTRowSet(rowSet: TRowSet): util.List[util.List[Object]] = { + val dataResult = new util.LinkedList[util.List[Object]] + + if (rowSet.getColumns == null) { + return rowSet.getRows.asScala + .map(t => t.getColVals.asScala.map(v => v.getFieldValue.asInstanceOf[Object]).asJava) + .asJava + } + + rowSet.getColumns.asScala.foreach { + case tColumn if tColumn.isSetBoolVal => + val nulls = util.BitSet.valueOf(tColumn.getBoolVal.getNulls) + if (dataResult.isEmpty) { + (1 to tColumn.getBoolVal.getValuesSize).foreach(_ => + dataResult.add(new util.LinkedList[Object]())) + } + + tColumn.getBoolVal.getValues.asScala.zipWithIndex.foreach { + case (_, rowIdx) if nulls.get(rowIdx) => + dataResult.get(rowIdx).add(null) + case (v, rowIdx) => + dataResult.get(rowIdx).add(v) + } + case tColumn if tColumn.isSetByteVal => + val nulls = util.BitSet.valueOf(tColumn.getByteVal.getNulls) + if (dataResult.isEmpty) { + (1 to tColumn.getByteVal.getValuesSize).foreach(_ => + dataResult.add(new util.LinkedList[Object]())) + } + + tColumn.getByteVal.getValues.asScala.zipWithIndex.foreach { + case (_, rowIdx) if nulls.get(rowIdx) => + dataResult.get(rowIdx).add(null) + case (v, rowIdx) => + dataResult.get(rowIdx).add(v) + } + case tColumn if tColumn.isSetI16Val => + val nulls = util.BitSet.valueOf(tColumn.getI16Val.getNulls) + if (dataResult.isEmpty) { + (1 to tColumn.getI16Val.getValuesSize).foreach(_ => + dataResult.add(new util.LinkedList[Object]())) + } + + tColumn.getI16Val.getValues.asScala.zipWithIndex.foreach { + case (_, rowIdx) if nulls.get(rowIdx) => + dataResult.get(rowIdx).add(null) + case (v, rowIdx) => + dataResult.get(rowIdx).add(v) + } + case tColumn if tColumn.isSetI32Val => + val nulls = util.BitSet.valueOf(tColumn.getI32Val.getNulls) + if (dataResult.isEmpty) { + (1 to tColumn.getI32Val.getValuesSize).foreach(_ => + dataResult.add(new util.LinkedList[Object]())) + } + + tColumn.getI32Val.getValues.asScala.zipWithIndex.foreach { + case (_, rowIdx) if nulls.get(rowIdx) => + dataResult.get(rowIdx).add(null) + case (v, rowIdx) => + dataResult.get(rowIdx).add(v) + } + case tColumn if tColumn.isSetI64Val => + val nulls = util.BitSet.valueOf(tColumn.getI64Val.getNulls) + if (dataResult.isEmpty) { + (1 to tColumn.getI64Val.getValuesSize).foreach(_ => + dataResult.add(new util.LinkedList[Object]())) + } + + tColumn.getI64Val.getValues.asScala.zipWithIndex.foreach { + case (_, rowIdx) if nulls.get(rowIdx) => + dataResult.get(rowIdx).add(null) + case (v, rowIdx) => + dataResult.get(rowIdx).add(v) + } + case tColumn if tColumn.isSetDoubleVal => + val nulls = util.BitSet.valueOf(tColumn.getDoubleVal.getNulls) + if (dataResult.isEmpty) { + (1 to tColumn.getDoubleVal.getValuesSize).foreach(_ => + dataResult.add(new util.LinkedList[Object]())) + } + + tColumn.getDoubleVal.getValues.asScala.zipWithIndex.foreach { + case (_, rowIdx) if nulls.get(rowIdx) => + dataResult.get(rowIdx).add(null) + case (v, rowIdx) => + dataResult.get(rowIdx).add(v) + } + case tColumn if tColumn.isSetBinaryVal => + val nulls = util.BitSet.valueOf(tColumn.getBinaryVal.getNulls) + if (dataResult.isEmpty) { + (1 to tColumn.getBinaryVal.getValuesSize).foreach(_ => + dataResult.add(new util.LinkedList[Object]())) + } + + tColumn.getBinaryVal.getValues.asScala.zipWithIndex.foreach { + case (_, rowIdx) if nulls.get(rowIdx) => + dataResult.get(rowIdx).add(null) + case (v, rowIdx) => + dataResult.get(rowIdx).add(v) + } + case tColumn => + val nulls = util.BitSet.valueOf(tColumn.getStringVal.getNulls) + if (dataResult.isEmpty) { + (1 to tColumn.getStringVal.getValuesSize).foreach(_ => + dataResult.add(new util.LinkedList[Object]())) + } + + tColumn.getStringVal.getValues.asScala.zipWithIndex.foreach { + case (_, rowIdx) if nulls.get(rowIdx) => + dataResult.get(rowIdx).add(null) + case (v, rowIdx) => + dataResult.get(rowIdx).add(v) + } + } + dataResult + } + + def toQueryError(queryStatus: OperationStatus): QueryError = { + val exception = queryStatus.exception + if (exception.isEmpty) { + null + } else { + new QueryError( + exception.get.getMessage, + queryStatus.state.name(), + GENERIC_INTERNAL_ERROR_CODE, + GENERIC_INTERNAL_ERROR_NAME, + GENERIC_INTERNAL_ERROR_TYPE, + null, + null) + } + } + } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoScalaObjectMapper.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoScalaObjectMapper.scala new file mode 100644 index 00000000000..33091e33878 --- /dev/null +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoScalaObjectMapper.scala @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.server.trino.api + +import javax.ws.rs.ext.ContextResolver + +import com.fasterxml.jackson.databind.{DeserializationFeature, MapperFeature, ObjectMapper} +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module + +class TrinoScalaObjectMapper extends ContextResolver[ObjectMapper] { + + // refer `io.trino.client.JsonCodec` + private lazy val mapper = new ObjectMapper() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(MapperFeature.AUTO_DETECT_CREATORS) + .disable(MapperFeature.AUTO_DETECT_FIELDS) + .disable(MapperFeature.AUTO_DETECT_SETTERS) + .disable(MapperFeature.AUTO_DETECT_GETTERS) + .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) + .disable(MapperFeature.USE_GETTERS_AS_SETTERS) + .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) + .disable(MapperFeature.INFER_PROPERTY_MUTATORS) + .disable(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS) + .registerModule(new Jdk8Module) + + override def getContext(aClass: Class[_]): ObjectMapper = mapper +} diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoServerConfig.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoServerConfig.scala index d1f7de336ba..298e60c9cac 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoServerConfig.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/TrinoServerConfig.scala @@ -21,6 +21,6 @@ import org.glassfish.jersey.server.ResourceConfig class TrinoServerConfig extends ResourceConfig { packages("org.apache.kyuubi.server.trino.api.v1") - register(classOf[KyuubiScalaObjectMapper]) + register(classOf[TrinoScalaObjectMapper]) register(classOf[RestExceptionMapper]) } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/ApiRootResource.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/ApiRootResource.scala index fa023637800..c703d1e20bf 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/ApiRootResource.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/ApiRootResource.scala @@ -37,8 +37,7 @@ private[v1] class ApiRootResource extends ApiRequestContext { @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "Get the version of Kyuubi server.") @GET @Path("version") diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/StatementResource.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/StatementResource.scala index 3d149b5f346..124b8468857 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/StatementResource.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/StatementResource.scala @@ -17,28 +17,42 @@ package org.apache.kyuubi.server.trino.api.v1 +import java.util +import java.util.UUID import javax.ws.rs._ -import javax.ws.rs.core.{Context, HttpHeaders, MediaType} +import javax.ws.rs.core.{Context, HttpHeaders, MediaType, Response, UriInfo} +import javax.ws.rs.core.MediaType.TEXT_PLAIN_TYPE +import javax.ws.rs.core.Response.Status.{BAD_REQUEST, NOT_FOUND} +import scala.util.Try +import scala.util.control.NonFatal + +import io.airlift.units.Duration import io.swagger.v3.oas.annotations.media.{Content, Schema} import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag import io.trino.client.QueryResults import org.apache.kyuubi.Logging -import org.apache.kyuubi.server.trino.api.{ApiRequestContext, KyuubiTrinoOperationTranslator} +import org.apache.kyuubi.jdbc.hive.Utils +import org.apache.kyuubi.operation.OperationHandle +import org.apache.kyuubi.server.trino.api.{ApiRequestContext, KyuubiTrinoOperationTranslator, Query, QueryId, Slug, TrinoContext} +import org.apache.kyuubi.server.trino.api.Slug.Context.{EXECUTING_QUERY, QUEUED_QUERY} import org.apache.kyuubi.server.trino.api.v1.dto.Ok +import org.apache.kyuubi.service.BackendService +import org.apache.kyuubi.sql.parser.trino.KyuubiTrinoFeParser +import org.apache.kyuubi.sql.plan.trino.{Deallocate, ExecuteForPreparing, Prepare} @Tag(name = "Statement") @Produces(Array(MediaType.APPLICATION_JSON)) private[v1] class StatementResource extends ApiRequestContext with Logging { lazy val translator = new KyuubiTrinoOperationTranslator(fe.be) + lazy val parser = new KyuubiTrinoFeParser() @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "test") @GET @Path("test") @@ -51,75 +65,204 @@ private[v1] class StatementResource extends ApiRequestContext with Logging { schema = new Schema(implementation = classOf[QueryResults]))), description = "Create a query") - @GET + @POST @Path("/") @Consumes(Array(MediaType.TEXT_PLAIN)) - def query(statement: String, @Context headers: HttpHeaders): QueryResults = { - throw new UnsupportedOperationException + def query( + statement: String, + @Context headers: HttpHeaders, + @Context uriInfo: UriInfo): Response = { + if (statement == null || statement.isEmpty) { + throw badRequest(BAD_REQUEST, "SQL statement is empty") + } + + val remoteAddr = Option(httpRequest.getRemoteAddr) + val trinoContext = TrinoContext(headers, remoteAddr) + + try { + parser.parsePlan(statement) match { + case Prepare(statementId, _) => + val query = Query( + statementId, + statement.split(s"$statementId FROM")(1), + trinoContext, + fe.be) + val qr = query.getPrepareQueryResults(query.getLastToken, uriInfo) + TrinoContext.buildTrinoResponse(qr, query.context) + case ExecuteForPreparing(statementId, parameters) => + val parametersMap = new util.HashMap[Integer, String]() + for (i <- 0 until parameters.size) { + parametersMap.put(i + 1, parameters(i)) + } + trinoContext.preparedStatement.get(statementId).map { originSql => + val realSql = Utils.updateSql(originSql, parametersMap) + val query = Query(realSql, trinoContext, translator, fe.be) + val qr = query.getQueryResults(query.getLastToken, uriInfo) + TrinoContext.buildTrinoResponse(qr, query.context) + }.get + case Deallocate(statementId) => + info(s"DEALLOCATE PREPARE ${statementId}") + val query = Query( + QueryId(new OperationHandle(UUID.randomUUID())), + trinoContext, + fe.be) + val qr = query.getPrepareQueryResults(query.getLastToken, uriInfo) + TrinoContext.buildTrinoResponse(qr, query.context) + case _ => + val query = Query(statement, trinoContext, translator, fe.be) + val qr = query.getQueryResults(query.getLastToken, uriInfo) + TrinoContext.buildTrinoResponse(qr, query.context) + } + } catch { + case e: Exception => + val errorMsg = + s"Error submitting sql" + error(errorMsg, e) + throw badRequest(BAD_REQUEST, errorMsg + "\n" + e.getMessage) + } } @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "Get queued statement status") @GET @Path("/queued/{queryId}/{slug}/{token}") def getQueuedStatementStatus( - @Context headers: HttpHeaders, @PathParam("queryId") queryId: String, @PathParam("slug") slug: String, - @PathParam("token") token: Long): QueryResults = { - throw new UnsupportedOperationException + @PathParam("token") token: Long, + @QueryParam("maxWait") maxWait: Duration, + @Context headers: HttpHeaders, + @Context uriInfo: UriInfo): Response = { + + val remoteAddr = Option(httpRequest.getRemoteAddr) + val trinoContext = TrinoContext(headers, remoteAddr) + val waitTime = if (maxWait == null) 0 else maxWait.toMillis + getQuery(fe.be, trinoContext, QueryId(queryId), slug, token, QUEUED_QUERY) + .flatMap(query => + Try(TrinoContext.buildTrinoResponse( + query.getQueryResults( + token, + uriInfo, + waitTime), + query.context))) + .recover { + case NonFatal(e) => + val errorMsg = + s"Error executing for query id $queryId" + error(errorMsg, e) + throw badRequest(NOT_FOUND, "Query not found") + }.get } @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "Get executing statement status") @GET @Path("/executing/{queryId}/{slug}/{token}") def getExecutingStatementStatus( - @Context headers: HttpHeaders, @PathParam("queryId") queryId: String, @PathParam("slug") slug: String, - @PathParam("token") token: Long): QueryResults = { - throw new UnsupportedOperationException + @PathParam("token") token: Long, + @QueryParam("maxWait") maxWait: Duration, + @Context headers: HttpHeaders, + @Context uriInfo: UriInfo): Response = { + + val remoteAddr = Option(httpRequest.getRemoteAddr) + val trinoContext = TrinoContext(headers, remoteAddr) + val waitTime = if (maxWait == null) 0 else maxWait.toMillis + getQuery(fe.be, trinoContext, QueryId(queryId), slug, token, EXECUTING_QUERY) + .flatMap(query => + Try(TrinoContext.buildTrinoResponse( + query.getQueryResults(token, uriInfo, waitTime), + query.context))) + .recover { + case NonFatal(e) => + val errorMsg = + s"Error executing for query id $queryId" + error(errorMsg, e) + throw badRequest(NOT_FOUND, "Query not found") + }.get } @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "Cancel queued statement") @DELETE @Path("/queued/{queryId}/{slug}/{token}") def cancelQueuedStatement( - @Context headers: HttpHeaders, @PathParam("queryId") queryId: String, @PathParam("slug") slug: String, - @PathParam("token") token: Long): QueryResults = { - throw new UnsupportedOperationException + @PathParam("token") token: Long, + @Context headers: HttpHeaders): Response = { + + val remoteAddr = Option(httpRequest.getRemoteAddr) + val trinoContext = TrinoContext(headers, remoteAddr) + getQuery(fe.be, trinoContext, QueryId(queryId), slug, token, QUEUED_QUERY) + .flatMap(query => Try(query.cancel)) + .recover { + case NonFatal(e) => + val errorMsg = + s"Error executing for query id $queryId" + error(errorMsg, e) + throw badRequest(NOT_FOUND, "Query not found") + }.get + Response.noContent.build } @ApiResponse( responseCode = "200", - content = Array(new Content( - mediaType = MediaType.APPLICATION_JSON)), + content = Array(new Content(mediaType = MediaType.APPLICATION_JSON)), description = "Cancel executing statement") @DELETE @Path("/executing/{queryId}/{slug}/{token}") def cancelExecutingStatementStatus( - @Context headers: HttpHeaders, @PathParam("queryId") queryId: String, @PathParam("slug") slug: String, - @PathParam("token") token: Long): QueryResults = { - throw new UnsupportedOperationException + @PathParam("token") token: Long, + @Context headers: HttpHeaders): Response = { + + val remoteAddr = Option(httpRequest.getRemoteAddr) + val trinoContext = TrinoContext(headers, remoteAddr) + getQuery(fe.be, trinoContext, QueryId(queryId), slug, token, EXECUTING_QUERY) + .flatMap(query => Try(query.cancel)) + .recover { + case NonFatal(e) => + val errorMsg = + s"Error executing for query id $queryId" + error(errorMsg, e) + throw badRequest(NOT_FOUND, "Query not found") + }.get + + Response.noContent.build } + private def getQuery( + be: BackendService, + context: TrinoContext, + queryId: QueryId, + slug: String, + token: Long, + slugContext: Slug.Context.Context): Try[Query] = { + Try(be.sessionManager.operationManager.getOperation(queryId.operationHandle)).map { op => + val sessionWithId = context.session ++ + Map(Query.KYUUBI_SESSION_ID -> op.getSession.handle.identifier.toString) + Query(queryId, context.copy(session = sessionWithId), be) + }.filter(_.getSlug.isValid(slugContext, slug, token)) + } + + private def badRequest(status: Response.Status, message: String) = + new WebApplicationException( + Response.status(status) + .`type`(TEXT_PLAIN_TYPE) + .entity(message) + .build) + } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/dto/Ok.java b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/dto/Ok.java index 50d04609fb9..982baa2ef38 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/dto/Ok.java +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/server/trino/api/v1/dto/Ok.java @@ -20,6 +20,9 @@ import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; @@ -28,10 +31,16 @@ public class Ok { public Ok() {} - public Ok(String content) { + /** + * Follow Trino way that explicitly specifies the json property since we disable the jackson + * auto detect feature. See {@link org.apache.kyuubi.server.trino.api.TrinoScalaObjectMapper} + */ + @JsonCreator + public Ok(@JsonProperty("content") String content) { this.content = content; } + @JsonProperty public String getContent() { return content; } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiBatchSessionImpl.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiBatchSessionImpl.scala index 967397c9575..228890a1e4e 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiBatchSessionImpl.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiBatchSessionImpl.scala @@ -17,20 +17,16 @@ package org.apache.kyuubi.session -import java.util.UUID - import scala.collection.JavaConverters._ -import com.codahale.metrics.MetricRegistry import org.apache.hive.service.rpc.thrift.TProtocolVersion import org.apache.kyuubi.client.api.v1.dto.BatchRequest -import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.client.util.BatchUtils._ +import org.apache.kyuubi.config.{KyuubiConf, KyuubiReservedKeys} import org.apache.kyuubi.engine.KyuubiApplicationManager import org.apache.kyuubi.engine.spark.SparkProcessBuilder import org.apache.kyuubi.events.{EventBus, KyuubiSessionEvent} -import org.apache.kyuubi.metrics.MetricsConstants.{CONN_OPEN, CONN_TOTAL} -import org.apache.kyuubi.metrics.MetricsSystem import org.apache.kyuubi.operation.OperationState import org.apache.kyuubi.server.metadata.api.Metadata import org.apache.kyuubi.session.SessionType.SessionType @@ -53,9 +49,10 @@ class KyuubiBatchSessionImpl( sessionManager) { override val sessionType: SessionType = SessionType.BATCH - override val handle: SessionHandle = recoveryMetadata.map { metadata => - SessionHandle(UUID.fromString(metadata.identifier)) - }.getOrElse(SessionHandle()) + override val handle: SessionHandle = { + val batchId = recoveryMetadata.map(_.identifier).getOrElse(conf(KYUUBI_BATCH_ID_KEY)) + SessionHandle.fromUUID(batchId) + } override def createTime: Long = recoveryMetadata.map(_.createTime).getOrElse(super.createTime) @@ -80,6 +77,10 @@ class KyuubiBatchSessionImpl( override lazy val name: Option[String] = Option(batchRequest.getName).orElse( normalizedConf.get(KyuubiConf.SESSION_NAME.key)) + // whether the resource file is from uploading + private[kyuubi] val isResourceUploaded: Boolean = batchRequest.getConf + .getOrDefault(KyuubiReservedKeys.KYUUBI_BATCH_RESOURCE_UPLOADED_KEY, "false").toBoolean + private[kyuubi] lazy val batchJobSubmissionOp = sessionManager.operationManager .newBatchJobSubmissionOperation( this, @@ -104,7 +105,7 @@ class KyuubiBatchSessionImpl( } private val sessionEvent = KyuubiSessionEvent(this) - recoveryMetadata.map(metadata => sessionEvent.engineId = metadata.engineId) + recoveryMetadata.foreach(metadata => sessionEvent.engineId = metadata.engineId) EventBus.post(sessionEvent) override def getSessionEvent: Option[KyuubiSessionEvent] = { @@ -116,7 +117,8 @@ class KyuubiBatchSessionImpl( batchRequest.getBatchType, normalizedConf, sessionManager.getConf) - if (batchRequest.getResource != SparkProcessBuilder.INTERNAL_RESOURCE) { + if (batchRequest.getResource != SparkProcessBuilder.INTERNAL_RESOURCE + && !isResourceUploaded) { KyuubiApplicationManager.checkApplicationAccessPath( batchRequest.getResource, sessionManager.getConf) @@ -124,10 +126,7 @@ class KyuubiBatchSessionImpl( } override def open(): Unit = handleSessionException { - MetricsSystem.tracing { ms => - ms.incCount(CONN_TOTAL) - ms.incCount(MetricRegistry.name(CONN_OPEN, user)) - } + traceMetricsOnOpen() if (recoveryMetadata.isEmpty) { val metaData = Metadata( @@ -147,6 +146,7 @@ class KyuubiBatchSessionImpl( engineType = batchRequest.getBatchType, clusterManager = batchJobSubmissionOp.builder.clusterManager()) + // there is a chance that operation failed w/ duplicated key error sessionManager.insertMetadata(metaData) } @@ -172,6 +172,6 @@ class KyuubiBatchSessionImpl( waitMetadataRequestsRetryCompletion() sessionEvent.endTime = System.currentTimeMillis() EventBus.post(sessionEvent) - MetricsSystem.tracing(_.decCount(MetricRegistry.name(CONN_OPEN, user))) + traceMetricsOnClose() } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSession.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSession.scala index e2c69282092..7316e367b3c 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSession.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSession.scala @@ -16,10 +16,13 @@ */ package org.apache.kyuubi.session +import com.codahale.metrics.MetricRegistry import org.apache.hive.service.rpc.thrift.TProtocolVersion import org.apache.kyuubi.config.KyuubiReservedKeys.{KYUUBI_SESSION_CONNECTION_URL_KEY, KYUUBI_SESSION_REAL_USER_KEY} -import org.apache.kyuubi.events.KyuubiSessionEvent +import org.apache.kyuubi.events.{EventBus, KyuubiSessionEvent} +import org.apache.kyuubi.metrics.MetricsConstants.{CONN_OPEN, CONN_TOTAL} +import org.apache.kyuubi.metrics.MetricsSystem import org.apache.kyuubi.session.SessionType.SessionType abstract class KyuubiSession( @@ -46,8 +49,25 @@ abstract class KyuubiSession( f } catch { case t: Throwable => - getSessionEvent.foreach(_.exception = Some(t)) + getSessionEvent.foreach { sessionEvent => + sessionEvent.exception = Some(t) + EventBus.post(sessionEvent) + } throw t } } + + protected def traceMetricsOnOpen(): Unit = MetricsSystem.tracing { ms => + ms.incCount(CONN_TOTAL) + ms.incCount(MetricRegistry.name(CONN_TOTAL, sessionType.toString)) + ms.incCount(MetricRegistry.name(CONN_OPEN, user)) + ms.incCount(MetricRegistry.name(CONN_OPEN, user, sessionType.toString)) + ms.incCount(MetricRegistry.name(CONN_OPEN, sessionType.toString)) + } + + protected def traceMetricsOnClose(): Unit = MetricsSystem.tracing { ms => + ms.decCount(MetricRegistry.name(CONN_OPEN, user)) + ms.decCount(MetricRegistry.name(CONN_OPEN, user, sessionType.toString)) + ms.decCount(MetricRegistry.name(CONN_OPEN, sessionType.toString)) + } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSessionImpl.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSessionImpl.scala index b669390969e..80df5c44dd0 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSessionImpl.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSessionImpl.scala @@ -21,19 +21,16 @@ import java.util.Base64 import scala.collection.JavaConverters._ -import com.codahale.metrics.MetricRegistry import org.apache.hive.service.rpc.thrift._ import org.apache.kyuubi.KyuubiSQLException import org.apache.kyuubi.client.KyuubiSyncThriftClient import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf._ -import org.apache.kyuubi.config.KyuubiReservedKeys.{KYUUBI_ENGINE_CREDENTIALS_KEY, KYUUBI_SESSION_SIGN_PUBLICKEY, KYUUBI_SESSION_USER_SIGN} +import org.apache.kyuubi.config.KyuubiReservedKeys.{KYUUBI_ENGINE_CREDENTIALS_KEY, KYUUBI_SESSION_HANDLE_KEY, KYUUBI_SESSION_SIGN_PUBLICKEY, KYUUBI_SESSION_USER_SIGN} import org.apache.kyuubi.engine.{EngineRef, KyuubiApplicationManager} import org.apache.kyuubi.events.{EventBus, KyuubiSessionEvent} import org.apache.kyuubi.ha.client.DiscoveryClientProvider._ -import org.apache.kyuubi.metrics.MetricsConstants._ -import org.apache.kyuubi.metrics.MetricsSystem import org.apache.kyuubi.operation.{Operation, OperationHandle} import org.apache.kyuubi.operation.log.OperationLog import org.apache.kyuubi.service.authentication.InternalSecurityAccessor @@ -80,7 +77,7 @@ class KyuubiSessionImpl( lazy val engine: EngineRef = new EngineRef( sessionConf, user, - sessionManager.groupProvider.primaryGroup(user, optimizedConf.asJava), + sessionManager.groupProvider, handle.identifier.toString, sessionManager.applicationManager) private[kyuubi] val launchEngineOp = sessionManager.operationManager @@ -108,11 +105,10 @@ class KyuubiSessionImpl( private var _engineSessionHandle: SessionHandle = _ + private var openSessionError: Option[Throwable] = None + override def open(): Unit = handleSessionException { - MetricsSystem.tracing { ms => - ms.incCount(CONN_TOTAL) - ms.incCount(MetricRegistry.name(CONN_OPEN, user)) - } + traceMetricsOnOpen() checkSessionAccessPathURIs() @@ -122,80 +118,84 @@ class KyuubiSessionImpl( runOperation(launchEngineOp) } - private[kyuubi] def openEngineSession(extraEngineLog: Option[OperationLog] = None): Unit = { - withDiscoveryClient(sessionConf) { discoveryClient => - var openEngineSessionConf = optimizedConf - if (engineCredentials.nonEmpty) { - sessionConf.set(KYUUBI_ENGINE_CREDENTIALS_KEY, engineCredentials) - openEngineSessionConf = - optimizedConf ++ Map(KYUUBI_ENGINE_CREDENTIALS_KEY -> engineCredentials) - } + private[kyuubi] def openEngineSession(extraEngineLog: Option[OperationLog] = None): Unit = + handleSessionException { + withDiscoveryClient(sessionConf) { discoveryClient => + var openEngineSessionConf = + optimizedConf ++ Map(KYUUBI_SESSION_HANDLE_KEY -> handle.identifier.toString) + if (engineCredentials.nonEmpty) { + sessionConf.set(KYUUBI_ENGINE_CREDENTIALS_KEY, engineCredentials) + openEngineSessionConf = + openEngineSessionConf ++ Map(KYUUBI_ENGINE_CREDENTIALS_KEY -> engineCredentials) + } - if (sessionConf.get(SESSION_USER_SIGN_ENABLED)) { - openEngineSessionConf = openEngineSessionConf + - (SESSION_USER_SIGN_ENABLED.key -> - sessionConf.get(SESSION_USER_SIGN_ENABLED).toString) + - (KYUUBI_SESSION_SIGN_PUBLICKEY -> - Base64.getEncoder.encodeToString( - sessionManager.signingPublicKey.getEncoded)) + - (KYUUBI_SESSION_USER_SIGN -> sessionUserSignBase64) - } + if (sessionConf.get(SESSION_USER_SIGN_ENABLED)) { + openEngineSessionConf = openEngineSessionConf + + (SESSION_USER_SIGN_ENABLED.key -> + sessionConf.get(SESSION_USER_SIGN_ENABLED).toString) + + (KYUUBI_SESSION_SIGN_PUBLICKEY -> + Base64.getEncoder.encodeToString( + sessionManager.signingPublicKey.getEncoded)) + + (KYUUBI_SESSION_USER_SIGN -> sessionUserSignBase64) + } - val maxAttempts = sessionManager.getConf.get(ENGINE_OPEN_MAX_ATTEMPTS) - val retryWait = sessionManager.getConf.get(ENGINE_OPEN_RETRY_WAIT) - var attempt = 0 - var shouldRetry = true - while (attempt <= maxAttempts && shouldRetry) { - val (host, port) = engine.getOrCreate(discoveryClient, extraEngineLog) - try { - val passwd = - if (sessionManager.getConf.get(ENGINE_SECURITY_ENABLED)) { - InternalSecurityAccessor.get().issueToken() - } else { - Option(password).filter(_.nonEmpty).getOrElse("anonymous") - } - _client = KyuubiSyncThriftClient.createClient(user, passwd, host, port, sessionConf) - _engineSessionHandle = _client.openSession(protocol, user, passwd, openEngineSessionConf) - logSessionInfo(s"Connected to engine [$host:$port]/[${client.engineId.getOrElse("")}]" + - s" with ${_engineSessionHandle}]") - shouldRetry = false - } catch { - case e: org.apache.thrift.transport.TTransportException - if attempt < maxAttempts && e.getCause.isInstanceOf[java.net.ConnectException] && - e.getCause.getMessage.contains("Connection refused (Connection refused)") => - warn( - s"Failed to open [${engine.defaultEngineName} $host:$port] after" + - s" $attempt/$maxAttempts times, retrying", - e.getCause) - Thread.sleep(retryWait) - shouldRetry = true - case e: Throwable => - error( - s"Opening engine [${engine.defaultEngineName} $host:$port]" + - s" for $user session failed", - e) - throw e - } finally { - attempt += 1 - if (shouldRetry && _client != null) { - try { - _client.closeSession() - } catch { - case e: Throwable => - warn( - "Error on closing broken client of engine " + - s"[${engine.defaultEngineName} $host:$port]", - e) + val maxAttempts = sessionManager.getConf.get(ENGINE_OPEN_MAX_ATTEMPTS) + val retryWait = sessionManager.getConf.get(ENGINE_OPEN_RETRY_WAIT) + var attempt = 0 + var shouldRetry = true + while (attempt <= maxAttempts && shouldRetry) { + val (host, port) = engine.getOrCreate(discoveryClient, extraEngineLog) + try { + val passwd = + if (sessionManager.getConf.get(ENGINE_SECURITY_ENABLED)) { + InternalSecurityAccessor.get().issueToken() + } else { + Option(password).filter(_.nonEmpty).getOrElse("anonymous") + } + _client = KyuubiSyncThriftClient.createClient(user, passwd, host, port, sessionConf) + _engineSessionHandle = + _client.openSession(protocol, user, passwd, openEngineSessionConf) + logSessionInfo(s"Connected to engine [$host:$port]/[${client.engineId.getOrElse("")}]" + + s" with ${_engineSessionHandle}]") + shouldRetry = false + } catch { + case e: org.apache.thrift.transport.TTransportException + if attempt < maxAttempts && e.getCause.isInstanceOf[java.net.ConnectException] && + e.getCause.getMessage.contains("Connection refused (Connection refused)") => + warn( + s"Failed to open [${engine.defaultEngineName} $host:$port] after" + + s" $attempt/$maxAttempts times, retrying", + e.getCause) + Thread.sleep(retryWait) + shouldRetry = true + case e: Throwable => + error( + s"Opening engine [${engine.defaultEngineName} $host:$port]" + + s" for $user session failed", + e) + openSessionError = Some(e) + throw e + } finally { + attempt += 1 + if (shouldRetry && _client != null) { + try { + _client.closeSession() + } catch { + case e: Throwable => + warn( + "Error on closing broken client of engine " + + s"[${engine.defaultEngineName} $host:$port]", + e) + } } } } + sessionEvent.openedTime = System.currentTimeMillis() + sessionEvent.remoteSessionId = _engineSessionHandle.identifier.toString + _client.engineId.foreach(e => sessionEvent.engineId = e) + EventBus.post(sessionEvent) } - sessionEvent.openedTime = System.currentTimeMillis() - sessionEvent.remoteSessionId = _engineSessionHandle.identifier.toString - _client.engineId.foreach(e => sessionEvent.engineId = e) - EventBus.post(sessionEvent) } - } override protected def runOperation(operation: Operation): OperationHandle = { if (operation != launchEngineOp) { @@ -251,10 +251,10 @@ class KyuubiSessionImpl( try { if (_client != null) _client.closeSession() } finally { - if (engine != null) engine.close() + openSessionError.foreach { _ => if (engine != null) engine.close() } sessionEvent.endTime = System.currentTimeMillis() EventBus.post(sessionEvent) - MetricsSystem.tracing(_.decCount(MetricRegistry.name(CONN_OPEN, user))) + traceMetricsOnClose() } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSessionManager.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSessionManager.scala index f4b12f3861f..73248cd5632 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSessionManager.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/KyuubiSessionManager.scala @@ -107,6 +107,7 @@ class KyuubiSessionManager private (name: String) extends SessionManager(name) { MetricsSystem.tracing { ms => ms.incCount(CONN_FAIL) ms.incCount(MetricRegistry.name(CONN_FAIL, user)) + ms.incCount(MetricRegistry.name(CONN_FAIL, SessionType.INTERACTIVE.toString)) } throw KyuubiSQLException( s"Error opening session for $username client ip $ipAddress, due to ${e.getMessage}", @@ -168,6 +169,7 @@ class KyuubiSessionManager private (name: String) extends SessionManager(name) { MetricsSystem.tracing { ms => ms.incCount(CONN_FAIL) ms.incCount(MetricRegistry.name(CONN_FAIL, user)) + ms.incCount(MetricRegistry.name(CONN_FAIL, SessionType.BATCH.toString)) } throw KyuubiSQLException( s"Error opening batch session[$handle] for $user client ip $ipAddress," + @@ -237,6 +239,7 @@ class KyuubiSessionManager private (name: String) extends SessionManager(name) { ms.registerGauge(CONN_OPEN, getOpenSessionCount, 0) ms.registerGauge(EXEC_POOL_ALIVE, getExecPoolSize, 0) ms.registerGauge(EXEC_POOL_ACTIVE, getActiveCount, 0) + ms.registerGauge(EXEC_POOL_WORK_QUEUE_SIZE, getWorkQueueSize, 0) } super.start() } @@ -297,6 +300,16 @@ class KyuubiSessionManager private (name: String) extends SessionManager(name) { userUnlimitedList) } + private[kyuubi] def getUnlimitedUsers(): Set[String] = { + limiter.orElse(batchLimiter).map(SessionLimiter.getUnlimitedUsers).getOrElse(Set.empty) + } + + private[kyuubi] def refreshUnlimitedUsers(conf: KyuubiConf): Unit = { + val unlimitedUsers = conf.get(SERVER_LIMIT_CONNECTIONS_USER_UNLIMITED_LIST).toSet + limiter.foreach(SessionLimiter.resetUnlimitedUsers(_, unlimitedUsers)) + batchLimiter.foreach(SessionLimiter.resetUnlimitedUsers(_, unlimitedUsers)) + } + private def applySessionLimiter( userLimit: Int, ipAddressLimit: Int, diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/SessionLimiter.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/SessionLimiter.scala index b7acbac3d8c..96ca36df176 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/session/SessionLimiter.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/session/SessionLimiter.scala @@ -105,7 +105,7 @@ class SessionLimiterWithUnlimitedUsersImpl( userLimit: Int, ipAddressLimit: Int, userIpAddressLimit: Int, - unlimitedUsers: Set[String]) + var unlimitedUsers: Set[String]) extends SessionLimiterImpl(userLimit, ipAddressLimit, userIpAddressLimit) { override def increment(userIpAddress: UserIpAddress): Unit = { if (!unlimitedUsers.contains(userIpAddress.user)) { @@ -118,6 +118,10 @@ class SessionLimiterWithUnlimitedUsersImpl( super.decrement(userIpAddress) } } + + private[kyuubi] def setUnlimitedUsers(unlimitedUsers: Set[String]): Unit = { + this.unlimitedUsers = unlimitedUsers + } } object SessionLimiter { @@ -126,12 +130,22 @@ object SessionLimiter { userLimit: Int, ipAddressLimit: Int, userIpAddressLimit: Int, - userWhiteList: Set[String] = Set.empty): SessionLimiter = { + unlimitedUsers: Set[String] = Set.empty): SessionLimiter = { new SessionLimiterWithUnlimitedUsersImpl( userLimit, ipAddressLimit, userIpAddressLimit, - userWhiteList) + unlimitedUsers) } + def resetUnlimitedUsers(limiter: SessionLimiter, unlimitedUsers: Set[String]): Unit = + limiter match { + case l: SessionLimiterWithUnlimitedUsersImpl => l.setUnlimitedUsers(unlimitedUsers) + case _ => + } + + def getUnlimitedUsers(limiter: SessionLimiter): Set[String] = limiter match { + case l: SessionLimiterWithUnlimitedUsersImpl => l.unlimitedUsers + case _ => Set.empty + } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/parser/trino/KyuubiTrinoFeAstBuilder.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/parser/trino/KyuubiTrinoFeAstBuilder.scala index c5ae9719947..8d1e38519d9 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/parser/trino/KyuubiTrinoFeAstBuilder.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/parser/trino/KyuubiTrinoFeAstBuilder.scala @@ -25,7 +25,7 @@ import org.apache.kyuubi.sql.KyuubiTrinoFeBaseParser._ import org.apache.kyuubi.sql.KyuubiTrinoFeBaseParserBaseVisitor import org.apache.kyuubi.sql.parser.KyuubiParser.unescapeSQLString import org.apache.kyuubi.sql.plan.{KyuubiTreeNode, PassThroughNode} -import org.apache.kyuubi.sql.plan.trino.{GetCatalogs, GetColumns, GetSchemas, GetTables, GetTableTypes, GetTypeInfo} +import org.apache.kyuubi.sql.plan.trino.{Deallocate, ExecuteForPreparing, GetCatalogs, GetColumns, GetPrimaryKeys, GetSchemas, GetTables, GetTableTypes, GetTypeInfo, Prepare} class KyuubiTrinoFeAstBuilder extends KyuubiTrinoFeBaseParserBaseVisitor[AnyRef] { @@ -92,6 +92,10 @@ class KyuubiTrinoFeAstBuilder extends KyuubiTrinoFeBaseParserBaseVisitor[AnyRef] GetColumns(catalog, schemaPattern, tableNamePattern, colNamePattern) } + override def visitGetPrimaryKeys(ctx: GetPrimaryKeysContext): KyuubiTreeNode = { + GetPrimaryKeys() + } + override def visitNullCatalog(ctx: NullCatalogContext): AnyRef = { null } @@ -119,4 +123,21 @@ class KyuubiTrinoFeAstBuilder extends KyuubiTrinoFeBaseParserBaseVisitor[AnyRef] override def visitTypesFilter(ctx: TypesFilterContext): List[String] = { ctx.stringLit().asScala.map(v => unescapeSQLString(v.getText)).toList } + + override def visitExecute(ctx: ExecuteContext): KyuubiTreeNode = { + val parameters = Option(ctx.parameterList()) match { + case Some(para) => + para.anyStr().asScala.toList.map(p => p.getText.substring(1, p.getText.length - 1)) + case None => List[String]() + } + ExecuteForPreparing(ctx.IDENTIFIER().getText, parameters) + } + + override def visitPrepare(ctx: PrepareContext): KyuubiTreeNode = { + Prepare(ctx.IDENTIFIER().getText, ctx.statement().getText) + } + + override def visitDeallocate(ctx: DeallocateContext): KyuubiTreeNode = { + Deallocate(ctx.IDENTIFIER().getText) + } } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/parser/trino/KyuubiTrinoFeParser.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/parser/trino/KyuubiTrinoFeParser.scala index 987288b0f82..5dececf20f0 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/parser/trino/KyuubiTrinoFeParser.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/parser/trino/KyuubiTrinoFeParser.scala @@ -56,4 +56,5 @@ class KyuubiTrinoFeParser extends KyuubiParserBase[KyuubiTrinoFeBaseParser] { } override def parseTree(parser: KyuubiTrinoFeBaseParser): ParseTree = parser.singleStatement() + } diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/plan/trino/TrinoFeOperations.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/plan/trino/TrinoFeOperations.scala index 85e6f168bcb..8d02a74c676 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/plan/trino/TrinoFeOperations.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/sql/plan/trino/TrinoFeOperations.scala @@ -55,3 +55,22 @@ case class GetColumns( colNamePattern: String) extends KyuubiTreeNode { override def name(): String = "Get Columns" } + +case class GetPrimaryKeys() extends KyuubiTreeNode { + override def name(): String = "Get Primary Keys" +} + +case class ExecuteForPreparing(statementId: String, parameters: List[String]) + extends KyuubiTreeNode { + override def name(): String = "Execute For Preparing" +} + +case class Prepare(statementId: String, sql: String) + extends KyuubiTreeNode { + override def name(): String = "Prepare Sql" +} + +case class Deallocate(statementId: String) + extends KyuubiTreeNode { + override def name(): String = "Deallocate Prepare" +} diff --git a/kyuubi-server/src/main/scala/org/apache/kyuubi/util/KubernetesUtils.scala b/kyuubi-server/src/main/scala/org/apache/kyuubi/util/KubernetesUtils.scala index 921aa04ae3c..0c934b51d06 100644 --- a/kyuubi-server/src/main/scala/org/apache/kyuubi/util/KubernetesUtils.scala +++ b/kyuubi-server/src/main/scala/org/apache/kyuubi/util/KubernetesUtils.scala @@ -22,7 +22,7 @@ import java.io.File import com.fasterxml.jackson.databind.ObjectMapper import com.google.common.base.Charsets import com.google.common.io.Files -import io.fabric8.kubernetes.client.{Config, ConfigBuilder, DefaultKubernetesClient, KubernetesClient} +import io.fabric8.kubernetes.client.{Config, ConfigBuilder, KubernetesClient, KubernetesClientBuilder} import io.fabric8.kubernetes.client.Config.autoConfigure import io.fabric8.kubernetes.client.okhttp.OkHttpClientFactory import okhttp3.{Dispatcher, OkHttpClient} @@ -93,7 +93,10 @@ object KubernetesUtils extends Logging { debug("Kubernetes client config: " + new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(config)) - Some(new DefaultKubernetesClient(factoryWithCustomDispatcher.createHttpClient(config), config)) + Some(new KubernetesClientBuilder() + .withHttpClientFactory(factoryWithCustomDispatcher) + .withConfig(config) + .build()) } implicit private class OptionConfigurableConfigBuilder(val configBuilder: ConfigBuilder) diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/RestClientTestHelper.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/RestClientTestHelper.scala index 5b362738196..8344cdef01d 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/RestClientTestHelper.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/RestClientTestHelper.scala @@ -54,7 +54,7 @@ trait RestClientTestHelper extends RestFrontendTestHelper with KerberizedTestHel .set(KyuubiConf.SERVER_SPNEGO_KEYTAB, testKeytab) .set(KyuubiConf.SERVER_SPNEGO_PRINCIPAL, testSpnegoPrincipal) .set(KyuubiConf.AUTHENTICATION_LDAP_URL, ldapUrl) - .set(KyuubiConf.AUTHENTICATION_LDAP_BASEDN, ldapBaseDn) + .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, ldapBaseDn.head) .set( KyuubiConf.AUTHENTICATION_CUSTOM_CLASS, classOf[UserDefineAuthenticationProviderImpl].getCanonicalName) diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/RestFrontendTestHelper.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/RestFrontendTestHelper.scala index c081185d8ac..fafdcf4a7b1 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/RestFrontendTestHelper.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/RestFrontendTestHelper.scala @@ -22,6 +22,7 @@ import javax.ws.rs.client.WebTarget import javax.ws.rs.core.{Application, Response, UriBuilder} import org.glassfish.jersey.client.ClientConfig +import org.glassfish.jersey.media.multipart.MultiPartFeature import org.glassfish.jersey.server.ResourceConfig import org.glassfish.jersey.test.JerseyTest import org.glassfish.jersey.test.jetty.JettyTestContainerFactory @@ -36,12 +37,14 @@ import org.apache.kyuubi.service.AbstractFrontendService object RestFrontendTestHelper { - private class RestApiBaseSuite extends JerseyTest { + class RestApiBaseSuite extends JerseyTest { override def configure: Application = new ResourceConfig(getClass) + .register(classOf[MultiPartFeature]) override def configureClient(config: ClientConfig): Unit = { config.register(classOf[KyuubiScalaObjectMapper]) + .register(classOf[MultiPartFeature]) } override def getTestContainerFactory: TestContainerFactory = new JettyTestContainerFactory @@ -55,7 +58,7 @@ trait RestFrontendTestHelper extends WithKyuubiServer { override protected val frontendProtocols: Seq[FrontendProtocol] = FrontendProtocols.REST :: Nil - private val restApiBaseSuite = new RestApiBaseSuite + protected val restApiBaseSuite: JerseyTest = new RestApiBaseSuite override def beforeAll(): Unit = { super.beforeAll() diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/TrinoClientTestHelper.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/TrinoClientTestHelper.scala deleted file mode 100644 index c0b3949f4ee..00000000000 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/TrinoClientTestHelper.scala +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.kyuubi - -import java.net.URI -import java.time.ZoneId -import java.util.{Locale, Optional} -import java.util.concurrent.TimeUnit - -import scala.collection.JavaConverters._ - -import io.airlift.units.Duration -import io.trino.client.{ClientSelectedRole, ClientSession, StatementClient, StatementClientFactory} -import okhttp3.OkHttpClient - -trait TrinoClientTestHelper extends RestFrontendTestHelper { - - override def afterAll(): Unit = { - super.afterAll() - } - - private val httpClient = new OkHttpClient.Builder().build() - - protected val clientSession = createClientSession(baseUri: URI) - - def getTrinoStatementClient(sql: String): StatementClient = { - StatementClientFactory.newStatementClient(httpClient, clientSession, sql) - } - - def createClientSession(connectUrl: URI): ClientSession = { - new ClientSession( - connectUrl, - "kyuubi_test", - Optional.of("test_user"), - "kyuubi", - Optional.of("test_token_tracing"), - Set[String]().asJava, - "test_client_info", - "test_catalog", - "test_schema", - "test_path", - ZoneId.systemDefault(), - Locale.getDefault, - Map[String, String]( - "test_resource_key0" -> "test_resource_value0", - "test_resource_key1" -> "test_resource_value1").asJava, - Map[String, String]( - "test_property_key0" -> "test_property_value0", - "test_property_key1" -> "test_propert_value1").asJava, - Map[String, String]( - "test_statement_key0" -> "select 1", - "test_statement_key1" -> "select 2").asJava, - Map[String, ClientSelectedRole]( - "test_role_key0" -> ClientSelectedRole.valueOf("ROLE"), - "test_role_key2" -> ClientSelectedRole.valueOf("ALL")).asJava, - Map[String, String]( - "test_credentials_key0" -> "test_credentials_value0", - "test_credentials_key1" -> "test_credentials_value1").asJava, - "test_transaction_id", - new Duration(2, TimeUnit.MINUTES), - true) - - } - -} diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/TrinoRestFrontendTestHelper.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/TrinoRestFrontendTestHelper.scala new file mode 100644 index 00000000000..1ff00e64fa2 --- /dev/null +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/TrinoRestFrontendTestHelper.scala @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi + +import org.glassfish.jersey.client.ClientConfig +import org.glassfish.jersey.test.JerseyTest + +import org.apache.kyuubi.config.KyuubiConf.FrontendProtocols +import org.apache.kyuubi.config.KyuubiConf.FrontendProtocols.FrontendProtocol +import org.apache.kyuubi.server.trino.api.TrinoScalaObjectMapper + +trait TrinoRestFrontendTestHelper extends RestFrontendTestHelper { + + private class TrinoRestBaseSuite extends RestFrontendTestHelper.RestApiBaseSuite { + override def configureClient(config: ClientConfig): Unit = { + config.register(classOf[TrinoScalaObjectMapper]) + } + } + + override protected val frontendProtocols: Seq[FrontendProtocol] = + FrontendProtocols.TRINO :: Nil + + override protected val restApiBaseSuite: JerseyTest = new TrinoRestBaseSuite + +} diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/WithKyuubiServerOnYarn.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/WithKyuubiServerOnYarn.scala index 727c5545e9d..3bc6bb1c578 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/WithKyuubiServerOnYarn.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/WithKyuubiServerOnYarn.scala @@ -17,14 +17,17 @@ package org.apache.kyuubi +import java.util.UUID + import scala.collection.JavaConverters._ import scala.concurrent.duration._ +import org.apache.kyuubi.client.util.BatchUtils._ import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf._ import org.apache.kyuubi.config.KyuubiConf.FrontendProtocols.FrontendProtocol +import org.apache.kyuubi.engine.{ApplicationState, YarnApplicationOperation} import org.apache.kyuubi.engine.ApplicationState._ -import org.apache.kyuubi.engine.YarnApplicationOperation import org.apache.kyuubi.operation.{FetchOrientation, HiveJDBCTestHelper, OperationState} import org.apache.kyuubi.operation.OperationState.ERROR import org.apache.kyuubi.server.MiniYarnService @@ -104,7 +107,10 @@ class KyuubiOperationYarnClusterSuite extends WithKyuubiServerOnYarn with HiveJD test("open batch session") { val batchRequest = - newSparkBatchRequest(Map("spark.master" -> "local", "spark.executor.instances" -> "1")) + newSparkBatchRequest(Map( + "spark.master" -> "local", + "spark.executor.instances" -> "1", + KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString)) val sessionHandle = sessionManager.openBatchSession( "kyuubi", @@ -117,7 +123,7 @@ class KyuubiOperationYarnClusterSuite extends WithKyuubiServerOnYarn with HiveJD val batchJobSubmissionOp = session.batchJobSubmissionOp eventually(timeout(3.minutes), interval(50.milliseconds)) { - val appInfo = batchJobSubmissionOp.currentApplicationInfo + val appInfo = batchJobSubmissionOp.getOrFetchCurrentApplicationInfo assert(appInfo.nonEmpty) assert(appInfo.exists(_.id.startsWith("application_"))) } @@ -152,7 +158,7 @@ class KyuubiOperationYarnClusterSuite extends WithKyuubiServerOnYarn with HiveJD val appUrl = rows("url") val appError = rows("error") - val appInfo2 = batchJobSubmissionOp.currentApplicationInfo.get + val appInfo2 = batchJobSubmissionOp.getOrFetchCurrentApplicationInfo.get assert(appId === appInfo2.id) assert(appName === appInfo2.name) assert(appState === appInfo2.state.toString) @@ -162,7 +168,9 @@ class KyuubiOperationYarnClusterSuite extends WithKyuubiServerOnYarn with HiveJD } test("prevent dead loop if the batch job submission process it not alive") { - val batchRequest = newSparkBatchRequest(Map("spark.submit.deployMode" -> "invalid")) + val batchRequest = newSparkBatchRequest(Map( + "spark.submit.deployMode" -> "invalid", + KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString)) val sessionHandle = sessionManager.openBatchSession( "kyuubi", @@ -175,7 +183,9 @@ class KyuubiOperationYarnClusterSuite extends WithKyuubiServerOnYarn with HiveJD val batchJobSubmissionOp = session.batchJobSubmissionOp eventually(timeout(3.minutes), interval(50.milliseconds)) { - assert(batchJobSubmissionOp.currentApplicationInfo.isEmpty) + assert(batchJobSubmissionOp.getOrFetchCurrentApplicationInfo.exists(_.id == null)) + assert(batchJobSubmissionOp.getOrFetchCurrentApplicationInfo.exists( + _.state == ApplicationState.NOT_FOUND)) assert(batchJobSubmissionOp.getStatus.state === OperationState.ERROR) } } @@ -186,7 +196,8 @@ class KyuubiOperationYarnClusterSuite extends WithKyuubiServerOnYarn with HiveJD "spark.submit.deployMode" -> "cluster", "spark.sql.defaultCatalog=spark_catalog" -> "spark_catalog", "spark.sql.catalog.spark_catalog.type" -> "invalid_type", - "kyuubi.session.engine.initialize.timeout" -> "PT10m"))(Map.empty) { + "kyuubi.session.engine.initialize.timeout" -> "PT10M", + KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString))(Map.empty) { val startTime = System.currentTimeMillis() val exception = intercept[Exception] { withJdbcStatement() { _ => } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/config/AllKyuubiConfiguration.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/config/AllKyuubiConfiguration.scala index 31ab67754f2..9fff482d449 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/config/AllKyuubiConfiguration.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/config/AllKyuubiConfiguration.scala @@ -17,13 +17,11 @@ package org.apache.kyuubi.config -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Path, Paths} +import java.nio.file.Paths import scala.collection.JavaConverters._ -import scala.collection.mutable.ArrayBuffer -import org.apache.kyuubi.{KyuubiFunSuite, TestUtils, Utils} +import org.apache.kyuubi.{KyuubiFunSuite, MarkdownBuilder, MarkdownUtils, Utils} import org.apache.kyuubi.ctl.CtlConf import org.apache.kyuubi.ha.HighAvailabilityConf import org.apache.kyuubi.metrics.MetricsConf @@ -37,12 +35,12 @@ import org.apache.kyuubi.zookeeper.ZookeeperConf * * To run the entire test suite: * {{{ - * build/mvn clean install -Pflink-provided,spark-provided,hive-provided -DwildcardSuites=org.apache.kyuubi.config.AllKyuubiConfiguration + * build/mvn clean test -pl kyuubi-server -am -Pflink-provided,spark-provided,hive-provided -Dtest=none -DwildcardSuites=org.apache.kyuubi.config.AllKyuubiConfiguration * }}} * * To re-generate golden files for entire suite, run: * {{{ - * KYUUBI_UPDATE=1 build/mvn clean install -Pflink-provided,spark-provided,hive-provided -DwildcardSuites=org.apache.kyuubi.config.AllKyuubiConfiguration + * KYUUBI_UPDATE=1 build/mvn clean test -pl kyuubi-server -am -Pflink-provided,spark-provided,hive-provided -Dtest=none -DwildcardSuites=org.apache.kyuubi.config.AllKyuubiConfiguration * }}} */ // scalastyle:on line.size.limit @@ -51,256 +49,197 @@ class AllKyuubiConfiguration extends KyuubiFunSuite { private val markdown = Paths.get(kyuubiHome, "docs", "deployment", "settings.md") .toAbsolutePath - def rewriteToConf(path: Path, buffer: ArrayBuffer[String]): Unit = { - val env = - Files.newBufferedReader(path, StandardCharsets.UTF_8) - - try { - buffer += "```bash" - var line = env.readLine() - while (line != null) { - buffer += line - line = env.readLine() - } - buffer += "```" - } finally { - env.close() - } - } + private def loadConfigs = Array( + KyuubiConf, + CtlConf, + HighAvailabilityConf, + JDBCMetadataStoreConf, + MetricsConf, + ZookeeperConf) test("Check all kyuubi configs") { - KyuubiConf - CtlConf - HighAvailabilityConf - JDBCMetadataStoreConf - MetricsConf - ZookeeperConf - - val newOutput = new ArrayBuffer[String]() - newOutput += "" - newOutput += "" - newOutput += "" - newOutput += "" - newOutput += "" - newOutput += "# Introduction to the Kyuubi Configurations System" - newOutput += "" - newOutput += "Kyuubi provides several ways to configure the system and corresponding engines." - newOutput += "" - newOutput += "" - newOutput += "## Environments" - newOutput += "" - newOutput += "" - newOutput += "You can configure the environment variables in" + - " `$KYUUBI_HOME/conf/kyuubi-env.sh`, e.g, `JAVA_HOME`, then this java runtime will be used" + - " both for Kyuubi server instance and the applications it launches. You can also change" + - " the variable in the subprocess's env configuration file, e.g." + - "`$SPARK_HOME/conf/spark-env.sh` to use more specific ENV for SQL engine applications." - - rewriteToConf(Paths.get(kyuubiHome, "conf", "kyuubi-env.sh.template"), newOutput) - - newOutput += "" - newOutput += "For the environment variables that only needed to be transferred into engine" + - " side, you can set it with a Kyuubi configuration item formatted" + - " `kyuubi.engineEnv.VAR_NAME`. For example, with `kyuubi.engineEnv.SPARK_DRIVER_MEMORY=4g`," + - " the environment variable `SPARK_DRIVER_MEMORY` with value `4g` would be transferred into" + - " engine side. With `kyuubi.engineEnv.SPARK_CONF_DIR=/apache/confs/spark/conf`, the" + - " value of `SPARK_CONF_DIR` in engine side is set to `/apache/confs/spark/conf`." - - newOutput += "" - newOutput += "## Kyuubi Configurations" - newOutput += "" - - newOutput += "You can configure the Kyuubi properties in" + - " `$KYUUBI_HOME/conf/kyuubi-defaults.conf`. For example:" - - rewriteToConf(Paths.get(kyuubiHome, "conf", "kyuubi-defaults.conf.template"), newOutput) + loadConfigs + + val builder = MarkdownBuilder(licenced = true, getClass.getName) + + builder + .lines(s""" + |# Introduction to the Kyuubi Configurations System + | + |Kyuubi provides several ways to configure the system and corresponding engines. + | + |## Environments + | + |""") + .line("""You can configure the environment variables in `$KYUUBI_HOME/conf/kyuubi-env.sh`, + | e.g, `JAVA_HOME`, then this java runtime will be used both for Kyuubi server instance and + | the applications it launches. You can also change the variable in the subprocess's env + | configuration file, e.g.`$SPARK_HOME/conf/spark-env.sh` to use more specific ENV for + | SQL engine applications. see `$KYUUBI_HOME/conf/kyuubi-env.sh.template` as an example. + | """) + .line( + """ + | For the environment variables that only needed to be transferred into engine + | side, you can set it with a Kyuubi configuration item formatted + | `kyuubi.engineEnv.VAR_NAME`. For example, with `kyuubi.engineEnv.SPARK_DRIVER_MEMORY=4g`, + | the environment variable `SPARK_DRIVER_MEMORY` with value `4g` would be transferred into + | engine side. With `kyuubi.engineEnv.SPARK_CONF_DIR=/apache/confs/spark/conf`, the + | value of `SPARK_CONF_DIR` on the engine side is set to `/apache/confs/spark/conf`. + | """) + .line("## Kyuubi Configurations") + .line(""" You can configure the Kyuubi properties in + | `$KYUUBI_HOME/conf/kyuubi-defaults.conf`, see + | `$KYUUBI_HOME/conf/kyuubi-defaults.conf.template` as an example. + | """) KyuubiConf.getConfigEntries().asScala - .toSeq + .toStream .filterNot(_.internal) .groupBy(_.key.split("\\.")(1)) .toSeq.sortBy(_._1).foreach { case (category, entries) => - newOutput += "" - newOutput += s"### ${category.capitalize}" - newOutput += "" - - newOutput += "Key | Default | Meaning | Type | Since" - newOutput += "--- | --- | --- | --- | ---" + builder.lines( + s"""### ${category.capitalize} + | Key | Default | Meaning | Type | Since + | --- | --- | --- | --- | --- + |""") entries.sortBy(_.key).foreach { c => val dft = c.defaultValStr.replace("<", "<").replace(">", ">") - val seq = Seq( + builder.line(Seq( s"${c.key}", s"$dft", s"${c.doc}", s"${c.typ}", - s"${c.version}") - newOutput += seq.mkString("|") + s"${c.version}").mkString("|")) } - newOutput += "" } - newOutput += ("## Spark Configurations") - newOutput += "" - - newOutput += ("### Via spark-defaults.conf") - newOutput += "" - - newOutput += ("Setting them in `$SPARK_HOME/conf/spark-defaults.conf`" + - " supplies with default values for SQL engine application. Available properties can be" + - " found at Spark official online documentation for" + - " [Spark Configurations](http://spark.apache.org/docs/latest/configuration.html)") - - newOutput += "" - newOutput += ("### Via kyuubi-defaults.conf") - newOutput += "" - newOutput += ("Setting them in `$KYUUBI_HOME/conf/kyuubi-defaults.conf`" + - " supplies with default values for SQL engine application too. These properties will" + - " override all settings in `$SPARK_HOME/conf/spark-defaults.conf`") - - newOutput += "" - newOutput += ("### Via JDBC Connection URL") - newOutput += "" - newOutput += ("Setting them in the JDBC Connection URL" + - " supplies session-specific for each SQL engine. For example: " + - "```" + - "jdbc:hive2://localhost:10009/default;#" + - "spark.sql.shuffle.partitions=2;spark.executor.memory=5g" + - "```") - newOutput += "" - newOutput += ("- **Runtime SQL Configuration**") - newOutput += "" - newOutput += (" - For [Runtime SQL Configurations](" + - "http://spark.apache.org/docs/latest/configuration.html#runtime-sql-configuration), they" + - " will take affect every time") - newOutput += "" - newOutput += ("- **Static SQL and Spark Core Configuration**") - newOutput += "" - newOutput += (" - For [Static SQL Configurations](" + - "http://spark.apache.org/docs/latest/configuration.html#static-sql-configuration) and" + - " other spark core configs, e.g. `spark.executor.memory`, they will take affect if there" + - " is no existing SQL engine application. Otherwise, they will just be ignored") - newOutput += "" - newOutput += ("### Via SET Syntax") - newOutput += "" - newOutput += ("Please refer to the Spark official online documentation for" + - " [SET Command](http://spark.apache.org/docs/latest/sql-ref-syntax-aux-conf-mgmt-set.html)") - newOutput += "" - - newOutput += ("## Flink Configurations") - newOutput += "" - - newOutput += ("### Via flink-conf.yaml") - newOutput += "" - newOutput += ("Setting them in `$FLINK_HOME/conf/flink-conf.yaml`" + - " supplies with default values for SQL engine application." + - " Available properties can be found at Flink official online documentation for" + - " [Flink Configurations]" + - "(https://nightlies.apache.org/flink/flink-docs-stable/docs/deployment/config/)") - newOutput += "" - - newOutput += ("### Via kyuubi-defaults.conf") - newOutput += "" - newOutput += ("Setting them in `$KYUUBI_HOME/conf/kyuubi-defaults.conf`" + - " supplies with default values for SQL engine application too." + - " You can use properties with the additional prefix `flink.` to override settings in" + - " `$FLINK_HOME/conf/flink-conf.yaml`.") - newOutput += "" - newOutput += ("For example:") - newOutput += ("```") - newOutput += ("flink.parallelism.default 2") - newOutput += ("flink.taskmanager.memory.process.size 5g") - newOutput += ("```") - newOutput += "" - newOutput += ("The below options in `kyuubi-defaults.conf` will set `parallelism.default: 2`" + - " and `taskmanager.memory.process.size: 5g` into flink configurations.") - newOutput += "" - - newOutput += ("### Via JDBC Connection URL") - newOutput += "" - newOutput += "Setting them in the JDBC Connection URL supplies session-specific" + - " for each SQL engine. For example: ```jdbc:hive2://localhost:10009/default;" + - "#parallelism.default=2;taskmanager.memory.process.size=5g```" - newOutput += "" - - newOutput += ("### Via SET Statements") - newOutput += "" - newOutput += ("Please refer to the Flink official online documentation for [SET Statements]" + - "(https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/table/sql/set/)") - newOutput += "" - - newOutput += ("## Logging") - newOutput += "" - newOutput += ("Kyuubi uses [log4j](https://logging.apache.org/log4j/2.x/) for logging." + - " You can configure it using `$KYUUBI_HOME/conf/log4j2.xml`.") - - rewriteToConf(Paths.get(kyuubiHome, "conf", "log4j2.xml.template"), newOutput) - - newOutput += "" - newOutput += ("## Other Configurations") - newOutput += "" - newOutput += ("### Hadoop Configurations") - newOutput += "" - newOutput += ("Specifying `HADOOP_CONF_DIR` to the directory contains hadoop configuration" + - " files or treating them as Spark properties with a `spark.hadoop.` prefix." + - " Please refer to the Spark official online documentation for" + - " [Inheriting Hadoop Cluster Configuration](http://spark.apache.org/docs/latest/" + - "configuration.html#inheriting-hadoop-cluster-configuration)." + - " Also, please refer to the [Apache Hadoop](http://hadoop.apache.org)'s" + - " online documentation for an overview on how to configure Hadoop.") - newOutput += "" - newOutput += ("### Hive Configurations") - newOutput += "" - newOutput += ("These configurations are used for SQL engine application to talk to" + - " Hive MetaStore and could be configured in a `hive-site.xml`." + - " Placed it in `$SPARK_HOME/conf` directory, or treating them as Spark properties with" + - " a `spark.hadoop.` prefix.") - - newOutput += "" - newOutput += ("## User Defaults") - newOutput += "" - newOutput += ("In Kyuubi, we can configure user default settings to meet separate needs." + - " These user defaults override system defaults, but will be overridden by those from" + - " [JDBC Connection URL](#via-jdbc-connection-url) or [Set Command](#via-set-syntax)" + - " if could be. They will take effect when creating the SQL engine application ONLY.") - newOutput += ("User default settings are in the form of `___{username}___.{config key}`." + - " There are three continuous underscores(`_`) at both sides of the `username` and" + - " a dot(`.`) that separates the config key and the prefix. For example:") - newOutput += ("```bash") - newOutput += ("# For system defaults") - newOutput += ("spark.master=local") - newOutput += ("spark.sql.adaptive.enabled=true") - newOutput += ("# For a user named kent") - newOutput += ("___kent___.spark.master=yarn") - newOutput += ("___kent___.spark.sql.adaptive.enabled=false") - newOutput += ("# For a user named bob") - newOutput += ("___bob___.spark.master=spark://master:7077") - newOutput += ("___bob___.spark.executor.memory=8g") - newOutput += ("```") - newOutput += "" - newOutput += "In the above case, if there are related configurations from" + - " [JDBC Connection URL](#via-jdbc-connection-url), `kent` will run his SQL engine" + - " application on YARN and prefer the Spark AQE to be off, while `bob` will activate" + - " his SQL engine application on a Spark standalone cluster with 8g heap memory for each" + - " executor and obey the Spark AQE behavior of Kyuubi system default. On the other hand," + - " for those users who do not have custom configurations will use system defaults." - - TestUtils.verifyOutput(markdown, newOutput, getClass.getCanonicalName) + builder + .lines(""" + |## Spark Configurations + |### Via spark-defaults.conf + |""") + .line(""" + | Setting them in `$SPARK_HOME/conf/spark-defaults.conf` + | supplies with default values for SQL engine application. Available properties can be + | found at Spark official online documentation for + | [Spark Configurations](https://spark.apache.org/docs/latest/configuration.html) + | """) + .line("### Via kyuubi-defaults.conf") + .line(""" + | Setting them in `$KYUUBI_HOME/conf/kyuubi-defaults.conf` + | supplies with default values for SQL engine application too. These properties will + | override all settings in `$SPARK_HOME/conf/spark-defaults.conf`""") + .line("### Via JDBC Connection URL") + .line(""" + | Setting them in the JDBC Connection URL + | supplies session-specific for each SQL engine. For example: + | ``` + |jdbc:hive2://localhost:10009/default;# + |spark.sql.shuffle.partitions=2;spark.executor.memory=5g + |```""") + .line() + .line("- **Runtime SQL Configuration**") + .line(""" - For [Runtime SQL Configurations]( + |https://spark.apache.org/docs/latest/configuration.html#runtime-sql-configuration), they + | will take affect every time""") + .line("- **Static SQL and Spark Core Configuration**") + .line(""" - For [Static SQL Configurations]( + |https://spark.apache.org/docs/latest/configuration.html#static-sql-configuration) and + | other spark core configs, e.g. `spark.executor.memory`, they will take effect if there + | is no existing SQL engine application. Otherwise, they will just be ignored""") + .line("### Via SET Syntax") + .line("""Please refer to the Spark official online documentation for + | [SET Command](https://spark.apache.org/docs/latest/sql-ref-syntax-aux-conf-mgmt-set.html) + |""") + + builder + .lines(""" + |## Flink Configurations + |### Via flink-conf.yaml""") + .line("""Setting them in `$FLINK_HOME/conf/flink-conf.yaml` + | supplies with default values for SQL engine application. + | Available properties can be found at Flink official online documentation for + | [Flink Configurations] + |(https://nightlies.apache.org/flink/flink-docs-stable/docs/deployment/config/)""") + .line("### Via kyuubi-defaults.conf") + .line("""Setting them in `$KYUUBI_HOME/conf/kyuubi-defaults.conf` + | supplies with default values for SQL engine application too. + | You can use properties with the additional prefix `flink.` to override settings in + | `$FLINK_HOME/conf/flink-conf.yaml`.""") + .lines(""" + | + |For example: + |``` + |flink.parallelism.default 2 + |flink.taskmanager.memory.process.size 5g + |```""") + .line("""The below options in `kyuubi-defaults.conf` will set `parallelism.default: 2` + | and `taskmanager.memory.process.size: 5g` into flink configurations.""") + .line("### Via JDBC Connection URL") + .line("""Setting them in the JDBC Connection URL supplies session-specific + | for each SQL engine. For example: ```jdbc:hive2://localhost:10009/default; + |#parallelism.default=2;taskmanager.memory.process.size=5g``` + |""") + .line("### Via SET Statements") + .line("""Please refer to the Flink official online documentation for [SET Statements] + |(https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/table/sql/set/)""") + + builder + .line("## Logging") + .line("""Kyuubi uses [log4j](https://logging.apache.org/log4j/2.x/) for logging. + | You can configure it using `$KYUUBI_HOME/conf/log4j2.xml`, see + | `$KYUUBI_HOME/conf/log4j2.xml.template` as an example. + | """) + + builder + .lines(""" + |## Other Configurations + |### Hadoop Configurations + |""") + .line("""Specifying `HADOOP_CONF_DIR` to the directory containing Hadoop configuration + | files or treating them as Spark properties with a `spark.hadoop.` prefix. + | Please refer to the Spark official online documentation for + | [Inheriting Hadoop Cluster Configuration](https://spark.apache.org/docs/latest/ + |configuration.html#inheriting-hadoop-cluster-configuration). + | Also, please refer to the [Apache Hadoop](https://hadoop.apache.org)'s + | online documentation for an overview on how to configure Hadoop.""") + .line("### Hive Configurations") + .line("""These configurations are used for SQL engine application to talk to + | Hive MetaStore and could be configured in a `hive-site.xml`. + | Placed it in `$SPARK_HOME/conf` directory, or treat them as Spark properties with + | a `spark.hadoop.` prefix.""") + + builder + .line("## User Defaults") + .line("""In Kyuubi, we can configure user default settings to meet separate needs. + | These user defaults override system defaults, but will be overridden by those from + | [JDBC Connection URL](#via-jdbc-connection-url) or [Set Command](#via-set-syntax) + | if could be. They will take effect when creating the SQL engine application ONLY.""") + .line("""User default settings are in the form of `___{username}___.{config key}`. + | There are three continuous underscores(`_`) at both sides of the `username` and + | a dot(`.`) that separates the config key and the prefix. For example:""") + .lines(""" + |```bash + |# For system defaults + |spark.master=local + |spark.sql.adaptive.enabled=true + |# For a user named kent + |___kent___.spark.master=yarn + |___kent___.spark.sql.adaptive.enabled=false + |# For a user named bob + |___bob___.spark.master=spark://master:7077 + |___bob___.spark.executor.memory=8g + |``` + | + |""") + .line("""In the above case, if there are related configurations from + | [JDBC Connection URL](#via-jdbc-connection-url), `kent` will run his SQL engine + | application on YARN and prefer the Spark AQE to be off, while `bob` will activate + | his SQL engine application on a Spark standalone cluster with 8g heap memory for each + | executor and obey the Spark AQE behavior of Kyuubi system default. On the other hand, + | for those users who do not have custom configurations will use system defaults.""") + + MarkdownUtils.verifyOutput(markdown, builder, getClass.getCanonicalName, "kyuubi-server") } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/EngineRefTests.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/EngineRefTests.scala index 5d8ae3177f5..8b050684a59 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/EngineRefTests.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/EngineRefTests.scala @@ -20,6 +20,8 @@ package org.apache.kyuubi.engine import java.util.UUID import java.util.concurrent.Executors +import scala.collection.JavaConverters._ + import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.apache.kyuubi.{KYUUBI_VERSION, Utils} @@ -33,6 +35,7 @@ import org.apache.kyuubi.ha.client.DiscoveryClientProvider import org.apache.kyuubi.ha.client.DiscoveryPaths import org.apache.kyuubi.metrics.MetricsConstants.ENGINE_TOTAL import org.apache.kyuubi.metrics.MetricsSystem +import org.apache.kyuubi.plugin.PluginLoader import org.apache.kyuubi.util.NamedThreadFactory trait EngineRefTests extends KyuubiFunSuite { @@ -68,7 +71,9 @@ trait EngineRefTests extends KyuubiFunSuite { Seq(None, Some("suffix")).foreach { domain => conf.set(KyuubiConf.ENGINE_SHARE_LEVEL, CONNECTION.toString) domain.foreach(conf.set(KyuubiConf.ENGINE_SHARE_LEVEL_SUBDOMAIN.key, _)) - val engine = new EngineRef(conf, user, "grp", id, null) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + + val engine = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine.engineSpace === DiscoveryPaths.makePath( s"kyuubi_${KYUUBI_VERSION}_${CONNECTION}_${engineType}", @@ -82,7 +87,9 @@ trait EngineRefTests extends KyuubiFunSuite { val id = UUID.randomUUID().toString conf.set(KyuubiConf.ENGINE_SHARE_LEVEL, USER.toString) conf.set(KyuubiConf.ENGINE_TYPE, FLINK_SQL.toString) - val appName = new EngineRef(conf, user, "grp", id, null) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + + val appName = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(appName.engineSpace === DiscoveryPaths.makePath( s"kyuubi_${KYUUBI_VERSION}_${USER}_$FLINK_SQL", @@ -94,7 +101,7 @@ trait EngineRefTests extends KyuubiFunSuite { k => conf.unset(KyuubiConf.ENGINE_SHARE_LEVEL_SUBDOMAIN) conf.set(k.key, "abc") - val appName2 = new EngineRef(conf, user, "grp", id, null) + val appName2 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(appName2.engineSpace === DiscoveryPaths.makePath( s"kyuubi_${KYUUBI_VERSION}_${USER}_${FLINK_SQL}", @@ -108,8 +115,12 @@ trait EngineRefTests extends KyuubiFunSuite { val id = UUID.randomUUID().toString conf.set(KyuubiConf.ENGINE_SHARE_LEVEL, GROUP.toString) conf.set(KyuubiConf.ENGINE_TYPE, SPARK_SQL.toString) - val primaryGroupName = "primary_grp" - val engineRef = new EngineRef(conf, user, primaryGroupName, id, null) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + + val primaryGroupName = + PluginLoader.loadGroupProvider(conf).primaryGroup(user, Map.empty[String, String].asJava) + + val engineRef = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engineRef.engineSpace === DiscoveryPaths.makePath( s"kyuubi_${KYUUBI_VERSION}_GROUP_SPARK_SQL", @@ -122,7 +133,7 @@ trait EngineRefTests extends KyuubiFunSuite { k => conf.unset(k) conf.set(k.key, "abc") - val engineRef2 = new EngineRef(conf, user, primaryGroupName, id, null) + val engineRef2 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engineRef2.engineSpace === DiscoveryPaths.makePath( s"kyuubi_${KYUUBI_VERSION}_${GROUP}_${SPARK_SQL}", @@ -137,7 +148,9 @@ trait EngineRefTests extends KyuubiFunSuite { val id = UUID.randomUUID().toString conf.set(KyuubiConf.ENGINE_SHARE_LEVEL, SERVER.toString) conf.set(KyuubiConf.ENGINE_TYPE, FLINK_SQL.toString) - val appName = new EngineRef(conf, user, "grp", id, null) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + + val appName = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(appName.engineSpace === DiscoveryPaths.makePath( s"kyuubi_${KYUUBI_VERSION}_${SERVER}_${FLINK_SQL}", @@ -146,7 +159,7 @@ trait EngineRefTests extends KyuubiFunSuite { assert(appName.defaultEngineName === s"kyuubi_${SERVER}_${FLINK_SQL}_${user}_default_$id") conf.set(KyuubiConf.ENGINE_SHARE_LEVEL_SUBDOMAIN.key, "abc") - val appName2 = new EngineRef(conf, user, "grp", id, null) + val appName2 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(appName2.engineSpace === DiscoveryPaths.makePath( s"kyuubi_${KYUUBI_VERSION}_${SERVER}_${FLINK_SQL}", @@ -161,31 +174,33 @@ trait EngineRefTests extends KyuubiFunSuite { // set subdomain and disable engine pool conf.set(ENGINE_SHARE_LEVEL_SUBDOMAIN.key, "abc") conf.set(ENGINE_POOL_SIZE, -1) - val engine1 = new EngineRef(conf, user, "grp", id, null) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + + val engine1 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine1.subdomain === "abc") // unset subdomain and disable engine pool conf.unset(ENGINE_SHARE_LEVEL_SUBDOMAIN) conf.set(ENGINE_POOL_SIZE, -1) - val engine2 = new EngineRef(conf, user, "grp", id, null) + val engine2 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine2.subdomain === "default") // set subdomain and 1 <= engine pool size < threshold conf.set(ENGINE_SHARE_LEVEL_SUBDOMAIN.key, "abc") conf.set(ENGINE_POOL_SIZE, 1) - val engine3 = new EngineRef(conf, user, "grp", id, null) + val engine3 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine3.subdomain === "abc") // unset subdomain and 1 <= engine pool size < threshold conf.unset(ENGINE_SHARE_LEVEL_SUBDOMAIN) conf.set(ENGINE_POOL_SIZE, 3) - val engine4 = new EngineRef(conf, user, "grp", id, null) + val engine4 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine4.subdomain.startsWith("engine-pool-")) // unset subdomain and engine pool size > threshold conf.unset(ENGINE_SHARE_LEVEL_SUBDOMAIN) conf.set(ENGINE_POOL_SIZE, 100) - val engine5 = new EngineRef(conf, user, "grp", id, null) + val engine5 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) val engineNumber = Integer.parseInt(engine5.subdomain.substring(12)) val threshold = ENGINE_POOL_SIZE_THRESHOLD.defaultVal.get assert(engineNumber <= threshold) @@ -195,7 +210,7 @@ trait EngineRefTests extends KyuubiFunSuite { val enginePoolName = "test-pool" conf.set(ENGINE_POOL_NAME, enginePoolName) conf.set(ENGINE_POOL_SIZE, 3) - val engine6 = new EngineRef(conf, user, "grp", id, null) + val engine6 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine6.subdomain.startsWith(s"$enginePoolName-")) conf.unset(ENGINE_SHARE_LEVEL_SUBDOMAIN) @@ -204,9 +219,9 @@ trait EngineRefTests extends KyuubiFunSuite { conf.set(ENGINE_POOL_NAME, pool_name) conf.set(HighAvailabilityConf.HA_NAMESPACE, "engine_test") conf.set(HighAvailabilityConf.HA_ADDRESSES, getConnectString()) - conf.set(ENGINE_POOL_BALANCE_POLICY, "POLLING") + conf.set(ENGINE_POOL_SELECT_POLICY, "POLLING") (0 until (10)).foreach { i => - val engine7 = new EngineRef(conf, user, "grp", id, null) + val engine7 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) val engineNumber = Integer.parseInt(engine7.subdomain.substring(pool_name.length + 1)) assert(engineNumber == (i % conf.get(ENGINE_POOL_SIZE))) } @@ -219,7 +234,9 @@ trait EngineRefTests extends KyuubiFunSuite { conf.set(KyuubiConf.FRONTEND_THRIFT_BINARY_BIND_PORT, 0) conf.set(HighAvailabilityConf.HA_NAMESPACE, "engine_test") conf.set(HighAvailabilityConf.HA_ADDRESSES, getConnectString()) - val engine = new EngineRef(conf, user, id, "grp", null) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + + val engine = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) var port1 = 0 var port2 = 0 @@ -261,6 +278,7 @@ trait EngineRefTests extends KyuubiFunSuite { conf.set(KyuubiConf.ENGINE_INIT_TIMEOUT, 3000L) conf.set(HighAvailabilityConf.HA_NAMESPACE, "engine_test2") conf.set(HighAvailabilityConf.HA_ADDRESSES, getConnectString()) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") val beforeEngines = MetricsSystem.counterValue(ENGINE_TOTAL).getOrElse(0L) val start = System.currentTimeMillis() @@ -272,7 +290,12 @@ trait EngineRefTests extends KyuubiFunSuite { executor.execute(() => { DiscoveryClientProvider.withDiscoveryClient(cloned) { client => try { - new EngineRef(cloned, user, "grp", id, null).getOrCreate(client) + new EngineRef( + cloned, + user, + PluginLoader.loadGroupProvider(conf), + id, + null).getOrCreate(client) } finally { times(i) = System.currentTimeMillis() } @@ -300,20 +323,22 @@ trait EngineRefTests extends KyuubiFunSuite { conf.set(ENGINE_SHARE_LEVEL_SUBDOMAIN.key, "abc") conf.set(ENGINE_POOL_IGNORE_SUBDOMAIN, false) conf.set(ENGINE_POOL_SIZE, -1) - val engine1 = new EngineRef(conf, user, "grp", id, null) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + + val engine1 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine1.subdomain === "abc") conf.set(ENGINE_POOL_SIZE, 1) - val engine2 = new EngineRef(conf, user, "grp", id, null) + val engine2 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine2.subdomain === "abc") conf.unset(ENGINE_SHARE_LEVEL_SUBDOMAIN) - val engine3 = new EngineRef(conf, user, "grp", id, null) + val engine3 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine3.subdomain.startsWith("engine-pool-")) conf.set(ENGINE_SHARE_LEVEL_SUBDOMAIN.key, "abc") conf.set(ENGINE_POOL_IGNORE_SUBDOMAIN, true) - val engine4 = new EngineRef(conf, user, "grp", id, null) + val engine4 = new EngineRef(conf, user, PluginLoader.loadGroupProvider(conf), id, null) assert(engine4.subdomain.startsWith("engine-pool-")) } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/EngineRefWithZookeeperSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/EngineRefWithZookeeperSuite.scala index 8695e13c414..40fc818706c 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/EngineRefWithZookeeperSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/EngineRefWithZookeeperSuite.scala @@ -29,6 +29,7 @@ import org.apache.kyuubi.engine.EngineType.SPARK_SQL import org.apache.kyuubi.engine.ShareLevel.USER import org.apache.kyuubi.ha.HighAvailabilityConf import org.apache.kyuubi.ha.client.DiscoveryClientProvider +import org.apache.kyuubi.plugin.PluginLoader import org.apache.kyuubi.zookeeper.EmbeddedZookeeper import org.apache.kyuubi.zookeeper.ZookeeperConf @@ -62,6 +63,8 @@ class EngineRefWithZookeeperSuite extends EngineRefTests { conf.set(KyuubiConf.ENGINE_INIT_TIMEOUT, 3000L) conf.set(HighAvailabilityConf.HA_NAMESPACE, "engine_test1") conf.set(HighAvailabilityConf.HA_ADDRESSES, getConnectString()) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + val conf1 = conf.clone conf1.set(KyuubiConf.ENGINE_TYPE, SPARK_SQL.toString) val conf2 = conf.clone @@ -74,7 +77,12 @@ class EngineRefWithZookeeperSuite extends EngineRefTests { executor.execute(() => { DiscoveryClientProvider.withDiscoveryClient(conf1) { client => try { - new EngineRef(conf1, user, "grp", UUID.randomUUID().toString, null) + new EngineRef( + conf1, + user, + PluginLoader.loadGroupProvider(conf), + UUID.randomUUID().toString, + null) .getOrCreate(client) } finally { times(0) = System.currentTimeMillis() @@ -84,7 +92,12 @@ class EngineRefWithZookeeperSuite extends EngineRefTests { executor.execute(() => { DiscoveryClientProvider.withDiscoveryClient(conf2) { client => try { - new EngineRef(conf2, user, "grp", UUID.randomUUID().toString, null) + new EngineRef( + conf2, + user, + PluginLoader.loadGroupProvider(conf), + UUID.randomUUID().toString, + null) .getOrCreate(client) } finally { times(1) = System.currentTimeMillis() diff --git a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/PySparkTests.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/spark/PySparkTests.scala similarity index 96% rename from externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/PySparkTests.scala rename to kyuubi-server/src/test/scala/org/apache/kyuubi/engine/spark/PySparkTests.scala index e2dd2609d8d..6af7e21e25d 100644 --- a/externals/kyuubi-spark-sql-engine/src/test/scala/org/apache/kyuubi/engine/spark/operation/PySparkTests.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/spark/PySparkTests.scala @@ -24,17 +24,19 @@ import java.util.Properties import scala.sys.process._ -import org.apache.kyuubi.engine.spark.WithSparkSQLEngine +import org.apache.kyuubi.WithKyuubiServer +import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.jdbc.KyuubiHiveDriver import org.apache.kyuubi.jdbc.hive.{KyuubiSQLException, KyuubiStatement} import org.apache.kyuubi.operation.HiveJDBCTestHelper import org.apache.kyuubi.tags.PySparkTest @PySparkTest -class PySparkTests extends WithSparkSQLEngine with HiveJDBCTestHelper { +class PySparkTests extends WithKyuubiServer with HiveJDBCTestHelper { override protected def jdbcUrl: String = getJdbcUrl - override def withKyuubiConf: Map[String, String] = Map.empty + + override protected val conf: KyuubiConf = new KyuubiConf test("pyspark support") { val code = "print(1)" diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/spark/SparkSqlEngineSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/spark/SparkSqlEngineSuite.scala index 1e35d2f1dc8..9ab627413d3 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/spark/SparkSqlEngineSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/engine/spark/SparkSqlEngineSuite.scala @@ -139,13 +139,13 @@ class SparkSqlEngineSuite extends WithKyuubiServer with HiveJDBCTestHelper { val utcResultSet = statement.executeQuery("select from_utc_timestamp(from_unixtime(" + "1670404535000/1000,'yyyy-MM-dd HH:mm:ss'),'GMT+08:00')") assert(utcResultSet.next()) - assert(utcResultSet.getString(1) == "2022-12-07 17:15:35.0") + assert(utcResultSet.getString(1) === "2022-12-07 17:15:35.0") val setGMT8ResultSet = statement.executeQuery("set spark.sql.session.timeZone=GMT+8") assert(setGMT8ResultSet.next()) val gmt8ResultSet = statement.executeQuery("select from_utc_timestamp(from_unixtime(" + "1670404535000/1000,'yyyy-MM-dd HH:mm:ss'),'GMT+08:00')") assert(gmt8ResultSet.next()) - assert(gmt8ResultSet.getString(1) == "2022-12-08 01:15:35.0") + assert(gmt8ResultSet.getString(1) === "2022-12-08 01:15:35.0") } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/events/handler/ServerJsonLoggingEventHandlerSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/events/handler/ServerJsonLoggingEventHandlerSuite.scala index d0c9924dc4d..3bdc9cd3808 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/events/handler/ServerJsonLoggingEventHandlerSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/events/handler/ServerJsonLoggingEventHandlerSuite.scala @@ -28,8 +28,10 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.apache.hadoop.conf.Configuration import org.apache.hadoop.fs.{FileSystem, Path} import org.apache.hive.service.rpc.thrift.{TOpenSessionReq, TStatusCode} +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.apache.kyuubi._ +import org.apache.kyuubi.client.util.BatchUtils._ import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.operation.HiveJDBCTestHelper import org.apache.kyuubi.operation.OperationState._ @@ -138,7 +140,7 @@ class ServerJsonLoggingEventHandlerSuite extends WithKyuubiServer with HiveJDBCT Utils.currentUser, "kyuubi", "127.0.0.1", - Map.empty, + Map(KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString), batchRequest) withSessionConf()(Map.empty)(Map("spark.sql.shuffle.partitions" -> "2")) { withJdbcStatement() { statement => @@ -157,7 +159,7 @@ class ServerJsonLoggingEventHandlerSuite extends WithKyuubiServer with HiveJDBCT } } - test("engine session id is not same with server session id") { + test("engine session id is same with server session id") { val name = UUID.randomUUID().toString withSessionConf()(Map.empty)(Map(KyuubiConf.SESSION_NAME.key -> name)) { withJdbcStatement() { statement => @@ -181,7 +183,7 @@ class ServerJsonLoggingEventHandlerSuite extends WithKyuubiServer with HiveJDBCT val res2 = statement.executeQuery( s"SELECT * FROM `json`.`$engineSessionEventPath` " + s"where sessionId = '$serverSessionId' limit 1") - assert(!res2.next()) + assert(res2.next()) } } } @@ -277,15 +279,17 @@ class ServerJsonLoggingEventHandlerSuite extends WithKyuubiServer with HiveJDBCT } } - val serverSessionEventPath = - Paths.get(serverLogRoot, "kyuubi_session", s"day=$currentDate") - withJdbcStatement() { statement => - val res = statement.executeQuery( - s"SELECT * FROM `json`.`$serverSessionEventPath` " + - s"where sessionName = '$name' and exception is not null limit 1") - assert(res.next()) - val exception = res.getObject("exception") - assert(exception.toString.contains("Invalid maximum heap size: -Xmxabc")) + eventually(timeout(2.minutes), interval(10.seconds)) { + val serverSessionEventPath = + Paths.get(serverLogRoot, "kyuubi_session", s"day=$currentDate") + withJdbcStatement() { statement => + val res = statement.executeQuery( + s"SELECT * FROM `json`.`$serverSessionEventPath` " + + s"where sessionName = '$name' and exception is not null limit 1") + assert(res.next()) + val exception = res.getObject("exception") + assert(exception.toString.contains("Invalid maximum heap size: -Xmxabc")) + } } } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationKerberosAndPlainAuthSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationKerberosAndPlainAuthSuite.scala index fc8e1ec70fc..31cde639734 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationKerberosAndPlainAuthSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationKerberosAndPlainAuthSuite.scala @@ -63,11 +63,12 @@ class KyuubiOperationKerberosAndPlainAuthSuite extends WithKyuubiServer with Ker UserGroupInformation.setConfiguration(config) assert(UserGroupInformation.isSecurityEnabled) - KyuubiConf().set(KyuubiConf.AUTHENTICATION_METHOD, Seq("KERBEROS", "LDAP", "CUSTOM")) + KyuubiConf() + .set(KyuubiConf.AUTHENTICATION_METHOD, Seq("KERBEROS", "LDAP", "CUSTOM")) .set(KyuubiConf.SERVER_KEYTAB, testKeytab) .set(KyuubiConf.SERVER_PRINCIPAL, testPrincipal) .set(KyuubiConf.AUTHENTICATION_LDAP_URL, ldapUrl) - .set(KyuubiConf.AUTHENTICATION_LDAP_BASEDN, ldapBaseDn) + .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, ldapBaseDn.head) .set( KyuubiConf.AUTHENTICATION_CUSTOM_CLASS, classOf[UserDefineAuthenticationProviderImpl].getCanonicalName) diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationPerConnectionSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationPerConnectionSuite.scala index 4c4faf63bbf..d04afbfb580 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationPerConnectionSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationPerConnectionSuite.scala @@ -26,14 +26,15 @@ import scala.collection.JavaConverters._ import org.apache.hive.service.rpc.thrift._ import org.scalatest.time.SpanSugar.convertIntToGrainOfTime -import org.apache.kyuubi.WithKyuubiServer -import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.{KYUUBI_VERSION, WithKyuubiServer} +import org.apache.kyuubi.config.{KyuubiConf, KyuubiReservedKeys} import org.apache.kyuubi.config.KyuubiConf.SESSION_CONF_ADVISOR import org.apache.kyuubi.engine.ApplicationState import org.apache.kyuubi.jdbc.KyuubiHiveDriver import org.apache.kyuubi.jdbc.hive.KyuubiConnection +import org.apache.kyuubi.metrics.{MetricsConstants, MetricsSystem} import org.apache.kyuubi.plugin.SessionConfAdvisor -import org.apache.kyuubi.session.KyuubiSessionManager +import org.apache.kyuubi.session.{KyuubiSessionManager, SessionType} /** * UT with Connection level engine shared cost much time, only run basic jdbc tests. @@ -136,6 +137,7 @@ class KyuubiOperationPerConnectionSuite extends WithKyuubiServer with HiveJDBCTe assert(connection.getEngineId.startsWith("local-")) assert(connection.getEngineName.startsWith("kyuubi")) assert(connection.getEngineUrl.nonEmpty) + assert(connection.getEngineRefId.nonEmpty) val stmt = connection.createStatement() try { stmt.execute("select engine_name()") @@ -239,6 +241,46 @@ class KyuubiOperationPerConnectionSuite extends WithKyuubiServer with HiveJDBCTe } } } + + test("trace the connection metrics with session type") { + val connOpenMetric = s"${MetricsConstants.CONN_OPEN}.${SessionType.INTERACTIVE}" + val connTotalMetric = s"${MetricsConstants.CONN_TOTAL}.${SessionType.INTERACTIVE}" + val connFailedMetric = s"${MetricsConstants.CONN_FAIL}.${SessionType.INTERACTIVE}" + val connTotalCount = MetricsSystem.counterValue(connTotalMetric).getOrElse(0L) + val connFailedCount = MetricsSystem.counterValue(connFailedMetric).getOrElse(0L) + + withJdbcStatement() { statement => + statement.executeQuery("select engine_name()") + } + eventually(timeout(5.seconds), interval(100.milliseconds)) { + assert(MetricsSystem.counterValue(connTotalMetric).getOrElse(0L) > connTotalCount) + assert(MetricsSystem.counterValue(connOpenMetric).getOrElse(0L) === 0) + } + + withSessionConf(Map.empty)(Map.empty)(Map( + KyuubiConf.SESSION_ENGINE_LAUNCH_ASYNC.key -> "false", + "spark.master" -> "invalid")) { + intercept[Exception] { + withJdbcStatement() { statement => + statement.executeQuery("select engine_name()") + } + } + } + + eventually(timeout(5.seconds), interval(100.milliseconds)) { + assert(MetricsSystem.counterValue(connTotalMetric).getOrElse(0L) - connTotalCount > 1) + assert(MetricsSystem.counterValue(connOpenMetric).getOrElse(0L) === 0) + assert(MetricsSystem.counterValue(connFailedMetric).getOrElse(0L) > connFailedCount) + } + } + + test("support to transfer client version when opening jdbc connection") { + withJdbcStatement() { stmt => + val rs = stmt.executeQuery(s"set spark.${KyuubiReservedKeys.KYUUBI_CLIENT_VERSION_KEY}") + assert(rs.next()) + assert(rs.getString(2) === KYUUBI_VERSION) + } + } } class TestSessionConfAdvisor extends SessionConfAdvisor { diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationPerUserSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationPerUserSuite.scala index 9ed72307977..21bf56b4fb4 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationPerUserSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiOperationPerUserSuite.scala @@ -28,6 +28,7 @@ import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf.KYUUBI_ENGINE_ENV_PREFIX import org.apache.kyuubi.engine.SemanticVersion import org.apache.kyuubi.jdbc.hive.KyuubiStatement +import org.apache.kyuubi.metrics.{MetricsConstants, MetricsSystem} import org.apache.kyuubi.session.{KyuubiSessionImpl, KyuubiSessionManager, SessionHandle} import org.apache.kyuubi.zookeeper.ZookeeperConf @@ -165,23 +166,6 @@ class KyuubiOperationPerUserSuite assert(r1 !== r2) } - test("test engine spark result max rows") { - withSessionConf()(Map.empty)(Map(KyuubiConf.OPERATION_RESULT_MAX_ROWS.key -> "1")) { - withJdbcStatement("va") { statement => - statement.executeQuery("create temporary view va as select * from values(1),(2)") - - val resultLimit1 = statement.executeQuery("select * from va") - assert(resultLimit1.next()) - assert(!resultLimit1.next()) - - statement.executeQuery(s"set ${KyuubiConf.OPERATION_RESULT_MAX_ROWS.key}=0") - val resultUnLimit = statement.executeQuery("select * from va") - assert(resultUnLimit.next()) - assert(resultUnLimit.next()) - } - } - } - test("support to interrupt the thrift request if remote engine is broken") { assume(!httpMode) withSessionConf(Map( @@ -216,9 +200,11 @@ class KyuubiOperationPerUserSuite val executeStmtResp = client.ExecuteStatement(executeStmtReq) assert(executeStmtResp.getStatus.getStatusCode === TStatusCode.ERROR_STATUS) assert(executeStmtResp.getStatus.getErrorMessage.contains( - "java.net.SocketException: Connection reset") || + "java.net.SocketException") || + executeStmtResp.getStatus.getErrorMessage.contains( + "org.apache.thrift.transport.TTransportException") || executeStmtResp.getStatus.getErrorMessage.contains( - "Caused by: java.net.SocketException: Broken pipe (Write failed)")) + "connection does not exist")) val elapsedTime = System.currentTimeMillis() - startTime assert(elapsedTime < 20 * 1000) assert(session.client.asyncRequestInterrupted) @@ -226,6 +212,28 @@ class KyuubiOperationPerUserSuite } } + test("max result rows") { + Seq("true", "false").foreach { incremental => + Seq("thrift", "arrow").foreach { resultFormat => + Seq("0", "1").foreach { maxResultRows => + withSessionConf()(Map.empty)(Map( + KyuubiConf.OPERATION_RESULT_FORMAT.key -> resultFormat, + KyuubiConf.OPERATION_RESULT_MAX_ROWS.key -> maxResultRows, + KyuubiConf.OPERATION_INCREMENTAL_COLLECT.key -> incremental)) { + withJdbcStatement("va") { statement => + statement.executeQuery("create temporary view va as select * from values(1),(2)") + val resultLimit = statement.executeQuery("select * from va") + assert(resultLimit.next()) + // always ignore max result rows on incremental collect mode + if (incremental == "true" || maxResultRows == "0") assert(resultLimit.next()) + assert(!resultLimit.next()) + } + } + } + } + } + } + test("scala NPE issue with hdfs jar") { val jarDir = Utils.createTempDir().toFile val udfCode = @@ -331,4 +339,50 @@ class KyuubiOperationPerUserSuite assert(!result.next()) } } + + test("accumulate the operation terminal state") { + val opType = classOf[ExecuteStatement].getSimpleName + val finishedMetric = s"${MetricsConstants.OPERATION_STATE}.$opType" + + s".${OperationState.FINISHED.toString.toLowerCase}" + val closedMetric = s"${MetricsConstants.OPERATION_STATE}.$opType" + + s".${OperationState.CLOSED.toString.toLowerCase}" + val finishedCount = MetricsSystem.meterValue(finishedMetric).getOrElse(0L) + val closedCount = MetricsSystem.meterValue(finishedMetric).getOrElse(0L) + withJdbcStatement() { statement => + statement.executeQuery("select engine_name()") + } + eventually(timeout(5.seconds), interval(100.milliseconds)) { + assert(MetricsSystem.meterValue(finishedMetric).getOrElse(0L) > finishedCount) + assert(MetricsSystem.meterValue(closedMetric).getOrElse(0L) > closedCount) + } + } + + test("trace ExecuteStatement exec time histogram") { + withJdbcStatement() { statement => + statement.executeQuery("select engine_name()") + } + val metric = + s"${MetricsConstants.OPERATION_EXEC_TIME}.${classOf[ExecuteStatement].getSimpleName}" + val snapshot = MetricsSystem.histogramSnapshot(metric).get + assert(snapshot.getMax > 0 && snapshot.getMedian > 0) + } + + test("align the server/engine session/executeStatement handle for Spark engine") { + withSessionConf(Map( + KyuubiConf.SESSION_ENGINE_LAUNCH_ASYNC.key -> "false"))(Map.empty)(Map.empty) { + withJdbcStatement() { _ => + val session = + server.backendService.sessionManager.allSessions().head.asInstanceOf[KyuubiSessionImpl] + eventually(timeout(10.seconds)) { + assert(session.handle === SessionHandle.apply(session.client.remoteSessionHandle)) + } + val opHandle = session.executeStatement("SELECT engine_id()", Map.empty, true, 0L) + eventually(timeout(10.seconds)) { + val operation = session.sessionManager.operationManager.getOperation( + opHandle).asInstanceOf[KyuubiOperation] + assert(opHandle == OperationHandle.apply(operation.remoteOpHandle())) + } + } + } + } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiRestAuthenticationSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiRestAuthenticationSuite.scala index 64707ce012e..61de8225171 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiRestAuthenticationSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/KyuubiRestAuthenticationSuite.scala @@ -25,7 +25,6 @@ import javax.ws.rs.core.MediaType import scala.collection.JavaConverters._ import org.apache.hadoop.security.UserGroupInformation -import org.apache.hive.service.rpc.thrift.TProtocolVersion import org.apache.kyuubi.RestClientTestHelper import org.apache.kyuubi.client.api.v1.dto.{SessionHandle, SessionOpenCount, SessionOpenRequest} @@ -129,11 +128,9 @@ class KyuubiRestAuthenticationSuite extends RestClientTestHelper { val proxyUser = "user1" UserGroupInformation.loginUserFromKeytab(testPrincipal, testKeytab) var token = generateToken(hostName) - val sessionOpenRequest = new SessionOpenRequest( - TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V11.getValue, - Map( - KyuubiConf.ENGINE_SHARE_LEVEL.key -> "CONNECTION", - "hive.server2.proxy.user" -> proxyUser).asJava) + val sessionOpenRequest = new SessionOpenRequest(Map( + KyuubiConf.ENGINE_SHARE_LEVEL.key -> "CONNECTION", + "hive.server2.proxy.user" -> proxyUser).asJava) var response = webTarget.path("api/v1/sessions") .request() diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/thrift/http/KyuubiOperationThriftHttpKerberosAndPlainAuthSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/thrift/http/KyuubiOperationThriftHttpKerberosAndPlainAuthSuite.scala index 4f6ae92f167..941e121a6cd 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/thrift/http/KyuubiOperationThriftHttpKerberosAndPlainAuthSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/operation/thrift/http/KyuubiOperationThriftHttpKerberosAndPlainAuthSuite.scala @@ -53,7 +53,7 @@ class KyuubiOperationThriftHttpKerberosAndPlainAuthSuite .set(KyuubiConf.SERVER_KEYTAB, testKeytab) .set(KyuubiConf.SERVER_PRINCIPAL, testPrincipal) .set(KyuubiConf.AUTHENTICATION_LDAP_URL, ldapUrl) - .set(KyuubiConf.AUTHENTICATION_LDAP_BASEDN, ldapBaseDn) + .set(KyuubiConf.AUTHENTICATION_LDAP_BASE_DN, ldapBaseDn.head) .set( KyuubiConf.AUTHENTICATION_CUSTOM_CLASS, classOf[UserDefineAuthenticationProviderImpl].getCanonicalName) diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/parser/trino/KyuubiTrinoFeParserSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/parser/trino/KyuubiTrinoFeParserSuite.scala index 3f5cf70b559..205a6a7be90 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/parser/trino/KyuubiTrinoFeParserSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/parser/trino/KyuubiTrinoFeParserSuite.scala @@ -20,7 +20,7 @@ package org.apache.kyuubi.parser.trino import org.apache.kyuubi.KyuubiFunSuite import org.apache.kyuubi.sql.parser.trino.KyuubiTrinoFeParser import org.apache.kyuubi.sql.plan.{KyuubiTreeNode, PassThroughNode} -import org.apache.kyuubi.sql.plan.trino.{GetCatalogs, GetColumns, GetSchemas, GetTables, GetTableTypes, GetTypeInfo} +import org.apache.kyuubi.sql.plan.trino.{Deallocate, ExecuteForPreparing, GetCatalogs, GetColumns, GetPrimaryKeys, GetSchemas, GetTables, GetTableTypes, GetTypeInfo} class KyuubiTrinoFeParserSuite extends KyuubiFunSuite { val parser = new KyuubiTrinoFeParser() @@ -354,4 +354,37 @@ class KyuubiTrinoFeParserSuite extends KyuubiFunSuite { tableName = "%aa", colName = "%bb") } + + test("Support GetPrimaryKeys for Trino Fe") { + val kyuubiTreeNode = parse( + """ + | SELECT CAST(NULL AS varchar) TABLE_CAT, + | CAST(NULL AS varchar) TABLE_SCHEM, + | CAST(NULL AS varchar) TABLE_NAME, + | CAST(NULL AS varchar) COLUMN_NAME, + | CAST(NULL AS smallint) KEY_SEQ, + | CAST(NULL AS varchar) PK_NAME + | WHERE false + |""".stripMargin) + + assert(kyuubiTreeNode.isInstanceOf[GetPrimaryKeys]) + } + + test("Support PreparedStatement for Trino Fe (ExecuteForPreparing)") { + val kyuubiTreeNode = parse( + """ + | EXECUTE statement1 USING INTEGER '1' + |""".stripMargin) + + assert(kyuubiTreeNode.isInstanceOf[ExecuteForPreparing]) + } + + test("Support PreparedStatement for Trino Fe (Deallocate)") { + val kyuubiTreeNode = parse( + """ + | DEALLOCATE PREPARE statement1 + |""".stripMargin) + + assert(kyuubiTreeNode.isInstanceOf[Deallocate]) + } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/BackendServiceMetricSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/BackendServiceMetricSuite.scala index 53a53ef1dbe..a58d1842cff 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/BackendServiceMetricSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/BackendServiceMetricSuite.scala @@ -78,7 +78,7 @@ class BackendServiceMetricSuite extends WithKyuubiServer with HiveJDBCTestHelper val meters2 = objMapper.readTree(Paths.get(reportPath.toString, "report.json").toFile).get("meters") - assert(meters2.get(MetricsConstants.BS_FETCH_RESULT_ROWS_RATE).get("count").asInt() == 7) + assert(meters2.get(MetricsConstants.BS_FETCH_RESULT_ROWS_RATE).get("count").asInt() == 8) assert(meters2.get(MetricsConstants.BS_FETCH_LOG_ROWS_RATE).get("count").asInt() >= logRows1) statement.executeQuery("DROP TABLE stu_test") diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/KyuubiTBinaryFrontendServiceSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/KyuubiTBinaryFrontendServiceSuite.scala index 69c10e7302f..5c54cbbb4b7 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/KyuubiTBinaryFrontendServiceSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/KyuubiTBinaryFrontendServiceSuite.scala @@ -17,7 +17,9 @@ package org.apache.kyuubi.server -import org.apache.hive.service.rpc.thrift.TOpenSessionReq +import scala.collection.JavaConverters._ + +import org.apache.hive.service.rpc.thrift.{TOpenSessionReq, TSessionHandle} import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.apache.kyuubi.{KyuubiFunSuite, Utils, WithKyuubiServer} @@ -79,4 +81,39 @@ class KyuubiTBinaryFrontendServiceSuite extends WithKyuubiServer with KyuubiFunS MetricsConstants.THRIFT_BINARY_CONN_OPEN).getOrElse(0L) - openConnections === 0) } } + + test("do not close session when disconnect") { + val sessionCount = server.backendService.sessionManager.allSessions().size + var handle: TSessionHandle = null + TClientTestUtils.withThriftClient(server.frontendServices.head) { + client => + val req = new TOpenSessionReq() + req.setUsername(Utils.currentUser) + req.setPassword("anonymous") + req.setConfiguration(Map("kyuubi.session.close.on.disconnect" -> "false").asJava) + val resp = client.OpenSession(req) + handle = resp.getSessionHandle + + assert(server.backendService.sessionManager.allSessions().size - sessionCount == 1) + } + Thread.sleep(3000L) + assert(server.backendService.sessionManager.allSessions().size - sessionCount == 1) + } + + test("close session when disconnect - default behavior") { + val sessionCount = server.backendService.sessionManager.allSessions().size + var handle: TSessionHandle = null + TClientTestUtils.withThriftClient(server.frontendServices.head) { + client => + val req = new TOpenSessionReq() + req.setUsername(Utils.currentUser) + req.setPassword("anonymous") + val resp = client.OpenSession(req) + handle = resp.getSessionHandle + + assert(server.backendService.sessionManager.allSessions().size - sessionCount == 1) + } + Thread.sleep(3000L) + assert(server.backendService.sessionManager.allSessions().size == sessionCount) + } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/AdminResourceSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/AdminResourceSuite.scala index bcbdad2cebe..a10994d7ea5 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/AdminResourceSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/AdminResourceSuite.scala @@ -18,25 +18,34 @@ package org.apache.kyuubi.server.api.v1 import java.util.{Base64, UUID} +import javax.ws.rs.client.Entity import javax.ws.rs.core.{GenericType, MediaType} +import scala.collection.JavaConverters._ + +import org.apache.hive.service.rpc.thrift.TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V2 import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.apache.kyuubi.{KYUUBI_VERSION, KyuubiFunSuite, RestFrontendTestHelper, Utils} -import org.apache.kyuubi.client.api.v1.dto.Engine +import org.apache.kyuubi.client.api.v1.dto.{Engine, OperationData, SessionData, SessionHandle, SessionOpenRequest} import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_CONNECTION_URL_KEY import org.apache.kyuubi.engine.{ApplicationState, EngineRef, KyuubiApplicationManager} import org.apache.kyuubi.engine.EngineType.SPARK_SQL import org.apache.kyuubi.engine.ShareLevel.{CONNECTION, USER} import org.apache.kyuubi.ha.HighAvailabilityConf import org.apache.kyuubi.ha.client.DiscoveryClientProvider.withDiscoveryClient import org.apache.kyuubi.ha.client.DiscoveryPaths +import org.apache.kyuubi.plugin.PluginLoader import org.apache.kyuubi.server.http.authentication.AuthenticationHandler.AUTHORIZATION_HEADER class AdminResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { private val engineMgr = new KyuubiApplicationManager() + override protected lazy val conf: KyuubiConf = KyuubiConf() + .set(KyuubiConf.SERVER_ADMINISTRATORS, Seq("admin001")) + override def beforeAll(): Unit = { super.beforeAll() engineMgr.initialize(KyuubiConf()) @@ -64,6 +73,24 @@ class AdminResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") .post(null) assert(200 == response.getStatus) + + val admin001AuthHeader = new String( + Base64.getEncoder.encode("admin001".getBytes()), + "UTF-8") + response = webTarget.path("api/v1/admin/refresh/hadoop_conf") + .request() + .header(AUTHORIZATION_HEADER, s"BASIC $admin001AuthHeader") + .post(null) + assert(200 == response.getStatus) + + val admin002AuthHeader = new String( + Base64.getEncoder.encode("admin002".getBytes()), + "UTF-8") + response = webTarget.path("api/v1/admin/refresh/hadoop_conf") + .request() + .header(AUTHORIZATION_HEADER, s"BASIC $admin002AuthHeader") + .post(null) + assert(405 == response.getStatus) } test("refresh user defaults config of the kyuubi server") { @@ -84,6 +111,173 @@ class AdminResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { assert(200 == response.getStatus) } + test("refresh unlimited users of the kyuubi server") { + var response = webTarget.path("api/v1/admin/refresh/unlimited_users") + .request() + .post(null) + assert(405 == response.getStatus) + + val adminUser = Utils.currentUser + val encodeAuthorization = new String( + Base64.getEncoder.encode( + s"$adminUser:".getBytes()), + "UTF-8") + response = webTarget.path("api/v1/admin/refresh/unlimited_users") + .request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .post(null) + assert(200 == response.getStatus) + } + + test("list/close sessions") { + val requestObj = new SessionOpenRequest(Map("testConfig" -> "testValue").asJava) + + var response = webTarget.path("api/v1/sessions") + .request(MediaType.APPLICATION_JSON_TYPE) + .post(Entity.entity(requestObj, MediaType.APPLICATION_JSON_TYPE)) + + val adminUser = Utils.currentUser + val encodeAuthorization = new String( + Base64.getEncoder.encode( + s"$adminUser:".getBytes()), + "UTF-8") + + // get session list + var response2 = webTarget.path("api/v1/admin/sessions").request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .get() + assert(200 == response2.getStatus) + val sessions1 = response2.readEntity(new GenericType[Seq[SessionData]]() {}) + assert(sessions1.nonEmpty) + assert(sessions1.head.getConf.get(KYUUBI_SESSION_CONNECTION_URL_KEY) === fe.connectionUrl) + + // close an opened session + val sessionHandle = response.readEntity(classOf[SessionHandle]).getIdentifier + response = webTarget.path(s"api/v1/admin/sessions/$sessionHandle").request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .delete() + assert(200 == response.getStatus) + + // get session list again + response2 = webTarget.path("api/v1/admin/sessions").request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .get() + assert(200 == response2.getStatus) + val sessions2 = response2.readEntity(classOf[Seq[SessionData]]) + assert(sessions2.isEmpty) + } + + test("list sessions/operations with filter") { + fe.be.openSession( + HIVE_CLI_SERVICE_PROTOCOL_V2, + "admin", + "123456", + "localhost", + Map("testConfig" -> "testValue")) + + fe.be.openSession( + HIVE_CLI_SERVICE_PROTOCOL_V2, + "admin", + "123456", + "localhost", + Map("testConfig" -> "testValue")) + + fe.be.openSession( + HIVE_CLI_SERVICE_PROTOCOL_V2, + "test_user_1", + "xxxxxx", + "localhost", + Map("testConfig" -> "testValue")) + + val sessionHandle = fe.be.openSession( + HIVE_CLI_SERVICE_PROTOCOL_V2, + "test_user_2", + "xxxxxx", + "localhost", + Map("testConfig" -> "testValue")) + + val adminUser = Utils.currentUser + val encodeAuthorization = new String( + Base64.getEncoder.encode( + s"$adminUser:".getBytes()), + "UTF-8") + + // list sessions + var response = webTarget.path("api/v1/admin/sessions") + .queryParam("users", "admin") + .request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .get() + var sessions = response.readEntity(classOf[Seq[SessionData]]) + assert(200 == response.getStatus) + assert(sessions.size == 2) + + response = webTarget.path("api/v1/admin/sessions") + .queryParam("users", "test_user_1,test_user_2") + .request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .get() + sessions = response.readEntity(classOf[Seq[SessionData]]) + assert(200 == response.getStatus) + assert(sessions.size == 2) + + // list operations + response = webTarget.path("api/v1/admin/operations") + .queryParam("users", "test_user_1,test_user_2") + .request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .get() + var operations = response.readEntity(classOf[Seq[OperationData]]) + assert(operations.size == 2) + + response = webTarget.path("api/v1/admin/operations") + .queryParam("sessionHandle", sessionHandle.identifier) + .request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .get() + operations = response.readEntity(classOf[Seq[OperationData]]) + assert(200 == response.getStatus) + assert(operations.size == 1) + } + + test("list/close operations") { + val sessionHandle = fe.be.openSession( + HIVE_CLI_SERVICE_PROTOCOL_V2, + "admin", + "123456", + "localhost", + Map("testConfig" -> "testValue")) + val operation = fe.be.getCatalogs(sessionHandle) + + val adminUser = Utils.currentUser + val encodeAuthorization = new String( + Base64.getEncoder.encode( + s"$adminUser:".getBytes()), + "UTF-8") + + // list operations + var response = webTarget.path("api/v1/admin/operations").request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .get() + assert(200 == response.getStatus) + var operations = response.readEntity(new GenericType[Seq[OperationData]]() {}) + assert(operations.nonEmpty) + assert(operations.map(op => op.getIdentifier).contains(operation.identifier.toString)) + + // close operation + response = webTarget.path(s"api/v1/admin/operations/${operation.identifier}").request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .delete() + assert(200 == response.getStatus) + + // list again + response = webTarget.path("api/v1/admin/operations").request() + .header(AUTHORIZATION_HEADER, s"BASIC $encodeAuthorization") + .get() + operations = response.readEntity(new GenericType[Seq[OperationData]]() {}) + assert(!operations.map(op => op.getIdentifier).contains(operation.identifier.toString)) + } + test("delete engine - user share level") { val id = UUID.randomUUID().toString conf.set(KyuubiConf.ENGINE_SHARE_LEVEL, USER.toString) @@ -91,7 +285,10 @@ class AdminResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { conf.set(KyuubiConf.FRONTEND_THRIFT_BINARY_BIND_PORT, 0) conf.set(HighAvailabilityConf.HA_NAMESPACE, "kyuubi_test") conf.set(KyuubiConf.ENGINE_IDLE_TIMEOUT, 180000L) - val engine = new EngineRef(conf.clone, Utils.currentUser, "grp", id, null) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + + val engine = + new EngineRef(conf.clone, Utils.currentUser, PluginLoader.loadGroupProvider(conf), id, null) val engineSpace = DiscoveryPaths.makePath( s"kyuubi_test_${KYUUBI_VERSION}_USER_SPARK_SQL", @@ -136,9 +333,11 @@ class AdminResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { conf.set(KyuubiConf.FRONTEND_THRIFT_BINARY_BIND_PORT, 0) conf.set(HighAvailabilityConf.HA_NAMESPACE, "kyuubi_test") conf.set(KyuubiConf.ENGINE_IDLE_TIMEOUT, 180000L) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") val id = UUID.randomUUID().toString - val engine = new EngineRef(conf.clone, Utils.currentUser, "grp", id, null) + val engine = + new EngineRef(conf.clone, Utils.currentUser, PluginLoader.loadGroupProvider(conf), id, null) val engineSpace = DiscoveryPaths.makePath( s"kyuubi_test_${KYUUBI_VERSION}_CONNECTION_SPARK_SQL", Utils.currentUser, @@ -174,7 +373,10 @@ class AdminResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { conf.set(KyuubiConf.FRONTEND_THRIFT_BINARY_BIND_PORT, 0) conf.set(HighAvailabilityConf.HA_NAMESPACE, "kyuubi_test") conf.set(KyuubiConf.ENGINE_IDLE_TIMEOUT, 180000L) - val engine = new EngineRef(conf.clone, Utils.currentUser, id, "grp", null) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + + val engine = + new EngineRef(conf.clone, Utils.currentUser, PluginLoader.loadGroupProvider(conf), id, null) val engineSpace = DiscoveryPaths.makePath( s"kyuubi_test_${KYUUBI_VERSION}_USER_SPARK_SQL", @@ -219,6 +421,7 @@ class AdminResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { conf.set(KyuubiConf.FRONTEND_THRIFT_BINARY_BIND_PORT, 0) conf.set(HighAvailabilityConf.HA_NAMESPACE, "kyuubi_test") conf.set(KyuubiConf.ENGINE_IDLE_TIMEOUT, 180000L) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") val engineSpace = DiscoveryPaths.makePath( s"kyuubi_test_${KYUUBI_VERSION}_CONNECTION_SPARK_SQL", @@ -226,14 +429,16 @@ class AdminResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { "") val id1 = UUID.randomUUID().toString - val engine1 = new EngineRef(conf.clone, Utils.currentUser, "grp", id1, null) + val engine1 = + new EngineRef(conf.clone, Utils.currentUser, PluginLoader.loadGroupProvider(conf), id1, null) val engineSpace1 = DiscoveryPaths.makePath( s"kyuubi_test_${KYUUBI_VERSION}_CONNECTION_SPARK_SQL", Utils.currentUser, id1) val id2 = UUID.randomUUID().toString - val engine2 = new EngineRef(conf.clone, Utils.currentUser, "grp", id2, null) + val engine2 = + new EngineRef(conf.clone, Utils.currentUser, PluginLoader.loadGroupProvider(conf), id2, null) val engineSpace2 = DiscoveryPaths.makePath( s"kyuubi_test_${KYUUBI_VERSION}_CONNECTION_SPARK_SQL", Utils.currentUser, @@ -283,5 +488,4 @@ class AdminResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { } } } - } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/BatchesResourceSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/BatchesResourceSuite.scala index 83c60878a73..055496ff322 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/BatchesResourceSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/BatchesResourceSuite.scala @@ -28,12 +28,16 @@ import scala.collection.mutable.ArrayBuffer import scala.concurrent.duration.DurationInt import org.apache.hive.service.rpc.thrift.TProtocolVersion +import org.glassfish.jersey.media.multipart.FormDataMultiPart +import org.glassfish.jersey.media.multipart.file.FileDataBodyPart import org.apache.kyuubi.{BatchTestHelper, KyuubiFunSuite, RestFrontendTestHelper} import org.apache.kyuubi.client.api.v1.dto._ +import org.apache.kyuubi.client.util.BatchUtils +import org.apache.kyuubi.client.util.BatchUtils._ import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiConf._ -import org.apache.kyuubi.engine.ApplicationInfo +import org.apache.kyuubi.engine.{ApplicationInfo, KyuubiApplicationManager} import org.apache.kyuubi.engine.spark.SparkBatchProcessBuilder import org.apache.kyuubi.metrics.{MetricsConstants, MetricsSystem} import org.apache.kyuubi.operation.{BatchJobSubmission, OperationState} @@ -41,15 +45,14 @@ import org.apache.kyuubi.operation.OperationState.OperationState import org.apache.kyuubi.server.KyuubiRestFrontendService import org.apache.kyuubi.server.http.authentication.AuthenticationHandler.AUTHORIZATION_HEADER import org.apache.kyuubi.server.metadata.api.Metadata -import org.apache.kyuubi.service.authentication.{KyuubiAuthenticationFactory, UserDefinedEngineSecuritySecretProvider} +import org.apache.kyuubi.service.authentication.KyuubiAuthenticationFactory import org.apache.kyuubi.session.{KyuubiBatchSessionImpl, KyuubiSessionManager, SessionHandle, SessionType} class BatchesResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper with BatchTestHelper { override protected lazy val conf: KyuubiConf = KyuubiConf() .set(KyuubiConf.ENGINE_SECURITY_ENABLED, true) - .set( - KyuubiConf.ENGINE_SECURITY_SECRET_PROVIDER, - classOf[UserDefinedEngineSecuritySecretProvider].getName) + .set(KyuubiConf.ENGINE_SECURITY_SECRET_PROVIDER, "simple") + .set(KyuubiConf.SIMPLE_SECURITY_SECRET_PROVIDER_PROVIDER_SECRET, "ENGINE____SECRET") .set( KyuubiConf.SESSION_LOCAL_DIR_ALLOW_LIST, Seq(Paths.get(sparkBatchTestResource.get).getParent.toString)) @@ -199,6 +202,56 @@ class BatchesResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper wi assert(!deleteBatchResponse.readEntity(classOf[CloseBatchResponse]).isSuccess) } + test("open batch session with uploading resource") { + val requestObj = newSparkBatchRequest(Map("spark.master" -> "local")) + val exampleJarFile = Paths.get(sparkBatchTestResource.get).toFile + val multipart = new FormDataMultiPart() + .field("batchRequest", requestObj, MediaType.APPLICATION_JSON_TYPE) + .bodyPart(new FileDataBodyPart("resourceFile", exampleJarFile)) + .asInstanceOf[FormDataMultiPart] + + val response = webTarget.path("api/v1/batches") + .request(MediaType.APPLICATION_JSON) + .post(Entity.entity(multipart, MediaType.MULTIPART_FORM_DATA)) + assert(200 == response.getStatus) + val batch = response.readEntity(classOf[Batch]) + assert(batch.getKyuubiInstance === fe.connectionUrl) + assert(batch.getBatchType === "SPARK") + assert(batch.getName === sparkBatchTestAppName) + assert(batch.getCreateTime > 0) + assert(batch.getEndTime === 0) + + webTarget.path(s"api/v1/batches/${batch.getId()}").request( + MediaType.APPLICATION_JSON_TYPE).delete() + eventually(timeout(3.seconds)) { + assert(KyuubiApplicationManager.uploadWorkDir.toFile.listFiles().isEmpty) + } + } + + test("open batch session w/ batch id") { + val batchId = UUID.randomUUID().toString + val reqObj = newSparkBatchRequest(Map( + "spark.master" -> "local", + KYUUBI_BATCH_ID_KEY -> batchId)) + + val resp1 = webTarget.path("api/v1/batches") + .request(MediaType.APPLICATION_JSON_TYPE) + .post(Entity.entity(reqObj, MediaType.APPLICATION_JSON_TYPE)) + assert(200 == resp1.getStatus) + val batch1 = resp1.readEntity(classOf[Batch]) + assert(batch1.getId === batchId) + + val resp2 = webTarget.path("api/v1/batches") + .request(MediaType.APPLICATION_JSON_TYPE) + .post(Entity.entity(reqObj, MediaType.APPLICATION_JSON_TYPE)) + assert(200 == resp2.getStatus) + val batch2 = resp2.readEntity(classOf[Batch]) + assert(batch2.getId === batchId) + + assert(batch1.getCreateTime === batch2.getCreateTime) + assert(BatchUtils.isDuplicatedSubmission(batch2)) + } + test("get batch session list") { val sessionManager = server.frontendServices.head .be.sessionManager.asInstanceOf[KyuubiSessionManager] @@ -223,7 +276,7 @@ class BatchesResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper wi "kyuubi", "kyuubi", InetAddress.getLocalHost.getCanonicalHostName, - Map.empty, + Map(KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString), newBatchRequest( "spark", sparkBatchTestResource.get, @@ -245,7 +298,7 @@ class BatchesResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper wi "kyuubi", "kyuubi", InetAddress.getLocalHost.getCanonicalHostName, - Map.empty, + Map(KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString), newBatchRequest( "spark", sparkBatchTestResource.get, @@ -255,7 +308,7 @@ class BatchesResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper wi "kyuubi", "kyuubi", InetAddress.getLocalHost.getCanonicalHostName, - Map.empty, + Map(KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString), newBatchRequest( "spark", sparkBatchTestResource.get, @@ -645,7 +698,7 @@ class BatchesResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper wi "kyuubi", "kyuubi", InetAddress.getLocalHost.getCanonicalHostName, - Map.empty, + Map(KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString), newSparkBatchRequest(Map("spark.jars" -> "disAllowPath"))) } val sessionHandleRegex = "\\[[\\S]*\\]".r diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/OperationsResourceSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/OperationsResourceSuite.scala index 238203b0bad..51701b231a0 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/OperationsResourceSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/OperationsResourceSuite.scala @@ -29,12 +29,16 @@ import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.apache.kyuubi.{KyuubiFunSuite, RestFrontendTestHelper} import org.apache.kyuubi.client.api.v1.dto._ +import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.events.KyuubiOperationEvent import org.apache.kyuubi.operation.{ExecuteStatement, OperationState} import org.apache.kyuubi.operation.OperationState.{FINISHED, OperationState} class OperationsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { + override protected lazy val conf: KyuubiConf = KyuubiConf() + .set(KyuubiConf.SERVER_LIMIT_CLIENT_FETCH_MAX_ROWS, 5000) + test("get an operation event") { val catalogsHandleStr = getOpHandleStr("") checkOpState(catalogsHandleStr, FINISHED) @@ -126,6 +130,40 @@ class OperationsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper assert(logRowSet.getRowCount == 1) } + test("test invalid max rows") { + val opHandleStr = getOpHandleStr("select \"test\", 1, 0.32d, true") + checkOpState(opHandleStr, FINISHED) + val response = webTarget.path( + s"api/v1/operations/$opHandleStr/rowset") + .queryParam("maxrows", "10000") + .request(MediaType.APPLICATION_JSON).get() + assert(400 == response.getStatus) + } + + test("test get result row set with null value") { + val opHandleStr = getOpHandleStr( + s""" + |select + |cast(null as string) as c1, + |cast(null as boolean) as c2, + |cast(null as byte) as c3, + |cast(null as double) as c4, + |cast(null as short) as c5, + |cast(null as int) as c6, + |cast(null as bigint) as c7 + |""".stripMargin) + checkOpState(opHandleStr, FINISHED) + val response = webTarget.path( + s"api/v1/operations/$opHandleStr/rowset") + .queryParam("maxrows", "2") + .queryParam("fetchorientation", "FETCH_NEXT") + .request(MediaType.APPLICATION_JSON).get() + assert(200 == response.getStatus) + val logRowSet = response.readEntity(classOf[ResultRowSet]) + assert(logRowSet.getRows.asScala.head.getFields.asScala.forall(_.getValue == null)) + assert(logRowSet.getRowCount == 1) + } + def getOpHandleStr(statement: String = "show tables"): String = { val sessionHandle = fe.be.openSession( HIVE_CLI_SERVICE_PROTOCOL_V2, diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/SessionsResourceSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/SessionsResourceSuite.scala index db5e1360bcf..07a711de6bc 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/SessionsResourceSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/api/v1/SessionsResourceSuite.scala @@ -19,7 +19,7 @@ package org.apache.kyuubi.server.api.v1 import java.nio.charset.StandardCharsets import java.util -import java.util.Base64 +import java.util.{Base64, Collections} import javax.ws.rs.client.Entity import javax.ws.rs.core.{GenericType, MediaType, Response} @@ -28,10 +28,11 @@ import scala.collection.JavaConverters._ import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.apache.kyuubi.{KyuubiFunSuite, RestFrontendTestHelper} +import org.apache.kyuubi.client.api.v1.dto import org.apache.kyuubi.client.api.v1.dto._ import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.config.KyuubiReservedKeys.KYUUBI_SESSION_CONNECTION_URL_KEY -import org.apache.kyuubi.events.KyuubiSessionEvent +import org.apache.kyuubi.engine.ShareLevel import org.apache.kyuubi.metrics.{MetricsConstants, MetricsSystem} import org.apache.kyuubi.operation.OperationHandle import org.apache.kyuubi.server.http.authentication.AuthenticationHandler.AUTHORIZATION_HEADER @@ -47,9 +48,7 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { } test("open/close and count session") { - val requestObj = new SessionOpenRequest( - 1, - Map("testConfig" -> "testValue").asJava) + val requestObj = new SessionOpenRequest(Map("testConfig" -> "testValue").asJava) var response = webTarget.path("api/v1/sessions") .request(MediaType.APPLICATION_JSON_TYPE) @@ -80,9 +79,7 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { } test("getSessionList") { - val requestObj = new SessionOpenRequest( - 1, - Map("testConfig" -> "testValue").asJava) + val requestObj = new SessionOpenRequest(Map("testConfig" -> "testValue").asJava) var response = webTarget.path("api/v1/sessions") .request(MediaType.APPLICATION_JSON_TYPE) @@ -108,9 +105,7 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { } test("get session event") { - val sessionOpenRequest = new SessionOpenRequest( - 1, - Map("testConfig" -> "testValue").asJava) + val sessionOpenRequest = new SessionOpenRequest(Map("testConfig" -> "testValue").asJava) val user = "kyuubi".getBytes() @@ -126,10 +121,10 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { // get session event var response = webTarget.path(s"api/v1/sessions/$sessionHandle").request().get() assert(200 == sessionOpenResp.getStatus) - val sessions = response.readEntity(classOf[KyuubiSessionEvent]) - assert(sessions.conf("testConfig").equals("testValue")) - assert(sessions.sessionType.equals(SessionType.INTERACTIVE.toString)) - assert(sessions.user.equals("kyuubi")) + val sessions = response.readEntity(classOf[dto.KyuubiSessionEvent]) + assert(sessions.getConf.get("testConfig").equals("testValue")) + assert(sessions.getSessionType.equals(SessionType.INTERACTIVE.toString)) + assert(sessions.getUser.equals("kyuubi")) // close an opened session response = webTarget.path(s"api/v1/sessions/$sessionHandle").request().delete() @@ -146,9 +141,9 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { val failedConnections = MetricsSystem.counterValue(MetricsConstants.REST_CONN_FAIL).getOrElse(0L) - val requestObj = new SessionOpenRequest( - 1, - Map("testConfig" -> "testValue", KyuubiConf.SERVER_INFO_PROVIDER.key -> "SERVER").asJava) + val requestObj = new SessionOpenRequest(Map( + "testConfig" -> "testValue", + KyuubiConf.SERVER_INFO_PROVIDER.key -> "SERVER").asJava) var response: Response = webTarget.path("api/v1/sessions") .request(MediaType.APPLICATION_JSON_TYPE) @@ -187,9 +182,7 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { } test("submit operation and get operation handle") { - val requestObj = new SessionOpenRequest( - 1, - Map("testConfig" -> "testValue").asJava) + val requestObj = new SessionOpenRequest(Map("testConfig" -> "testValue").asJava) var response: Response = webTarget.path("api/v1/sessions") .request(MediaType.APPLICATION_JSON_TYPE) @@ -199,7 +192,7 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { val pathPrefix = s"api/v1/sessions/$sessionHandle" - val statementReq = new StatementRequest("show tables", true, 3000) + var statementReq = new StatementRequest("show tables", true, 3000) response = webTarget .path(s"$pathPrefix/operations/statement").request(MediaType.APPLICATION_JSON_TYPE) .post(Entity.entity(statementReq, MediaType.APPLICATION_JSON_TYPE)) @@ -207,6 +200,18 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { var operationHandle = response.readEntity(classOf[OperationHandle]) assert(operationHandle !== null) + statementReq = new StatementRequest( + "spark.sql(\"show tables\")", + true, + 3000, + Collections.singletonMap(KyuubiConf.OPERATION_LANGUAGE.key, "SCALA")) + response = webTarget + .path(s"$pathPrefix/operations/statement").request(MediaType.APPLICATION_JSON_TYPE) + .post(Entity.entity(statementReq, MediaType.APPLICATION_JSON_TYPE)) + assert(200 == response.getStatus) + operationHandle = response.readEntity(classOf[OperationHandle]) + assert(operationHandle !== null) + response = webTarget.path(s"$pathPrefix/operations/typeInfo").request() .post(Entity.entity(null, MediaType.APPLICATION_JSON_TYPE)) assert(200 == response.getStatus) @@ -277,4 +282,23 @@ class SessionsResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { .post(Entity.entity(getCrossReferenceReq, MediaType.APPLICATION_JSON_TYPE)) assert(404 == response.getStatus) } + + test("post session exception if failed to open engine session") { + val requestObj = new SessionOpenRequest(Map( + "spark.master" -> "invalid", + KyuubiConf.ENGINE_SHARE_LEVEL.key -> ShareLevel.CONNECTION.toString).asJava) + + var response = webTarget.path("api/v1/sessions") + .request(MediaType.APPLICATION_JSON_TYPE) + .post(Entity.entity(requestObj, MediaType.APPLICATION_JSON_TYPE)) + + val sessionHandle = response.readEntity(classOf[SessionHandle]).getIdentifier + + eventually(timeout(1.minutes), interval(200.milliseconds)) { + response = webTarget.path(s"api/v1/sessions/$sessionHandle").request().get() + // will meet json parse exception with response.readEntity(classOf[KyuubiSessionEvent]) + val sessionEvent = response.readEntity(classOf[String]) + assert(sessionEvent.contains("The last 10 line(s) of log are:")) + } + } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/metadata/MetadataManagerSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/metadata/MetadataManagerSuite.scala index d8a8af20274..75c935a3de2 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/metadata/MetadataManagerSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/metadata/MetadataManagerSuite.scala @@ -25,103 +25,152 @@ import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.apache.kyuubi.{KyuubiException, KyuubiFunSuite} import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.config.KyuubiConf._ import org.apache.kyuubi.metrics.{MetricsConstants, MetricsSystem} +import org.apache.kyuubi.metrics.MetricsConstants._ import org.apache.kyuubi.server.metadata.api.Metadata import org.apache.kyuubi.session.SessionType class MetadataManagerSuite extends KyuubiFunSuite { - val metadataManager = new MetadataManager() - val metricsSystem = new MetricsSystem() - val conf = KyuubiConf().set(KyuubiConf.METADATA_REQUEST_RETRY_INTERVAL, 100L) - - override def beforeAll(): Unit = { - super.beforeAll() - metricsSystem.initialize(conf) - metricsSystem.start() - metadataManager.initialize(conf) - metadataManager.start() - } - override def afterAll(): Unit = { - metadataManager.getBatches(null, null, null, 0, 0, 0, Int.MaxValue).foreach { batch => - metadataManager.cleanupMetadataById(batch.getId) + test("fail fast on duplicated key") { + Seq("true", "false").foreach { enableAsyncRetry => + withMetadataManager(Map( + METADATA_REQUEST_ASYNC_RETRY_ENABLED.key -> enableAsyncRetry, + METADATA_REQUEST_RETRY_INTERVAL.key -> "100")) { metadataManager => + val metadata = newMetadata() + metadataManager.insertMetadata(metadata) + Seq(true, false).foreach { asyncRetryOnError => + intercept[KyuubiException] { + metadataManager.insertMetadata(metadata, asyncRetryOnError) + } + } + } } - metadataManager.stop() - metricsSystem.stop() - super.afterAll() } - override protected def afterEach(): Unit = { - eventually(timeout(5.seconds), interval(200.milliseconds)) { - assert(MetricsSystem.counterValue( - MetricsConstants.METADATA_REQUEST_OPENED).getOrElse(0L) === 0) + test("async retry the metadata store requests") { + withMetadataManager( + Map( + METADATA_REQUEST_ASYNC_RETRY_ENABLED.key -> "true", + METADATA_REQUEST_RETRY_INTERVAL.key -> "100"), + () => + new MetadataManager { + override protected def unrecoverableDBErr(cause: Throwable): Boolean = false + }) { metadataManager => + val metadata = newMetadata() + metadataManager.insertMetadata(metadata) + metadataManager.insertMetadata(metadata, asyncRetryOnError = true) + val retryRef = metadataManager.getMetadataRequestsRetryRef(metadata.identifier) + val metadataToUpdate = metadata.copy(state = "RUNNING") + retryRef.addRetryingMetadataRequest(UpdateMetadata(metadataToUpdate)) + eventually(timeout(3.seconds)) { + assert(retryRef.hasRemainingRequests()) + assert(metadataManager.getBatch(metadata.identifier).getState === "PENDING") + } + + val metadata2 = metadata.copy(identifier = UUID.randomUUID().toString) + val metadata2ToUpdate = metadata2.copy( + engineId = "app_id", + engineName = "app_name", + engineUrl = "app_url", + engineState = "app_state", + state = "RUNNING") + + metadataManager.addMetadataRetryRequest(InsertMetadata(metadata2)) + metadataManager.addMetadataRetryRequest(UpdateMetadata(metadata2ToUpdate)) + + val retryRef2 = metadataManager.getMetadataRequestsRetryRef(metadata2.identifier) + + eventually(timeout(3.seconds)) { + assert(!retryRef2.hasRemainingRequests()) + assert(metadataManager.getBatch(metadata2.identifier).getState === "RUNNING") + } + + metadataManager.identifierRequestsAsyncRetryRefs.clear() + eventually(timeout(3.seconds)) { + metadataManager.identifierRequestsAsyncRetryingCounts.asScala.forall(_._2.get() == 0) + } + metadataManager.identifierRequestsAsyncRetryingCounts.clear() } } - test("retry the metadata store requests") { - val metadata = Metadata( - identifier = UUID.randomUUID().toString, - sessionType = SessionType.BATCH, - realUser = "kyuubi", - username = "kyuubi", - ipAddress = "127.0.0.1", - kyuubiInstance = "localhost:10009", - state = "PENDING", - resource = "intern", - className = "org.apache.kyuubi.SparkWC", - requestName = "kyuubi_batch", - requestConf = Map("spark.master" -> "local"), - requestArgs = Seq("100"), - createTime = System.currentTimeMillis(), - engineType = "spark", - clusterManager = Some("local")) - metadataManager.insertMetadata(metadata) - intercept[KyuubiException] { - metadataManager.insertMetadata(metadata, retryOnError = false) - } - metadataManager.insertMetadata(metadata, retryOnError = true) - val retryRef = metadataManager.getMetadataRequestsRetryRef(metadata.identifier) - val metadataToUpdate = metadata.copy(state = "RUNNING") - retryRef.addRetryingMetadataRequest(UpdateMetadata(metadataToUpdate)) - eventually(timeout(3.seconds)) { - assert(retryRef.hasRemainingRequests()) - assert(metadataManager.getBatch(metadata.identifier).getState === "PENDING") - } - - val metadata2 = metadata.copy(identifier = UUID.randomUUID().toString) - val metadata2ToUpdate = metadata2.copy( - engineId = "app_id", - engineName = "app_name", - engineUrl = "app_url", - engineState = "app_state", - state = "RUNNING") - - metadataManager.addMetadataRetryRequest(InsertMetadata(metadata2)) - metadataManager.addMetadataRetryRequest(UpdateMetadata(metadata2ToUpdate)) - - val retryRef2 = metadataManager.getMetadataRequestsRetryRef(metadata2.identifier) - - eventually(timeout(3.seconds)) { - assert(!retryRef2.hasRemainingRequests()) - assert(metadataManager.getBatch(metadata2.identifier).getState === "RUNNING") + test("async metadata request metrics") { + withMetadataManager(Map( + METADATA_REQUEST_ASYNC_RETRY_ENABLED.key -> "true", + METADATA_REQUEST_RETRY_INTERVAL.key -> "100")) { metadataManager => + val totalRequests = MetricsSystem.meterValue(METADATA_REQUEST_TOTAL).getOrElse(0L) + val failedRequests = MetricsSystem.meterValue(METADATA_REQUEST_FAIL).getOrElse(0L) + val retryingRequests = MetricsSystem.meterValue(METADATA_REQUEST_RETRYING).getOrElse(0L) + + val metadata = newMetadata() + metadataManager.insertMetadata(metadata) + + assert( + MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_TOTAL) + .getOrElse(0L) - totalRequests === 1) + assert( + MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_FAIL) + .getOrElse(0L) - failedRequests === 0) + assert( + MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_RETRYING) + .getOrElse(0L) - retryingRequests === 0) + + val invalidMetadata = metadata.copy(kyuubiInstance = null) + intercept[Exception](metadataManager.insertMetadata(invalidMetadata, false)) + assert( + MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_TOTAL) + .getOrElse(0L) - totalRequests === 2) + assert( + MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_FAIL) + .getOrElse(0L) - failedRequests === 1) + assert( + MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_RETRYING) + .getOrElse(0L) - retryingRequests === 0) + + metadataManager.insertMetadata(invalidMetadata, true) + + assert( + MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_TOTAL) + .getOrElse(0L) - totalRequests === 3) + assert( + MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_FAIL) + .getOrElse(0L) - failedRequests === 2) + assert( + MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_RETRYING) + .getOrElse(0L) - retryingRequests === 1) } + } - metadataManager.identifierRequestsRetryRefs.clear() - eventually(timeout(3.seconds)) { - metadataManager.identifierRequestsRetryingCounts.asScala.forall(_._2.get() == 0) + private def withMetadataManager( + confOverlay: Map[String, String], + newMetadataMgr: () => MetadataManager = () => new MetadataManager())( + f: MetadataManager => Unit): Unit = { + val metricsSystem = new MetricsSystem() + val metadataManager = newMetadataMgr() + val conf = KyuubiConf() + confOverlay.foreach { case (k, v) => conf.set(k, v) } + try { + metricsSystem.initialize(conf) + metricsSystem.start() + metadataManager.initialize(conf) + metadataManager.start() + f(metadataManager) + } finally { + metadataManager.getBatches(null, null, null, 0, 0, 0, Int.MaxValue).foreach { batch => + metadataManager.cleanupMetadataById(batch.getId) + } + // ensure no metadata request leak + eventually(timeout(5.seconds), interval(200.milliseconds)) { + assert(MetricsSystem.counterValue(METADATA_REQUEST_OPENED).getOrElse(0L) === 0) + } + metadataManager.stop() + metricsSystem.stop() } - metadataManager.identifierRequestsRetryingCounts.clear() } - test("metadata request metrics") { - val totalRequests = - MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_TOTAL).getOrElse(0L) - val failedRequests = - MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_FAIL).getOrElse(0L) - val retryingRequests = - MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_RETRYING).getOrElse(0L) - - val metadata = Metadata( + private def newMetadata(): Metadata = { + Metadata( identifier = UUID.randomUUID().toString, sessionType = SessionType.BATCH, realUser = "kyuubi", @@ -137,37 +186,5 @@ class MetadataManagerSuite extends KyuubiFunSuite { createTime = System.currentTimeMillis(), engineType = "spark", clusterManager = Some("local")) - metadataManager.insertMetadata(metadata) - - assert( - MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_TOTAL).getOrElse( - 0L) - totalRequests === 1) - assert( - MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_FAIL).getOrElse( - 0L) - failedRequests === 0) - assert(MetricsSystem.meterValue( - MetricsConstants.METADATA_REQUEST_RETRYING).getOrElse(0L) - retryingRequests === 0) - - val invalidMetadata = metadata.copy(kyuubiInstance = null) - intercept[Exception](metadataManager.insertMetadata(invalidMetadata, false)) - assert( - MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_TOTAL).getOrElse( - 0L) - totalRequests === 2) - assert( - MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_FAIL).getOrElse( - 0L) - failedRequests === 1) - assert(MetricsSystem.meterValue( - MetricsConstants.METADATA_REQUEST_RETRYING).getOrElse(0L) - retryingRequests === 0) - - metadataManager.insertMetadata(invalidMetadata, true) - - assert( - MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_TOTAL).getOrElse( - 0L) - totalRequests === 3) - assert( - MetricsSystem.meterValue(MetricsConstants.METADATA_REQUEST_FAIL).getOrElse( - 0L) - failedRequests === 2) - assert(MetricsSystem.meterValue( - MetricsConstants.METADATA_REQUEST_RETRYING).getOrElse(0L) - retryingRequests === 1) } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStoreSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStoreSuite.scala index 73dc105c3d6..aa53af3a908 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStoreSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/metadata/jdbc/JDBCMetadataStoreSuite.scala @@ -279,4 +279,20 @@ class JDBCMetadataStoreSuite extends KyuubiFunSuite { jdbcMetadataStore.updateMetadata(metadata) } } + + test("get schema urls with correct version ordering") { + val url1 = "metadata-store-schema-1.7.0.mysql.sql" + val url2 = "metadata-store-schema-1.7.1.mysql.sql" + val url3 = "metadata-store-schema-1.8.0.mysql.sql" + val url4 = "metadata-store-schema-1.10.0.mysql.sql" + val url5 = "metadata-store-schema-2.1.0.mysql.sql" + assert(jdbcMetadataStore.getSchemaVersion(url1) === ((1, 7, 0))) + assert(jdbcMetadataStore.getSchemaVersion(url2) === ((1, 7, 1))) + assert(jdbcMetadataStore.getSchemaVersion(url3) === ((1, 8, 0))) + assert(jdbcMetadataStore.getSchemaVersion(url4) === ((1, 10, 0))) + assert(jdbcMetadataStore.getSchemaVersion(url5) === ((2, 1, 0))) + assert(jdbcMetadataStore.getLatestSchemaUrl(Seq(url1, url2, url3, url4)).get === url4) + assert(jdbcMetadataStore.getLatestSchemaUrl(Seq(url1, url3, url4, url2)).get === url4) + assert(jdbcMetadataStore.getLatestSchemaUrl(Seq(url1, url2, url3, url4, url5)).get === url5) + } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/mysql/MySQLJDBCTestHelper.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/mysql/MySQLJDBCTestHelper.scala index c258b6e6924..e6df1fb20b9 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/mysql/MySQLJDBCTestHelper.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/mysql/MySQLJDBCTestHelper.scala @@ -22,7 +22,7 @@ import org.apache.kyuubi.operation.JDBCTestHelper trait MySQLJDBCTestHelper extends JDBCTestHelper { - override def jdbcDriverClass: String = "com.mysql.jdbc.Driver" + override def jdbcDriverClass: String = "com.mysql.cj.jdbc.Driver" protected lazy val user: String = Utils.currentUser @@ -42,7 +42,7 @@ trait MySQLJDBCTestHelper extends JDBCTestHelper { if (jdbcConfigs.isEmpty) { "" } else { - "?" + jdbcConfigs.map(kv => kv._1 + "=" + kv._2).mkString(";") + "?" + jdbcConfigs.map(kv => kv._1 + "=" + kv._2).mkString("&") } jdbcUrl + jdbcConfStr } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/AdminCtlSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/AdminCtlSuite.scala index f7cbb20016c..389b67e4738 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/AdminCtlSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/AdminCtlSuite.scala @@ -26,6 +26,7 @@ import org.apache.kyuubi.engine.EngineRef import org.apache.kyuubi.ha.HighAvailabilityConf import org.apache.kyuubi.ha.client.DiscoveryClientProvider.withDiscoveryClient import org.apache.kyuubi.ha.client.DiscoveryPaths +import org.apache.kyuubi.plugin.PluginLoader class AdminCtlSuite extends RestClientTestHelper with TestPrematureExit { override def beforeAll(): Unit = { @@ -53,8 +54,10 @@ class AdminCtlSuite extends RestClientTestHelper with TestPrematureExit { conf.set(HighAvailabilityConf.HA_NAMESPACE, "kyuubi_test") conf.set(KyuubiConf.ENGINE_IDLE_TIMEOUT, 180000L) conf.set(KyuubiConf.AUTHENTICATION_METHOD, Seq("LDAP", "CUSTOM")) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") + val user = ldapUser - val engine = new EngineRef(conf.clone, user, "grp", id, null) + val engine = new EngineRef(conf.clone, user, PluginLoader.loadGroupProvider(conf), id, null) val engineSpace = DiscoveryPaths.makePath( s"kyuubi_test_${KYUUBI_VERSION}_USER_SPARK_SQL", diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/AdminRestApiSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/AdminRestApiSuite.scala index ab1a102026c..b79e62a12f4 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/AdminRestApiSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/AdminRestApiSuite.scala @@ -21,6 +21,8 @@ import java.util.UUID import scala.collection.JavaConverters.asScalaBufferConverter +import org.apache.hive.service.rpc.thrift.TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V2 + import org.apache.kyuubi.{KYUUBI_VERSION, RestClientTestHelper} import org.apache.kyuubi.client.{AdminRestApi, KyuubiRestClient} import org.apache.kyuubi.config.{KyuubiConf, KyuubiReservedKeys} @@ -28,6 +30,7 @@ import org.apache.kyuubi.engine.EngineRef import org.apache.kyuubi.ha.HighAvailabilityConf import org.apache.kyuubi.ha.client.DiscoveryClientProvider.withDiscoveryClient import org.apache.kyuubi.ha.client.DiscoveryPaths +import org.apache.kyuubi.plugin.PluginLoader class AdminRestApiSuite extends RestClientTestHelper { test("refresh kyuubi server hadoop conf") { @@ -46,8 +49,9 @@ class AdminRestApiSuite extends RestClientTestHelper { conf.set(HighAvailabilityConf.HA_NAMESPACE, "kyuubi_test") conf.set(KyuubiConf.ENGINE_IDLE_TIMEOUT, 180000L) conf.set(KyuubiConf.AUTHENTICATION_METHOD, Seq("LDAP", "CUSTOM")) + conf.set(KyuubiConf.GROUP_PROVIDER, "hadoop") val user = ldapUser - val engine = new EngineRef(conf.clone, user, "grp", id, null) + val engine = new EngineRef(conf.clone, user, PluginLoader.loadGroupProvider(conf), id, null) val engineSpace = DiscoveryPaths.makePath( s"kyuubi_test_${KYUUBI_VERSION}_USER_SPARK_SQL", @@ -84,4 +88,64 @@ class AdminRestApiSuite extends RestClientTestHelper { engines = adminRestApi.listEngines("spark_sql", "user", "default", "").asScala assert(engines.size == 0) } + + test("list/close session") { + fe.be.sessionManager.openSession( + HIVE_CLI_SERVICE_PROTOCOL_V2, + "admin", + "123456", + "localhost", + Map("testConfig" -> "testValue")) + + val spnegoKyuubiRestClient: KyuubiRestClient = + KyuubiRestClient.builder(baseUri.toString) + .authHeaderMethod(KyuubiRestClient.AuthHeaderMethod.SPNEGO) + .spnegoHost("localhost") + .build() + val adminRestApi = new AdminRestApi(spnegoKyuubiRestClient) + + // list sessions + var sessions = adminRestApi.listSessions().asScala + assert(sessions.nonEmpty) + assert(sessions.head.getUser == "admin") + + // close session + val response = adminRestApi.closeSession(sessions.head.getIdentifier) + assert(response.contains("success")) + + // list again + sessions = adminRestApi.listSessions().asScala + assert(sessions.isEmpty) + } + + test("list/close operation") { + val sessionHandle = fe.be.openSession( + HIVE_CLI_SERVICE_PROTOCOL_V2, + "admin", + "123456", + "localhost", + Map("testConfig" -> "testValue")) + val operation = fe.be.getCatalogs(sessionHandle) + + val spnegoKyuubiRestClient: KyuubiRestClient = + KyuubiRestClient.builder(baseUri.toString) + .authHeaderMethod(KyuubiRestClient.AuthHeaderMethod.SPNEGO) + .spnegoHost("localhost") + .build() + val adminRestApi = new AdminRestApi(spnegoKyuubiRestClient) + + // list operations + var operations = adminRestApi.listOperations().asScala + assert(operations.nonEmpty) + assert(operations.map(op => op.getIdentifier).contains(operation.identifier.toString)) + + // close operation + val response = adminRestApi.closeOperation(operation.identifier.toString) + assert(response.contains("success")) + + // list again + operations = adminRestApi.listOperations().asScala + assert(!operations.map(op => op.getIdentifier).contains(operation.identifier.toString)) + + } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/BatchCliSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/BatchCliSuite.scala index 9d0a9b15a4a..ff807ef027b 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/BatchCliSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/BatchCliSuite.scala @@ -21,6 +21,7 @@ import java.io.File import java.net.InetAddress import java.nio.charset.StandardCharsets import java.nio.file.{Files, Paths} +import java.util.UUID import org.apache.hadoop.security.UserGroupInformation import org.apache.hadoop.shaded.com.nimbusds.jose.util.StandardCharset @@ -28,6 +29,7 @@ import org.apache.hive.service.rpc.thrift.TProtocolVersion import org.scalatest.time.SpanSugar.convertIntToGrainOfTime import org.apache.kyuubi.{BatchTestHelper, RestClientTestHelper, Utils} +import org.apache.kyuubi.client.util.BatchUtils._ import org.apache.kyuubi.config.KyuubiConf import org.apache.kyuubi.ctl.{CtlConf, TestPrematureExit} import org.apache.kyuubi.metrics.{MetricsConstants, MetricsSystem} @@ -256,7 +258,7 @@ class BatchCliSuite extends RestClientTestHelper with TestPrematureExit with Bat "kyuubi", "kyuubi", InetAddress.getLocalHost.getCanonicalHostName, - Map.empty, + Map(KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString), newBatchRequest( "spark", "", @@ -278,7 +280,7 @@ class BatchCliSuite extends RestClientTestHelper with TestPrematureExit with Bat "kyuubi", "kyuubi", InetAddress.getLocalHost.getCanonicalHostName, - Map.empty, + Map(KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString), newBatchRequest( "spark", "", @@ -288,7 +290,7 @@ class BatchCliSuite extends RestClientTestHelper with TestPrematureExit with Bat "kyuubi", "kyuubi", InetAddress.getLocalHost.getCanonicalHostName, - Map.empty, + Map(KYUUBI_BATCH_ID_KEY -> UUID.randomUUID().toString), newBatchRequest( "spark", "", diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/BatchRestApiSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/BatchRestApiSuite.scala index b425f62d65f..cb7905286f9 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/BatchRestApiSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/BatchRestApiSuite.scala @@ -17,14 +17,16 @@ package org.apache.kyuubi.server.rest.client +import java.nio.file.Paths import java.util.Base64 import org.scalatest.time.SpanSugar.convertIntToGrainOfTime -import org.apache.kyuubi.{BatchTestHelper, RestClientTestHelper} +import org.apache.kyuubi.{BatchTestHelper, KYUUBI_VERSION, RestClientTestHelper} import org.apache.kyuubi.client.{BatchRestApi, KyuubiRestClient} import org.apache.kyuubi.client.api.v1.dto.Batch import org.apache.kyuubi.client.exception.KyuubiRestException +import org.apache.kyuubi.config.KyuubiReservedKeys import org.apache.kyuubi.metrics.{MetricsConstants, MetricsSystem} import org.apache.kyuubi.session.{KyuubiSession, SessionHandle} @@ -83,6 +85,25 @@ class BatchRestApiSuite extends RestClientTestHelper with BatchTestHelper { basicKyuubiRestClient.close() } + test("basic batch rest client with uploading resource file") { + val basicKyuubiRestClient: KyuubiRestClient = + KyuubiRestClient.builder(baseUri.toString) + .authHeaderMethod(KyuubiRestClient.AuthHeaderMethod.BASIC) + .username(ldapUser) + .password(ldapUserPasswd) + .socketTimeout(30000) + .build() + val batchRestApi: BatchRestApi = new BatchRestApi(basicKyuubiRestClient) + + val requestObj = newSparkBatchRequest(Map("spark.master" -> "local")) + val exampleJarFile = Paths.get(sparkBatchTestResource.get).toFile + val batch: Batch = batchRestApi.createBatch(requestObj, exampleJarFile) + + assert(batch.getKyuubiInstance === fe.connectionUrl) + assert(batch.getBatchType === "SPARK") + basicKyuubiRestClient.close() + } + test("basic batch rest client with invalid user") { val totalConnections = MetricsSystem.counterValue(MetricsConstants.REST_CONN_TOTAL).getOrElse(0L) @@ -195,4 +216,22 @@ class BatchRestApiSuite extends RestClientTestHelper with BatchTestHelper { batchRestApi.listBatches(null, null, null, 0, 0, 0, 1) batchRestApi.listBatches(null, null, null, 0, 0, 0, 1) } + + test("support to transfer client version when creating batch") { + val spnegoKyuubiRestClient: KyuubiRestClient = + KyuubiRestClient.builder(baseUri.toString) + .authHeaderMethod(KyuubiRestClient.AuthHeaderMethod.SPNEGO) + .spnegoHost("localhost") + .build() + val batchRestApi: BatchRestApi = new BatchRestApi(spnegoKyuubiRestClient) + // create batch + val requestObj = + newSparkBatchRequest(Map("spark.master" -> "local")) + + val batch = batchRestApi.createBatch(requestObj) + val batchSession = + server.backendService.sessionManager.getSession(SessionHandle.fromUUID(batch.getId)) + assert( + batchSession.conf.get(KyuubiReservedKeys.KYUUBI_CLIENT_VERSION_KEY) == Some(KYUUBI_VERSION)) + } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/SessionRestApiSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/SessionRestApiSuite.scala index 1edfb5e5393..ed116d077cc 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/SessionRestApiSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/rest/client/SessionRestApiSuite.scala @@ -17,21 +17,153 @@ package org.apache.kyuubi.server.rest.client -import scala.collection.JavaConverters.asScalaBufferConverter +import java.util -import org.apache.hive.service.rpc.thrift.TProtocolVersion +import scala.collection.JavaConverters._ + +import org.apache.hive.service.rpc.thrift.TGetInfoType import org.apache.kyuubi.RestClientTestHelper import org.apache.kyuubi.client.{KyuubiRestClient, SessionRestApi} +import org.apache.kyuubi.client.api.v1.dto +import org.apache.kyuubi.client.api.v1.dto._ +import org.apache.kyuubi.client.exception.KyuubiRestException +import org.apache.kyuubi.config.KyuubiConf +import org.apache.kyuubi.session.SessionType class SessionRestApiSuite extends RestClientTestHelper { - test("list session") { - fe.be.sessionManager.openSession( - TProtocolVersion.findByValue(1), - "admin", - "123456", - "localhost", - Map("testConfig" -> "testValue")) + test("get/close/list/count session") { + withSessionRestApi { sessionRestApi => + { + // open session + val sessionOpenRequest = new SessionOpenRequest(Map("testConfig" -> "testValue").asJava) + sessionRestApi.openSession(sessionOpenRequest) + + // list sessions + var sessions = sessionRestApi.listSessions().asScala + assert(sessions.size == 1) + val sessionHandle = sessions(0).getIdentifier + + // get open session count + var sessionCount = sessionRestApi.getOpenSessionCount + assert(sessionCount == 1) + + // close session + sessionRestApi.closeSession(sessionHandle) + + // list sessions again + sessions = sessionRestApi.listSessions().asScala + assert(sessions.isEmpty) + + // get open session count again + sessionCount = sessionRestApi.getOpenSessionCount + assert(sessionCount == 0) + } + } + } + + test("get session event") { + withSessionRestApi { sessionRestApi => + // open session + val sessionOpenRequest = new SessionOpenRequest(Map("testConfig" -> "testValue").asJava) + val sessionHandle = sessionRestApi.openSession(sessionOpenRequest) + + // get session event + val kyuubiEvent = sessionRestApi.getSessionEvent( + sessionHandle.getIdentifier.toString).asInstanceOf[dto.KyuubiSessionEvent] + assert(kyuubiEvent.getConf.get("testConfig").equals("testValue")) + assert(kyuubiEvent.getSessionType.equals(SessionType.INTERACTIVE.toString)) + } + } + + test("get info type") { + withSessionRestApi { sessionRestApi => + // open session + val sessionOpenRequest = new SessionOpenRequest( + Map("testConfig" -> "testValue", KyuubiConf.SERVER_INFO_PROVIDER.key -> "SERVER").asJava) + val sessionHandle = sessionRestApi.openSession(sessionOpenRequest) + + // get session info + val info = sessionRestApi.getSessionInfo( + sessionHandle.getIdentifier.toString, + TGetInfoType.CLI_SERVER_NAME.getValue) + assert(info.getInfoType.equals("CLI_SERVER_NAME")) + assert(info.getInfoValue.equals("Apache Kyuubi")) + } + } + + test("submit operation") { + withSessionRestApi { sessionRestApi => + // open session + val sessionOpenRequest = new SessionOpenRequest(Map("testConfig" -> "testValue").asJava) + val sessionHandle = sessionRestApi.openSession(sessionOpenRequest) + val sessionHandleStr = sessionHandle.getIdentifier.toString + + // execute statement + val op1 = sessionRestApi.executeStatement( + sessionHandleStr, + new StatementRequest("show tables", true, 3000)) + assert(op1.getIdentifier != null) + + // get type info + val op2 = sessionRestApi.getTypeInfo(sessionHandleStr) + assert(op2.getIdentifier != null) + + // get catalogs + val op3 = sessionRestApi.getCatalogs(sessionHandleStr) + assert(op3.getIdentifier != null) + + // get schemas + val op4 = sessionRestApi.getSchemas( + sessionHandleStr, + new GetSchemasRequest("spark_catalog", "default")) + assert(op4.getIdentifier != null) + + // get tables + val tableTypes = new util.ArrayList[String]() + val op5 = sessionRestApi.getTables( + sessionHandleStr, + new GetTablesRequest("spark_catalog", "default", "default", tableTypes)) + assert(op5.getIdentifier != null) + + // get table types + val op6 = sessionRestApi.getTableTypes(sessionHandleStr) + assert(op6.getIdentifier != null) + + // get columns + val op7 = sessionRestApi.getColumns( + sessionHandleStr, + new GetColumnsRequest("spark_catalog", "default", "default", "default")) + assert(op7.getIdentifier != null) + + // get function + val op8 = sessionRestApi.getFunctions( + sessionHandleStr, + new GetFunctionsRequest("default", "default", "default")) + assert(op8.getIdentifier != null) + + // get primary keys + assertThrows[KyuubiRestException] { + sessionRestApi.getPrimaryKeys( + sessionHandleStr, + new GetPrimaryKeysRequest("spark_catalog", "default", "default")) + } + + // get cross reference + val getCrossReferenceReq = new GetCrossReferenceRequest( + "spark_catalog", + "default", + "default", + "spark_catalog", + "default", + "default") + assertThrows[KyuubiRestException] { + sessionRestApi.getCrossReference(sessionHandleStr, getCrossReferenceReq) + } + } + } + + def withSessionRestApi[T](f: SessionRestApi => T): T = { val basicKyuubiRestClient: KyuubiRestClient = KyuubiRestClient.builder(baseUri.toString) .authHeaderMethod(KyuubiRestClient.AuthHeaderMethod.BASIC) @@ -39,12 +171,7 @@ class SessionRestApiSuite extends RestClientTestHelper { .password(ldapUserPasswd) .socketTimeout(30000) .build() - val sessionRestApi = new SessionRestApi(basicKyuubiRestClient) - val sessions = sessionRestApi.listSessions().asScala - assert(sessions.size == 1) - assert(sessions(0).getUser == "admin") - assert(sessions(0).getIpAddr == "localhost") - assert(sessions(0).getConf.toString == "{testConfig=testValue}") + f(sessionRestApi) } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/TrinoClientApiSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/TrinoClientApiSuite.scala new file mode 100644 index 00000000000..478bf917463 --- /dev/null +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/TrinoClientApiSuite.scala @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kyuubi.server.trino.api + +import java.net.URI +import java.time.ZoneId +import java.util.{Collections, Locale, Optional} +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +import scala.annotation.tailrec +import scala.collection.JavaConverters._ + +import com.google.common.base.Verify +import io.airlift.units.Duration +import io.trino.client.{ClientSession, StatementClient, StatementClientFactory} +import okhttp3.OkHttpClient + +import org.apache.kyuubi.{KyuubiFunSuite, KyuubiSQLException, TrinoRestFrontendTestHelper} + +class TrinoClientApiSuite extends KyuubiFunSuite with TrinoRestFrontendTestHelper { + + private val httpClient = + new OkHttpClient.Builder() + .readTimeout(5, TimeUnit.MINUTES) + .build() + private lazy val clientSession = + new AtomicReference[ClientSession](createTestClientSession(baseUri)) + + test("submit query with trino client api") { + val trino = getTrinoStatementClient("select 1") + val result = execute(trino) + val sessionId = trino.getSetSessionProperties.asScala.get(Query.KYUUBI_SESSION_ID) + assert(result == List(List(1))) + + updateClientSession(trino) + + val trino1 = getTrinoStatementClient("set k=v") + val result1 = execute(trino1) + val sessionId1 = trino1.getSetSessionProperties.asScala.get(Query.KYUUBI_SESSION_ID) + assert(result1 == List(List("k", "v"))) + assert(sessionId == sessionId1) + + updateClientSession(trino) + + val trino2 = getTrinoStatementClient("set k") + val result2 = execute(trino2) + val sessionId2 = trino2.getSetSessionProperties.asScala.get(Query.KYUUBI_SESSION_ID) + assert(result2 == List(List("k", "v"))) + assert(sessionId == sessionId2) + + trino.close() + } + + private def updateClientSession(trino: StatementClient): Unit = { + val session = clientSession.get + + var builder = ClientSession.builder(session) + // update catalog and schema + if (trino.getSetCatalog.isPresent || trino.getSetSchema.isPresent) { + builder = builder + .withCatalog(trino.getSetCatalog.orElse(session.getCatalog)) + .withSchema(trino.getSetSchema.orElse(session.getSchema)) + } + + // update path if present + if (trino.getSetPath.isPresent) { + builder = builder.withPath(trino.getSetPath.get) + } + + // update session properties if present + if (!trino.getSetSessionProperties.isEmpty || !trino.getResetSessionProperties.isEmpty) { + val properties = session.getProperties.asScala.clone() + properties ++= trino.getSetSessionProperties.asScala + properties --= trino.getResetSessionProperties.asScala + builder = builder.withProperties(properties.asJava) + } + clientSession.set(builder.build()) + } + + private def execute(trino: StatementClient): List[List[Any]] = { + @tailrec + def getData(trino: StatementClient): (Boolean, List[List[Any]]) = { + if (trino.isRunning) { + val data = trino.currentData().getData() + trino.advance() + if (data != null) { + (true, data.asScala.toList.map(_.asScala.toList)) + } else { + getData(trino) + } + } else { + Verify.verify(trino.isFinished) + val finalStatus = trino.finalStatusInfo() + if (finalStatus.getError() != null) { + throw KyuubiSQLException( + s"Query ${finalStatus.getId} failed: ${finalStatus.getError.getMessage}") + } + (false, List[List[Any]]()) + } + } + + Iterator.continually(getData(trino)).takeWhile(_._1).flatMap(_._2).toList + } + + private def getTrinoStatementClient(sql: String): StatementClient = { + StatementClientFactory.newStatementClient(httpClient, clientSession.get, sql) + } + + private def createTestClientSession(connectUrl: URI): ClientSession = { + new ClientSession( + connectUrl, + "kyuubi_test", + Optional.of("test_user"), + "kyuubi", + Optional.of("test_token_tracing"), + Set[String]().asJava, + "test_client_info", + "test_catalog", + "test_schema", + null, + ZoneId.systemDefault(), + Locale.getDefault, + Collections.emptyMap(), + Map[String, String]( + "test_property_key0" -> "test_property_value0", + "test_property_key1" -> "test_propert_value1").asJava, + Map[String, String]( + "test_statement_key0" -> "select 1", + "test_statement_key1" -> "select 2").asJava, + Collections.emptyMap(), + Collections.emptyMap(), + null, + new Duration(2, TimeUnit.MINUTES), + true) + + } + +} diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/TrinoContextSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/TrinoContextSuite.scala index 67a502288ec..87c8eda968a 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/TrinoContextSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/TrinoContextSuite.scala @@ -17,13 +17,24 @@ package org.apache.kyuubi.server.trino.api +import java.net.URI import java.time.ZoneId +import javax.ws.rs.core.MediaType + +import scala.collection.JavaConverters._ import io.trino.client.ProtocolHeaders.TRINO_HEADERS +import org.apache.hive.service.rpc.thrift.TProtocolVersion.HIVE_CLI_SERVICE_PROTOCOL_V9 +import org.scalatest.concurrent.PatienceConfiguration.Timeout +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime + +import org.apache.kyuubi.{KyuubiFunSuite, RestFrontendTestHelper} +import org.apache.kyuubi.events.KyuubiOperationEvent +import org.apache.kyuubi.operation.{FetchOrientation, OperationHandle} +import org.apache.kyuubi.operation.OperationState.{FINISHED, OperationState} -import org.apache.kyuubi.KyuubiFunSuite +class TrinoContextSuite extends KyuubiFunSuite with RestFrontendTestHelper { -class TrinoContextSuite extends KyuubiFunSuite { import TrinoContext._ test("create trino request context with header") { @@ -67,4 +78,83 @@ class TrinoContextSuite extends KyuubiFunSuite { assert(actual == expectedTrinoContext) } + test("test convert") { + val opHandle = getOpHandle("select 1") + val opHandleStr = opHandle.identifier.toString + checkOpState(opHandleStr, FINISHED) + + val metadataResp = fe.be.getResultSetMetadata(opHandle) + val tRowSet = fe.be.fetchResults(opHandle, FetchOrientation.FETCH_NEXT, 1000, false) + val status = fe.be.getOperationStatus(opHandle, Some(0)) + + val uri = new URI("sfdsfsdfdsf") + val results = TrinoContext + .createQueryResults("/xdfd/xdf", uri, uri, status, Option(metadataResp), Option(tRowSet)) + + print(results.toString) + assert(results.getColumns.get(0).getType.equals("integer")) + assert(results.getData.asScala.last.get(0) == 1) + } + + test("test convert from table") { + initSql("CREATE DATABASE IF NOT EXISTS INIT_DB") + initSql( + "CREATE TABLE IF NOT EXISTS INIT_DB.test(a int, b double, c String," + + "d BOOLEAN,e DATE,f TIMESTAMP,g ARRAY,h DECIMAL," + + "i MAP) USING PARQUET;") + initSql( + "INSERT INTO INIT_DB.test VALUES (1,2.2,'3',true,current_date()," + + "current_timestamp(),array('1','2'),2.0, map('m','p') )") + + val opHandle = getOpHandle("SELECT * FROM INIT_DB.test") + val opHandleStr = opHandle.identifier.toString + checkOpState(opHandleStr, FINISHED) + + val metadataResp = fe.be.getResultSetMetadata(opHandle) + val tRowSet = fe.be.fetchResults(opHandle, FetchOrientation.FETCH_NEXT, 1000, false) + val status = fe.be.getOperationStatus(opHandle, Some(0)) + + val uri = new URI("sfdsfsdfdsf") + val results = TrinoContext + .createQueryResults("/xdfd/xdf", uri, uri, status, Option(metadataResp), Option(tRowSet)) + + print(results.toString) + assert(results.getColumns.get(0).getType.equals("integer")) + assert(results.getData.asScala.last.get(0) != null) + } + + def getOpHandleStr(statement: String = "show tables"): String = { + getOpHandle(statement).identifier.toString + } + + def getOpHandle(statement: String = "show tables"): OperationHandle = { + val sessionHandle = fe.be.openSession( + HIVE_CLI_SERVICE_PROTOCOL_V9, + "admin", + "123456", + "localhost", + Map("testConfig" -> "testValue")) + + if (statement.nonEmpty) { + fe.be.executeStatement(sessionHandle, statement, Map.empty, runAsync = false, 30000) + } else { + fe.be.getCatalogs(sessionHandle) + } + } + + private def checkOpState(opHandleStr: String, state: OperationState): Unit = { + eventually(Timeout(30.seconds)) { + val response = webTarget.path(s"api/v1/operations/$opHandleStr/event") + .request(MediaType.APPLICATION_JSON_TYPE).get() + assert(response.getStatus === 200) + val operationEvent = response.readEntity(classOf[KyuubiOperationEvent]) + assert(operationEvent.state === state.name()) + } + } + + private def initSql(sql: String): Unit = { + val initOpHandle = getOpHandle(sql) + val initOpHandleStr = initOpHandle.identifier.toString + checkOpState(initOpHandleStr, FINISHED) + } } diff --git a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/v1/StatementResourceSuite.scala b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/v1/StatementResourceSuite.scala index b60c7c67aa2..44602759c21 100644 --- a/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/v1/StatementResourceSuite.scala +++ b/kyuubi-server/src/test/scala/org/apache/kyuubi/server/trino/api/v1/StatementResourceSuite.scala @@ -17,15 +17,26 @@ package org.apache.kyuubi.server.trino.api.v1 -import org.apache.kyuubi.{KyuubiFunSuite, RestFrontendTestHelper} -import org.apache.kyuubi.config.KyuubiConf.FrontendProtocols -import org.apache.kyuubi.config.KyuubiConf.FrontendProtocols.FrontendProtocol +import javax.ws.rs.client.Entity +import javax.ws.rs.core.{MediaType, Response} + +import scala.collection.JavaConverters._ + +import io.trino.client.{QueryError, QueryResults} +import io.trino.client.ProtocolHeaders.TRINO_HEADERS + +import org.apache.kyuubi.{KyuubiFunSuite, KyuubiSQLException, TrinoRestFrontendTestHelper} +import org.apache.kyuubi.server.trino.api.{Query, TrinoContext} import org.apache.kyuubi.server.trino.api.v1.dto.Ok +import org.apache.kyuubi.session.SessionHandle -class StatementResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper { +class StatementResourceSuite extends KyuubiFunSuite with TrinoRestFrontendTestHelper { - override protected val frontendProtocols: Seq[FrontendProtocol] = - FrontendProtocols.TRINO :: Nil + case class TrinoResponse( + response: Option[Response] = None, + queryError: Option[QueryError] = None, + data: List[List[Any]] = List[List[Any]](), + isEnd: Boolean = false) test("statement test") { val response = webTarget.path("v1/statement/test").request().get() @@ -33,4 +44,72 @@ class StatementResourceSuite extends KyuubiFunSuite with RestFrontendTestHelper assert(result == new Ok("trino server is running")) } + test("statement submit for query error") { + + val response = webTarget.path("v1/statement") + .request().post(Entity.entity("select a", MediaType.TEXT_PLAIN_TYPE)) + + val trinoResponseIter = Iterator.iterate(TrinoResponse(response = Option(response)))(getData) + val isErr = trinoResponseIter.takeWhile(_.isEnd == false).exists { t => + t.queryError != None && t.response == None + } + assert(isErr == true) + } + + test("statement submit and get result") { + val response = webTarget.path("v1/statement") + .request().post(Entity.entity("select 1", MediaType.TEXT_PLAIN_TYPE)) + + val trinoResponseIter = Iterator.iterate(TrinoResponse(response = Option(response)))(getData) + val dataSet = trinoResponseIter + .takeWhile(_.isEnd == false) + .map(_.data) + .flatten.toList + assert(dataSet == List(List(1))) + } + + test("query cancel") { + val response = webTarget.path("v1/statement") + .request().post(Entity.entity("select 1", MediaType.TEXT_PLAIN_TYPE)) + assert(response.getStatus == 200) + val qr = response.readEntity(classOf[QueryResults]) + val sessionManager = fe.be.sessionManager + val sessionHandle = + response.getStringHeaders.get(TRINO_HEADERS.responseSetSession).asScala + .map(_.split("=")) + .find { + case Array(Query.KYUUBI_SESSION_ID, _) => true + } + .map { + case Array(_, value) => SessionHandle.fromUUID(TrinoContext.urlDecode(value)) + }.get + sessionManager.getSession(sessionHandle) + + val path = qr.getNextUri.getPath + val nextResponse = webTarget.path(path).request().header( + TRINO_HEADERS.requestSession(), + s"${Query.KYUUBI_SESSION_ID}=${TrinoContext.urlEncode(sessionHandle.identifier.toString)}") + .delete() + assert(nextResponse.getStatus == 204) + val exception = intercept[KyuubiSQLException](sessionManager.getSession(sessionHandle)) + assert(exception.getMessage === s"Invalid $sessionHandle") + } + + private def getData(current: TrinoResponse): TrinoResponse = { + current.response.map { response => + assert(response.getStatus == 200) + val qr = response.readEntity(classOf[QueryResults]) + val nextData = Option(qr.getData) + .map(_.asScala.toList.map(_.asScala.toList)) + .getOrElse(List[List[Any]]()) + val nextResponse = Option(qr.getNextUri).map { + uri => + val path = uri.getPath + val headers = response.getHeaders + webTarget.path(path).request().headers(headers).get() + } + TrinoResponse(nextResponse, Option(qr.getError), nextData) + }.getOrElse(TrinoResponse(isEnd = true)) + } + } diff --git a/kyuubi-server/web-ui/.env.development b/kyuubi-server/web-ui/.env.development index d8297cf3624..d1d91dd384d 100644 --- a/kyuubi-server/web-ui/.env.development +++ b/kyuubi-server/web-ui/.env.development @@ -15,4 +15,4 @@ NODE_ENV=development -VITE_APP_DEV_WEB_URL='/' +VITE_APP_DEV_WEB_URL='http://0.0.0.0:10099/' diff --git a/kyuubi-server/web-ui/.eslintrc b/kyuubi-server/web-ui/.eslintrc index ebbf401995e..f2bff2cd6e3 100644 --- a/kyuubi-server/web-ui/.eslintrc +++ b/kyuubi-server/web-ui/.eslintrc @@ -69,6 +69,9 @@ "exports": "never", "functions": "never" }], + "prettier/prettier": ["error", { + "bracketSameLine": true + }], "vue/multi-word-component-names": "off", "vue/component-definition-name-casing": "off", "vue/require-valid-default-prop": "off", diff --git a/kyuubi-server/web-ui/.gitignore b/kyuubi-server/web-ui/.gitignore index be5bdb2366b..c6cab4b869c 100644 --- a/kyuubi-server/web-ui/.gitignore +++ b/kyuubi-server/web-ui/.gitignore @@ -14,6 +14,7 @@ # limitations under the License. .DS_Store +node node_modules /dist /coverage diff --git a/kyuubi-server/web-ui/.prettierrc b/kyuubi-server/web-ui/.prettierrc index 1fceefb9885..01db7f49bc1 100644 --- a/kyuubi-server/web-ui/.prettierrc +++ b/kyuubi-server/web-ui/.prettierrc @@ -4,7 +4,7 @@ "vueIndentScriptAndStyle": true, "singleQuote": true, "quoteProps": "as-needed", - "jsxBracketSameLine": false, + "bracketSameLine": true, "jsxSingleQuote": true, "arrowParens": "always", "htmlWhitespaceSensitivity": "strict", diff --git a/kyuubi-server/web-ui/README.md b/kyuubi-server/web-ui/README.md index cc5654b231e..b892a690261 100644 --- a/kyuubi-server/web-ui/README.md +++ b/kyuubi-server/web-ui/README.md @@ -15,8 +15,15 @@ npm install ### Development Project -To do this you can change the VITE_APP_DEV_WEB_URL parameter variable as the service url in `.env.development` in the project root directory, such as http://127.0. 0.1:8090 +Notice: +Before you start the Web UI project, please make sure the Kyuubi server has been started. + +Kyuubi Web UI will proxy the requests to Kyuubi server, with the default endpoint path to`http://localhost:10099`. Modify `VITE_APP_DEV_WEB_URL` in `.env.development` for customizing targeted endpoint path. + +#### Why proxy to http://localhost:10099 + +Currently kyuubi server binds on `http://0.0.0.0:10099` in case your are running kyuubi server in MacOS or Windows(If in linux, you should config kyuubi server `kyuubi.frontend.rest.bind.host=0.0.0.0`, or change `VITE_APP_DEV_WEB_URL` in `.env.development`). ```shell npm run dev @@ -56,3 +63,4 @@ pnpm run build # Code Format pnpm run prettier ``` + diff --git a/kyuubi-server/web-ui/index.html b/kyuubi-server/web-ui/index.html index bd4f506721c..2c4579eb0de 100644 --- a/kyuubi-server/web-ui/index.html +++ b/kyuubi-server/web-ui/index.html @@ -22,7 +22,7 @@ - Vite + Vue + TS + Apache Kyuubi Dashboard
                diff --git a/kyuubi-server/web-ui/package-lock.json b/kyuubi-server/web-ui/package-lock.json index 77a3991e5ad..0a2feeba118 100644 --- a/kyuubi-server/web-ui/package-lock.json +++ b/kyuubi-server/web-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "kyuubi-ui", - "version": "1.7.0-SNAPSHOT", + "version": "1.8.0-SNAPSHOT", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kyuubi-ui", - "version": "1.7.0-SNAPSHOT", + "version": "1.8.0-SNAPSHOT", "dependencies": { "@element-plus/icons-vue": "^2.0.9", "axios": "^0.27.2", diff --git a/kyuubi-server/web-ui/package.json b/kyuubi-server/web-ui/package.json index 2ae37cdb99f..131e69b7f71 100644 --- a/kyuubi-server/web-ui/package.json +++ b/kyuubi-server/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "kyuubi-ui", "private": true, - "version": "1.7.0-SNAPSHOT", + "version": "1.8.0-SNAPSHOT", "type": "module", "scripts": { "dev": "vue-tsc --noEmit && vite --port 9090", @@ -17,6 +17,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.0.9", "axios": "^0.27.2", + "date-fns": "^2.29.3", "element-plus": "^2.2.12", "pinia": "^2.0.18", "pinia-plugin-persistedstate": "^2.1.1", diff --git a/kyuubi-server/web-ui/pnpm-lock.yaml b/kyuubi-server/web-ui/pnpm-lock.yaml index 61fc5124dbe..1926352abe6 100644 --- a/kyuubi-server/web-ui/pnpm-lock.yaml +++ b/kyuubi-server/web-ui/pnpm-lock.yaml @@ -12,6 +12,7 @@ specifiers: '@vue/eslint-config-typescript': ^11.0.0 '@vue/test-utils': ^2.0.2 axios: ^0.27.2 + date-fns: ^2.29.3 element-plus: ^2.2.12 eslint: ^8.21.0 eslint-plugin-prettier: ^4.2.1 @@ -32,6 +33,7 @@ specifiers: dependencies: '@element-plus/icons-vue': 2.0.9_vue@3.2.37 axios: 0.27.2 + date-fns: 2.29.3 element-plus: 2.2.13_vue@3.2.37 pinia: 2.0.18_j6bzmzd4ujpabbp5objtwxyjp4 pinia-plugin-persistedstate: 2.1.1_pinia@2.0.18 @@ -907,6 +909,11 @@ packages: whatwg-url: 11.0.0 dev: true + /date-fns/2.29.3: + resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==} + engines: {node: '>=0.11'} + dev: false + /dayjs/1.11.5: resolution: {integrity: sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==} dev: false diff --git a/kyuubi-server/web-ui/src/api/session/index.ts b/kyuubi-server/web-ui/src/api/session/index.ts new file mode 100644 index 00000000000..6af5a817f30 --- /dev/null +++ b/kyuubi-server/web-ui/src/api/session/index.ts @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import request from '@/utils/request' + +export function getAllSessions() { + return request({ + url: 'api/v1/sessions', + method: 'get' + }) +} + +export function deleteSession(sessionId: string) { + return request({ + url: `api/v1/sessions/${sessionId}`, + method: 'delete' + }) +} diff --git a/kyuubi-server/web-ui/src/components/menu/index.vue b/kyuubi-server/web-ui/src/components/menu/index.vue index b563b491ec8..d6d4d1b56f1 100644 --- a/kyuubi-server/web-ui/src/components/menu/index.vue +++ b/kyuubi-server/web-ui/src/components/menu/index.vue @@ -21,14 +21,12 @@ class="el-menu-container" :collapse="isCollapse" :default-active="activePath" - :router="true" - > + :router="true">