diff --git a/.github/workflows/build_feature_server.yml b/.github/workflows/build_feature_server.yml index 43f5361584e..d9a0515978a 100644 --- a/.github/workflows/build_feature_server.yml +++ b/.github/workflows/build_feature_server.yml @@ -4,6 +4,9 @@ on: push: branches: - master + paths: + - "protos/**" + - "sdk/python**" jobs: build-docker-image: diff --git a/.github/workflows/build_feature_transformation_server.yml b/.github/workflows/build_feature_transformation_server.yml index ff5e0aada7b..d21d74ff6d7 100644 --- a/.github/workflows/build_feature_transformation_server.yml +++ b/.github/workflows/build_feature_transformation_server.yml @@ -4,6 +4,9 @@ on: push: branches: - master + paths: + - "protos/**" + - "sdk/python**" jobs: build-docker-image: diff --git a/.github/workflows/java_complete.yml b/.github/workflows/java_complete.yml new file mode 100644 index 00000000000..6527a80030c --- /dev/null +++ b/.github/workflows/java_complete.yml @@ -0,0 +1,83 @@ +name: java_complete + +on: [push, pull_request] + +jobs: + lint-java: + container: gcr.io/kf-feast/feast-ci:latest + runs-on: [ubuntu-latest] + steps: + - uses: actions/checkout@v2 + with: + submodules: 'true' + - name: Lint java + run: make lint-java + + unit-test-java: + runs-on: ubuntu-latest + needs: lint-java + steps: + - uses: actions/checkout@v2 + with: + submodules: 'true' + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: '11' + java-package: jdk + architecture: x64 + - uses: actions/cache@v2 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-ut-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-ut-maven- + - name: Test java + run: make test-java-with-coverage + - uses: actions/upload-artifact@v2 + with: + name: java-coverage-report + path: ${{ github.workspace }}/docs/coverage/java/target/site/jacoco-aggregate/ + + integration-test: + runs-on: ubuntu-latest + needs: unit-test-java + services: + redis: + image: redis + ports: + - 6389:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v2 + with: + submodules: 'true' + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: '11' + java-package: jdk + architecture: x64 + - uses: actions/setup-python@v2 + with: + python-version: '3.7' + architecture: 'x64' + - uses: actions/cache@v2 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-it-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-it-maven- + - name: Run integration tests + run: make test-java-integration + - name: Save report + uses: actions/upload-artifact@v2 + if: failure() + with: + name: it-report + path: spark/ingestion/target/test-reports/TestSuite.txt + retention-days: 5 diff --git a/.github/workflows/java_master_only.yml b/.github/workflows/java_master_only.yml new file mode 100644 index 00000000000..417a99d9aa8 --- /dev/null +++ b/.github/workflows/java_master_only.yml @@ -0,0 +1,45 @@ +name: master only + +on: + push: + branches: + - master + tags: + - 'v*.*.*' + +jobs: + build-docker-images: + runs-on: ubuntu-latest + strategy: + matrix: + component: [core, serving] + env: + MAVEN_CACHE: gs://feast-templocation-kf-feast/.m2.2020-08-19.tar + steps: + - uses: actions/checkout@v2 + with: + submodules: 'true' + - uses: google-github-actions/setup-gcloud@master + with: + version: '290.0.1' + export_default_credentials: true + project_id: ${{ secrets.GCP_PROJECT_ID }} + service_account_key: ${{ secrets.GCP_SA_KEY }} + - run: gcloud auth configure-docker --quiet + - name: Get m2 cache + run: | + infra/scripts/download-maven-cache.sh \ + --archive-uri ${MAVEN_CACHE} \ + --output-dir . + - name: Get version + run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + - name: Build image + run: make build-${{ matrix.component }}-docker REGISTRY=gcr.io/kf-feast VERSION=${GITHUB_SHA} + - name: Push image + run: make push-${{ matrix.component }}-docker REGISTRY=gcr.io/kf-feast VERSION=${GITHUB_SHA} + - name: Push development Docker image + run: | + if [ ${GITHUB_REF#refs/*/} == "master" ]; then + docker tag gcr.io/kf-feast/feast-${{ matrix.component }}:${GITHUB_SHA} gcr.io/kf-feast/feast-${{ matrix.component }}:develop + docker push gcr.io/kf-feast/feast-${{ matrix.component }}:develop + fi diff --git a/.github/workflows/java_release.yml b/.github/workflows/java_release.yml new file mode 100644 index 00000000000..a634f70c51d --- /dev/null +++ b/.github/workflows/java_release.yml @@ -0,0 +1,161 @@ +name: release +# TODO(adchia): update DockerHub + Maven repo paths so version can match existing Feast versions. +on: + push: + tags: + - 'v*.*.*' + +jobs: + get-version: + runs-on: ubuntu-latest + outputs: + release_version: ${{ steps.get_release_version.outputs.release_version }} + version_without_prefix: ${{ steps.get_release_version_without_prefix.outputs.version_without_prefix }} + highest_semver_tag: ${{ steps.get_highest_semver.outputs.highest_semver_tag }} + steps: + - uses: actions/checkout@v2 + - name: Get release version + id: get_release_version + run: echo ::set-output name=release_version::${GITHUB_REF#refs/*/} + - name: Get release version without prefix + id: get_release_version_without_prefix + env: + RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} + run: | + echo ::set-output name=version_without_prefix::${RELEASE_VERSION:1} + - name: Get highest semver + id: get_highest_semver + env: + RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} + run: | + source infra/scripts/setup-common-functions.sh + SEMVER_REGEX='^v[0-9]+\.[0-9]+\.[0-9]+(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' + if echo "${RELEASE_VERSION}" | grep -P "$SEMVER_REGEX" &>/dev/null ; then + echo ::set-output name=highest_semver_tag::$(get_tag_release -m) + fi + - name: Check output + env: + RELEASE_VERSION: ${{ steps.get_release_version.outputs.release_version }} + VERSION_WITHOUT_PREFIX: ${{ steps.get_release_version_without_prefix.outputs.version_without_prefix }} + HIGHEST_SEMVER_TAG: ${{ steps.get_highest_semver.outputs.highest_semver_tag }} + run: | + echo $RELEASE_VERSION + echo $VERSION_WITHOUT_PREFIX + echo $HIGHEST_SEMVER_TAG + + build-publish-docker-images: + runs-on: [ubuntu-latest] + needs: get-version + strategy: + matrix: + component: [core, serving] + env: + MAVEN_CACHE: gs://feast-templocation-kf-feast/.m2.2020-08-19.tar + steps: + - uses: actions/checkout@v2 + with: + submodules: 'true' + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: GoogleCloudPlatform/github-actions/setup-gcloud@master + with: + version: '290.0.1' + export_default_credentials: true + project_id: ${{ secrets.GCP_PROJECT_ID }} + service_account_key: ${{ secrets.GCP_SA_KEY }} + - run: gcloud auth configure-docker --quiet + - name: Get m2 cache + run: | + infra/scripts/download-maven-cache.sh \ + --archive-uri ${MAVEN_CACHE} \ + --output-dir . + - name: Build and push versioned images + env: + RELEASE_VERSION: ${{ needs.get-version.outputs.release_version }} + VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} + HIGHEST_SEMVER_TAG: ${{ needs.get-version.outputs.highest_semver_tag }} + run: | + docker build --build-arg VERSION=$RELEASE_VERSION \ + -t gcr.io/kf-feast/feast-${{ matrix.component }}:${GITHUB_SHA} \ + -t gcr.io/kf-feast/feast-${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} \ + -t feastdev/feast-${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} \ + -f infra/docker/${{ matrix.component }}/Dockerfile . + docker push gcr.io/kf-feast/feast-${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} + docker push feastdev/feast-${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} + + echo "Only push to latest tag if tag is the highest semver version $HIGHEST_SEMVER_TAG" + if [ "${VERSION_WITHOUT_PREFIX}" = "${HIGHEST_SEMVER_TAG:1}" ] + then + docker tag feastdev/feast-${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} feastdev/feast-${{ matrix.component }}:latest + docker tag gcr.io/kf-feast/feast-${{ matrix.component }}:${VERSION_WITHOUT_PREFIX} gcr.io/kf-feast/feast-${{ matrix.component }}:latest + docker push feastdev/feast-${{ matrix.component }}:latest + docker push gcr.io/kf-feast/feast-${{ matrix.component }}:latest + fi + + publish-java-sdk: + container: maven:3.6-jdk-11 + runs-on: [ubuntu-latest] + needs: get-version + steps: + - uses: actions/checkout@v2 + with: + submodules: 'true' + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: '11' + java-package: jdk + architecture: x64 + - uses: actions/setup-python@v2 + with: + python-version: '3.6' + architecture: 'x64' + - uses: actions/cache@v2 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-it-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-it-maven- + - name: Publish java sdk + env: + VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} + GPG_PUBLIC_KEY: ${{ secrets.GPG_PUBLIC_KEY }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + MAVEN_SETTINGS: ${{ secrets.MAVEN_SETTINGS }} + run: | + echo -n "$GPG_PUBLIC_KEY" > /root/public-key + echo -n "$GPG_PRIVATE_KEY" > /root/private-key + mkdir -p /root/.m2/ + echo -n "$MAVEN_SETTINGS" > /root/.m2/settings.xml + infra/scripts/publish-java-sdk.sh --revision ${VERSION_WITHOUT_PREFIX} --gpg-key-import-dir /root + + publish-helm-charts: + runs-on: ubuntu-latest + needs: get-version + env: + HELM_VERSION: v2.17.0 + VERSION_WITHOUT_PREFIX: ${{ needs.get-version.outputs.version_without_prefix }} + steps: + - uses: actions/checkout@v2 + - uses: GoogleCloudPlatform/github-actions/setup-gcloud@master + with: + version: '290.0.1' + export_default_credentials: true + project_id: ${{ secrets.GCP_PROJECT_ID }} + service_account_key: ${{ secrets.GCP_SA_KEY }} + - run: gcloud auth configure-docker --quiet + - name: Remove previous Helm + run: sudo rm -rf $(which helm) + - name: Install Helm + run: ./infra/scripts/install-helm.sh + - name: Validate all version consistency + run: ./infra/scripts/validate-helm-chart-versions.sh $VERSION_WITHOUT_PREFIX + - name: Publish Helm charts + run: ./infra/scripts/push-helm-charts.sh $VERSION_WITHOUT_PREFIX \ No newline at end of file diff --git a/.github/workflows/pr_integration_tests.yml b/.github/workflows/pr_integration_tests.yml index 816be27cbf5..7699d703818 100644 --- a/.github/workflows/pr_integration_tests.yml +++ b/.github/workflows/pr_integration_tests.yml @@ -8,10 +8,48 @@ on: - labeled jobs: + build-docker-image: + # all jobs MUST have this if check for 'ok-to-test' or 'approved' for security purposes. + if: (github.event.action == 'labeled' && (github.event.label.name == 'approved' || github.event.label.name == 'ok-to-test')) + || (github.event.action != 'labeled' && (contains(github.event.pull_request.labels.*.name, 'ok-to-test') || contains(github.event.pull_request.labels.*.name, 'approved'))) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + # pull_request_target runs the workflow in the context of the base repo + # as such actions/checkout needs to be explicit configured to retrieve + # code from the PR. + ref: refs/pull/${{ github.event.pull_request.number }}/merge + submodules: recursive + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Set up AWS SDK + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-west-2 + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + - name: Build and push + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: feast-python-server + # Note: the image tags should be in sync with sdk/python/feast/infra/aws.py:_get_docker_image_version + run: | + docker build \ + --file sdk/python/feast/infra/feature_servers/aws_lambda/Dockerfile \ + --tag $ECR_REGISTRY/$ECR_REPOSITORY:`git rev-parse HEAD` \ + . + docker push $ECR_REGISTRY/$ECR_REPOSITORY:`git rev-parse HEAD` integration-test-python: # all jobs MUST have this if check for 'ok-to-test' or 'approved' for security purposes. if: (github.event.action == 'labeled' && (github.event.label.name == 'approved' || github.event.label.name == 'ok-to-test')) || (github.event.action != 'labeled' && (contains(github.event.pull_request.labels.*.name, 'ok-to-test') || contains(github.event.pull_request.labels.*.name, 'approved'))) + needs: build-docker-image runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index dd01a63a5e1..9a842a16753 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -41,6 +41,8 @@ jobs: run: make install-python-ci-dependencies - name: Test Python run: FEAST_USAGE=False pytest -n 8 --cov=./ --cov-report=xml --verbose --color=yes sdk/python/tests + - name: Ensure conflict-free dependencies + run: FEAST_USAGE=False pip-compile --dry-run sdk/python/setup.py --extra ci - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 with: diff --git a/.prow.yaml b/.prow.yaml index f541f81e0e7..b03a71a475a 100644 --- a/.prow.yaml +++ b/.prow.yaml @@ -4,7 +4,7 @@ presubmits: spec: containers: - image: maven:3.6-jdk-11 - command: ["infra/scripts/test-core-ingestion.sh"] + command: ["infra/scripts/test-java-core-ingestion.sh"] resources: requests: cpu: "2000m" @@ -18,7 +18,7 @@ presubmits: spec: containers: - image: maven:3.6-jdk-8 - command: ["infra/scripts/test-core-ingestion.sh"] + command: ["infra/scripts/test-java-core-ingestion.sh"] resources: requests: cpu: "2000m" @@ -31,7 +31,7 @@ presubmits: spec: containers: - image: maven:3.6-jdk-11 - command: ["infra/scripts/test-serving.sh"] + command: ["infra/scripts/test-java-serving.sh"] skip_branches: - ^v0\.(3|4)-branch$ @@ -41,7 +41,7 @@ presubmits: spec: containers: - image: maven:3.6-jdk-8 - command: ["infra/scripts/test-serving.sh"] + command: ["infra/scripts/test-java-serving.sh"] branches: - ^v0\.(3|4)-branch$ diff --git a/CHANGELOG.md b/CHANGELOG.md index a43207f834d..ba4e344ba3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog +## [v0.15.1](https://github.com/feast-dev/feast/tree/v0.15.1) (2021-11-13) + +[Full Changelog](https://github.com/feast-dev/feast/compare/v0.15.0...v0.15.1) + +**Fixed bugs:** + +- Add missing comma in setup.py [\#2031](https://github.com/feast-dev/feast/pull/2031) ([achals](https://github.com/achals)) +- Correct cleanup after usage e2e tests [\#2015](https://github.com/feast-dev/feast/pull/2015) ([pyalex](https://github.com/pyalex)) +- Change Environment timestamps to be in UTC [\#2007](https://github.com/feast-dev/feast/pull/2007) ([felixwang9817](https://github.com/felixwang9817)) +- ODFV UDFs should handle list types [\#2002](https://github.com/feast-dev/feast/pull/2002) ([Agent007](https://github.com/Agent007)) + +**Merged pull requests:** + +- Remove unsupported java parts [\#2029](https://github.com/feast-dev/feast/pull/2029) ([pyalex](https://github.com/pyalex)) +- Fix checked out branch for PR docker image build workflow [\#2018](https://github.com/feast-dev/feast/pull/2018) ([tsotnet](https://github.com/tsotnet)) +- Remove duplicates in setup.py and run rudimentary verifications [\#2016](https://github.com/feast-dev/feast/pull/2016) ([achals](https://github.com/achals)) +- Upload feature server docker image to ECR on approved PRs [\#2014](https://github.com/feast-dev/feast/pull/2014) ([tsotnet](https://github.com/tsotnet)) +- Add integration tests for AWS Lambda feature server [\#2001](https://github.com/feast-dev/feast/pull/2001) ([tsotnet](https://github.com/tsotnet)) +- Moving Feast Java back into main repo under java/ package [\#1997](https://github.com/feast-dev/feast/pull/1997) ([adchia](https://github.com/adchia)) + +## [v0.15.0](https://github.com/feast-dev/feast/tree/v0.15.0) (2021-11-08) + +[Full Changelog](https://github.com/feast-dev/feast/compare/v0.14.1...v0.15.0) + +**Implemented enhancements:** + +- Adding stream ingestion alpha documentation [\#2005](https://github.com/feast-dev/feast/pull/2005) ([adchia](https://github.com/adchia)) +- Feature transformation server docker image [\#1972](https://github.com/feast-dev/feast/pull/1972) ([felixwang9817](https://github.com/felixwang9817)) +- eventtime check before writing features, use pipelines, ttl [\#1961](https://github.com/feast-dev/feast/pull/1961) ([vas28r13](https://github.com/vas28r13)) +- Plugin repo universal tests [\#1946](https://github.com/feast-dev/feast/pull/1946) ([felixwang9817](https://github.com/felixwang9817)) +- direct data ingestion into Online store [\#1939](https://github.com/feast-dev/feast/pull/1939) ([vas28r13](https://github.com/vas28r13)) +- Add an interface for TransformationService and a basic implementation [\#1932](https://github.com/feast-dev/feast/pull/1932) ([achals](https://github.com/achals)) +- Allows registering of features in request data as RequestFeatureView. Refactors common logic into a BaseFeatureView class [\#1931](https://github.com/feast-dev/feast/pull/1931) ([adchia](https://github.com/adchia)) +- Add final\_output\_feature\_names in Query context to avoid SELECT \* EXCEPT [\#1911](https://github.com/feast-dev/feast/pull/1911) ([MattDelac](https://github.com/MattDelac)) +- Add Dockerfile for GCP CloudRun FeatureServer [\#1887](https://github.com/feast-dev/feast/pull/1887) ([judahrand](https://github.com/judahrand)) + +**Fixed bugs:** + +- feast=0.14.0 `query_generator()` unecessary used twice [\#1978](https://github.com/feast-dev/feast/issues/1978) +- get\_online\_features on demand transform bug fixes + local integration test mode [\#2004](https://github.com/feast-dev/feast/pull/2004) ([adchia](https://github.com/adchia)) +- Always pass full and partial feature names to ODFV [\#2003](https://github.com/feast-dev/feast/pull/2003) ([judahrand](https://github.com/judahrand)) +- Update bq\_to\_feast\_value\_type with BOOLEAN type as a legacy sql data type [\#1996](https://github.com/feast-dev/feast/pull/1996) ([mavysavydav](https://github.com/mavysavydav)) +- Fix bug where using some Pandas dtypes in the output of an ODFV fails [\#1994](https://github.com/feast-dev/feast/pull/1994) ([judahrand](https://github.com/judahrand)) +- Fix duplicate update infra [\#1990](https://github.com/feast-dev/feast/pull/1990) ([felixwang9817](https://github.com/felixwang9817)) +- Improve performance of \_convert\_arrow\_to\_proto [\#1984](https://github.com/feast-dev/feast/pull/1984) ([nossrannug](https://github.com/nossrannug)) +- Fix duplicate upload entity [\#1981](https://github.com/feast-dev/feast/pull/1981) ([achals](https://github.com/achals)) +- fix redis cluster materialization [\#1968](https://github.com/feast-dev/feast/pull/1968) ([qooba](https://github.com/qooba)) +- Allow plugin repos to actually overwrite repo configs [\#1966](https://github.com/feast-dev/feast/pull/1966) ([felixwang9817](https://github.com/felixwang9817)) +- Delete keys from Redis when tearing down online store [\#1965](https://github.com/feast-dev/feast/pull/1965) ([achals](https://github.com/achals)) +- Fix issues with lint test and upgrade pip version [\#1964](https://github.com/feast-dev/feast/pull/1964) ([felixwang9817](https://github.com/felixwang9817)) +- Move IntegrationTestRepoConfig class to another module [\#1962](https://github.com/feast-dev/feast/pull/1962) ([felixwang9817](https://github.com/felixwang9817)) +- Solve package conflict in \[gcp\] and \[ci\] [\#1955](https://github.com/feast-dev/feast/pull/1955) ([ysk24ok](https://github.com/ysk24ok)) +- Remove some paths from unit test cache [\#1944](https://github.com/feast-dev/feast/pull/1944) ([achals](https://github.com/achals)) +- Fix bug in feast alpha enable CLI command [\#1940](https://github.com/feast-dev/feast/pull/1940) ([felixwang9817](https://github.com/felixwang9817)) +- Fix conditional statements for if OnDemandFVs exist [\#1937](https://github.com/feast-dev/feast/pull/1937) ([codyjlin](https://github.com/codyjlin)) +- Fix \_\_getitem\_\_ return value for feature view and on-demand feature view [\#1936](https://github.com/feast-dev/feast/pull/1936) ([mavysavydav](https://github.com/mavysavydav)) +- Corrected setup.py BigQuery version that's needed for a contributor's merged PR 1844 [\#1934](https://github.com/feast-dev/feast/pull/1934) ([mavysavydav](https://github.com/mavysavydav)) + +**Merged pull requests:** + +- Fix protobuf version conflict in \[gcp\] and \[ci\] packages [\#1992](https://github.com/feast-dev/feast/pull/1992) ([ysk24ok](https://github.com/ysk24ok)) +- Improve aws lambda deployment \(logging, idempotency, etc\) [\#1985](https://github.com/feast-dev/feast/pull/1985) ([tsotnet](https://github.com/tsotnet)) +- Extend context for usage statistics collection & add latencies for performance analysis [\#1983](https://github.com/feast-dev/feast/pull/1983) ([pyalex](https://github.com/pyalex)) +- Update CHANGELOG for Feast v0.14.1 [\#1982](https://github.com/feast-dev/feast/pull/1982) ([felixwang9817](https://github.com/felixwang9817)) +- Document AWS Lambda permissions [\#1970](https://github.com/feast-dev/feast/pull/1970) ([tsotnet](https://github.com/tsotnet)) +- Update online store helper docstring [\#1957](https://github.com/feast-dev/feast/pull/1957) ([amommendes](https://github.com/amommendes)) +- Add public docs for entity aliasing [\#1951](https://github.com/feast-dev/feast/pull/1951) ([codyjlin](https://github.com/codyjlin)) +- Updating roadmap + hero image [\#1950](https://github.com/feast-dev/feast/pull/1950) ([adchia](https://github.com/adchia)) +- Add David and Matt as approvers as well [\#1943](https://github.com/feast-dev/feast/pull/1943) ([achals](https://github.com/achals)) +- Add David and Matt as reviewers, and add actions for issue/PR assignment [\#1942](https://github.com/feast-dev/feast/pull/1942) ([achals](https://github.com/achals)) +- Simplify BigQuery load jobs [\#1935](https://github.com/feast-dev/feast/pull/1935) ([judahrand](https://github.com/judahrand)) + ## [v0.14.1](https://github.com/feast-dev/feast/tree/v0.14.1) (2021-10-28) [Full Changelog](https://github.com/feast-dev/feast/compare/v0.14.0...v0.14.1) @@ -339,7 +411,7 @@ **Merged pull requests:** - Use drop\_duplicates\(\) instead of groupby \(about 1.5~2x faster\) [\#1617](https://github.com/feast-dev/feast/pull/1617) ([rightx2](https://github.com/rightx2)) -- Use CONCAT\(\) instead of ROW\_NUMBER\(\) [\#1601](https://github.com/feast-dev/feast/pull/1601) ([MattDelac](https://github.com/MattDelac)) +- Use CONCAT\(\) instead of ROW\_NUMBER\(\) [\#1601](https://github.com/feast-dev/feast/pull/1601) ([MattDelac](https://github.com/MattDelac)) - Minor doc fix in the code snippet: Fix to reference the right instance for the retrieved job instance object [\#1599](https://github.com/feast-dev/feast/pull/1599) ([dmatrix](https://github.com/dmatrix)) - Append nanoseconds to dataset name in test\_historical\_retrival to prevent tests stomping over each other [\#1593](https://github.com/feast-dev/feast/pull/1593) ([achals](https://github.com/achals)) - Make start and end timestamps tz aware in the CLI [\#1590](https://github.com/feast-dev/feast/pull/1590) ([achals](https://github.com/achals)) @@ -874,7 +946,7 @@ - BQ sink produces sample of successful inserts [\#875](https://github.com/feast-dev/feast/pull/875) ([pyalex](https://github.com/pyalex)) - Add Branch and RC Awareness to Version Lint & Fix Semver Regex [\#998](https://github.com/feast-dev/feast/pull/998) ([mrzzy](https://github.com/mrzzy)) -## [v0.6.2](https://github.com/feast-dev/feast/tree/v0.6.2) (2020-08-02) +## [v0.6.2](https://github.com/feast-dev/feast/tree/v0.6.2) (2020-08-02) [Full Changelog](https://github.com/feast-dev/feast/compare/v0.6.1...v0.6.2) **Implemented enhancements:** @@ -1566,4 +1638,4 @@ See [Feast 0.5 Release Issue](https://github.com/feast-dev/feast/issues/527) for - Directory structure changes [\#7](https://github.com/feast-dev/feast/pull/7) ([zhilingc](https://github.com/zhilingc)) - Change register to apply [\#4](https://github.com/feast-dev/feast/pull/4) ([zhilingc](https://github.com/zhilingc)) - Empty response handling in serving api [\#3](https://github.com/feast-dev/feast/pull/3) ([pradithya](https://github.com/pradithya)) -- Proto file fixes [\#1](https://github.com/feast-dev/feast/pull/1) ([pradithya](https://github.com/pradithya)) +- Proto file fixes [\#1](https://github.com/feast-dev/feast/pull/1) ([pradithya](https://github.com/pradithya)) \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7afa98033aa..c7a8e2430f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,7 @@ pip install --upgrade pip 4. Install development dependencies for Feast Python SDK / CLI ```sh -pip install -e "sdk/python[ci]" +pip install -e "sdk/python[dev]" ``` ### Code Style & Linting diff --git a/Makefile b/Makefile index 3ee9b1468cd..fab6861b6f6 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,7 @@ # ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +MVN := mvn -f java/pom.xml ${MAVEN_EXTRA_OPTS} PROTO_TYPE_SUBDIRS = core serving types storage PROTO_SERVICE_SUBDIRS = core serving OS := linux @@ -24,17 +25,17 @@ endif # General -format: format-python format-go +format: format-python format-java format-go -lint: lint-python lint-go +lint: lint-python lint-java lint-go -test: test-python test-go +test: test-python test-java test-go protos: compile-protos-go compile-protos-python compile-protos-docs -build: protos build-docker build-html +build: protos build-java build-docker build-html -install-ci-dependencies: install-python-ci-dependencies install-go-ci-dependencies +install-ci-dependencies: install-python-ci-dependencies install-java-ci-dependencies install-go-ci-dependencies # Python SDK @@ -80,6 +81,32 @@ lint-python: cd ${ROOT_DIR}/sdk/python; python -m flake8 feast/ tests/ cd ${ROOT_DIR}/sdk/python; python -m black --check feast tests +# Java + +install-java-ci-dependencies: + ${MVN} verify clean --fail-never + +format-java: + ${MVN} spotless:apply + +lint-java: + ${MVN} --no-transfer-progress spotless:check + +test-java: + ${MVN} --no-transfer-progress -DskipITs=true test + +test-java-integration: + ${MVN} --no-transfer-progress -Dmaven.javadoc.skip=true -Dgpg.skip -DskipUTs=true clean verify + +test-java-with-coverage: + ${MVN} --no-transfer-progress -DskipITs=true test jacoco:report-aggregate + +build-java: + ${MVN} clean verify + +build-java-no-tests: + ${MVN} --no-transfer-progress -Dmaven.javadoc.skip=true -Dgpg.skip -DskipUTs=true -DskipITs=true -Drevision=${REVISION} clean package + # Go SDK install-go-ci-dependencies: @@ -104,8 +131,10 @@ lint-go: build-push-docker: @$(MAKE) build-docker registry=$(REGISTRY) version=$(VERSION) @$(MAKE) push-ci-docker registry=$(REGISTRY) version=$(VERSION) + @$(MAKE) push-core-docker registry=$(REGISTRY) version=$(VERSION) + @$(MAKE) push-serving-docker registry=$(REGISTRY) version=$(VERSION) -build-docker: build-ci-docker +build-docker: build-ci-docker build-core-docker build-serving-docker push-ci-docker: docker push $(REGISTRY)/feast-ci:$(VERSION) @@ -116,6 +145,18 @@ build-ci-docker: build-local-test-docker: docker build -t feast:local -f infra/docker/tests/Dockerfile . +push-core-docker: + docker push $(REGISTRY)/feast-core:$(VERSION) + +push-serving-docker: + docker push $(REGISTRY)/feast-serving:$(VERSION) + +build-core-docker: + docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY)/feast-core:$(VERSION) -f infra/docker/core/Dockerfile . + +build-serving-docker: + docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY)/feast-serving:$(VERSION) -f infra/docker/serving/Dockerfile . + # Documentation install-dependencies-proto-docs: diff --git a/README.md b/README.md index 91f87b52ed4..42e2553d5ea 100644 --- a/README.md +++ b/README.md @@ -137,31 +137,36 @@ The list below contains the functionality that contributors are planning to deve * [x] [Redshift source](https://docs.feast.dev/reference/data-sources/redshift) * [x] [BigQuery source](https://docs.feast.dev/reference/data-sources/bigquery) * [x] [Parquet file source](https://docs.feast.dev/reference/data-sources/file) - * [ ] Kafka source (Planned for Q4 2021) - * [ ] Synapse source (Planned for Q4 2021) + * [x] [Synapse source (community plugin)](https://github.com/Azure/feast-azure) + * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) + * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) + * [x] Kafka source (with [push support into the online store](reference/alpha-stream-ingestion.md)) * [ ] Snowflake source (Planned for Q4 2021) * [ ] HTTP source * **Offline Stores** * [x] [Redshift](https://docs.feast.dev/reference/offline-stores/redshift) * [x] [BigQuery](https://docs.feast.dev/reference/offline-stores/bigquery) + * [x] [Synapse (community plugin)](https://github.com/Azure/feast-azure) + * [x] [Hive (community plugin)](https://github.com/baineng/feast-hive) + * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) * [x] [In-memory / Pandas](https://docs.feast.dev/reference/offline-stores/file) * [x] [Custom offline store support](https://docs.feast.dev/how-to-guides/adding-a-new-offline-store) - * [x] [Hive (community maintained)](https://github.com/baineng/feast-hive) - * [x] [Postgres (community maintained)](https://github.com/nossrannug/feast-postgres) * [ ] Snowflake (Planned for Q4 2021) - * [ ] Synapse (Planned for Q4 2021) + * [ ] Trino (Planned for Q4 2021) * **Online Stores** * [x] [DynamoDB](https://docs.feast.dev/reference/online-stores/dynamodb) * [x] [Redis](https://docs.feast.dev/reference/online-stores/redis) * [x] [Datastore](https://docs.feast.dev/reference/online-stores/datastore) * [x] [SQLite](https://docs.feast.dev/reference/online-stores/sqlite) + * [x] [Azure Cache for Redis (community plugin)](https://github.com/Azure/feast-azure) + * [x] [Postgres (community plugin)](https://github.com/nossrannug/feast-postgres) * [x] [Custom online store support](https://docs.feast.dev/how-to-guides/adding-support-for-a-new-online-store) - * [x] [Postgres (community maintained)](https://github.com/nossrannug/feast-postgres) * [ ] Bigtable - * [ ] Cassandra\\ + * [ ] Cassandra * **Streaming** * [x] [Custom streaming ingestion job support](https://docs.feast.dev/how-to-guides/creating-a-custom-provider) - * [ ] Streaming ingestion on AWS (Planned for Q4 2021) + * [x] [Push based streaming data ingestion](reference/alpha-stream-ingestion.md) + * [ ] Streaming ingestion on AWS * [ ] Streaming ingestion on GCP * **Feature Engineering** * [x] On-demand Transformations (Alpha release. See [RFC](https://docs.google.com/document/d/1lgfIw0Drc65LpaxbUu49RCeJgMew547meSJttnUqz7c/edit#)) @@ -171,14 +176,14 @@ The list below contains the functionality that contributors are planning to deve * [x] AWS Lambda (Alpha release. See [RFC](https://docs.google.com/document/d/1eZWKWzfBif66LDN32IajpaG-j82LSHCCOzY6R7Ax7MI/edit)) * [ ] Cloud Run * [ ] Kubernetes - * [ ] KNative\\ + * [ ] KNative * **Feature Serving** * [x] Python Client * [x] REST Feature Server (Python) (Alpha release. See [RFC](https://docs.google.com/document/d/1iXvFhAsJ5jgAhPOpTdB3j-Wj1S9x3Ev\_Wr6ZpnLzER4/edit)) * [x] gRPC Feature Server (Java) (See [#1497](https://github.com/feast-dev/feast/issues/1497)) + * [x] Push API * [ ] Java Client * [ ] Go Client - * [ ] Push API * [ ] Delete API * [ ] Feature Logging (for training) * **Data Quality Management** diff --git a/infra/scripts/publish-java-sdk.sh b/infra/scripts/publish-java-sdk.sh index 91123c8d4ee..ed00799e84a 100755 --- a/infra/scripts/publish-java-sdk.sh +++ b/infra/scripts/publish-java-sdk.sh @@ -56,7 +56,7 @@ if [ -z $REVISION ]; then usage; exit 1; fi echo "============================================================" echo "Checking Maven and GPG versions" echo "============================================================" -mvn --version +mvn -f java/pom.xml --version echo "" gpg --version @@ -69,4 +69,4 @@ gpg --import --batch --yes $GPG_KEY_IMPORT_DIR/private-key echo "============================================================" echo "Deploying Java SDK with revision: $REVISION" echo "============================================================" -mvn --projects datatypes/java,sdk/java -Drevision=$REVISION --batch-mode clean deploy +mvn -f java/pom.xml --projects datatypes/java,sdk/java -Drevision=$REVISION --batch-mode clean deploy diff --git a/infra/scripts/test-core-ingestion.sh b/infra/scripts/test-java-core-ingestion.sh similarity index 82% rename from infra/scripts/test-core-ingestion.sh rename to infra/scripts/test-java-core-ingestion.sh index d3b42926d83..a812edc6166 100755 --- a/infra/scripts/test-core-ingestion.sh +++ b/infra/scripts/test-java-core-ingestion.sh @@ -11,9 +11,9 @@ infra/scripts/download-maven-cache.sh \ # Core depends on Ingestion so they are tested together # Skip Maven enforcer: https://stackoverflow.com/questions/50647223/maven-enforcer-issue-when-running-from-reactor-level -mvn --projects core,ingestion --batch-mode --define skipTests=true \ +mvn -f java/pom.xml --projects core,ingestion --batch-mode --define skipTests=true \ --define enforcer.skip=true clean install -mvn --projects core,ingestion --define enforcer.skip=true test +mvn -f java/pom.xml --projects core,ingestion --define enforcer.skip=true test TEST_EXIT_CODE=$? # Default artifact location setting in Prow jobs diff --git a/infra/scripts/test-java-sdk.sh b/infra/scripts/test-java-sdk.sh index 0731b77976f..58dc5b1a832 100755 --- a/infra/scripts/test-java-sdk.sh +++ b/infra/scripts/test-java-sdk.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # Skip Maven enforcer: https://stackoverflow.com/questions/50647223/maven-enforcer-issue-when-running-from-reactor-level -mvn --projects sdk/java --batch-mode --define skipTests=true \ +mvn -f java/pom.xml --projects sdk/java --batch-mode --define skipTests=true \ --define enforcer.skip=true clean install -mvn --projects sdk/java --define enforcer.skip=true test +mvn -f java/pom.xml --projects sdk/java --define enforcer.skip=true test TEST_EXIT_CODE=$? # Default artifact location setting in Prow jobs LOGS_ARTIFACT_PATH=/logs/artifacts -cp -r sdk/java/target/surefire-reports ${LOGS_ARTIFACT_PATH}/surefire-reports +cp -r java/sdk/java/target/surefire-reports ${LOGS_ARTIFACT_PATH}/surefire-reports exit ${TEST_EXIT_CODE} \ No newline at end of file diff --git a/infra/scripts/test-serving.sh b/infra/scripts/test-java-serving.sh similarity index 83% rename from infra/scripts/test-serving.sh rename to infra/scripts/test-java-serving.sh index ce9dc0a8162..755722e7a82 100755 --- a/infra/scripts/test-serving.sh +++ b/infra/scripts/test-java-serving.sh @@ -4,7 +4,7 @@ infra/scripts/download-maven-cache.sh \ --archive-uri gs://feast-templocation-kf-feast/.m2.2019-10-24.tar \ --output-dir /root/ -mvn --batch-mode --also-make --projects serving test +mvn -f java/pom.xml --batch-mode --also-make --projects serving test TEST_EXIT_CODE=$? # Default artifact location setting in Prow jobs diff --git a/java/.gitignore b/java/.gitignore new file mode 100644 index 00000000000..0d767e93ae7 --- /dev/null +++ b/java/.gitignore @@ -0,0 +1,191 @@ +### Scratch files ### +scratch* + +### Feast UI deprecated folder #### +ui/ + +### Local Environment ### +*local*.env + +### Secret ### +**/service_account.json + +### Gradle ### +**/.gradle +!gradle/wrapper/gradle-wrapper.jar + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +## Build Files ## +**/temp/ +**/build/ +core/target/ +**/bin/ +dependency-reduced-pom.xml + +# govendor +vendor + +## direnv +.envrc +.direnv + +.terraform/ +*.tfvars + +# python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.prof + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +classes/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +.vscode + +# .flattened-pom.xml is generated by flatten-maven-plugin. +# This pom should not be committed because it is only used during release / deployment. +.flattened-pom.xml + +sdk/python/docs/html + +# Generated python code +*_pb2.py +*_pb2.pyi +*_pb2_grpc.py + +# VSCode +.bloop +.metals +*.code-workspace diff --git a/java/CONTRIBUTING.md b/java/CONTRIBUTING.md new file mode 100644 index 00000000000..1694b3f33f9 --- /dev/null +++ b/java/CONTRIBUTING.md @@ -0,0 +1,162 @@ +# Development Guide: feast-java +> The higher level [Development Guide](https://docs.feast.dev/contributing/development-guide) +> gives contributing to Feast codebase as a whole. + +### Overview +This guide is targeted at developers looking to contribute to Feast components in +the feast-java Repository: +- [Feast Core](#feast-core) +- [Feast Serving](#feast-serving) +- [Feast Java Client](#feast-java-client) + +> Don't see the Feast component that you want to contribute to here? +> Check out the [Development Guide](https://docs.feast.dev/contributing/development-guide) +> to learn how Feast components are distributed over multiple repositories. + +#### Common Setup +Common Environment Setup for all feast-java Feast components: +1. . Ensure following development tools are installed: +- Java SE Development Kit 11, Maven 3.6, `make` + +#### Code Style +feast-java's codebase conforms to the [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html). + +Automatically format the code to conform the style guide by: + +```sh +# formats all code in the feast-java repository +mvn spotless:apply +``` + +> If you're using IntelliJ, you can import these [code style settings](https://github.com/google/styleguide/blob/gh-pages/intellij-java-google-style.xml) +> if you'd like to use the IDE's reformat function. + +#### Project Makefile +The Project Makefile provides useful shorthands for common development tasks: + + +Run all Unit tests: +``` +make test-java +``` + +Run all Integration tests: +``` +make test-java-integration +``` + +Building Docker images for Feast Core & Feast Serving: +``` +make build-docker REGISTRY=gcr.io/kf-feast VERSION=develop +``` + + +#### IDE Setup +If you're using IntelliJ, some additional steps may be needed to make sure IntelliJ autocomplete works as expected. +Specifically, proto-generated code is not indexed by IntelliJ. To fix this, navigate to the following window in IntelliJ: +`Project Structure > Modules > datatypes-java`, and mark the following folders as `Source` directorys: +- target/generated-sources/protobuf/grpc-java +- target/generated-sources/protobuf/java +- target/generated-sources/annotations + + +## Feast Core +### Environment Setup +Setting up your development environment for Feast Core: +1. Complete the feast-java [Common Setup](#common-setup) +2. Boot up a PostgreSQL instance (version 11 and above). Example of doing so via Docker: +```sh +# spawn a PostgreSQL instance as a Docker container running in the background +docker run \ + --rm -it -d \ + --name postgres \ + -e POSTGRES_DB=postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=password \ + -p 5432:5432 postgres:12-alpine +``` + +### Configuration +Feast Core is configured using it's [application.yml](https://docs.feast.dev/reference/configuration-reference#1-feast-core-and-feast-online-serving). + +### Building and Running +1. Build / Compile Feast Core with Maven to produce an executable Feast Core JAR +```sh +mvn package -pl core --also-make -Dmaven.test.skip=true +``` + +2. Run Feast Core using the built JAR: +```sh +# where X.X.X is the version of the Feast Core JAR built +java -jar core/target/feast-core-X.X.X-exec.jar +``` + +### Unit / Integration Tests +Unit & Integration Tests can be used to verify functionality: +```sh +# run unit tests +mvn test -pl core --also-make +# run integration tests +mvn verify -pl core --also-make +``` + +## Feast Serving +### Environment Setup +Setting up your development environment for Feast Serving: +1. Complete the feast-java [Common Setup](#common-setup) +2. Boot up a Redis instance (version 5.x). Example of doing so via Docker: +```sh +docker run --name redis --rm -it -d -p 6379:6379 redis:5-alpine +``` + +> Feast Serving requires a running Feast Core instance to retrieve Feature metadata +> in order to serve features. See the [Feast Core section](#feast-core) for +> how to get a Feast Core instance running. + +### Configuration +Feast Serving is configured using it's [application.yml](https://docs.feast.dev/reference/configuration-reference#1-feast-core-and-feast-online-serving). + +### Building and Running +1. Build / Compile Feast Serving with Maven to produce an executable Feast Serving JAR +```sh +mvn package -pl serving --also-make -Dmaven.test.skip=true + +2. Run Feast Serving using the built JAR: +```sh +# where X.X.X is the version of the Feast serving JAR built +java -jar serving/target/feast-serving-X.X.X-exec.jar +``` + +### Unit / Integration Tests +Unit & Integration Tests can be used to verify functionality: +```sh +# run unit tests +mvn test -pl serving --also-make +# run integration tests +mvn verify -pl serving --also-make +``` + +## Feast Java Client +### Environment Setup +Setting up your development environment for Feast Java SDK: +1. Complete the feast-java [Common Setup](#common-setup) + +> Feast Java Client is a Java Client for retrieving Features from a running Feast Serving instance. +> See the [Feast Serving Section](#feast-serving) section for how to get a Feast Serving instance running. + +### Configuration +Feast Java Client is [configured as code](https://docs.feast.dev/v/master/reference/configuration-reference#4-feast-java-and-go-sdk) + +### Building +1. Build / Compile Feast Java Client with Maven: + +```sh +mvn package -pl sdk/java --also-make -Dmaven.test.skip=true +``` + +### Unit Tests +Unit Tests can be used to verify functionality: + +```sh +mvn package -pl sdk/java test --also-make +``` diff --git a/java/Makefile b/java/Makefile new file mode 100644 index 00000000000..1bc46736e76 --- /dev/null +++ b/java/Makefile @@ -0,0 +1,77 @@ +# +# Copyright 2019 The Feast Authors +# +# Licensed 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 +# +# https://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. +# + +ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +MVN := mvn ${MAVEN_EXTRA_OPTS} + +# General + +format: format-java + +lint: lint-java + +test: test-java + +build: build-java build-docker + +install-ci-dependencies: install-java-ci-dependencies + +# Java + +install-java-ci-dependencies: + ${MVN} verify clean --fail-never + +format-java: + ${MVN} spotless:apply + +lint-java: + ${MVN} --no-transfer-progress spotless:check + +test-java: + ${MVN} --no-transfer-progress -DskipITs=true test + +test-java-integration: + ${MVN} --no-transfer-progress -Dmaven.javadoc.skip=true -Dgpg.skip -DskipUTs=true clean verify + +test-java-with-coverage: + ${MVN} --no-transfer-progress -DskipITs=true test jacoco:report-aggregate + +build-java: + ${MVN} clean verify + +build-java-no-tests: + ${MVN} --no-transfer-progress -Dmaven.javadoc.skip=true -Dgpg.skip -DskipUTs=true -DskipITs=true -Drevision=${REVISION} clean package + +# Docker + +build-push-docker: + @$(MAKE) build-docker registry=$(REGISTRY) version=$(VERSION) + @$(MAKE) push-core-docker registry=$(REGISTRY) version=$(VERSION) + @$(MAKE) push-serving-docker registry=$(REGISTRY) version=$(VERSION) + +build-docker: build-core-docker build-serving-docker + +push-core-docker: + docker push $(REGISTRY)/feast-core:$(VERSION) + +push-serving-docker: + docker push $(REGISTRY)/feast-serving:$(VERSION) + +build-core-docker: + docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY)/feast-core:$(VERSION) -f infra/docker/core/Dockerfile . + +build-serving-docker: + docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY)/feast-serving:$(VERSION) -f infra/docker/serving/Dockerfile . diff --git a/java/README.md b/java/README.md new file mode 100644 index 00000000000..8d6141faa84 --- /dev/null +++ b/java/README.md @@ -0,0 +1,22 @@ +# Feast Java components +[![complete](https://github.com/feast-dev/feast-java/actions/workflows/complete.yml/badge.svg)](https://github.com/feast-dev/feast-java/actions/workflows/complete.yml) + +### Overview + +This repository contains the following Feast components. +* Feast Serving: A service used to serve the latest feature values to models. +* Feast Java SDK: A client used to retrieve features from Feast Serving. + +### Architecture + +Feast Serving has a dependency on an online store (Redis) for retrieving features. +The process of ingesting data into the online store (Redis) is decoupled from the process of reading from it. + +### Contributing +Guides on Contributing: +- [Contribution Process for Feast](https://docs.feast.dev/v/master/contributing/contributing) +- [Development Guide for Feast](https://docs.feast.dev/contributing/development-guide) +- [Development Guide for feast-java (this repository)](CONTRIBUTING.md) + +### Installing using Helm +Please see the Helm charts in [charts](https://github.com/feast-dev/feast-helm-charts). diff --git a/java/common/pom.xml b/java/common/pom.xml new file mode 100644 index 00000000000..0c5651876ea --- /dev/null +++ b/java/common/pom.xml @@ -0,0 +1,211 @@ + + + + 4.0.0 + + + feast-parent + dev.feast + ${revision} + + + Feast Common + Feast common module with functionality that can be reused + feast-common + + + + dev.feast + datatypes-java + ${project.version} + compile + + + com.google.protobuf + protobuf-java-util + + + + org.apache.commons + commons-lang3 + 3.6 + + + + + org.projectlombok + lombok + + + com.google.auto.value + auto-value-annotations + + + + + com.google.code.gson + gson + + + io.gsonfire + gson-fire + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + + org.springframework + spring-context-support + + + net.devh + grpc-server-spring-boot-starter + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.hibernate.validator + hibernate-validator + 6.1.5.Final + + + + + org.springframework.security + spring-security-core + + + org.springframework.security + spring-security-config + + + org.springframework.security + spring-security-oauth2-resource-server + + + org.springframework.security + spring-security-oauth2-jose + + + + + org.slf4j + slf4j-api + + + org.fluentd + fluent-logger + 0.3.1 + + + + + javax.xml.bind + jaxb-api + + + javax.validation + validation-api + + + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + + + org.hamcrest + hamcrest-library + test + + + + junit + junit + 4.13.2 + + + org.springframework + spring-test + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + + org.jacoco + jacoco-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M4 + + -Xms2048m -Xmx2048m -Djdk.net.URLClassPath.disableClassPathURLCheck=true + + + + + diff --git a/java/common/src/main/java/feast/common/logging/AuditLogger.java b/java/common/src/main/java/feast/common/logging/AuditLogger.java new file mode 100644 index 00000000000..5f70fbfc97b --- /dev/null +++ b/java/common/src/main/java/feast/common/logging/AuditLogger.java @@ -0,0 +1,182 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; +import feast.common.logging.config.LoggingProperties; +import feast.common.logging.config.LoggingProperties.AuditLogProperties; +import feast.common.logging.entry.*; +import feast.common.logging.entry.LogResource.ResourceType; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.fluentd.logger.FluentLogger; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; +import org.slf4j.event.Level; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.info.BuildProperties; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class AuditLogger { + private static final String FLUENTD_DESTINATION = "fluentd"; + private static final Marker AUDIT_MARKER = MarkerFactory.getMarker("AUDIT_MARK"); + private static FluentLogger fluentLogger; + private static AuditLogProperties properties; + private static BuildProperties buildProperties; + + @Autowired + public AuditLogger(LoggingProperties loggingProperties, BuildProperties buildProperties) { + // Spring runs this constructor when creating the AuditLogger bean, + // which allows us to populate the AuditLogger class with dependencies. + // This allows us to use the dependencies in the AuditLogger's static methods + AuditLogger.properties = loggingProperties.getAudit(); + AuditLogger.buildProperties = buildProperties; + if (AuditLogger.properties.getMessageLogging() != null + && AuditLogger.properties.getMessageLogging().isEnabled()) { + AuditLogger.fluentLogger = + FluentLogger.getLogger( + "feast", + AuditLogger.properties.getMessageLogging().getFluentdHost(), + AuditLogger.properties.getMessageLogging().getFluentdPort()); + } + } + + /** + * Log the handling of a Protobuf message by a service call. + * + * @param level log level + * @param entryBuilder with all fields set except instance. + */ + public static void logMessage(Level level, MessageAuditLogEntry.Builder entryBuilder) { + log( + level, + entryBuilder + .setComponent(buildProperties.getArtifact()) + .setVersion(buildProperties.getVersion()) + .build()); + } + + /** + * Log an action being taken on a specific resource + * + * @param level describing the severity of the log. + * @param action name of the action being taken on specific resource. + * @param resourceType the type of resource being logged. + * @param resourceId resource specific identifier identifing the instance of the resource. + */ + public static void logAction( + Level level, String action, ResourceType resourceType, String resourceId) { + log( + level, + ActionAuditLogEntry.of( + buildProperties.getArtifact(), + buildProperties.getArtifact(), + LogResource.of(resourceType, resourceId), + action)); + } + + /** + * Log a transition in state/status in a specific resource. + * + * @param level describing the severity of the log. + * @param status name of end status which the resource transition to. + * @param resourceType the type of resource being logged. + * @param resourceId resource specific identifier identifing the instance of the resource. + */ + public static void logTransition( + Level level, String status, ResourceType resourceType, String resourceId) { + log( + level, + TransitionAuditLogEntry.of( + buildProperties.getArtifact(), + buildProperties.getArtifact(), + LogResource.of(resourceType, resourceId), + status)); + } + + /** + * Log given {@link AuditLogEntry} at the given logging {@link Level} to the Audit log. + * + * @param level describing the severity of the log. + * @param entry the {@link AuditLogEntry} to push to the audit log. + */ + private static void log(Level level, AuditLogEntry entry) { + // Check if audit logging is of this specific log entry enabled. + if (!properties.isEnabled()) { + return; + } + + // Either forward log to logging layer or log to console + String destination = properties.getMessageLogging().getDestination(); + if (destination.equals(FLUENTD_DESTINATION)) { + if (entry.getKind() == AuditLogEntryKind.MESSAGE) { + Map fluentdLogs = new HashMap<>(); + MessageAuditLogEntry messageAuditLogEntry = (MessageAuditLogEntry) entry; + String releaseName; + + try { + releaseName = + StringUtils.defaultIfEmpty( + System.getenv("RELEASE_NAME"), InetAddress.getLocalHost().getHostAddress()); + } catch (UnknownHostException e) { + releaseName = StringUtils.defaultIfEmpty(System.getenv("RELEASE_NAME"), ""); + } + + fluentdLogs.put("id", messageAuditLogEntry.getId()); + fluentdLogs.put("identity", messageAuditLogEntry.getIdentity()); + fluentdLogs.put("service", messageAuditLogEntry.getService()); + fluentdLogs.put("status_code", messageAuditLogEntry.getStatusCode()); + fluentdLogs.put("method", messageAuditLogEntry.getMethod()); + fluentdLogs.put("release_name", releaseName); + try { + fluentdLogs.put("request", JsonFormat.printer().print(messageAuditLogEntry.getRequest())); + fluentdLogs.put( + "response", JsonFormat.printer().print(messageAuditLogEntry.getResponse())); + } catch (InvalidProtocolBufferException e) { + } + fluentLogger.log("fluentd", fluentdLogs); + } + } else { + // Log event to audit log through enabled formats + String entryJSON = entry.toJSON(); + switch (level) { + case TRACE: + log.trace(AUDIT_MARKER, entryJSON); + break; + case DEBUG: + log.debug(AUDIT_MARKER, entryJSON); + break; + case INFO: + log.info(AUDIT_MARKER, entryJSON); + break; + case WARN: + log.warn(AUDIT_MARKER, entryJSON); + break; + case ERROR: + log.error(AUDIT_MARKER, entryJSON); + break; + } + } + } +} diff --git a/java/common/src/main/java/feast/common/logging/config/LoggingProperties.java b/java/common/src/main/java/feast/common/logging/config/LoggingProperties.java new file mode 100644 index 00000000000..06e62f71af3 --- /dev/null +++ b/java/common/src/main/java/feast/common/logging/config/LoggingProperties.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging.config; + +import feast.common.validators.OneOfStrings; +import javax.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LoggingProperties { + @NotNull private AuditLogProperties audit; + + @Getter + @Setter + public static class AuditLogProperties { + // Whether to enable/disable audit logging entirely. + private boolean enabled; + + private MessageLogging messageLogging; + + @Getter + @Setter + public static class MessageLogging { + // Whether to enable/disable message level (ie request/response) audit logging. + private boolean enabled; + + // Whether to log to console or fluentd + @OneOfStrings({"console", "fluentd"}) + private String destination; + + // fluentD service host for external (request/response) logging. + private String fluentdHost; + + // fluentD service port for external (request/response) logging. + private Integer fluentdPort; + } + } +} diff --git a/java/common/src/main/java/feast/common/logging/entry/ActionAuditLogEntry.java b/java/common/src/main/java/feast/common/logging/entry/ActionAuditLogEntry.java new file mode 100644 index 00000000000..4fdeaee32a4 --- /dev/null +++ b/java/common/src/main/java/feast/common/logging/entry/ActionAuditLogEntry.java @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging.entry; + +import com.google.auto.value.AutoValue; + +/** ActionAuditLogEntry records an action being taken on a specific resource */ +@AutoValue +public abstract class ActionAuditLogEntry extends AuditLogEntry { + /** @return The name of the action taken on the resource. */ + public abstract String getAction(); + + /** @return The target resource of which the action was taken on. */ + public abstract LogResource getResource(); + + /** + * Create an {@link AuditLogEntry} that records an action being taken on a specific resource. + * + * @param component The name of th Feast component producing this {@link AuditLogEntry}. + * @param version The version of Feast producing this {@link AuditLogEntry}. + * @param resource The target resource of which the action was taken on. + * @param action The name of the action being taken on the given resource. + * @return log entry that records an action being taken on a specific resource + */ + public static ActionAuditLogEntry of( + String component, String version, LogResource resource, String action) { + return new AutoValue_ActionAuditLogEntry( + component, version, AuditLogEntryKind.ACTION, action, resource); + } +} diff --git a/java/common/src/main/java/feast/common/logging/entry/AuditLogEntry.java b/java/common/src/main/java/feast/common/logging/entry/AuditLogEntry.java new file mode 100644 index 00000000000..8148c474b0e --- /dev/null +++ b/java/common/src/main/java/feast/common/logging/entry/AuditLogEntry.java @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging.entry; + +import com.google.gson.Gson; + +/** + * AuditLogEntry represents a single audit Log Entry. Audit log entry can converted into string with + * {{@link #toString()} for human readable representation. Or structured JSON with {{@link + * #toJSON()} for a machine parsable representation. + */ +public abstract class AuditLogEntry { + /** Declare Log Type to allow external Logging systems to filter out {@link AuditLogEntry} */ + public final String logType = "FeastAuditLogEntry"; + + public final String application = "Feast"; + + /** + * The name of the Feast component producing this {@link AuditLogEntry} + * + * @return the component + */ + public abstract String getComponent(); + + /** + * The version of Feast producing this {@link AuditLogEntry} + * + * @return version + */ + public abstract String getVersion(); + + public abstract AuditLogEntryKind getKind(); + + /** + * Return a structured JSON representation of this {@link AuditLogEntry} + * + * @return structured JSON representation + */ + public String toJSON() { + Gson gson = new Gson(); + return gson.toJson(this); + } +} diff --git a/java/common/src/main/java/feast/common/logging/entry/AuditLogEntryKind.java b/java/common/src/main/java/feast/common/logging/entry/AuditLogEntryKind.java new file mode 100644 index 00000000000..d673f6bdb30 --- /dev/null +++ b/java/common/src/main/java/feast/common/logging/entry/AuditLogEntryKind.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging.entry; + +/** AuditLogEntryKind lists the various kinds of {@link AuditLogEntry} */ +public enum AuditLogEntryKind { + MESSAGE, + ACTION, + TRANSITION, +} diff --git a/java/common/src/main/java/feast/common/logging/entry/LogResource.java b/java/common/src/main/java/feast/common/logging/entry/LogResource.java new file mode 100644 index 00000000000..1d0345a4042 --- /dev/null +++ b/java/common/src/main/java/feast/common/logging/entry/LogResource.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging.entry; + +import com.google.auto.value.AutoValue; + +@AutoValue +/** + * LogResource is used in {@link AuditLogEntry} to reference a specific resource as the subject of + * the log + */ +public abstract class LogResource { + public enum ResourceType { + JOB, + FEATURE_TABLE + } + + public abstract ResourceType getType(); + + public abstract String getId(); + + public static LogResource of(ResourceType type, String id) { + return new AutoValue_LogResource(type, id); + } +} diff --git a/java/common/src/main/java/feast/common/logging/entry/MessageAuditLogEntry.java b/java/common/src/main/java/feast/common/logging/entry/MessageAuditLogEntry.java new file mode 100644 index 00000000000..6e5072f66ef --- /dev/null +++ b/java/common/src/main/java/feast/common/logging/entry/MessageAuditLogEntry.java @@ -0,0 +1,123 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging.entry; + +import com.google.auto.value.AutoValue; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.protobuf.Empty; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import io.grpc.Status.Code; +import java.lang.reflect.Type; +import java.util.UUID; + +/** MessageAuditLogEntry records the handling of a Protobuf message by a service call. */ +@AutoValue +public abstract class MessageAuditLogEntry extends AuditLogEntry { + /** @return Id used to identify the service call that the log entry is recording */ + public abstract UUID getId(); + + /** @return The name of the service that was used to handle the service call. */ + public abstract String getService(); + + /** @return The name of the method that was used to handle the service call. */ + public abstract String getMethod(); + + /** + * @return The request Protobuf {@link Message} that was passed to the Service in the service + * call. + */ + public abstract Message getRequest(); + + /** + * @return The response Protobuf {@link Message} that was passed to the Service in the service + * call. May be an {@link Empty} protobuf no request could be collected due to an error. + */ + public abstract Message getResponse(); + + /** + * @return The authenticated identity that was assumed during the handling of the service call. + * For example, the user id or email that identifies the user making the call. Empty if the + * service call is not authenticated. + */ + public abstract String getIdentity(); + + /** @return The result status code of the service call. */ + public abstract Code getStatusCode(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setId(UUID id); + + public abstract Builder setComponent(String component); + + public abstract Builder setVersion(String component); + + public abstract Builder setKind(AuditLogEntryKind kind); + + public abstract Builder setService(String name); + + public abstract Builder setMethod(String name); + + public abstract Builder setRequest(Message request); + + public abstract Builder setResponse(Message response); + + public abstract Builder setIdentity(String identity); + + public abstract Builder setStatusCode(Code statusCode); + + public abstract MessageAuditLogEntry build(); + } + + public static MessageAuditLogEntry.Builder newBuilder() { + return new AutoValue_MessageAuditLogEntry.Builder() + .setKind(AuditLogEntryKind.MESSAGE) + .setId(UUID.randomUUID()); + } + + @Override + public String toJSON() { + // GSON requires custom typeadapter (serializer) to convert Protobuf messages to JSON properly + Gson gson = + new GsonBuilder() + .registerTypeAdapter( + Message.class, + new JsonSerializer() { + @Override + public JsonElement serialize( + Message message, Type type, JsonSerializationContext context) { + try { + String messageJSON = JsonFormat.printer().print(message); + return new JsonParser().parse(messageJSON); + } catch (InvalidProtocolBufferException e) { + + throw new RuntimeException( + "Unexpected exception converting Protobuf to JSON", e); + } + } + }) + .create(); + return gson.toJson(this); + } +} diff --git a/java/common/src/main/java/feast/common/logging/entry/TransitionAuditLogEntry.java b/java/common/src/main/java/feast/common/logging/entry/TransitionAuditLogEntry.java new file mode 100644 index 00000000000..224f10e0b5f --- /dev/null +++ b/java/common/src/main/java/feast/common/logging/entry/TransitionAuditLogEntry.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging.entry; + +import com.google.auto.value.AutoValue; + +/** TransitionAuditLogEntry records a transition in state/status in a specific resource. */ +@AutoValue +public abstract class TransitionAuditLogEntry extends AuditLogEntry { + /** @return The resource which the state/status transition occured. */ + public abstract LogResource getResource(); + + /** @return The end status with the resource transition to. */ + public abstract String getStatus(); + + /** + * Construct a new {@link AuditLogEntry} to record a transition in state/status in a specific + * resource. + * + * @param component The name of th Feast component producing this {@link AuditLogEntry}. + * @param version The version of Feast producing this {@link AuditLogEntry}. + * @param resource the resource which the transtion occured + * @param status the end status which the resource transitioned to. + * @return log entry to record a transition in state/status in a specific resource + */ + public static TransitionAuditLogEntry of( + String component, String version, LogResource resource, String status) { + return new AutoValue_TransitionAuditLogEntry( + component, version, AuditLogEntryKind.TRANSITION, resource, status); + } +} diff --git a/java/common/src/main/java/feast/common/logging/interceptors/GrpcMessageInterceptor.java b/java/common/src/main/java/feast/common/logging/interceptors/GrpcMessageInterceptor.java new file mode 100644 index 00000000000..ffd7c6b9543 --- /dev/null +++ b/java/common/src/main/java/feast/common/logging/interceptors/GrpcMessageInterceptor.java @@ -0,0 +1,127 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging.interceptors; + +import com.google.protobuf.Empty; +import com.google.protobuf.Message; +import feast.common.logging.AuditLogger; +import feast.common.logging.config.LoggingProperties; +import feast.common.logging.entry.MessageAuditLogEntry; +import io.grpc.ForwardingServerCall.SimpleForwardingServerCall; +import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCall.Listener; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import org.slf4j.event.Level; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +/** + * GrpcMessageInterceptor intercepts a GRPC calls to log handling of GRPC messages to the Audit Log. + * Intercepts the incoming and outgoing messages logs them to the audit log, together with method + * name and assumed authenticated identity (if authentication is enabled). NOTE: + * GrpcMessageInterceptor assumes that all service calls are unary (ie single request/response). + */ +@Component +public class GrpcMessageInterceptor implements ServerInterceptor { + private LoggingProperties loggingProperties; + + /** + * Construct GrpcMessageIntercetor. + * + * @param loggingProperties properties used to configure logging interceptor. + */ + @Autowired + public GrpcMessageInterceptor(LoggingProperties loggingProperties) { + this.loggingProperties = loggingProperties; + } + + @Override + public Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + // Disable the message logging interceptor entirely if message logging is disabled. + if (!loggingProperties.getAudit().getMessageLogging().isEnabled()) { + return next.startCall(call, headers); + } + + MessageAuditLogEntry.Builder entryBuilder = MessageAuditLogEntry.newBuilder(); + // default response/request message to empty proto in log entry. + // request could be empty when the client closes the connection before sending a request + // message. + // response could be unset when the service encounters an error when processsing the service + // call. + entryBuilder.setRequest(Empty.newBuilder().build()); + entryBuilder.setResponse(Empty.newBuilder().build()); + + // Unpack service & method name from call + // full method name is in format ./ + String fullMethodName = call.getMethodDescriptor().getFullMethodName(); + entryBuilder.setService( + fullMethodName.substring(fullMethodName.lastIndexOf(".") + 1, fullMethodName.indexOf("/"))); + entryBuilder.setMethod(fullMethodName.substring(fullMethodName.indexOf("/") + 1)); + + // Attempt Extract current authenticated identity. + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String identity = (authentication != null) ? getIdentity(authentication) : ""; + entryBuilder.setIdentity(identity); + + // Register forwarding call to intercept outgoing response and log to audit log + call = + new SimpleForwardingServerCall(call) { + @Override + public void sendMessage(RespT message) { + // 2. Track the response & Log entry to audit logger + super.sendMessage(message); + entryBuilder.setResponse((Message) message); + } + + @Override + public void close(Status status, Metadata trailers) { + super.close(status, trailers); + // 3. Log the message log entry to the audit log + Level logLevel = (status.isOk()) ? Level.INFO : Level.ERROR; + entryBuilder.setStatusCode(status.getCode()); + AuditLogger.logMessage(logLevel, entryBuilder); + } + }; + + ServerCall.Listener listener = next.startCall(call, headers); + return new SimpleForwardingServerCallListener(listener) { + @Override + // Register listener to intercept incoming request messages and log to audit log + public void onMessage(ReqT message) { + super.onMessage(message); + // 1. Track the request. + entryBuilder.setRequest((Message) message); + } + }; + } + + /** + * Extract current authenticated identity from given {@link Authentication}. Extracts subject + * claim if specified in AuthorizationProperties, otherwise returns authentication subject. + */ + private String getIdentity(Authentication authentication) { + // use subject claim as identity if set in security authorization properties + return authentication.getName(); + } +} diff --git a/java/common/src/main/java/feast/common/models/FeatureTable.java b/java/common/src/main/java/feast/common/models/FeatureTable.java new file mode 100644 index 00000000000..88fac151ce7 --- /dev/null +++ b/java/common/src/main/java/feast/common/models/FeatureTable.java @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.models; + +import feast.proto.core.FeatureTableProto.FeatureTableSpec; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; + +public class FeatureTable { + + /** + * Accepts FeatureTableSpec object and returns its reference in String + * "project/featuretable_name". + * + * @param project project name + * @param featureTableSpec {@link FeatureTableSpec} + * @return String format of FeatureTableReference + */ + public static String getFeatureTableStringRef(String project, FeatureTableSpec featureTableSpec) { + return String.format("%s/%s", project, featureTableSpec.getName()); + } + + /** + * Accepts FeatureReferenceV2 object and returns its reference in String + * "project/featuretable_name". + * + * @param project project name + * @param featureReference {@link FeatureReferenceV2} + * @return String format of FeatureTableReference + */ + public static String getFeatureTableStringRef( + String project, FeatureReferenceV2 featureReference) { + return String.format("%s/%s", project, featureReference.getFeatureTable()); + } +} diff --git a/java/common/src/main/java/feast/common/models/FeatureV2.java b/java/common/src/main/java/feast/common/models/FeatureV2.java new file mode 100644 index 00000000000..8420cca80c6 --- /dev/null +++ b/java/common/src/main/java/feast/common/models/FeatureV2.java @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.models; + +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; + +public class FeatureV2 { + + /** + * Accepts FeatureReferenceV2 object and returns its reference in String + * "featuretable_name:feature_name". + * + * @param featureReference {@link FeatureReferenceV2} + * @return String format of FeatureReferenceV2 + */ + public static String getFeatureStringRef(FeatureReferenceV2 featureReference) { + String ref = featureReference.getName(); + if (!featureReference.getFeatureTable().isEmpty()) { + ref = featureReference.getFeatureTable() + ":" + ref; + } + return ref; + } + + /** + * Accepts either a feature reference of the form "featuretable_name:feature_name" or just a + * feature name, and returns just the feature name. For example, given either + * "driver_hourly_stats:conv_rate" or "conv_rate", "conv_rate" would be returned. + * + * @param featureReference {String} + * @return Base feature name of the feature reference + */ + public static String getFeatureName(String featureReference) { + String[] tokens = featureReference.split(":", 2); + return tokens[tokens.length - 1]; + } +} diff --git a/java/common/src/main/java/feast/common/validators/OneOfStringValidator.java b/java/common/src/main/java/feast/common/validators/OneOfStringValidator.java new file mode 100644 index 00000000000..924953a2c47 --- /dev/null +++ b/java/common/src/main/java/feast/common/validators/OneOfStringValidator.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.validators; + +import java.util.Arrays; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +/** Validates whether a string value is found within a collection. */ +public class OneOfStringValidator implements ConstraintValidator { + + /** Values that are permitted for a specific instance of this validator */ + String[] allowedValues; + + /** + * Initialize the OneOfStringValidator with a collection of allowed String values. + * + * @param constraintAnnotation constraint annotation + */ + @Override + public void initialize(OneOfStrings constraintAnnotation) { + allowedValues = constraintAnnotation.value(); + } + + /** + * Validates whether a string value is found within the collection defined in the annotation. + * + * @param value String value that should be validated + * @param context Provides contextual data and operation when applying a given constraint + * validator + * @return Boolean value indicating whether the string is found within the allowed values. + */ + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return Arrays.asList(allowedValues).contains(value); + } +} diff --git a/java/common/src/main/java/feast/common/validators/OneOfStrings.java b/java/common/src/main/java/feast/common/validators/OneOfStrings.java new file mode 100644 index 00000000000..b236f6f1aff --- /dev/null +++ b/java/common/src/main/java/feast/common/validators/OneOfStrings.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.validators; + +import java.lang.annotation.*; +import javax.validation.Constraint; +import javax.validation.Payload; + +/** + * Annotation for String "one of" validation. Allows for the definition of a collection through an + * annotation. The collection is used to test values defined in the object. + */ +@Target({ + ElementType.METHOD, + ElementType.FIELD, + ElementType.ANNOTATION_TYPE, + ElementType.CONSTRUCTOR, + ElementType.PARAMETER +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint(validatedBy = OneOfStringValidator.class) +public @interface OneOfStrings { + /** @return Default error message that is returned if the incorrect value is set */ + String message() default "Field value must be one of the following: {value}"; + + /** @return Allows for the specification of validation groups to which this constraint belongs. */ + Class[] groups() default {}; + + /** + * @return An attribute payload that can be used to assign custom payload objects to a constraint. + */ + Class[] payload() default {}; + + /** @return Default value that is returned if no allowed values are configured */ + String[] value() default {}; +} diff --git a/java/common/src/main/resources/log4j2.xml b/java/common/src/main/resources/log4j2.xml new file mode 100644 index 00000000000..c75c2db13cc --- /dev/null +++ b/java/common/src/main/resources/log4j2.xml @@ -0,0 +1,48 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${hostName} --- [%15.15t] %-40.40c{1.} : %m%n%ex + + + {"time":"%d{yyyy-MM-dd'T'HH:mm:ssXXX}","hostname":"${hostName}","severity":"%p","message":%m}%n%ex + + + + + + + + + + + + + + + + + + + + + + + diff --git a/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java b/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java new file mode 100644 index 00000000000..cf355e09e4b --- /dev/null +++ b/java/common/src/test/java/feast/common/logging/entry/AuditLogEntryTest.java @@ -0,0 +1,97 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.logging.entry; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import feast.common.logging.entry.LogResource.ResourceType; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues; +import feast.proto.types.ValueProto.Value; +import io.grpc.Status; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +public class AuditLogEntryTest { + public List getTestAuditLogs() { + GetOnlineFeaturesRequestV2 requestSpec = + GetOnlineFeaturesRequestV2.newBuilder() + .addAllFeatures( + Arrays.asList( + FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature1") + .build(), + FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature2") + .build())) + .build(); + + GetOnlineFeaturesResponse responseSpec = + GetOnlineFeaturesResponse.newBuilder() + .addAllFieldValues( + Arrays.asList( + FieldValues.newBuilder() + .putFields( + "featuretable_1:feature_1", Value.newBuilder().setInt32Val(32).build()) + .build(), + FieldValues.newBuilder() + .putFields( + "featuretable_1:feature2", Value.newBuilder().setInt32Val(64).build()) + .build())) + .build(); + + return Arrays.asList( + MessageAuditLogEntry.newBuilder() + .setComponent("feast-serving") + .setVersion("0.9") + .setService("ServingService") + .setMethod("getOnlineFeatures") + .setRequest(requestSpec) + .setResponse(responseSpec) + .setStatusCode(Status.OK.getCode()) + .setIdentity("adam@no.such.email") + .build(), + ActionAuditLogEntry.of( + "core", "0.9", LogResource.of(ResourceType.JOB, "kafka-to-redis"), "CREATE"), + TransitionAuditLogEntry.of( + "core", "0.9", LogResource.of(ResourceType.FEATURE_TABLE, "featuretable_1"), "READY")); + } + + @Test + public void shouldReturnJSONRepresentationOfAuditLog() { + for (AuditLogEntry auditLog : getTestAuditLogs()) { + // Check that auditLog's toJSON() returns valid JSON + String logJSON = auditLog.toJSON(); + System.out.println(logJSON); + JsonParser parser = new JsonParser(); + + // check basic fields are present in JSON representation. + JsonObject logObject = parser.parse(logJSON).getAsJsonObject(); + assertThat(logObject.getAsJsonPrimitive("logType").getAsString(), equalTo(auditLog.logType)); + assertThat( + logObject.getAsJsonPrimitive("kind").getAsString(), equalTo(auditLog.getKind().name())); + } + } +} diff --git a/java/common/src/test/java/feast/common/models/FeaturesTest.java b/java/common/src/test/java/feast/common/models/FeaturesTest.java new file mode 100644 index 00000000000..180f7e4e697 --- /dev/null +++ b/java/common/src/test/java/feast/common/models/FeaturesTest.java @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.common.models; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; + +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import org.junit.Before; +import org.junit.Test; + +public class FeaturesTest { + + private FeatureReferenceV2 featureReference; + + @Before + public void setUp() { + featureReference = + FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature1") + .build(); + } + + @Test + public void shouldReturnFeatureStringRef() { + String actualFeatureStringRef = FeatureV2.getFeatureStringRef(featureReference); + String expectedFeatureStringRef = "featuretable_1:feature1"; + + assertThat(actualFeatureStringRef, equalTo(expectedFeatureStringRef)); + } +} diff --git a/java/datatypes/java/README.md b/java/datatypes/java/README.md new file mode 100644 index 00000000000..7fc355f7739 --- /dev/null +++ b/java/datatypes/java/README.md @@ -0,0 +1,55 @@ +Feast Data Types for Java +========================= + +This module produces Java class files for Feast's data type and gRPC service +definitions, from Protobuf IDL. These are used across Feast components for wire +interchange, contracts, etc. + +End users of Feast will be best served by our Java SDK which adds higher-level +conveniences, but the data types are published independently for custom needs, +without any additional dependencies the SDK may add. + +Dependency Coordinates +---------------------- + +```xml + + dev.feast + datatypes-java + 0.26.2 + +``` + +Use the version corresponding to the Feast release you have deployed in your +environment—see the [Feast release notes] for details. + +[Feast release notes]: ../../CHANGELOG.md + +Using the `.proto` Definitions +------------------------------ + +The `.proto` definitions are packaged as resources within the Maven artifact, +which may be useful to `include` them in dependent Protobuf definitions in a +downstream project, or for other JVM languages to consume from their builds to +generate more idiomatic bindings. + +Google's Gradle plugin, for instance, [can use protos in dependencies][Gradle] +either for `include` or to compile with a different `protoc` plugin than Java. + +[sbt-protoc] offers similar functionality for sbt/Scala. + +[Gradle]: https://github.com/google/protobuf-gradle-plugin#protos-in-dependencies +[sbt-protoc]: https://github.com/thesamet/sbt-protoc + +Releases +-------- + +The module is published to Maven Central upon each release of Feast (since +v0.3.7). + +For developers, the publishing process is automated along with the Java SDK by +[the `publish-java-sdk` build task in Prow][prow task], where you can see how +it works. Artifacts are staged to Sonatype where a maintainer needs to take a +release action for them to go live on Maven Central. + +[prow task]: https://github.com/feast-dev/feast/blob/17e7dca8238aae4dcbf0ff9f0db5d80ef8e035cf/.prow/config.yaml#L166-L192 diff --git a/java/datatypes/java/pom.xml b/java/datatypes/java/pom.xml new file mode 100644 index 00000000000..0d165f0cd53 --- /dev/null +++ b/java/datatypes/java/pom.xml @@ -0,0 +1,111 @@ + + + + 4.0.0 + + Feast Data Types for Java + + Data types and service contracts used throughout Feast components and + their interchanges. These are generated from Protocol Buffers and gRPC + definitions included in the package. + + datatypes-java + + + dev.feast + feast-parent + ${revision} + ../.. + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + + javax.annotation + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + true + + com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier} + + grpc-java + + io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier} + + + + + + compile + compile-custom + test-compile + + + + + + + + + + + com.google.guava + guava + + + com.google.protobuf + protobuf-java + + + + io.grpc + grpc-core + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-services + + + io.grpc + grpc-stub + + + + javax.annotation + javax.annotation-api + + + + diff --git a/java/datatypes/java/src/main/proto/feast b/java/datatypes/java/src/main/proto/feast new file mode 120000 index 00000000000..53364e5f45f --- /dev/null +++ b/java/datatypes/java/src/main/proto/feast @@ -0,0 +1 @@ +../../../../../../protos/feast \ No newline at end of file diff --git a/java/datatypes/java/src/main/proto/tensorflow_metadata b/java/datatypes/java/src/main/proto/tensorflow_metadata new file mode 120000 index 00000000000..225345970c2 --- /dev/null +++ b/java/datatypes/java/src/main/proto/tensorflow_metadata @@ -0,0 +1 @@ +../../../../../../protos/tensorflow_metadata \ No newline at end of file diff --git a/java/docs/coverage/java/pom.xml b/java/docs/coverage/java/pom.xml new file mode 100644 index 00000000000..5f794224969 --- /dev/null +++ b/java/docs/coverage/java/pom.xml @@ -0,0 +1,87 @@ + + + + 4.0.0 + + + + + dev.feast + feast-parent + ${revision} + ../../.. + + + Feast Coverage Java + feast-coverage + + + true + + + + + dev.feast + feast-storage-api + ${project.version} + + + + dev.feast + feast-storage-connector-redis + ${project.version} + + + + dev.feast + feast-serving + ${project.version} + + + + dev.feast + feast-sdk + ${project.version} + + + + + + + org.jacoco + jacoco-maven-plugin + + + report-aggregate + prepare-package + + report-aggregate + + + + + + + + diff --git a/java/infra/docker/serving/Dockerfile b/java/infra/docker/serving/Dockerfile new file mode 100644 index 00000000000..0aacf856305 --- /dev/null +++ b/java/infra/docker/serving/Dockerfile @@ -0,0 +1,53 @@ +# ============================================================ +# Build stage 1: Builder +# ============================================================ + +FROM maven:3.6-jdk-11 as builder + +WORKDIR /build + +COPY pom.xml . +COPY datatypes/java/pom.xml datatypes/java/pom.xml +COPY common/pom.xml common/pom.xml +COPY core/pom.xml core/pom.xml +COPY serving/pom.xml serving/pom.xml +COPY storage/api/pom.xml storage/api/pom.xml +COPY storage/connectors/pom.xml storage/connectors/pom.xml +COPY storage/connectors/redis/pom.xml storage/connectors/redis/pom.xml +COPY sdk/java/pom.xml sdk/java/pom.xml +COPY docs/coverage/java/pom.xml docs/coverage/java/pom.xml +COPY deps/feast/protos/ deps/feast/protos/ + +# Setting Maven repository .m2 directory relative to /build folder gives the +# user to optionally use cached repository when building the image by copying +# the existing .m2 directory to $FEAST_REPO_ROOT/.m2 +ENV MAVEN_OPTS="-Dmaven.repo.local=/build/.m2/repository -DdependencyLocationsEnabled=false -Dmaven.wagon.httpconnectionManager.ttlSeconds=25 -Dmaven.wagon.http.retryHandler.count=3" +COPY pom.xml .m2/* .m2/ +RUN mvn dependency:go-offline -DexcludeGroupIds:dev.feast 2>/dev/null || true + +COPY . . + +ARG VERSION=dev +RUN mvn --also-make --projects serving -Drevision=$VERSION \ + -DskipUTs=true --batch-mode clean package +# +# Download grpc_health_probe to run health check for Feast Serving +# https://kubernetes.io/blog/2018/10/01/health-checking-grpc-servers-on-kubernetes/ +# +RUN wget -q https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/v0.3.1/grpc_health_probe-linux-amd64 \ + -O /usr/bin/grpc-health-probe && \ + chmod +x /usr/bin/grpc-health-probe + +# ============================================================ +# Build stage 2: Production +# ============================================================ + +FROM amazoncorretto:11 as production +ARG VERSION=dev +COPY --from=builder /build/serving/target/feast-serving-$VERSION-exec.jar /opt/feast/feast-serving.jar +COPY --from=builder /usr/bin/grpc-health-probe /usr/bin/grpc-health-probe +CMD ["java",\ + "-Xms1g",\ + "-Xmx4g",\ + "-jar",\ + "/opt/feast/feast-serving.jar"] diff --git a/java/infra/docker/serving/Dockerfile.dev b/java/infra/docker/serving/Dockerfile.dev new file mode 100644 index 00000000000..93bbbbb7185 --- /dev/null +++ b/java/infra/docker/serving/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM openjdk:11-jre as production +ARG REVISION=dev +# +# Download grpc_health_probe to run health check for Feast Serving +# https://kubernetes.io/blog/2018/10/01/health-checking-grpc-servers-on-kubernetes/ +# +RUN wget -q https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/v0.3.1/grpc_health_probe-linux-amd64 \ + -O /usr/bin/grpc-health-probe && \ + chmod +x /usr/bin/grpc-health-probe +ADD $PWD/serving/target/feast-serving-$REVISION-exec.jar /opt/feast/feast-serving.jar +CMD ["java",\ + "-Xms1024m",\ + "-Xmx1024m",\ + "-jar",\ + "/opt/feast/feast-serving.jar"] diff --git a/java/pom.xml b/java/pom.xml new file mode 100644 index 00000000000..073616b69a4 --- /dev/null +++ b/java/pom.xml @@ -0,0 +1,855 @@ + + + + 4.0.0 + + Feast + Feature Store for Machine Learning + ${github.url} + + dev.feast + feast-parent + ${revision} + pom + + + datatypes/java + storage/api + storage/connectors + serving + sdk/java + docs/coverage/java + common + + + + 0.26.2 + https://github.com/feast-dev/feast + + UTF-8 + UTF-8 + + 1.30.2 + 3.12.2 + 3.12.2 + 2.3.1.RELEASE + 5.2.7.RELEASE + 5.3.0.RELEASE + 2.9.0.RELEASE + 1.111.1 + 0.8.0 + 1.9.10 + 1.3 + 2.28.2 + + 0.26.0 + + 2.13.2 + 2.9.9 + 2.0.2 + 2.5.0.RELEASE + 1.18.12 + 1.8.4 + 2.8.6 + 1.5.24 + 3.14.7 + 3.10 + 2.3.1 + 1.3.2 + 2.0.1.Final + 2.8.0 + 0.20.0 + 1.6.6 + + + + ${maven.multiModuleProjectDirectory} + + false + false + feast.common.auth.providers.http.client + + + + Gojek + https://www.gojek.com + + + + + Feast Authors + ${github.url} + Gojek + https://www.gojek.com + + + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + ${github.url} + scm:git:${github.url}.git + scm:git:git@github.com:feast-dev/feast.git + HEAD + + + + GitHub Issues + ${github.url}/issues + + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + + + org.apache.commons + commons-lang3 + ${commons.lang3.version} + + + + + com.google.cloud + google-cloud-bigquery + ${com.google.cloud.version} + + + com.google.cloud + google-cloud-storage + ${com.google.cloud.version} + + + + + com.google.cloud + google-cloud-nio + 0.83.0-alpha + + + + io.opencensus + opencensus-api + ${opencensus.version} + + + io.opencensus + opencensus-contrib-grpc-util + ${opencensus.version} + + + io.opencensus + opencensus-contrib-http-util + ${opencensus.version} + + + + + io.grpc + grpc-core + ${grpc.version} + + + io.grpc + grpc-api + ${grpc.version} + + + io.grpc + grpc-context + ${grpc.version} + + + io.grpc + grpc-all + ${grpc.version} + + + io.grpc + grpc-okhttp + ${grpc.version} + + + io.grpc + grpc-auth + ${grpc.version} + + + io.grpc + grpc-grpclb + ${grpc.version} + + + io.grpc + grpc-alts + ${grpc.version} + + + io.grpc + grpc-netty + ${grpc.version} + + + io.grpc + grpc-netty-shaded + ${grpc.version} + + + io.grpc + grpc-protobuf + ${grpc.version} + + + io.grpc + grpc-services + ${grpc.version} + + + io.grpc + grpc-stub + ${grpc.version} + + + io.grpc + grpc-testing + ${grpc.version} + test + + + + + org.apache.arrow + arrow-java-root + 5.0.0 + pom + + + + + org.apache.arrow + arrow-vector + 5.0.0 + + + + + org.apache.arrow + arrow-memory + 5.0.0 + pom + + + + + org.apache.arrow + arrow-memory-netty + 5.0.0 + runtime + + + + + net.devh + grpc-server-spring-boot-starter + ${grpc.spring.boot.starter.version} + + + + + io.prometheus + simpleclient + ${io.prometheus.version} + + + io.prometheus + simpleclient_servlet + ${io.prometheus.version} + + + + + org.springframework.security + spring-security-core + ${spring.security.version} + + + org.springframework.security + spring-security-config + ${spring.security.version} + + + org.springframework.security + spring-security-oauth2-resource-server + ${spring.security.version} + + + org.springframework.security + spring-security-oauth2-jose + ${spring.security.version} + + + com.google.auth + google-auth-library-oauth2-http + ${google.auth.library.oauth2.http.version} + + + + + joda-time + joda-time + ${joda.time.version} + + + com.datadoghq + java-dogstatsd-client + 2.6.1 + + + com.google.guava + guava + 29.0-jre + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + + com.google.protobuf + protobuf-java-util + ${protobuf.version} + + + org.projectlombok + lombok + ${lombok.version} + provided + + + com.google.auto.value + auto-value-annotations + ${auto.value.version} + + + com.google.auto.value + auto-value + ${auto.value.version} + + + com.google.code.gson + gson + ${gson.version} + + + io.gsonfire + gson-fire + ${gson.fire.version} + + + + com.github.kstyrc + embedded-redis + 0.6 + test + + + + + + net.bytebuddy + byte-buddy + ${byte-buddy.version} + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.springframework.boot + spring-boot-starter-web + ${spring.boot.version} + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.apache.logging.log4j + log4j-api + ${log4jVersion} + + + org.apache.logging.log4j + log4j-core + ${log4jVersion} + + + org.apache.logging.log4j + log4j-jul + ${log4jVersion} + + + org.apache.logging.log4j + log4j-web + ${log4jVersion} + + + org.apache.logging.log4j + log4j-slf4j-impl + ${log4jVersion} + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + com.squareup.okio + okio + 1.17.2 + + + javax.xml.bind + jaxb-api + ${javax.xml.bind.version} + + + javax.annotation + javax.annotation-api + ${javax.annotation.version} + + + javax.validation + validation-api + ${javax.validation.version} + + + + + + + + kr.motd.maven + os-maven-plugin + 1.6.2 + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + feast.proto.*:io.grpc.*:org.tensorflow.* + + + + com.diffplug.spotless + spotless-maven-plugin + 1.26.1 + + + + ${license.content} + + + 1.7 + + + + src/main/java/**/BatchLoadsWithResult.java + + + + + + ${license.content} + + + + + + + spotless-check + process-test-classes + + check + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + 8 + + + com.google.auto.value + auto-value + ${auto.value.version} + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.0.0-M2 + + + org.codehaus.mojo + extra-enforcer-rules + 1.2 + + + + + valid-build-environment + + enforce + + + + + [3.6,4.0) + + + [11.0,) + + + + + + + consistent-dependency-versions + + enforce + + + + + + + + + no-snapshot-deps-at-release + + enforce + + + + + true + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + @{argLine} -Xms2048m -Xmx2048m -Djdk.net.URLClassPath.disableClassPathURLCheck=true + ${skipUTs} + + + + org.junit.vintage + junit-vintage-engine + 5.5.2 + + + + + maven-failsafe-plugin + 3.0.0-M5 + + + org.junit.jupiter + junit-jupiter-engine + 5.6.2 + + + + + integration-tests + + integration-test + verify + + + + + + ${project.groupId}:${project.artifactId} + + + ${project.build.outputDirectory} + + + + + org.springframework.boot + spring-boot-maven-plugin + + + true + + + + build-info + + build-info + + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + ossrh + https://oss.sonatype.org/ + + true + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.1.0 + + oss + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + verify + + sign + + + + + --pinentry-mode + loopback + + + + ${gpg.passphrase} + + + + + + + + + + io.fabric8 + docker-maven-plugin + 0.20.1 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + -Xlint:all + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.1.1 + + + + org.apache.maven.shared + maven-dependency-analyzer + 1.11.1 + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.1.1 + + all + + + + org.codehaus.mojo + exec-maven-plugin + 1.6.0 + + false + + + + org.jacoco + jacoco-maven-plugin + 0.8.5 + + + + prepare-agent + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + exec + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + + + + + diff --git a/java/sdk/java/pom.xml b/java/sdk/java/pom.xml new file mode 100644 index 00000000000..6b2295b6b33 --- /dev/null +++ b/java/sdk/java/pom.xml @@ -0,0 +1,143 @@ + + + 4.0.0 + + Feast SDK for Java + SDK for registering, storing, and retrieving features + feast-sdk + + + dev.feast + feast-parent + ${revision} + ../.. + + + + + 5.5.2 + 2.28.2 + 0.33.0 + + + + + dev.feast + datatypes-java + ${project.version} + + + + dev.feast + feast-common + ${project.version} + + + + + io.grpc + grpc-netty-shaded + + + io.grpc + grpc-protobuf + + + io.grpc + grpc-stub + + + io.grpc + grpc-testing + + + com.google.protobuf + protobuf-java-util + + + com.google.protobuf + protobuf-java + + + + + io.opentracing.contrib + opentracing-grpc + 0.2.3 + + + io.opentracing + opentracing-api + ${opentracing.version} + + + io.opentracing + opentracing-noop + ${opentracing.version} + + + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + org.apache.commons + commons-lang3 + 3.6 + compile + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-inline + ${mockito.version} + test + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + true + + + + org.jacoco + jacoco-maven-plugin + + + + + diff --git a/java/sdk/java/src/main/java/com/gojek/feast/FeastClient.java b/java/sdk/java/src/main/java/com/gojek/feast/FeastClient.java new file mode 100644 index 00000000000..94836d88aa4 --- /dev/null +++ b/java/sdk/java/src/main/java/com/gojek/feast/FeastClient.java @@ -0,0 +1,211 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 com.gojek.feast; + +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; +import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingServiceGrpc; +import feast.proto.serving.ServingServiceGrpc.ServingServiceBlockingStub; +import io.grpc.CallCredentials; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.opentracing.contrib.grpc.TracingClientInterceptor; +import io.opentracing.util.GlobalTracer; +import java.io.File; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import javax.net.ssl.SSLException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@SuppressWarnings("WeakerAccess") +public class FeastClient implements AutoCloseable { + Logger logger = LoggerFactory.getLogger(FeastClient.class); + + private static final int CHANNEL_SHUTDOWN_TIMEOUT_SEC = 5; + + private final ManagedChannel channel; + private final ServingServiceBlockingStub stub; + + /** + * Create a client to access Feast Serving. + * + * @param host hostname or ip address of Feast serving GRPC server + * @param port port number of Feast serving GRPC server + * @return {@link FeastClient} + */ + public static FeastClient create(String host, int port) { + // configure client with no security config. + return FeastClient.createSecure(host, port, SecurityConfig.newBuilder().build()); + } + + /** + * Create a authenticated client that can access Feast serving with authentication enabled. + * Supports the {@link CallCredentials} in the {@link feast.common.auth.credentials} package. + * + * @param host hostname or ip address of Feast serving GRPC server + * @param port port number of Feast serving GRPC server + * @param securityConfig security options to configure the Feast client. See {@link + * SecurityConfig} for options. + * @return {@link FeastClient} + */ + public static FeastClient createSecure(String host, int port, SecurityConfig securityConfig) { + // Configure client TLS + ManagedChannel channel = null; + if (securityConfig.isTLSEnabled()) { + if (securityConfig.getCertificatePath().isPresent()) { + String certificatePath = securityConfig.getCertificatePath().get(); + // Use custom certificate for TLS + File certificateFile = new File(certificatePath); + try { + channel = + NettyChannelBuilder.forAddress(host, port) + .useTransportSecurity() + .sslContext(GrpcSslContexts.forClient().trustManager(certificateFile).build()) + .build(); + } catch (SSLException e) { + throw new IllegalArgumentException( + String.format("Invalid Certificate provided at path: %s", certificatePath), e); + } + } else { + // Use system certificates for TLS + channel = ManagedChannelBuilder.forAddress(host, port).useTransportSecurity().build(); + } + } else { + // Disable TLS + channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build(); + } + + return new FeastClient(channel, securityConfig.getCredentials()); + } + + /** + * Obtain info about Feast Serving. + * + * @return {@link GetFeastServingInfoResponse} containing Feast version, Serving type etc. + */ + public GetFeastServingInfoResponse getFeastServingInfo() { + return stub.getFeastServingInfo(GetFeastServingInfoRequest.newBuilder().build()); + } + + /** + * Get online features from Feast, without indicating project, will use `default`. + * + *

See {@link #getOnlineFeatures(List, List, String)} + * + * @param featureRefs list of string feature references to retrieve in the following format + * featureTable:feature, where 'featureTable' and 'feature' refer to the FeatureTable and + * Feature names respectively. Only the Feature name is required. + * @param rows list of {@link Row} to select the entities to retrieve the features for. + * @return list of {@link Row} containing retrieved data fields. + */ + public List getOnlineFeatures(List featureRefs, List rows) { + return getOnlineFeatures(featureRefs, rows, ""); + } + + /** + * Get online features from Feast. + * + *

Example of retrieving online features for the driver FeatureTable, with features driver_id + * and driver_name + * + *

{@code
+   * FeastClient client = FeastClient.create("localhost", 6566);
+   * List requestedFeatureIds = Arrays.asList("driver:driver_id", "driver:driver_name");
+   * List requestedRows =
+   *         Arrays.asList(Row.create().set("driver_id", 123), Row.create().set("driver_id", 456));
+   * List retrievedFeatures = client.getOnlineFeatures(requestedFeatureIds, requestedRows);
+   * retrievedFeatures.forEach(System.out::println);
+   * }
+ * + * @param featureRefs list of string feature references to retrieve in the following format + * featureTable:feature, where 'featureTable' and 'feature' refer to the FeatureTable and + * Feature names respectively. Only the Feature name is required. + * @param rows list of {@link Row} to select the entities to retrieve the features for + * @param project {@link String} Specifies the project override. If specified uses the project for + * retrieval. Overrides the projects set in Feature References if also specified. + * @return list of {@link Row} containing retrieved data fields. + */ + public List getOnlineFeatures(List featureRefs, List rows, String project) { + List features = RequestUtil.createFeatureRefs(featureRefs); + // build entity rows and collect entity references + HashSet entityRefs = new HashSet<>(); + List entityRows = + rows.stream() + .map( + row -> { + entityRefs.addAll(row.getFields().keySet()); + return EntityRow.newBuilder() + .setTimestamp(row.getEntityTimestamp()) + .putAllFields(row.getFields()) + .build(); + }) + .collect(Collectors.toList()); + + GetOnlineFeaturesResponse response = + stub.getOnlineFeaturesV2( + GetOnlineFeaturesRequestV2.newBuilder() + .addAllFeatures(features) + .addAllEntityRows(entityRows) + .setProject(project) + .build()); + + return response.getFieldValuesList().stream() + .map( + fieldValues -> { + Row row = Row.create(); + for (String fieldName : fieldValues.getFieldsMap().keySet()) { + row.set( + fieldName, + fieldValues.getFieldsMap().get(fieldName), + fieldValues.getStatusesMap().get(fieldName)); + } + return row; + }) + .collect(Collectors.toList()); + } + + protected FeastClient(ManagedChannel channel, Optional credentials) { + this.channel = channel; + TracingClientInterceptor tracingInterceptor = + TracingClientInterceptor.newBuilder().withTracer(GlobalTracer.get()).build(); + + ServingServiceBlockingStub servingStub = + ServingServiceGrpc.newBlockingStub(tracingInterceptor.intercept(channel)); + + if (credentials.isPresent()) { + servingStub = servingStub.withCallCredentials(credentials.get()); + } + + this.stub = servingStub; + } + + public void close() throws Exception { + if (channel != null) { + channel.shutdown().awaitTermination(CHANNEL_SHUTDOWN_TIMEOUT_SEC, TimeUnit.SECONDS); + } + } +} diff --git a/java/sdk/java/src/main/java/com/gojek/feast/RequestUtil.java b/java/sdk/java/src/main/java/com/gojek/feast/RequestUtil.java new file mode 100644 index 00000000000..69c8f9f737a --- /dev/null +++ b/java/sdk/java/src/main/java/com/gojek/feast/RequestUtil.java @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 com.gojek.feast; + +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import java.util.List; +import java.util.stream.Collectors; + +@SuppressWarnings("WeakerAccess") +public class RequestUtil { + + /** + * Create feature references protos from given string feature reference. + * + * @param featureRefStrings to create Feature Reference protos from + * @return List of parsed {@link FeatureReferenceV2} protos + */ + public static List createFeatureRefs(List featureRefStrings) { + if (featureRefStrings == null) { + throw new IllegalArgumentException("FeatureReferences cannot be null"); + } + + List featureRefs = + featureRefStrings.stream() + .map(refStr -> parseFeatureRef(refStr)) + .collect(Collectors.toList()); + + return featureRefs; + } + + /** + * Parse a feature reference proto builder from the given featureRefString + * + * @param featureRefString string feature reference to parse from. + * @return a parsed {@link FeatureReferenceV2} + */ + public static FeatureReferenceV2 parseFeatureRef(String featureRefString) { + featureRefString = featureRefString.trim(); + if (featureRefString.isEmpty()) { + throw new IllegalArgumentException("Cannot parse a empty feature reference"); + } + if (featureRefString.contains("/")) { + throw new IllegalArgumentException( + String.format( + "Unsupported feature reference: Specifying project in string" + + " Feature References is not longer supported: %s", + featureRefString)); + } + if (!featureRefString.contains(":")) { + throw new IllegalArgumentException( + String.format( + "Unsupported feature reference: %s - FeatureTable name and Feature name should be provided in string" + + " Feature References, in : format.", + featureRefString)); + } + + String[] featureReferenceParts = featureRefString.split(":"); + FeatureReferenceV2 featureRef = + FeatureReferenceV2.newBuilder() + .setFeatureTable(featureReferenceParts[0]) + .setName(featureReferenceParts[1]) + .build(); + + return featureRef; + } +} diff --git a/java/sdk/java/src/main/java/com/gojek/feast/Row.java b/java/sdk/java/src/main/java/com/gojek/feast/Row.java new file mode 100644 index 00000000000..51f820e320a --- /dev/null +++ b/java/sdk/java/src/main/java/com/gojek/feast/Row.java @@ -0,0 +1,161 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 com.gojek.feast; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Timestamp; +import com.google.protobuf.util.Timestamps; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus; +import feast.proto.types.ValueProto.Value; +import feast.proto.types.ValueProto.Value.ValCase; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@SuppressWarnings("UnusedReturnValue") +public class Row { + private Timestamp entity_timestamp; + private HashMap fields; + private HashMap fieldStatuses; + + public static Row create() { + Row row = new Row(); + row.entity_timestamp = Timestamps.fromMillis(System.currentTimeMillis()); + row.fields = new HashMap<>(); + row.fieldStatuses = new HashMap<>(); + return row; + } + + public Row setEntityTimestamp(Instant timestamp) { + entity_timestamp = Timestamps.fromMillis(timestamp.toEpochMilli()); + return this; + } + + public Timestamp getEntityTimestamp() { + return entity_timestamp; + } + + public Row setEntityTimestamp(String dateTime) { + entity_timestamp = Timestamps.fromMillis(Instant.parse(dateTime).toEpochMilli()); + return this; + } + + public Row set(String fieldName, Object value) { + return this.set(fieldName, value, FieldStatus.PRESENT); + } + + public Row set(String fieldName, Object value, FieldStatus status) { + String valueType = value.getClass().getCanonicalName(); + switch (valueType) { + case "java.lang.Integer": + fields.put(fieldName, Value.newBuilder().setInt32Val((int) value).build()); + break; + case "java.lang.Long": + fields.put(fieldName, Value.newBuilder().setInt64Val((long) value).build()); + break; + case "java.lang.Float": + fields.put(fieldName, Value.newBuilder().setFloatVal((float) value).build()); + break; + case "java.lang.Double": + fields.put(fieldName, Value.newBuilder().setDoubleVal((double) value).build()); + break; + case "java.lang.String": + fields.put(fieldName, Value.newBuilder().setStringVal((String) value).build()); + break; + case "byte[]": + fields.put( + fieldName, Value.newBuilder().setBytesVal(ByteString.copyFrom((byte[]) value)).build()); + break; + case "feast.proto.types.ValueProto.Value": + fields.put(fieldName, (Value) value); + break; + default: + throw new IllegalArgumentException( + String.format( + "Type '%s' is unsupported in Feast. Please use one of these value types: Integer, Long, Float, Double, String, byte[].", + valueType)); + } + + fieldStatuses.put(fieldName, status); + return this; + } + + public Map getFields() { + return fields; + } + + public Integer getInt(String fieldName) { + return getValue(fieldName).map(Value::getInt32Val).orElse(null); + } + + public Long getLong(String fieldName) { + return getValue(fieldName).map(Value::getInt64Val).orElse(null); + } + + public Float getFloat(String fieldName) { + return getValue(fieldName).map(Value::getFloatVal).orElse(null); + } + + public Double getDouble(String fieldName) { + return getValue(fieldName).map(Value::getDoubleVal).orElse(null); + } + + public String getString(String fieldName) { + return getValue(fieldName).map(Value::getStringVal).orElse(null); + } + + public byte[] getByte(String fieldName) { + return getValue(fieldName).map(Value::getBytesVal).map(ByteString::toByteArray).orElse(null); + } + + public Map getStatuses() { + return fieldStatuses; + } + + public FieldStatus getStatus(String fieldName) { + return fieldStatuses.get(fieldName); + } + + @Override + public String toString() { + List parts = new ArrayList<>(); + fields.forEach( + (key, value) -> + parts.add( + key + + ":" + + (value.getValCase().equals(ValCase.VAL_NOT_SET) + ? "NULL" + : value.toString().trim()))); + return String.join(", ", parts); + } + + private Optional getValue(String fieldName) { + if (!fields.containsKey(fieldName)) { + throw new IllegalArgumentException( + String.format("Row does not contain field '%s'", fieldName)); + } + Value value = fields.get(fieldName); + if (value.getValCase().equals(ValCase.VAL_NOT_SET)) { + return Optional.empty(); + } + return Optional.of(value); + } +} diff --git a/java/sdk/java/src/main/java/com/gojek/feast/SecurityConfig.java b/java/sdk/java/src/main/java/com/gojek/feast/SecurityConfig.java new file mode 100644 index 00000000000..94c779cf440 --- /dev/null +++ b/java/sdk/java/src/main/java/com/gojek/feast/SecurityConfig.java @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 com.gojek.feast; + +import com.google.auto.value.AutoValue; +import io.grpc.CallCredentials; +import java.util.Optional; + +/** SecurityConfig captures the security related configuration for FeastClient */ +@AutoValue +public abstract class SecurityConfig { + /** + * Enables authentication If specified, the call credentials used to provide credentials to + * authenticate with Feast. + * + * @return credentials + */ + public abstract Optional getCredentials(); + + /** + * Whether to use TLS transport security is use when connecting to Feast. + * + * @return true if enabled + */ + public abstract boolean isTLSEnabled(); + + /** + * If specified and TLS is enabled, provides path to TLS certificate use the verify Service + * identity. + * + * @return certificate path + */ + public abstract Optional getCertificatePath(); + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setCredentials(Optional credentials); + + public abstract Builder setTLSEnabled(boolean isTLSEnabled); + + public abstract Builder setCertificatePath(Optional certificatePath); + + public abstract SecurityConfig build(); + } + + public static SecurityConfig.Builder newBuilder() { + return new AutoValue_SecurityConfig.Builder() + .setCredentials(Optional.empty()) + .setTLSEnabled(false) + .setCertificatePath(Optional.empty()); + } +} diff --git a/java/sdk/java/src/test/java/com/gojek/feast/FeastClientTest.java b/java/sdk/java/src/test/java/com/gojek/feast/FeastClientTest.java new file mode 100644 index 00000000000..29185cd153c --- /dev/null +++ b/java/sdk/java/src/test/java/com/gojek/feast/FeastClientTest.java @@ -0,0 +1,168 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 com.gojek.feast; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.mock; + +import com.google.protobuf.Timestamp; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues; +import feast.proto.serving.ServingServiceGrpc.ServingServiceImplBase; +import feast.proto.types.ValueProto.Value; +import io.grpc.*; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.GrpcCleanupRule; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class FeastClientTest { + private final String AUTH_TOKEN = "test token"; + + @Rule public GrpcCleanupRule grpcRule; + private AtomicBoolean isAuthenticated; + + private ServingServiceImplBase servingMock = + mock( + ServingServiceImplBase.class, + delegatesTo( + new ServingServiceImplBase() { + @Override + public void getOnlineFeaturesV2( + GetOnlineFeaturesRequestV2 request, + StreamObserver responseObserver) { + if (!request.equals(FeastClientTest.getFakeRequest())) { + responseObserver.onError(Status.FAILED_PRECONDITION.asRuntimeException()); + } + + responseObserver.onNext(FeastClientTest.getFakeResponse()); + responseObserver.onCompleted(); + } + })); + + private FeastClient client; + + @Before + public void setup() throws Exception { + this.grpcRule = new GrpcCleanupRule(); + this.isAuthenticated = new AtomicBoolean(false); + // setup fake serving service + String serverName = InProcessServerBuilder.generateName(); + this.grpcRule.register( + InProcessServerBuilder.forName(serverName) + .directExecutor() + .addService(this.servingMock) + .build() + .start()); + + // setup test feast client target + ManagedChannel channel = + this.grpcRule.register( + InProcessChannelBuilder.forName(serverName).directExecutor().build()); + this.client = new FeastClient(channel, Optional.empty()); + } + + @Test + public void shouldGetOnlineFeatures() { + shouldGetOnlineFeaturesWithClient(this.client); + } + + private void shouldGetOnlineFeaturesWithClient(FeastClient client) { + List rows = + client.getOnlineFeatures( + Arrays.asList("driver:name", "driver:rating", "driver:null_value"), + Arrays.asList( + Row.create().set("driver_id", 1).setEntityTimestamp(Instant.ofEpochSecond(100))), + "driver_project"); + + assertEquals( + rows.get(0).getFields(), + new HashMap() { + { + put("driver_id", intValue(1)); + put("driver:name", strValue("david")); + put("driver:rating", intValue(3)); + put("driver:null_value", Value.newBuilder().build()); + } + }); + assertEquals( + rows.get(0).getStatuses(), + new HashMap() { + { + put("driver_id", FieldStatus.PRESENT); + put("driver:name", FieldStatus.PRESENT); + put("driver:rating", FieldStatus.PRESENT); + put("driver:null_value", FieldStatus.NULL_VALUE); + } + }); + } + + private static GetOnlineFeaturesRequestV2 getFakeRequest() { + // setup mock serving service stub + return GetOnlineFeaturesRequestV2.newBuilder() + .addFeatures( + FeatureReferenceV2.newBuilder().setFeatureTable("driver").setName("name").build()) + .addFeatures( + FeatureReferenceV2.newBuilder().setFeatureTable("driver").setName("rating").build()) + .addFeatures( + FeatureReferenceV2.newBuilder().setFeatureTable("driver").setName("null_value").build()) + .addEntityRows( + EntityRow.newBuilder() + .setTimestamp(Timestamp.newBuilder().setSeconds(100)) + .putFields("driver_id", intValue(1))) + .setProject("driver_project") + .build(); + } + + private static GetOnlineFeaturesResponse getFakeResponse() { + return GetOnlineFeaturesResponse.newBuilder() + .addFieldValues( + FieldValues.newBuilder() + .putFields("driver_id", intValue(1)) + .putStatuses("driver_id", FieldStatus.PRESENT) + .putFields("driver:name", strValue("david")) + .putStatuses("driver:name", FieldStatus.PRESENT) + .putFields("driver:rating", intValue(3)) + .putStatuses("driver:rating", FieldStatus.PRESENT) + .putFields("driver:null_value", Value.newBuilder().build()) + .putStatuses("driver:null_value", FieldStatus.NULL_VALUE) + .build()) + .build(); + } + + private static Value strValue(String val) { + return Value.newBuilder().setStringVal(val).build(); + } + + private static Value intValue(int val) { + return Value.newBuilder().setInt32Val(val).build(); + } +} diff --git a/java/sdk/java/src/test/java/com/gojek/feast/RequestUtilTest.java b/java/sdk/java/src/test/java/com/gojek/feast/RequestUtilTest.java new file mode 100644 index 00000000000..1592e20664d --- /dev/null +++ b/java/sdk/java/src/test/java/com/gojek/feast/RequestUtilTest.java @@ -0,0 +1,104 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 com.gojek.feast; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.protobuf.TextFormat; +import feast.common.models.FeatureV2; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; + +class RequestUtilTest { + + private static Stream provideValidFeatureRefs() { + return Stream.of( + Arguments.of( + Arrays.asList("driver:driver_id"), + Arrays.asList( + FeatureReferenceV2.newBuilder() + .setFeatureTable("driver") + .setName("driver_id") + .build()))); + } + + @ParameterizedTest + @MethodSource("provideValidFeatureRefs") + void createFeatureRefs_ShouldReturnFeaturesForValidFeatureRefs( + List input, List expected) { + List actual = RequestUtil.createFeatureRefs(input); + // Order of the actual and expected FeatureTables do no not matter + actual.sort(Comparator.comparing(FeatureReferenceV2::getName)); + expected.sort(Comparator.comparing(FeatureReferenceV2::getName)); + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + String expectedString = TextFormat.printer().printToString(expected.get(i)); + String actualString = TextFormat.printer().printToString(actual.get(i)); + assertEquals(expectedString, actualString); + } + } + + @ParameterizedTest + @MethodSource("provideValidFeatureRefs") + void renderFeatureRef_ShouldReturnFeatureRefString( + List expected, List input) { + input = input.stream().map(ref -> ref.toBuilder().build()).collect(Collectors.toList()); + List actual = + input.stream().map(ref -> FeatureV2.getFeatureStringRef(ref)).collect(Collectors.toList()); + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + assertEquals(expected.get(i), actual.get(i)); + } + } + + private static Stream provideInvalidFeatureRefs() { + return Stream.of(Arguments.of(ImmutableList.of("project/feature"))); + } + + private static Stream provideMissingFeatureTableFeatureRefs() { + return Stream.of(Arguments.of(ImmutableList.of("feature"))); + } + + @ParameterizedTest + @MethodSource("provideInvalidFeatureRefs") + void createFeatureRefs_ShouldThrowExceptionForProjectInFeatureRefs(List input) { + assertThrows(IllegalArgumentException.class, () -> RequestUtil.createFeatureRefs(input)); + } + + @ParameterizedTest + @MethodSource("provideMissingFeatureTableFeatureRefs") + void createFeatureRefs_ShouldThrowExceptionForMissingFeatureTableInFeatureRefs( + List input) { + assertThrows(IllegalArgumentException.class, () -> RequestUtil.createFeatureRefs(input)); + } + + @ParameterizedTest + @NullSource + void createFeatureRefs_ShouldThrowExceptionForNullFeatureRefs(List input) { + assertThrows(IllegalArgumentException.class, () -> RequestUtil.createFeatureRefs(input)); + } +} diff --git a/java/serving/.dockerignore b/java/serving/.dockerignore new file mode 100644 index 00000000000..441c3d1a50c --- /dev/null +++ b/java/serving/.dockerignore @@ -0,0 +1,3 @@ +*/.gradle/ +.idea/ +*/build/ \ No newline at end of file diff --git a/java/serving/.gitignore b/java/serving/.gitignore new file mode 100644 index 00000000000..6c6b6d8d8f8 --- /dev/null +++ b/java/serving/.gitignore @@ -0,0 +1,37 @@ +### Scratch files ### +scratch* + +### Local Environment ### +*local*.env + +### Gradle ### +.gradle +**/build/ +!gradle/wrapper/gradle-wrapper.jar +feast-serving.jar + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +## Feast Temporary Files ## +/temp/ \ No newline at end of file diff --git a/java/serving/README.md b/java/serving/README.md new file mode 100644 index 00000000000..cce8c7d6e29 --- /dev/null +++ b/java/serving/README.md @@ -0,0 +1,101 @@ +### Getting Started Guide for Feast Serving Developers + +Pre-requisites: + +- [Maven](https://maven.apache.org/install.html) build tool version 3.6.x +- A running Feast Core instance +- A running Store instance e.g. local Redis Store instance + +From the Feast project root directory, run the following Maven command to start Feast Serving gRPC service running on port 6566 locally: + +```bash +# Assumptions: +# - Local Feast Core is running on localhost:6565 +# Uses configuration from serving/src/main/resources/application.yml +mvn -pl serving spring-boot:run -Dspring-boot.run.arguments=\ +--feast.core-host=localhost,\ +--feast.core-port=6565 +``` + +If you have [grpc_cli](https://github.com/grpc/grpc/blob/master/doc/command_line_tool.md) installed, you can check that Feast Serving is running +``` +grpc_cli ls localhost:6566 +grpc_cli call localhost:6566 GetFeastServingVersion '' +grpc_cli call localhost:6566 GetFeastServingType '' +``` + +```bash +grpc_cli call localhost:6565 ApplyFeatureSet ' +feature_set { + name: "driver" + entities { + name: "driver_id" + value_type: STRING + } + features { + name: "city" + value_type: STRING + } + features { + name: "booking_completed_count" + value_type: INT64 + } + source { + type: KAFKA + kafka_source_config { + bootstrap_servers: "localhost:9092" + } + } +} +' + +grpc_cli call localhost:6565 GetFeatureSets ' +filter { + feature_set_name: "driver" +} +' + +grpc_cli call localhost:6566 GetBatchFeatures ' +feature_sets { + name: "driver" + feature_names: "booking_completed_count" + max_age { + seconds: 86400 + } +} +entity_dataset { + entity_names: "driver_id" + entity_dataset_rows { + entity_timestamp { + seconds: 1569873954 + } + } +} +' +``` + +``` +python3 < + + + 4.0.0 + + + dev.feast + feast-parent + ${revision} + + + feast-serving + Feast Serving + Feature serving API service + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 11 + + + + + org.jacoco + jacoco-maven-plugin + + + + org.springframework.boot + spring-boot-maven-plugin + + false + + + + build-info + + build-info + + + + + + + + + + + dev.feast + datatypes-java + ${project.version} + + + + + dev.feast + feast-storage-api + ${project.version} + + + + dev.feast + feast-storage-connector-redis + ${project.version} + + + + dev.feast + feast-storage-connector-bigtable + ${project.version} + + + + dev.feast + feast-storage-connector-cassandra + ${project.version} + + + + + + org.slf4j + slf4j-api + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-log4j2 + + + org.apache.logging.log4j + log4j-web + + + + org.springframework.boot + spring-boot-devtools + true + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-test + 2.3.1.RELEASE + test + + + + + io.grpc + grpc-services + + + + io.grpc + grpc-stub + + + + com.google.protobuf + protobuf-java-util + + + + + com.google.guava + guava + + + + joda-time + joda-time + + + + io.jaegertracing + jaeger-client + 1.3.2 + + + io.opentracing + opentracing-api + 0.33.0 + + + io.opentracing + opentracing-noop + 0.33.0 + + + io.opentracing.contrib + opentracing-grpc + 0.2.3 + + + + + io.prometheus + simpleclient + 0.8.0 + + + + + io.prometheus + simpleclient_hotspot + 0.8.0 + + + + + io.prometheus + simpleclient_servlet + 0.8.0 + + + io.prometheus + simpleclient_spring_boot + 0.8.0 + + + + com.google.auto.value + auto-value-annotations + 1.6.6 + + + + + + io.grpc + grpc-testing + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + + + + + + org.mockito + mockito-core + test + + + + + org.apache.avro + avro + 1.10.2 + + + + + org.apache.arrow + arrow-java-root + 5.0.0 + pom + + + + + org.apache.arrow + arrow-vector + 5.0.0 + + + + + org.apache.arrow + arrow-memory + 5.0.0 + pom + + + + + org.apache.arrow + arrow-memory-netty + 5.0.0 + runtime + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + + com.github.kstyrc + embedded-redis + test + + + jakarta.validation + jakarta.validation-api + ${jakarta.validation.api.version} + + + net.devh + grpc-server-spring-boot-starter + ${grpc.spring.boot.starter.version} + + + org.testcontainers + testcontainers + 1.16.2 + test + + + org.testcontainers + junit-jupiter + 1.16.2 + test + + + org.awaitility + awaitility + 3.0.0 + test + + + com.squareup.okhttp + okhttp + 2.7.4 + test + + + + + + profile-local + + + !ci + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.1.0 + + + @ + + false + + + + + + + + src/main/resources + true + + + + + + diff --git a/java/serving/src/main/java/feast/serving/ServingApplication.java b/java/serving/src/main/java/feast/serving/ServingApplication.java new file mode 100644 index 00000000000..ab036d04d18 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/ServingApplication.java @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving; + +import feast.serving.config.FeastProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication( + exclude = { + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + HibernateJpaAutoConfiguration.class + }) +@EnableConfigurationProperties(FeastProperties.class) +public class ServingApplication { + public static void main(String[] args) { + SpringApplication.run(ServingApplication.class, args); + } +} diff --git a/java/serving/src/main/java/feast/serving/config/FeastProperties.java b/java/serving/src/main/java/feast/serving/config/FeastProperties.java new file mode 100644 index 00000000000..493f8f9ed62 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/config/FeastProperties.java @@ -0,0 +1,365 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.config; + +// Feast configuration properties that maps Feast configuration from default application.yml file to +// a Java object. +// https://www.baeldung.com/configuration-properties-in-spring-boot +// https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-external-config.html#boot-features-external-config-typesafe-configuration-properties + +import feast.common.logging.config.LoggingProperties; +import feast.storage.connectors.redis.retriever.RedisClusterStoreConfig; +import feast.storage.connectors.redis.retriever.RedisStoreConfig; +import io.lettuce.core.ReadFrom; +import java.time.Duration; +import java.util.*; +import javax.annotation.PostConstruct; +import javax.validation.*; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.info.BuildProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; + +/** Feast Serving properties. */ +@ComponentScan("feast.common.logging") +@ConfigurationProperties(prefix = "feast", ignoreInvalidFields = true) +public class FeastProperties { + + /** + * Instantiates a new Feast Serving properties. + * + * @param buildProperties the build properties + */ + @Autowired + public FeastProperties(BuildProperties buildProperties) { + setVersion(buildProperties.getVersion()); + } + + /** Instantiates a new Feast class. */ + public FeastProperties() {} + + /* Feast Serving build version */ + @NotBlank private String version = "unknown"; + + @NotBlank private String registry; + + public String getRegistry() { + return registry; + } + + public void setRegistry(final String registry) { + this.registry = registry; + } + + private String transformationServiceEndpoint; + + public String getTransformationServiceEndpoint() { + return transformationServiceEndpoint; + } + + public void setTransformationServiceEndpoint(final String transformationServiceEndpoint) { + this.transformationServiceEndpoint = transformationServiceEndpoint; + } + + /** + * Finds and returns the active store + * + * @return Returns the {@link Store} model object + */ + public Store getActiveStore() { + for (Store store : getStores()) { + if (activeStore.equals(store.getName())) { + return store; + } + } + throw new RuntimeException( + String.format("Active store is misconfigured. Could not find store: %s.", activeStore)); + } + + /** + * Set the name of the active store found in the "stores" configuration list + * + * @param activeStore String name to active store + */ + public void setActiveStore(String activeStore) { + this.activeStore = activeStore; + } + + /** Name of the active store configuration (only one store can be active at a time). */ + @NotBlank private String activeStore; + + /** + * Collection of store configurations. The active store is selected by the "activeStore" field. + */ + private List stores = new ArrayList<>(); + + /* Metric tracing properties. */ + private TracingProperties tracing; + + /* Feast Audit Logging properties */ + @NotNull private LoggingProperties logging; + + @Bean + LoggingProperties loggingProperties() { + return getLogging(); + } + + /** + * Gets Serving store configuration as a list of {@link Store}. + * + * @return List of stores objects + */ + public List getStores() { + return stores; + } + + /** + * Gets Feast Serving build version. + * + * @return the build version + */ + public String getVersion() { + return version; + } + + /** + * Sets build version + * + * @param version the build version + */ + public void setVersion(String version) { + this.version = version; + } + + /** + * Sets the collection of configured stores. + * + * @param stores List of {@link Store} + */ + public void setStores(List stores) { + this.stores = stores; + } + + /** Store configuration class for database that this Feast Serving uses. */ + public static class Store { + + private String name; + + private String type; + + private Map config = new HashMap<>(); + + /** + * Gets name of this store. This is unique to this specific instance. + * + * @return the name of the store + */ + public String getName() { + return name; + } + + /** + * Sets the name of this store. + * + * @param name the name of the store + */ + public void setName(String name) { + this.name = name; + } + + /** + * Gets the store type. Example are REDIS, REDIS_CLUSTER, BIGTABLE or CASSANDRA + * + * @return the store type as a String. + */ + public StoreType getType() { + return StoreType.valueOf(this.type); + } + + /** + * Sets the store type + * + * @param type the type + */ + public void setType(String type) { + this.type = type; + } + + /** + * Gets the configuration to this specific store. This is a map of strings. These options are + * unique to the store. Please see protos/feast/core/Store.proto for the store specific + * configuration options + * + * @return Returns the store specific configuration + */ + public Map getConfig() { + return config; + } + + public RedisClusterStoreConfig getRedisClusterConfig() { + return new RedisClusterStoreConfig( + this.config.get("connection_string"), + ReadFrom.valueOf(this.config.get("read_from")), + Duration.parse(this.config.get("timeout"))); + } + + public RedisStoreConfig getRedisConfig() { + return new RedisStoreConfig( + this.config.get("host"), + Integer.valueOf(this.config.get("port")), + Boolean.valueOf(this.config.getOrDefault("ssl", "false")), + this.config.getOrDefault("password", "")); + } + + /** + * Sets the store config. Please protos/feast/core/Store.proto for the specific options for each + * store. + * + * @param config the config map + */ + public void setConfig(Map config) { + this.config = config; + } + } + + public enum StoreType { + REDIS, + REDIS_CLUSTER; + } + + /** + * Gets tracing properties + * + * @return tracing properties + */ + public TracingProperties getTracing() { + return tracing; + } + + /** + * Sets the tracing configuration. + * + * @param tracing the tracing + */ + public void setTracing(TracingProperties tracing) { + this.tracing = tracing; + } + + /** + * Gets logging properties + * + * @return logging properties + */ + public LoggingProperties getLogging() { + return logging; + } + + /** + * Sets logging properties + * + * @param logging the logging properties + */ + public void setLogging(LoggingProperties logging) { + this.logging = logging; + } + + /** Trace metric collection properties */ + public static class TracingProperties { + + /** Tracing enabled/disabled */ + private boolean enabled; + + /** Name of tracer to use (only "jaeger") */ + private String tracerName; + + /** Service name uniquely identifies this Feast Serving deployment */ + private String serviceName; + + /** + * Is tracing enabled + * + * @return boolean flag + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets tracing enabled or disabled. + * + * @param enabled flag + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * Gets tracer name ('jaeger') + * + * @return the tracer name + */ + public String getTracerName() { + return tracerName; + } + + /** + * Sets tracer name. + * + * @param tracerName the tracer name + */ + public void setTracerName(String tracerName) { + this.tracerName = tracerName; + } + + /** + * Gets the service name. The service name uniquely identifies this Feast serving instance. + * + * @return the service name + */ + public String getServiceName() { + return serviceName; + } + + /** + * Sets service name. + * + * @param serviceName the service name + */ + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + } + + /** + * Validates all FeastProperties. This method runs after properties have been initialized and + * individually and conditionally validates each class. + */ + @PostConstruct + public void validate() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + Validator validator = factory.getValidator(); + + // Validate root fields in FeastProperties + Set> violations = validator.validate(this); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + } +} diff --git a/java/serving/src/main/java/feast/serving/config/InstrumentationConfig.java b/java/serving/src/main/java/feast/serving/config/InstrumentationConfig.java new file mode 100644 index 00000000000..295b263f664 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/config/InstrumentationConfig.java @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.config; + +import io.opentracing.Tracer; +import io.opentracing.contrib.grpc.TracingServerInterceptor; +import io.opentracing.noop.NoopTracerFactory; +import io.prometheus.client.exporter.MetricsServlet; +import io.prometheus.client.hotspot.DefaultExports; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class InstrumentationConfig { + + private FeastProperties feastProperties; + + @Autowired + public InstrumentationConfig(FeastProperties feastProperties) { + this.feastProperties = feastProperties; + } + + @Bean + public ServletRegistrationBean servletRegistrationBean() { + DefaultExports.initialize(); + return new ServletRegistrationBean(new MetricsServlet(), "/metrics"); + } + + @Bean + public Tracer tracer() { + if (!feastProperties.getTracing().isEnabled()) { + return NoopTracerFactory.create(); + } + + if (!feastProperties.getTracing().getTracerName().equalsIgnoreCase("jaeger")) { + throw new IllegalArgumentException("Only 'jaeger' tracer is supported for now."); + } + + return io.jaegertracing.Configuration.fromEnv(feastProperties.getTracing().getServiceName()) + .getTracer(); + } + + @Bean + public TracingServerInterceptor tracingInterceptor(Tracer tracer) { + return TracingServerInterceptor.newBuilder().withTracer(tracer).build(); + } +} diff --git a/java/serving/src/main/java/feast/serving/config/ServingApiConfiguration.java b/java/serving/src/main/java/feast/serving/config/ServingApiConfiguration.java new file mode 100644 index 00000000000..ce4fe134373 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/config/ServingApiConfiguration.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.config; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.protobuf.ProtobufJsonFormatHttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class ServingApiConfiguration implements WebMvcConfigurer { + @Autowired private ProtobufJsonFormatHttpMessageConverter protobufConverter; + + @Bean + ProtobufJsonFormatHttpMessageConverter protobufHttpMessageConverter() { + return new ProtobufJsonFormatHttpMessageConverter(); + } + + @Override + public void configureMessageConverters(List> converters) { + converters.add(protobufConverter); + } +} diff --git a/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java b/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java new file mode 100644 index 00000000000..3a7936cb578 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/config/ServingServiceConfigV2.java @@ -0,0 +1,79 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.config; + +import feast.serving.registry.LocalRegistryRepo; +import feast.serving.service.OnlineServingServiceV2; +import feast.serving.service.OnlineTransformationService; +import feast.serving.service.ServingServiceV2; +import feast.serving.specs.FeatureSpecRetriever; +import feast.serving.specs.RegistryFeatureSpecRetriever; +import feast.storage.api.retriever.OnlineRetrieverV2; +import feast.storage.connectors.redis.retriever.*; +import io.opentracing.Tracer; +import java.nio.file.Paths; +import org.slf4j.Logger; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ServingServiceConfigV2 { + private static final Logger log = org.slf4j.LoggerFactory.getLogger(ServingServiceConfigV2.class); + + @Bean + public ServingServiceV2 registryBasedServingServiceV2( + FeastProperties feastProperties, Tracer tracer) { + final ServingServiceV2 servingService; + final FeastProperties.Store store = feastProperties.getActiveStore(); + + OnlineRetrieverV2 retrieverV2; + // TODO: Support more store types, and potentially use a plugin model here. + switch (store.getType()) { + case REDIS_CLUSTER: + RedisClientAdapter redisClusterClient = + RedisClusterClient.create(store.getRedisClusterConfig()); + retrieverV2 = new OnlineRetriever(redisClusterClient, new EntityKeySerializerV2()); + break; + case REDIS: + RedisClientAdapter redisClient = RedisClient.create(store.getRedisConfig()); + log.info("Created EntityKeySerializerV2"); + retrieverV2 = new OnlineRetriever(redisClient, new EntityKeySerializerV2()); + break; + default: + throw new RuntimeException( + String.format( + "Unable to identify online store type: %s for Regsitry Backed Serving Service", + store.getType())); + } + + final FeatureSpecRetriever featureSpecRetriever; + log.info("Created RegistryFeatureSpecRetriever"); + log.info("Working Directory = " + System.getProperty("user.dir")); + final LocalRegistryRepo repo = new LocalRegistryRepo(Paths.get(feastProperties.getRegistry())); + featureSpecRetriever = new RegistryFeatureSpecRetriever(repo); + + final String transformationServiceEndpoint = feastProperties.getTransformationServiceEndpoint(); + final OnlineTransformationService onlineTransformationService = + new OnlineTransformationService(transformationServiceEndpoint, featureSpecRetriever); + + servingService = + new OnlineServingServiceV2( + retrieverV2, tracer, featureSpecRetriever, onlineTransformationService); + + return servingService; + } +} diff --git a/java/serving/src/main/java/feast/serving/config/WebSecurityConfig.java b/java/serving/src/main/java/feast/serving/config/WebSecurityConfig.java new file mode 100644 index 00000000000..04d3f4b5afb --- /dev/null +++ b/java/serving/src/main/java/feast/serving/config/WebSecurityConfig.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +/** + * WebSecurityConfig disables auto configuration of Spring HTTP Security and allows security methods + * to be overridden + */ +@Configuration +@EnableWebSecurity +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + /** + * Allows for custom web security rules to be applied. + * + * @param http {@link HttpSecurity} for configuring web based security + * @throws Exception exception + */ + @Override + protected void configure(HttpSecurity http) throws Exception { + + // Bypasses security/authentication for the following paths + http.authorizeRequests() + .antMatchers("/actuator/**", "/metrics/**") + .permitAll() + .anyRequest() + .authenticated() + .and() + .csrf() + .disable(); + } +} diff --git a/java/serving/src/main/java/feast/serving/controller/HealthServiceController.java b/java/serving/src/main/java/feast/serving/controller/HealthServiceController.java new file mode 100644 index 00000000000..ef675d4c157 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/controller/HealthServiceController.java @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.controller; + +import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; +import feast.serving.interceptors.GrpcMonitoringInterceptor; +import feast.serving.service.ServingServiceV2; +import io.grpc.health.v1.HealthGrpc.HealthImplBase; +import io.grpc.health.v1.HealthProto.HealthCheckRequest; +import io.grpc.health.v1.HealthProto.HealthCheckResponse; +import io.grpc.health.v1.HealthProto.ServingStatus; +import io.grpc.stub.StreamObserver; +import net.devh.boot.grpc.server.service.GrpcService; +import org.springframework.beans.factory.annotation.Autowired; + +// Reference: https://github.com/grpc/grpc/blob/master/doc/health-checking.md + +@GrpcService(interceptors = {GrpcMonitoringInterceptor.class}) +public class HealthServiceController extends HealthImplBase { + private final ServingServiceV2 servingService; + + @Autowired + public HealthServiceController(final ServingServiceV2 servingService) { + this.servingService = servingService; + } + + @Override + public void check( + HealthCheckRequest request, StreamObserver responseObserver) { + // TODO: Implement proper logic to determine if ServingServiceV2 is healthy e.g. + // if it's online service check that it the service can retrieve dummy/random + // feature table. + // Implement similarly for batch service. + + try { + servingService.getFeastServingInfo(GetFeastServingInfoRequest.getDefaultInstance()); + responseObserver.onNext( + HealthCheckResponse.newBuilder().setStatus(ServingStatus.SERVING).build()); + } catch (Exception e) { + responseObserver.onNext( + HealthCheckResponse.newBuilder().setStatus(ServingStatus.NOT_SERVING).build()); + } + responseObserver.onCompleted(); + } +} diff --git a/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java b/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java new file mode 100644 index 00000000000..048a53fad1b --- /dev/null +++ b/java/serving/src/main/java/feast/serving/controller/ServingServiceGRpcController.java @@ -0,0 +1,107 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.controller; + +import feast.common.logging.interceptors.GrpcMessageInterceptor; +import feast.proto.serving.ServingAPIProto; +import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; +import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingServiceGrpc.ServingServiceImplBase; +import feast.serving.config.FeastProperties; +import feast.serving.exception.SpecRetrievalException; +import feast.serving.interceptors.GrpcMonitoringContext; +import feast.serving.interceptors.GrpcMonitoringInterceptor; +import feast.serving.service.ServingServiceV2; +import feast.serving.util.RequestHelper; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import io.opentracing.Span; +import io.opentracing.Tracer; +import io.opentracing.contrib.grpc.TracingServerInterceptor; +import net.devh.boot.grpc.server.service.GrpcService; +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.AccessDeniedException; + +@GrpcService( + interceptors = { + TracingServerInterceptor.class, + GrpcMessageInterceptor.class, + GrpcMonitoringInterceptor.class + }) +public class ServingServiceGRpcController extends ServingServiceImplBase { + + private static final Logger log = + org.slf4j.LoggerFactory.getLogger(ServingServiceGRpcController.class); + private final ServingServiceV2 servingServiceV2; + private final String version; + private final Tracer tracer; + + @Autowired + public ServingServiceGRpcController( + ServingServiceV2 servingServiceV2, FeastProperties feastProperties, Tracer tracer) { + this.servingServiceV2 = servingServiceV2; + this.version = feastProperties.getVersion(); + this.tracer = tracer; + } + + @Override + public void getFeastServingInfo( + GetFeastServingInfoRequest request, + StreamObserver responseObserver) { + GetFeastServingInfoResponse feastServingInfo = servingServiceV2.getFeastServingInfo(request); + feastServingInfo = feastServingInfo.toBuilder().setVersion(version).build(); + responseObserver.onNext(feastServingInfo); + responseObserver.onCompleted(); + } + + @Override + public void getOnlineFeaturesV2( + ServingAPIProto.GetOnlineFeaturesRequestV2 request, + StreamObserver responseObserver) { + try { + // authorize for the project in request object. + if (request.getProject() != null && !request.getProject().isEmpty()) { + // update monitoring context + GrpcMonitoringContext.getInstance().setProject(request.getProject()); + } + RequestHelper.validateOnlineRequest(request); + Span span = tracer.buildSpan("getOnlineFeaturesV2").start(); + GetOnlineFeaturesResponse onlineFeatures = servingServiceV2.getOnlineFeatures(request); + if (span != null) { + span.finish(); + } + responseObserver.onNext(onlineFeatures); + responseObserver.onCompleted(); + } catch (SpecRetrievalException e) { + log.error("Failed to retrieve specs in SpecService", e); + responseObserver.onError( + Status.NOT_FOUND.withDescription(e.getMessage()).withCause(e).asException()); + } catch (AccessDeniedException e) { + log.info(String.format("User prevented from accessing one of the projects in request")); + responseObserver.onError( + Status.PERMISSION_DENIED + .withDescription(e.getMessage()) + .withCause(e) + .asRuntimeException()); + } catch (Exception e) { + log.warn("Failed to get Online Features", e); + responseObserver.onError(e); + } + } +} diff --git a/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java b/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java new file mode 100644 index 00000000000..8a198a82019 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/controller/ServingServiceRestController.java @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.controller; + +import static feast.serving.util.mappers.ResponseJSONMapper.mapGetOnlineFeaturesResponse; + +import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; +import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.serving.config.FeastProperties; +import feast.serving.service.ServingServiceV2; +import feast.serving.util.RequestHelper; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ServingServiceRestController { + + private final ServingServiceV2 servingService; + private final String version; + + @Autowired + public ServingServiceRestController( + ServingServiceV2 servingService, FeastProperties feastProperties) { + this.servingService = servingService; + this.version = feastProperties.getVersion(); + } + + @RequestMapping(value = "/api/v1/info", produces = "application/json") + public GetFeastServingInfoResponse getInfo() { + GetFeastServingInfoResponse feastServingInfo = + servingService.getFeastServingInfo(GetFeastServingInfoRequest.getDefaultInstance()); + return feastServingInfo.toBuilder().setVersion(version).build(); + } + + @RequestMapping( + value = "/api/v1/features/online", + produces = "application/json", + consumes = "application/json") + public List> getOnlineFeatures( + @RequestBody GetOnlineFeaturesRequestV2 request) { + RequestHelper.validateOnlineRequest(request); + GetOnlineFeaturesResponse onlineFeatures = servingService.getOnlineFeatures(request); + return mapGetOnlineFeaturesResponse(onlineFeatures); + } +} diff --git a/java/serving/src/main/java/feast/serving/exception/SpecRetrievalException.java b/java/serving/src/main/java/feast/serving/exception/SpecRetrievalException.java new file mode 100644 index 00000000000..fbcba969fa4 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/exception/SpecRetrievalException.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.exception; + +/** Application-specific exception for any failure of retrieving feature/entity/storage spec. */ +public class SpecRetrievalException extends RuntimeException { + public SpecRetrievalException() { + super(); + } + + public SpecRetrievalException(String message) { + super(message); + } + + public SpecRetrievalException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/java/serving/src/main/java/feast/serving/interceptors/GrpcMonitoringContext.java b/java/serving/src/main/java/feast/serving/interceptors/GrpcMonitoringContext.java new file mode 100644 index 00000000000..48d8d76a914 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/interceptors/GrpcMonitoringContext.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.interceptors; + +import java.util.Optional; + +public class GrpcMonitoringContext { + private static GrpcMonitoringContext INSTANCE; + + final ThreadLocal project = new ThreadLocal(); + + private GrpcMonitoringContext() {} + + public static GrpcMonitoringContext getInstance() { + if (INSTANCE == null) { + INSTANCE = new GrpcMonitoringContext(); + } + + return INSTANCE; + } + + public void setProject(String name) { + this.project.set(name); + } + + public Optional getProject() { + return Optional.ofNullable(this.project.get()); + } + + public void clearProject() { + this.project.set(null); + } +} diff --git a/java/serving/src/main/java/feast/serving/interceptors/GrpcMonitoringInterceptor.java b/java/serving/src/main/java/feast/serving/interceptors/GrpcMonitoringInterceptor.java new file mode 100644 index 00000000000..735f8c556d6 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/interceptors/GrpcMonitoringInterceptor.java @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.interceptors; + +import feast.serving.util.Metrics; +import io.grpc.ForwardingServerCall.SimpleForwardingServerCall; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCall.Listener; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import java.util.Optional; + +/** + * GrpcMonitoringInterceptor intercepts GRPC calls to provide request latency histogram metrics in + * the Prometheus client. + */ +public class GrpcMonitoringInterceptor implements ServerInterceptor { + + @Override + public Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + + long startCallMillis = System.currentTimeMillis(); + String fullMethodName = call.getMethodDescriptor().getFullMethodName(); + String methodName = fullMethodName.substring(fullMethodName.indexOf("/") + 1); + + GrpcMonitoringContext.getInstance().clearProject(); + + return next.startCall( + new SimpleForwardingServerCall(call) { + @Override + public void close(Status status, Metadata trailers) { + Optional projectName = GrpcMonitoringContext.getInstance().getProject(); + + Metrics.requestLatency + .labels(methodName, projectName.orElse("")) + .observe((System.currentTimeMillis() - startCallMillis) / 1000f); + Metrics.grpcRequestCount.labels(methodName, status.getCode().name()).inc(); + super.close(status, trailers); + } + }, + headers); + } +} diff --git a/java/serving/src/main/java/feast/serving/registry/LocalRegistryRepo.java b/java/serving/src/main/java/feast/serving/registry/LocalRegistryRepo.java new file mode 100644 index 00000000000..4178541a0fe --- /dev/null +++ b/java/serving/src/main/java/feast/serving/registry/LocalRegistryRepo.java @@ -0,0 +1,121 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.registry; + +import feast.proto.core.FeatureProto; +import feast.proto.core.FeatureViewProto; +import feast.proto.core.OnDemandFeatureViewProto; +import feast.proto.core.RegistryProto; +import feast.proto.serving.ServingAPIProto; +import feast.serving.exception.SpecRetrievalException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class LocalRegistryRepo implements RegistryRepository { + private final RegistryProto.Registry registry; + private Map featureViewNameToSpec; + private Map + onDemandFeatureViewNameToSpec; + + public LocalRegistryRepo(Path localRegistryPath) { + if (!localRegistryPath.toFile().exists()) { + throw new RuntimeException( + String.format("Local registry not found at path %s", localRegistryPath)); + } + try { + final byte[] registryContents = Files.readAllBytes(localRegistryPath); + this.registry = RegistryProto.Registry.parseFrom(registryContents); + } catch (final Exception e) { + throw new RuntimeException(e); + } + + final RegistryProto.Registry registry = this.getRegistry(); + List featureViewSpecs = + registry.getFeatureViewsList().stream() + .map(fv -> fv.getSpec()) + .collect(Collectors.toList()); + featureViewNameToSpec = + featureViewSpecs.stream() + .collect( + Collectors.toMap(FeatureViewProto.FeatureViewSpec::getName, Function.identity())); + List onDemandFeatureViewSpecs = + registry.getOnDemandFeatureViewsList().stream() + .map(odfv -> odfv.getSpec()) + .collect(Collectors.toList()); + onDemandFeatureViewNameToSpec = + onDemandFeatureViewSpecs.stream() + .collect( + Collectors.toMap( + OnDemandFeatureViewProto.OnDemandFeatureViewSpec::getName, + Function.identity())); + } + + @Override + public RegistryProto.Registry getRegistry() { + return this.registry; + } + + @Override + public FeatureViewProto.FeatureViewSpec getFeatureViewSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { + String featureViewName = featureReference.getFeatureTable(); + if (featureViewNameToSpec.containsKey(featureViewName)) { + return featureViewNameToSpec.get(featureViewName); + } + throw new SpecRetrievalException( + String.format("Unable to find feature view with name: %s", featureViewName)); + } + + @Override + public FeatureProto.FeatureSpecV2 getFeatureSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { + final FeatureViewProto.FeatureViewSpec spec = + this.getFeatureViewSpec(projectName, featureReference); + for (final FeatureProto.FeatureSpecV2 featureSpec : spec.getFeaturesList()) { + if (featureSpec.getName().equals(featureReference.getName())) { + return featureSpec; + } + } + + throw new SpecRetrievalException( + String.format( + "Unable to find feature with name: %s in feature view: %s", + featureReference.getName(), featureReference.getFeatureTable())); + } + + @Override + public OnDemandFeatureViewProto.OnDemandFeatureViewSpec getOnDemandFeatureViewSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { + String onDemandFeatureViewName = featureReference.getFeatureTable(); + if (onDemandFeatureViewNameToSpec.containsKey(onDemandFeatureViewName)) { + return onDemandFeatureViewNameToSpec.get(onDemandFeatureViewName); + } + throw new SpecRetrievalException( + String.format( + "Unable to find on demand feature view with name: %s", onDemandFeatureViewName)); + } + + @Override + public boolean isOnDemandFeatureReference(ServingAPIProto.FeatureReferenceV2 featureReference) { + String onDemandFeatureViewName = featureReference.getFeatureTable(); + return onDemandFeatureViewNameToSpec.containsKey(onDemandFeatureViewName); + } +} diff --git a/java/serving/src/main/java/feast/serving/registry/RegistryRepository.java b/java/serving/src/main/java/feast/serving/registry/RegistryRepository.java new file mode 100644 index 00000000000..21a21838aad --- /dev/null +++ b/java/serving/src/main/java/feast/serving/registry/RegistryRepository.java @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.registry; + +import feast.proto.core.FeatureProto; +import feast.proto.core.FeatureViewProto; +import feast.proto.core.OnDemandFeatureViewProto; +import feast.proto.core.RegistryProto; +import feast.proto.serving.ServingAPIProto; + +/** + * RegistryRepository allows the ServingService to retrieve feature definitions from a Registry + * object. This approach is needed for a feature store created using feast 0.10+. + */ +public interface RegistryRepository { + RegistryProto.Registry getRegistry(); + + FeatureViewProto.FeatureViewSpec getFeatureViewSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference); + + FeatureProto.FeatureSpecV2 getFeatureSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference); + + OnDemandFeatureViewProto.OnDemandFeatureViewSpec getOnDemandFeatureViewSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference); + + boolean isOnDemandFeatureReference(ServingAPIProto.FeatureReferenceV2 featureReference); +} diff --git a/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java b/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java new file mode 100644 index 00000000000..2992c5022d2 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/service/OnlineServingServiceV2.java @@ -0,0 +1,428 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.service; + +import static feast.common.models.FeatureTable.getFeatureTableStringRef; + +import com.google.protobuf.Duration; +import feast.common.models.FeatureV2; +import feast.proto.serving.ServingAPIProto.FeastServingType; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetFeastServingInfoRequest; +import feast.proto.serving.ServingAPIProto.GetFeastServingInfoResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesRequest; +import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesResponse; +import feast.proto.serving.TransformationServiceAPIProto.ValueType; +import feast.proto.types.ValueProto; +import feast.serving.exception.SpecRetrievalException; +import feast.serving.specs.FeatureSpecRetriever; +import feast.serving.util.Metrics; +import feast.storage.api.retriever.Feature; +import feast.storage.api.retriever.OnlineRetrieverV2; +import io.grpc.Status; +import io.opentracing.Span; +import io.opentracing.Tracer; +import java.io.*; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.commons.lang3.tuple.Pair; +import org.slf4j.Logger; + +public class OnlineServingServiceV2 implements ServingServiceV2 { + + private static final Logger log = org.slf4j.LoggerFactory.getLogger(OnlineServingServiceV2.class); + private final Tracer tracer; + private final OnlineRetrieverV2 retriever; + private final FeatureSpecRetriever featureSpecRetriever; + private final OnlineTransformationService onlineTransformationService; + + public OnlineServingServiceV2( + OnlineRetrieverV2 retriever, + Tracer tracer, + FeatureSpecRetriever featureSpecRetriever, + OnlineTransformationService onlineTransformationService) { + this.retriever = retriever; + this.tracer = tracer; + this.featureSpecRetriever = featureSpecRetriever; + this.onlineTransformationService = onlineTransformationService; + } + + /** {@inheritDoc} */ + @Override + public GetFeastServingInfoResponse getFeastServingInfo( + GetFeastServingInfoRequest getFeastServingInfoRequest) { + return GetFeastServingInfoResponse.newBuilder() + .setType(FeastServingType.FEAST_SERVING_TYPE_ONLINE) + .build(); + } + + @Override + public GetOnlineFeaturesResponse getOnlineFeatures(GetOnlineFeaturesRequestV2 request) { + // Autofill default project if project is not specified + String projectName = request.getProject(); + if (projectName.isEmpty()) { + projectName = "default"; + } + + // Split all feature references into non-ODFV (e.g. batch and stream) references and ODFV. + List allFeatureReferences = request.getFeaturesList(); + List featureReferences = + allFeatureReferences.stream() + .filter(r -> !this.featureSpecRetriever.isOnDemandFeatureReference(r)) + .collect(Collectors.toList()); + List onDemandFeatureReferences = + allFeatureReferences.stream() + .filter(r -> this.featureSpecRetriever.isOnDemandFeatureReference(r)) + .collect(Collectors.toList()); + + // Get the set of request data feature names and feature inputs from the ODFV references. + Pair, List> pair = + this.onlineTransformationService.extractRequestDataFeatureNamesAndOnDemandFeatureInputs( + onDemandFeatureReferences, projectName); + Set requestDataFeatureNames = pair.getLeft(); + List onDemandFeatureInputs = pair.getRight(); + + // Add on demand feature inputs to list of feature references to retrieve. + Set addedFeatureReferences = new HashSet(); + for (FeatureReferenceV2 onDemandFeatureInput : onDemandFeatureInputs) { + if (!featureReferences.contains(onDemandFeatureInput)) { + featureReferences.add(onDemandFeatureInput); + addedFeatureReferences.add(onDemandFeatureInput); + } + } + + // Separate entity rows into entity data and request feature data. + Pair, Map>> + entityRowsAndRequestDataFeatures = + this.onlineTransformationService.separateEntityRows(requestDataFeatureNames, request); + List entityRows = + entityRowsAndRequestDataFeatures.getLeft(); + Map> requestDataFeatures = + entityRowsAndRequestDataFeatures.getRight(); + // TODO: error checking on lengths of lists in entityRows and requestDataFeatures + + // Extract values and statuses to be used later in constructing FieldValues for the response. + // The online features retrieved will augment these two data structures. + List> values = + entityRows.stream().map(r -> new HashMap<>(r.getFieldsMap())).collect(Collectors.toList()); + List> statuses = + entityRows.stream() + .map( + r -> + r.getFieldsMap().entrySet().stream() + .map(entry -> Pair.of(entry.getKey(), getMetadata(entry.getValue(), false))) + .collect(Collectors.toMap(Pair::getLeft, Pair::getRight))) + .collect(Collectors.toList()); + + String finalProjectName = projectName; + Map featureMaxAges = + featureReferences.stream() + .distinct() + .collect( + Collectors.toMap( + Function.identity(), + ref -> this.featureSpecRetriever.getMaxAge(finalProjectName, ref))); + List entityNames = + featureReferences.stream() + .map(ref -> this.featureSpecRetriever.getEntitiesList(finalProjectName, ref)) + .findFirst() + .get(); + + Map featureValueTypes = + featureReferences.stream() + .distinct() + .collect( + Collectors.toMap( + Function.identity(), + ref -> { + try { + return this.featureSpecRetriever + .getFeatureSpec(finalProjectName, ref) + .getValueType(); + } catch (SpecRetrievalException e) { + return ValueProto.ValueType.Enum.INVALID; + } + })); + + Span storageRetrievalSpan = tracer.buildSpan("storageRetrieval").start(); + if (storageRetrievalSpan != null) { + storageRetrievalSpan.setTag("entities", entityRows.size()); + storageRetrievalSpan.setTag("features", featureReferences.size()); + } + List> entityRowsFeatures = + retriever.getOnlineFeatures(projectName, entityRows, featureReferences, entityNames); + if (storageRetrievalSpan != null) { + storageRetrievalSpan.finish(); + } + + if (entityRowsFeatures.size() != entityRows.size()) { + throw Status.INTERNAL + .withDescription( + "The no. of FeatureRow obtained from OnlineRetriever" + + "does not match no. of entityRow passed.") + .asRuntimeException(); + } + + Span postProcessingSpan = tracer.buildSpan("postProcessing").start(); + + for (int i = 0; i < entityRows.size(); i++) { + GetOnlineFeaturesRequestV2.EntityRow entityRow = entityRows.get(i); + List curEntityRowFeatures = entityRowsFeatures.get(i); + + Map featureReferenceFeatureMap = + getFeatureRefFeatureMap(curEntityRowFeatures); + + Map rowValues = values.get(i); + Map rowStatuses = statuses.get(i); + + for (FeatureReferenceV2 featureReference : featureReferences) { + if (featureReferenceFeatureMap.containsKey(featureReference)) { + Feature feature = featureReferenceFeatureMap.get(featureReference); + + ValueProto.Value value = + feature.getFeatureValue(featureValueTypes.get(feature.getFeatureReference())); + + Boolean isOutsideMaxAge = + checkOutsideMaxAge( + feature, entityRow, featureMaxAges.get(feature.getFeatureReference())); + + if (value != null) { + rowValues.put(FeatureV2.getFeatureStringRef(feature.getFeatureReference()), value); + } else { + rowValues.put( + FeatureV2.getFeatureStringRef(feature.getFeatureReference()), + ValueProto.Value.newBuilder().build()); + } + + rowStatuses.put( + FeatureV2.getFeatureStringRef(feature.getFeatureReference()), + getMetadata(value, isOutsideMaxAge)); + } else { + rowValues.put( + FeatureV2.getFeatureStringRef(featureReference), + ValueProto.Value.newBuilder().build()); + + rowStatuses.put( + FeatureV2.getFeatureStringRef(featureReference), getMetadata(null, false)); + } + } + // Populate metrics/log request + populateCountMetrics(rowStatuses, projectName); + } + + if (postProcessingSpan != null) { + postProcessingSpan.finish(); + } + + populateHistogramMetrics(entityRows, featureReferences, projectName); + populateFeatureCountMetrics(featureReferences, projectName); + + // Handle ODFVs. For each ODFV reference, we send a TransformFeaturesRequest to the FTS. + // The request should contain the entity data, the retrieved features, and the request data. + if (!onDemandFeatureReferences.isEmpty()) { + // Augment values, which contains the entity data and retrieved features, with the request + // data. Also augment statuses. + for (int i = 0; i < values.size(); i++) { + Map rowValues = values.get(i); + Map rowStatuses = statuses.get(i); + + for (Map.Entry> entry : requestDataFeatures.entrySet()) { + String key = entry.getKey(); + List fieldValues = entry.getValue(); + rowValues.put(key, fieldValues.get(i)); + rowStatuses.put(key, GetOnlineFeaturesResponse.FieldStatus.PRESENT); + } + } + + // Serialize the augmented values. + ValueType transformationInput = + this.onlineTransformationService.serializeValuesIntoArrowIPC(values); + + // Send out requests to the FTS and process the responses. + Set onDemandFeatureStringReferences = + onDemandFeatureReferences.stream() + .map(r -> FeatureV2.getFeatureStringRef(r)) + .collect(Collectors.toSet()); + for (FeatureReferenceV2 featureReference : onDemandFeatureReferences) { + String onDemandFeatureViewName = featureReference.getFeatureTable(); + TransformFeaturesRequest transformFeaturesRequest = + TransformFeaturesRequest.newBuilder() + .setOnDemandFeatureViewName(onDemandFeatureViewName) + .setProject(projectName) + .setTransformationInput(transformationInput) + .build(); + + TransformFeaturesResponse transformFeaturesResponse = + this.onlineTransformationService.transformFeatures(transformFeaturesRequest); + + this.onlineTransformationService.processTransformFeaturesResponse( + transformFeaturesResponse, + onDemandFeatureViewName, + onDemandFeatureStringReferences, + values, + statuses); + } + + // Remove all features that were added as inputs for ODFVs. + Set addedFeatureStringReferences = + addedFeatureReferences.stream() + .map(r -> FeatureV2.getFeatureStringRef(r)) + .collect(Collectors.toSet()); + for (int i = 0; i < values.size(); i++) { + Map rowValues = values.get(i); + Map rowStatuses = statuses.get(i); + List keysToRemove = + rowValues.keySet().stream() + .filter(k -> addedFeatureStringReferences.contains(k)) + .collect(Collectors.toList()); + for (String key : keysToRemove) { + rowValues.remove(key); + rowStatuses.remove(key); + } + } + } + + // Build response field values from entityValuesMap and entityStatusesMap + // Response field values should be in the same order as the entityRows provided by the user. + List fieldValuesList = + IntStream.range(0, entityRows.size()) + .mapToObj( + entityRowIdx -> + GetOnlineFeaturesResponse.FieldValues.newBuilder() + .putAllFields(values.get(entityRowIdx)) + .putAllStatuses(statuses.get(entityRowIdx)) + .build()) + .collect(Collectors.toList()); + + return GetOnlineFeaturesResponse.newBuilder().addAllFieldValues(fieldValuesList).build(); + } + + private static Map getFeatureRefFeatureMap(List features) { + return features.stream() + .collect(Collectors.toMap(Feature::getFeatureReference, Function.identity())); + } + + /** + * Generate Field level Status metadata for the given valueMap. + * + * @param value value to generate metadata for. + * @param isOutsideMaxAge whether the given valueMap contains values with age outside + * FeatureTable's max age. + * @return a 1:1 map keyed by field name containing field status metadata instead of values in the + * given valueMap. + */ + private static GetOnlineFeaturesResponse.FieldStatus getMetadata( + ValueProto.Value value, boolean isOutsideMaxAge) { + + if (value == null) { + return GetOnlineFeaturesResponse.FieldStatus.NOT_FOUND; + } else if (isOutsideMaxAge) { + return GetOnlineFeaturesResponse.FieldStatus.OUTSIDE_MAX_AGE; + } else if (value.getValCase().equals(ValueProto.Value.ValCase.VAL_NOT_SET)) { + return GetOnlineFeaturesResponse.FieldStatus.NULL_VALUE; + } + return GetOnlineFeaturesResponse.FieldStatus.PRESENT; + } + + /** + * Determine if the feature data in the given feature row is outside maxAge. Data is outside + * maxAge to be when the difference ingestion time set in feature row and the retrieval time set + * in entity row exceeds FeatureTable max age. + * + * @param feature contains the ingestion timing and feature data. + * @param entityRow contains the retrieval timing of when features are pulled. + * @param maxAge feature's max age. + */ + private static boolean checkOutsideMaxAge( + Feature feature, GetOnlineFeaturesRequestV2.EntityRow entityRow, Duration maxAge) { + + if (maxAge.equals(Duration.getDefaultInstance())) { // max age is not set + return false; + } + + long givenTimestamp = entityRow.getTimestamp().getSeconds(); + if (givenTimestamp == 0) { + givenTimestamp = System.currentTimeMillis() / 1000; + } + long timeDifference = givenTimestamp - feature.getEventTimestamp().getSeconds(); + return timeDifference > maxAge.getSeconds(); + } + + /** + * Populate histogram metrics that can be used for analysing online retrieval calls + * + * @param entityRows entity rows provided in request + * @param featureReferences feature references provided in request + * @param project project name provided in request + */ + private void populateHistogramMetrics( + List entityRows, + List featureReferences, + String project) { + Metrics.requestEntityCountDistribution + .labels(project) + .observe(Double.valueOf(entityRows.size())); + Metrics.requestFeatureCountDistribution + .labels(project) + .observe(Double.valueOf(featureReferences.size())); + + long countDistinctFeatureTables = + featureReferences.stream() + .map(featureReference -> getFeatureTableStringRef(project, featureReference)) + .distinct() + .count(); + Metrics.requestFeatureTableCountDistribution + .labels(project) + .observe(Double.valueOf(countDistinctFeatureTables)); + } + + /** + * Populate count metrics that can be used for analysing online retrieval calls + * + * @param statusMap Statuses of features which have been requested + * @param project Project where request for features was called from + */ + private void populateCountMetrics( + Map statusMap, String project) { + statusMap.forEach( + (featureRefString, status) -> { + if (status == GetOnlineFeaturesResponse.FieldStatus.NOT_FOUND) { + Metrics.notFoundKeyCount.labels(project, featureRefString).inc(); + } + if (status == GetOnlineFeaturesResponse.FieldStatus.OUTSIDE_MAX_AGE) { + Metrics.staleKeyCount.labels(project, featureRefString).inc(); + } + }); + } + + private void populateFeatureCountMetrics( + List featureReferences, String project) { + featureReferences.forEach( + featureReference -> + Metrics.requestFeatureCount + .labels(project, FeatureV2.getFeatureStringRef(featureReference)) + .inc()); + } +} diff --git a/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java new file mode 100644 index 00000000000..541fe46898a --- /dev/null +++ b/java/serving/src/main/java/feast/serving/service/OnlineTransformationService.java @@ -0,0 +1,412 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.service; + +import com.google.protobuf.ByteString; +import feast.common.models.FeatureV2; +import feast.proto.core.DataSourceProto; +import feast.proto.core.FeatureProto; +import feast.proto.core.FeatureViewProto; +import feast.proto.core.OnDemandFeatureViewProto; +import feast.proto.serving.ServingAPIProto; +import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesRequest; +import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesResponse; +import feast.proto.serving.TransformationServiceAPIProto.ValueType; +import feast.proto.serving.TransformationServiceGrpc; +import feast.proto.types.ValueProto; +import feast.serving.specs.FeatureSpecRetriever; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Status; +import java.io.IOException; +import java.nio.channels.Channels; +import java.util.*; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.*; +import org.apache.arrow.vector.ipc.ArrowFileReader; +import org.apache.arrow.vector.ipc.ArrowFileWriter; +import org.apache.arrow.vector.types.FloatingPointPrecision; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.Schema; +import org.apache.arrow.vector.util.ByteArrayReadableSeekableByteChannel; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.tomcat.util.http.fileupload.ByteArrayOutputStream; +import org.slf4j.Logger; + +public class OnlineTransformationService implements TransformationService { + + private static final Logger log = + org.slf4j.LoggerFactory.getLogger(OnlineTransformationService.class); + private final TransformationServiceGrpc.TransformationServiceBlockingStub stub; + private final FeatureSpecRetriever featureSpecRetriever; + static final int INT64_BITWIDTH = 64; + static final int INT32_BITWIDTH = 32; + + public OnlineTransformationService( + String transformationServiceEndpoint, FeatureSpecRetriever featureSpecRetriever) { + if (transformationServiceEndpoint != null) { + final ManagedChannel channel = + ManagedChannelBuilder.forTarget(transformationServiceEndpoint).usePlaintext().build(); + this.stub = TransformationServiceGrpc.newBlockingStub(channel); + } else { + this.stub = null; + } + this.featureSpecRetriever = featureSpecRetriever; + } + + /** {@inheritDoc} */ + @Override + public TransformFeaturesResponse transformFeatures( + TransformFeaturesRequest transformFeaturesRequest) { + return this.stub.transformFeatures(transformFeaturesRequest); + } + + /** {@inheritDoc} */ + @Override + public Pair, List> + extractRequestDataFeatureNamesAndOnDemandFeatureInputs( + List onDemandFeatureReferences, String projectName) { + Set requestDataFeatureNames = new HashSet(); + List onDemandFeatureInputs = + new ArrayList(); + for (ServingAPIProto.FeatureReferenceV2 featureReference : onDemandFeatureReferences) { + OnDemandFeatureViewProto.OnDemandFeatureViewSpec onDemandFeatureViewSpec = + this.featureSpecRetriever.getOnDemandFeatureViewSpec(projectName, featureReference); + Map inputs = + onDemandFeatureViewSpec.getInputsMap(); + + for (OnDemandFeatureViewProto.OnDemandInput input : inputs.values()) { + OnDemandFeatureViewProto.OnDemandInput.InputCase inputCase = input.getInputCase(); + switch (inputCase) { + case REQUEST_DATA_SOURCE: + DataSourceProto.DataSource requestDataSource = input.getRequestDataSource(); + DataSourceProto.DataSource.RequestDataOptions requestDataOptions = + requestDataSource.getRequestDataOptions(); + Set requestDataNames = requestDataOptions.getSchemaMap().keySet(); + requestDataFeatureNames.addAll(requestDataNames); + break; + case FEATURE_VIEW: + FeatureViewProto.FeatureView featureView = input.getFeatureView(); + FeatureViewProto.FeatureViewSpec featureViewSpec = featureView.getSpec(); + String featureViewName = featureViewSpec.getName(); + for (FeatureProto.FeatureSpecV2 featureSpec : featureViewSpec.getFeaturesList()) { + String featureName = featureSpec.getName(); + ServingAPIProto.FeatureReferenceV2 onDemandFeatureInput = + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable(featureViewName) + .setName(featureName) + .build(); + onDemandFeatureInputs.add(onDemandFeatureInput); + } + break; + default: + throw Status.INTERNAL + .withDescription( + "OnDemandInput proto input field has an unexpected type: " + inputCase) + .asRuntimeException(); + } + } + } + Pair, List> pair = + new ImmutablePair, List>( + requestDataFeatureNames, onDemandFeatureInputs); + return pair; + } + + /** {@inheritDoc} */ + public Pair< + List, + Map>> + separateEntityRows( + Set requestDataFeatureNames, ServingAPIProto.GetOnlineFeaturesRequestV2 request) { + // Separate entity rows into entity data and request feature data. + List entityRows = + new ArrayList(); + Map> requestDataFeatures = + new HashMap>(); + + for (ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow entityRow : + request.getEntityRowsList()) { + Map fieldsMap = new HashMap(); + + for (Map.Entry entry : entityRow.getFieldsMap().entrySet()) { + String key = entry.getKey(); + ValueProto.Value value = entry.getValue(); + + if (requestDataFeatureNames.contains(key)) { + if (!requestDataFeatures.containsKey(key)) { + requestDataFeatures.put(key, new ArrayList()); + } + requestDataFeatures.get(key).add(value); + } else { + fieldsMap.put(key, value); + } + } + + // Construct new entity row containing the extracted entity data, if necessary. + if (!fieldsMap.isEmpty()) { + ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow newEntityRow = + ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow.newBuilder() + .setTimestamp(entityRow.getTimestamp()) + .putAllFields(fieldsMap) + .build(); + entityRows.add(newEntityRow); + } + } + + Pair< + List, + Map>> + pair = + new ImmutablePair< + List, + Map>>(entityRows, requestDataFeatures); + return pair; + } + + /** {@inheritDoc} */ + public void processTransformFeaturesResponse( + feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesResponse + transformFeaturesResponse, + String onDemandFeatureViewName, + Set onDemandFeatureStringReferences, + List> values, + List> statuses) { + try { + BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE); + ArrowFileReader reader = + new ArrowFileReader( + new ByteArrayReadableSeekableByteChannel( + transformFeaturesResponse + .getTransformationOutput() + .getArrowValue() + .toByteArray()), + allocator); + reader.loadNextBatch(); + VectorSchemaRoot readBatch = reader.getVectorSchemaRoot(); + Schema responseSchema = readBatch.getSchema(); + List responseFields = responseSchema.getFields(); + + for (Field field : responseFields) { + String columnName = field.getName(); + String fullFeatureName = onDemandFeatureViewName + ":" + columnName; + ArrowType columnType = field.getType(); + + // The response will contain all features for the specified ODFV, so we + // skip the features that were not requested. + if (!onDemandFeatureStringReferences.contains(fullFeatureName)) { + continue; + } + + FieldVector fieldVector = readBatch.getVector(field); + int valueCount = fieldVector.getValueCount(); + + // TODO: support all Feast types + // TODO: clean up the switch statement + if (columnType instanceof ArrowType.Int) { + int bitWidth = ((ArrowType.Int) columnType).getBitWidth(); + switch (bitWidth) { + case INT64_BITWIDTH: + for (int i = 0; i < valueCount; i++) { + long int64Value = ((BigIntVector) fieldVector).get(i); + Map rowValues = values.get(i); + Map rowStatuses = + statuses.get(i); + ValueProto.Value value = + ValueProto.Value.newBuilder().setInt64Val(int64Value).build(); + rowValues.put(fullFeatureName, value); + rowStatuses.put( + fullFeatureName, ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.PRESENT); + } + break; + case INT32_BITWIDTH: + for (int i = 0; i < valueCount; i++) { + int intValue = ((IntVector) fieldVector).get(i); + Map rowValues = values.get(i); + Map rowStatuses = + statuses.get(i); + ValueProto.Value value = + ValueProto.Value.newBuilder().setInt32Val(intValue).build(); + rowValues.put(fullFeatureName, value); + rowStatuses.put( + fullFeatureName, ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.PRESENT); + } + break; + default: + throw Status.INTERNAL + .withDescription( + "Column " + + columnName + + " is of type ArrowType.Int but has bitWidth " + + bitWidth + + " which cannot be handled.") + .asRuntimeException(); + } + } else if (columnType instanceof ArrowType.FloatingPoint) { + FloatingPointPrecision precision = ((ArrowType.FloatingPoint) columnType).getPrecision(); + switch (precision) { + case DOUBLE: + for (int i = 0; i < valueCount; i++) { + double doubleValue = ((Float8Vector) fieldVector).get(i); + Map rowValues = values.get(i); + Map rowStatuses = + statuses.get(i); + ValueProto.Value value = + ValueProto.Value.newBuilder().setDoubleVal(doubleValue).build(); + rowValues.put(fullFeatureName, value); + rowStatuses.put( + fullFeatureName, ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.PRESENT); + } + break; + case SINGLE: + for (int i = 0; i < valueCount; i++) { + float floatValue = ((Float4Vector) fieldVector).get(i); + Map rowValues = values.get(i); + Map rowStatuses = + statuses.get(i); + ValueProto.Value value = + ValueProto.Value.newBuilder().setFloatVal(floatValue).build(); + rowValues.put(fullFeatureName, value); + rowStatuses.put( + fullFeatureName, ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus.PRESENT); + } + break; + default: + throw Status.INTERNAL + .withDescription( + "Column " + + columnName + + " is of type ArrowType.FloatingPoint but has precision " + + precision + + " which cannot be handled.") + .asRuntimeException(); + } + } + } + } catch (IOException e) { + log.info(e.toString()); + throw Status.INTERNAL + .withDescription( + "Unable to correctly process transform features response: " + e.toString()) + .asRuntimeException(); + } + } + + /** {@inheritDoc} */ + public ValueType serializeValuesIntoArrowIPC(List> values) { + // In order to be serialized correctly, the data must be packaged in a VectorSchemaRoot. + // We first construct all the columns. + Map columnNameToColumn = new HashMap(); + BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE); + Map firstAugmentedRowValues = values.get(0); + for (Map.Entry entry : firstAugmentedRowValues.entrySet()) { + // The Python FTS does not expect full feature names, so we extract the feature name. + String columnName = FeatureV2.getFeatureName(entry.getKey()); + ValueProto.Value.ValCase valCase = entry.getValue().getValCase(); + FieldVector column; + // TODO: support all Feast types + switch (valCase) { + case INT32_VAL: + column = new IntVector(columnName, allocator); + break; + case INT64_VAL: + column = new BigIntVector(columnName, allocator); + break; + case DOUBLE_VAL: + column = new Float8Vector(columnName, allocator); + break; + case FLOAT_VAL: + column = new Float4Vector(columnName, allocator); + break; + default: + throw Status.INTERNAL + .withDescription( + "Column " + columnName + " has a type that is currently not handled: " + valCase) + .asRuntimeException(); + } + column.allocateNew(); + columnNameToColumn.put(columnName, column); + } + + // Add the data, row by row. + for (int i = 0; i < values.size(); i++) { + Map augmentedRowValues = values.get(i); + + for (Map.Entry entry : augmentedRowValues.entrySet()) { + String columnName = FeatureV2.getFeatureName(entry.getKey()); + ValueProto.Value value = entry.getValue(); + ValueProto.Value.ValCase valCase = value.getValCase(); + FieldVector column = columnNameToColumn.get(columnName); + // TODO: support all Feast types + switch (valCase) { + case INT32_VAL: + ((IntVector) column).setSafe(i, value.getInt32Val()); + break; + case INT64_VAL: + ((BigIntVector) column).setSafe(i, value.getInt64Val()); + break; + case DOUBLE_VAL: + ((Float8Vector) column).setSafe(i, value.getDoubleVal()); + break; + case FLOAT_VAL: + ((Float4Vector) column).setSafe(i, value.getFloatVal()); + break; + default: + throw Status.INTERNAL + .withDescription( + "Column " + + columnName + + " has a type that is currently not handled: " + + valCase) + .asRuntimeException(); + } + } + } + + // Construct the VectorSchemaRoot. + List columnFields = new ArrayList(); + List columns = new ArrayList(); + for (FieldVector column : columnNameToColumn.values()) { + column.setValueCount(values.size()); + columnFields.add(column.getField()); + columns.add(column); + } + VectorSchemaRoot schemaRoot = new VectorSchemaRoot(columnFields, columns); + + // Serialize the VectorSchemaRoot into Arrow IPC format. + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ArrowFileWriter writer = new ArrowFileWriter(schemaRoot, null, Channels.newChannel(out)); + try { + writer.start(); + writer.writeBatch(); + writer.end(); + } catch (IOException e) { + log.info(e.toString()); + throw Status.INTERNAL + .withDescription( + "ArrowFileWriter could not write properly; failed with error: " + e.toString()) + .asRuntimeException(); + } + byte[] byteData = out.toByteArray(); + ByteString inputData = ByteString.copyFrom(byteData); + ValueType transformationInput = ValueType.newBuilder().setArrowValue(inputData).build(); + return transformationInput; + } +} diff --git a/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java b/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java new file mode 100644 index 00000000000..05acb31b78e --- /dev/null +++ b/java/serving/src/main/java/feast/serving/service/ServingServiceV2.java @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.service; + +import feast.proto.serving.ServingAPIProto; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; + +public interface ServingServiceV2 { + /** + * Get information about the Feast serving deployment. + * + *

For Bigquery deployments, this includes the default job staging location to load + * intermediate files to. Otherwise, this method only returns the current Feast Serving backing + * store type. + * + * @param getFeastServingInfoRequest {@link ServingAPIProto.GetFeastServingInfoRequest} + * @return {@link ServingAPIProto.GetFeastServingInfoResponse} + */ + ServingAPIProto.GetFeastServingInfoResponse getFeastServingInfo( + ServingAPIProto.GetFeastServingInfoRequest getFeastServingInfoRequest); + + /** + * Get features from an online serving store, given a list of {@link + * feast.proto.serving.ServingAPIProto.FeatureReferenceV2}s to retrieve, and list of {@link + * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow}s to join the + * retrieved values to. + * + *

Features can be queried across feature tables, but each {@link + * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow} must contain all + * entities for all feature tables included in the request. + * + *

This request is fulfilled synchronously. + * + * @param getFeaturesRequest {@link GetOnlineFeaturesRequestV2} containing list of {@link + * feast.proto.serving.ServingAPIProto.FeatureReferenceV2}s to retrieve and list of {@link + * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow}s to join the + * retrieved values to. + * @return {@link GetOnlineFeaturesResponse} with list of {@link + * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues} for each {@link + * feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow} supplied. + */ + GetOnlineFeaturesResponse getOnlineFeatures(GetOnlineFeaturesRequestV2 getFeaturesRequest); +} diff --git a/java/serving/src/main/java/feast/serving/service/TransformationService.java b/java/serving/src/main/java/feast/serving/service/TransformationService.java new file mode 100644 index 00000000000..caa52793020 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/service/TransformationService.java @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.service; + +import feast.proto.serving.ServingAPIProto; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesRequest; +import feast.proto.serving.TransformationServiceAPIProto.TransformFeaturesResponse; +import feast.proto.serving.TransformationServiceAPIProto.ValueType; +import feast.proto.types.ValueProto; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.commons.lang3.tuple.Pair; + +public interface TransformationService { + /** + * Apply on demand transformations for the specified ODFVs. + * + * @param transformFeaturesRequest proto containing the ODFV references and necessary data + * @return a proto object containing the response + */ + TransformFeaturesResponse transformFeatures(TransformFeaturesRequest transformFeaturesRequest); + + /** + * Extract the set of request data feature names and the list of on demand feature inputs from a + * list of ODFV references. + * + * @param onDemandFeatureReferences list of ODFV references to be parsed + * @param projectName project name + * @return a pair containing the set of request data feature names and list of on demand feature + * inputs + */ + Pair, List> + extractRequestDataFeatureNamesAndOnDemandFeatureInputs( + List onDemandFeatureReferences, String projectName); + + /** + * Separate the entity rows of a request into entity data and request feature data. + * + * @param requestDataFeatureNames set of feature names for the request data + * @param request the GetOnlineFeaturesRequestV2 containing the entity rows + * @return a pair containing the set of request data feature names and list of on demand feature + * inputs + */ + Pair, Map>> + separateEntityRows(Set requestDataFeatureNames, GetOnlineFeaturesRequestV2 request); + + /** + * Process a response from the feature transformation server by augmenting the given lists of + * field maps and status maps with the correct fields from the response. + * + * @param transformFeaturesResponse response to be processed + * @param onDemandFeatureViewName name of ODFV to which the response corresponds + * @param onDemandFeatureStringReferences set of all ODFV references that should be kept + * @param values list of field maps to be augmented with additional fields from the response + * @param statuses list of status maps to be augmented + */ + void processTransformFeaturesResponse( + TransformFeaturesResponse transformFeaturesResponse, + String onDemandFeatureViewName, + Set onDemandFeatureStringReferences, + List> values, + List> statuses); + + /** + * Serialize data into Arrow IPC format, to be sent to the Python feature transformation server. + * + * @param values list of field maps to be serialized + * @return the data packaged into a ValueType proto object + */ + ValueType serializeValuesIntoArrowIPC(List> values); +} diff --git a/java/serving/src/main/java/feast/serving/specs/FeatureSpecRetriever.java b/java/serving/src/main/java/feast/serving/specs/FeatureSpecRetriever.java new file mode 100644 index 00000000000..57e931ccf9d --- /dev/null +++ b/java/serving/src/main/java/feast/serving/specs/FeatureSpecRetriever.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.specs; + +import com.google.protobuf.Duration; +import feast.proto.core.FeatureProto; +import feast.proto.core.FeatureViewProto; +import feast.proto.core.OnDemandFeatureViewProto; +import feast.proto.serving.ServingAPIProto; +import java.util.List; + +public interface FeatureSpecRetriever { + + Duration getMaxAge(String projectName, ServingAPIProto.FeatureReferenceV2 featureReference); + + List getEntitiesList( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference); + + FeatureProto.FeatureSpecV2 getFeatureSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference); + + FeatureViewProto.FeatureViewSpec getBatchFeatureViewSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference); + + OnDemandFeatureViewProto.OnDemandFeatureViewSpec getOnDemandFeatureViewSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference); + + boolean isOnDemandFeatureReference(ServingAPIProto.FeatureReferenceV2 featureReference); +} diff --git a/java/serving/src/main/java/feast/serving/specs/RegistryFeatureSpecRetriever.java b/java/serving/src/main/java/feast/serving/specs/RegistryFeatureSpecRetriever.java new file mode 100644 index 00000000000..540e7f504e5 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/specs/RegistryFeatureSpecRetriever.java @@ -0,0 +1,70 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.specs; + +import com.google.protobuf.Duration; +import feast.proto.core.FeatureProto; +import feast.proto.core.FeatureViewProto; +import feast.proto.core.OnDemandFeatureViewProto; +import feast.proto.serving.ServingAPIProto; +import feast.serving.registry.RegistryRepository; +import java.util.List; + +public class RegistryFeatureSpecRetriever implements FeatureSpecRetriever { + private final RegistryRepository registryRepository; + + public RegistryFeatureSpecRetriever(RegistryRepository registryRepository) { + this.registryRepository = registryRepository; + } + + @Override + public Duration getMaxAge( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { + return this.registryRepository.getFeatureViewSpec(projectName, featureReference).getTtl(); + } + + @Override + public List getEntitiesList( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { + return this.registryRepository + .getFeatureViewSpec(projectName, featureReference) + .getEntitiesList(); + } + + @Override + public FeatureProto.FeatureSpecV2 getFeatureSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { + return this.registryRepository.getFeatureSpec(projectName, featureReference); + } + + @Override + public FeatureViewProto.FeatureViewSpec getBatchFeatureViewSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { + return this.registryRepository.getFeatureViewSpec(projectName, featureReference); + } + + @Override + public OnDemandFeatureViewProto.OnDemandFeatureViewSpec getOnDemandFeatureViewSpec( + String projectName, ServingAPIProto.FeatureReferenceV2 featureReference) { + return this.registryRepository.getOnDemandFeatureViewSpec(projectName, featureReference); + } + + @Override + public boolean isOnDemandFeatureReference(ServingAPIProto.FeatureReferenceV2 featureReference) { + return this.registryRepository.isOnDemandFeatureReference(featureReference); + } +} diff --git a/java/serving/src/main/java/feast/serving/util/Metrics.java b/java/serving/src/main/java/feast/serving/util/Metrics.java new file mode 100644 index 00000000000..dca2b5e82aa --- /dev/null +++ b/java/serving/src/main/java/feast/serving/util/Metrics.java @@ -0,0 +1,90 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.util; + +import io.prometheus.client.Counter; +import io.prometheus.client.Histogram; + +public class Metrics { + + public static final Histogram requestLatency = + Histogram.build() + .name("request_latency_seconds") + .subsystem("feast_serving") + .help("Request latency in seconds") + .labelNames("method", "project") + .register(); + + public static final Histogram requestEntityCountDistribution = + Histogram.build() + .buckets(1, 2, 5, 10, 20, 50, 100, 200) + .name("request_entity_count_distribution") + .subsystem("feast_serving") + .help("Number of entity rows per request") + .labelNames("project") + .register(); + + public static final Histogram requestFeatureCountDistribution = + Histogram.build() + .buckets(1, 2, 5, 10, 15, 20, 30, 50) + .name("request_feature_count_distribution") + .subsystem("feast_serving") + .help("Number of feature rows per request") + .labelNames("project") + .register(); + + public static final Histogram requestFeatureTableCountDistribution = + Histogram.build() + .buckets(1, 2, 5, 10, 20) + .name("request_feature_table_count_distribution") + .subsystem("feast_serving") + .help("Number of feature tables per request") + .labelNames("project") + .register(); + + public static final Counter requestFeatureCount = + Counter.build() + .name("request_feature_count") + .subsystem("feast_serving") + .help("number of feature rows requested") + .labelNames("project", "feature_name") + .register(); + + public static final Counter notFoundKeyCount = + Counter.build() + .name("not_found_feature_count") + .subsystem("feast_serving") + .help("number requested feature rows that were not found") + .labelNames("project", "feature_name") + .register(); + + public static final Counter staleKeyCount = + Counter.build() + .name("stale_feature_count") + .subsystem("feast_serving") + .help("number requested feature rows that were stale") + .labelNames("project", "feature_name") + .register(); + + public static final Counter grpcRequestCount = + Counter.build() + .name("grpc_request_count") + .subsystem("feast_serving") + .help("number of grpc requests served") + .labelNames("method", "status_code") + .register(); +} diff --git a/java/serving/src/main/java/feast/serving/util/RequestHelper.java b/java/serving/src/main/java/feast/serving/util/RequestHelper.java new file mode 100644 index 00000000000..4d478f430f2 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/util/RequestHelper.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.util; + +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; + +public class RequestHelper { + + public static void validateOnlineRequest(GetOnlineFeaturesRequestV2 request) { + // All EntityRows should not be empty + if (request.getEntityRowsCount() <= 0) { + throw new IllegalArgumentException("Entity value must be provided"); + } + // All FeatureReferences should have FeatureTable name and Feature name + for (FeatureReferenceV2 featureReference : request.getFeaturesList()) { + validateOnlineRequestFeatureReference(featureReference); + } + } + + public static void validateOnlineRequestFeatureReference(FeatureReferenceV2 featureReference) { + if (featureReference.getFeatureTable().isEmpty()) { + throw new IllegalArgumentException("FeatureTable name must be provided in FeatureReference"); + } + if (featureReference.getName().isEmpty()) { + throw new IllegalArgumentException("Feature name must be provided in FeatureReference"); + } + } +} diff --git a/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java b/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java new file mode 100644 index 00000000000..238df549513 --- /dev/null +++ b/java/serving/src/main/java/feast/serving/util/mappers/ResponseJSONMapper.java @@ -0,0 +1,75 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.util.mappers; + +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues; +import feast.proto.types.ValueProto.Value; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +// ResponseJSONMapper maps GRPC Response types to more human readable JSON responses +public class ResponseJSONMapper { + + public static List> mapGetOnlineFeaturesResponse( + GetOnlineFeaturesResponse response) { + return response.getFieldValuesList().stream() + .map(fieldValues -> convertFieldValuesToMap(fieldValues)) + .collect(Collectors.toList()); + } + + private static Map convertFieldValuesToMap(FieldValues fieldValues) { + return fieldValues.getFieldsMap().entrySet().stream() + .collect(Collectors.toMap(es -> es.getKey(), es -> extractValue(es.getValue()))); + } + + private static Object extractValue(Value value) { + switch (value.getValCase().getNumber()) { + case 1: + return value.getBytesVal(); + case 2: + return value.getStringVal(); + case 3: + return value.getInt32Val(); + case 4: + return value.getInt64Val(); + case 5: + return value.getDoubleVal(); + case 6: + return value.getFloatVal(); + case 7: + return value.getBoolVal(); + case 11: + return value.getBytesListVal(); + case 12: + return value.getStringListVal(); + case 13: + return value.getInt32ListVal(); + case 14: + return value.getInt64ListVal(); + case 15: + return value.getDoubleListVal(); + case 16: + return value.getFloatListVal(); + case 17: + return value.getBoolListVal(); + default: + return null; + } + } +} diff --git a/java/serving/src/main/resources/application.yml b/java/serving/src/main/resources/application.yml new file mode 100644 index 00000000000..087bb46f2fe --- /dev/null +++ b/java/serving/src/main/resources/application.yml @@ -0,0 +1,64 @@ +feast: + # Indicates the active store. Only a single store in the last can be active at one time. In the future this key + # will be deprecated in order to allow multiple stores to be served from a single serving instance + active_store: online + + # List of store configurations + stores: + # Please see https://api.docs.feast.dev/grpc/feast.core.pb.html#Store for configuration options + - name: online # Name of the store (referenced by active_store) + type: REDIS # Type of the store. REDIS, REDIS_CLUSTER are available options + config: # Store specific configuration. See + host: localhost + port: 6379 + # Subscriptions indicate which feature sets needs to be retrieved and used to populate this store + - name: online_cluster + type: REDIS_CLUSTER + config: # Store specific configuration. + # Connection string specifies the host:port of Redis instances in the redis cluster. + connection_string: "localhost:7000,localhost:7001,localhost:7002,localhost:7003,localhost:7004,localhost:7005" + read_from: MASTER + # Redis operation timeout in ISO-8601 format + timeout: PT0.5S + tracing: + # If true, Feast will provide tracing data (using OpenTracing API) for various RPC method calls + # which can be useful to debug performance issues and perform benchmarking + enabled: false + # Only Jaeger tracer is supported currently + # https://opentracing.io/docs/supported-tracers/ + tracer-name: jaeger + # The service name identifier for the tracing data + service-name: feast_serving + + logging: + # Audit logging provides a machine readable structured JSON log that can give better + # insight into what is happening in Feast. + audit: + # Whether audit logging is enabled. + enabled: true + # Whether to enable message level (ie request/response) audit logging + messageLogging: + enabled: false + # Logging forwarder currently provides a machine readable structured JSON log to an + # external fluentd service that can give better insight into what is happening in Feast. + # Accepts console / fluentd as destination + destination: console + fluentdHost: localhost + fluentdPort: 24224 + +grpc: + server: + # The port number Feast Serving GRPC service should listen on + # It is set default to 6566 so it does not conflict with the GRPC server on Feast Core + # which defaults to port 6565 + port: ${GRPC_PORT:6566} + security: + enabled: false + certificateChain: server.crt + privateKey: server.key + +server: + # The port number on which the Tomcat webserver that serves REST API endpoints should listen + # It is set by default to 8081 so it does not conflict with Tomcat webserver on Feast Core + # if both Feast Core and Serving are running on the same machine + port: ${SERVER_PORT:8081} diff --git a/java/serving/src/main/resources/banner.txt b/java/serving/src/main/resources/banner.txt new file mode 100644 index 00000000000..44aa9c4cad9 --- /dev/null +++ b/java/serving/src/main/resources/banner.txt @@ -0,0 +1,14 @@ + +███████╗███████╗ █████╗ ███████╗████████╗ +██╔════╝██╔════╝██╔══██╗██╔════╝╚══██╔══╝ +█████╗ █████╗ ███████║███████╗ ██║ +██╔══╝ ██╔══╝ ██╔══██║╚════██║ ██║ +██║ ███████╗██║ ██║███████║ ██║ +╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ + +███████╗███████╗██████╗ ██╗ ██╗██╗███╗ ██╗ ██████╗ +██╔════╝██╔════╝██╔══██╗██║ ██║██║████╗ ██║██╔════╝ +███████╗█████╗ ██████╔╝██║ ██║██║██╔██╗ ██║██║ ███╗ +╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██║██║╚██╗██║██║ ██║ +███████║███████╗██║ ██║ ╚████╔╝ ██║██║ ╚████║╚██████╔╝ +╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ diff --git a/java/serving/src/main/resources/log4j2.xml b/java/serving/src/main/resources/log4j2.xml new file mode 100644 index 00000000000..c75c2db13cc --- /dev/null +++ b/java/serving/src/main/resources/log4j2.xml @@ -0,0 +1,48 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %5p ${hostName} --- [%15.15t] %-40.40c{1.} : %m%n%ex + + + {"time":"%d{yyyy-MM-dd'T'HH:mm:ssXXX}","hostname":"${hostName}","severity":"%p","message":%m}%n%ex + + + + + + + + + + + + + + + + + + + + + + + diff --git a/java/serving/src/test/java/feast/serving/controller/ServingServiceGRpcControllerTest.java b/java/serving/src/test/java/feast/serving/controller/ServingServiceGRpcControllerTest.java new file mode 100644 index 00000000000..b7f02315213 --- /dev/null +++ b/java/serving/src/test/java/feast/serving/controller/ServingServiceGRpcControllerTest.java @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.controller; + +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.protobuf.Timestamp; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.types.ValueProto.Value; +import feast.serving.config.FeastProperties; +import feast.serving.service.ServingServiceV2; +import io.grpc.stub.StreamObserver; +import io.jaegertracing.Configuration; +import io.opentracing.Tracer; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.springframework.security.core.Authentication; + +public class ServingServiceGRpcControllerTest { + + @Mock private ServingServiceV2 mockServingServiceV2; + + @Mock private StreamObserver mockStreamObserver; + + private GetOnlineFeaturesRequestV2 validRequest; + + private ServingServiceGRpcController service; + + @Mock private Authentication authentication; + + @Before + public void setUp() { + initMocks(this); + + validRequest = + GetOnlineFeaturesRequestV2.newBuilder() + .addFeatures( + FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature1") + .build()) + .addFeatures( + FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature2") + .build()) + .addEntityRows( + EntityRow.newBuilder() + .setTimestamp(Timestamp.newBuilder().setSeconds(100)) + .putFields("entity1", Value.newBuilder().setInt64Val(1).build()) + .putFields("entity2", Value.newBuilder().setInt64Val(1).build())) + .build(); + } + + private ServingServiceGRpcController getServingServiceGRpcController(boolean enableAuth) { + Tracer tracer = Configuration.fromEnv("dummy").getTracer(); + FeastProperties feastProperties = new FeastProperties(); + + return new ServingServiceGRpcController(mockServingServiceV2, feastProperties, tracer); + } + + @Test + public void shouldPassValidRequestAsIs() { + service = getServingServiceGRpcController(false); + service.getOnlineFeaturesV2(validRequest, mockStreamObserver); + Mockito.verify(mockServingServiceV2).getOnlineFeatures(validRequest); + } + + @Test + public void shouldCallOnErrorIfEntityDatasetIsNotSet() { + service = getServingServiceGRpcController(false); + GetOnlineFeaturesRequestV2 missingEntityName = + GetOnlineFeaturesRequestV2.newBuilder(validRequest).clearEntityRows().build(); + service.getOnlineFeaturesV2(missingEntityName, mockStreamObserver); + Mockito.verify(mockStreamObserver).onError(Mockito.any(IllegalArgumentException.class)); + } +} diff --git a/java/serving/src/test/java/feast/serving/it/ServingServiceRedisIT.java b/java/serving/src/test/java/feast/serving/it/ServingServiceRedisIT.java new file mode 100644 index 00000000000..6fd065d2640 --- /dev/null +++ b/java/serving/src/test/java/feast/serving/it/ServingServiceRedisIT.java @@ -0,0 +1,206 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.it; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Timestamp; +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.Request; +import com.squareup.okhttp.Response; +import feast.proto.serving.ServingAPIProto; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingServiceGrpc; +import feast.serving.util.DataGenerator; +import io.grpc.ManagedChannel; +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.ClassRule; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@ActiveProfiles("it") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "feast.registry:src/test/resources/docker-compose/feast10/registry.db", + }) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) +@Testcontainers +public class ServingServiceRedisIT { + + public static final Logger log = LoggerFactory.getLogger(ServingServiceRedisIT.class); + + static ServingServiceGrpc.ServingServiceBlockingStub servingStub; + + static final int FEAST_SERVING_PORT = 6568; + + @LocalServerPort private int metricsPort; + + @ClassRule @Container + public static DockerComposeContainer environment = + new DockerComposeContainer( + new File("src/test/resources/docker-compose/docker-compose-redis-it.yml")) + .withExposedService("redis", 6379) + .withOptions() + .waitingFor( + "materialize", + Wait.forLogMessage(".*Materialization finished.*\\n", 1) + .withStartupTimeout(Duration.ofMinutes(5))); + + @DynamicPropertySource + static void initialize(DynamicPropertyRegistry registry) { + registry.add("grpc.server.port", () -> FEAST_SERVING_PORT); + + registry.add("feast.stores[0].name", () -> "online"); + registry.add("feast.stores[0].type", () -> "REDIS"); + registry.add("feast.stores[0].config.host", () -> environment.getServiceHost("redis", 6379)); + registry.add("feast.stores[0].config.port", () -> environment.getServicePort("redis", 6379)); + } + + @BeforeAll + static void globalSetup() { + servingStub = TestUtils.getServingServiceStub(false, FEAST_SERVING_PORT, null); + } + + @AfterAll + static void tearDown() throws Exception { + ((ManagedChannel) servingStub.getChannel()).shutdown().awaitTermination(10, TimeUnit.SECONDS); + } + + private GetOnlineFeaturesRequestV2 buildOnlineRequest(int driverId) { + // getOnlineFeatures Information + String projectName = "feast_project"; + String entityName = "driver_id"; + + // Instantiate EntityRows + final Timestamp timestamp = Timestamp.getDefaultInstance(); + GetOnlineFeaturesRequestV2.EntityRow entityRow1 = + DataGenerator.createEntityRow( + entityName, DataGenerator.createInt64Value(driverId), timestamp.getSeconds()); + ImmutableList entityRows = ImmutableList.of(entityRow1); + + // Instantiate FeatureReferences + ServingAPIProto.FeatureReferenceV2 feature1Reference = + DataGenerator.createFeatureReference("driver_hourly_stats", "conv_rate"); + ServingAPIProto.FeatureReferenceV2 feature2Reference = + DataGenerator.createFeatureReference("driver_hourly_stats", "avg_daily_trips"); + ImmutableList featureReferences = + ImmutableList.of(feature1Reference, feature2Reference); + + // Build GetOnlineFeaturesRequestV2 + return TestUtils.createOnlineFeatureRequest(projectName, featureReferences, entityRows); + } + + @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + public void shouldGetOnlineFeatures() { + GetOnlineFeaturesResponse featureResponse = + servingStub.getOnlineFeaturesV2(buildOnlineRequest(1005)); + + assertEquals(1, featureResponse.getFieldValuesCount()); + + final GetOnlineFeaturesResponse.FieldValues fieldValue = featureResponse.getFieldValues(0); + for (final String key : + ImmutableList.of( + "driver_hourly_stats:avg_daily_trips", "driver_hourly_stats:conv_rate", "driver_id")) { + assertTrue(fieldValue.containsFields(key)); + assertTrue(fieldValue.containsStatuses(key)); + assertEquals( + GetOnlineFeaturesResponse.FieldStatus.PRESENT, fieldValue.getStatusesOrThrow(key)); + } + + assertEquals( + 500, fieldValue.getFieldsOrThrow("driver_hourly_stats:avg_daily_trips").getInt64Val()); + assertEquals(1005, fieldValue.getFieldsOrThrow("driver_id").getInt64Val()); + assertEquals( + 0.5, fieldValue.getFieldsOrThrow("driver_hourly_stats:conv_rate").getDoubleVal(), 0.0001); + } + + @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + public void shouldGetOnlineFeaturesWithOutsideMaxAgeStatus() { + GetOnlineFeaturesResponse featureResponse = + servingStub.getOnlineFeaturesV2(buildOnlineRequest(1001)); + + assertEquals(1, featureResponse.getFieldValuesCount()); + + final GetOnlineFeaturesResponse.FieldValues fieldValue = featureResponse.getFieldValues(0); + for (final String key : + ImmutableList.of("driver_hourly_stats:avg_daily_trips", "driver_hourly_stats:conv_rate")) { + assertTrue(fieldValue.containsFields(key)); + assertTrue(fieldValue.containsStatuses(key)); + assertEquals( + GetOnlineFeaturesResponse.FieldStatus.OUTSIDE_MAX_AGE, + fieldValue.getStatusesOrThrow(key)); + } + + assertEquals( + 100, fieldValue.getFieldsOrThrow("driver_hourly_stats:avg_daily_trips").getInt64Val()); + assertEquals(1001, fieldValue.getFieldsOrThrow("driver_id").getInt64Val()); + assertEquals( + 0.1, fieldValue.getFieldsOrThrow("driver_hourly_stats:conv_rate").getDoubleVal(), 0.0001); + } + + @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + public void shouldGetOnlineFeaturesWithNotFoundStatus() { + GetOnlineFeaturesResponse featureResponse = + servingStub.getOnlineFeaturesV2(buildOnlineRequest(-1)); + + assertEquals(1, featureResponse.getFieldValuesCount()); + + final GetOnlineFeaturesResponse.FieldValues fieldValue = featureResponse.getFieldValues(0); + for (final String key : + ImmutableList.of("driver_hourly_stats:avg_daily_trips", "driver_hourly_stats:conv_rate")) { + assertTrue(fieldValue.containsFields(key)); + assertTrue(fieldValue.containsStatuses(key)); + assertEquals( + GetOnlineFeaturesResponse.FieldStatus.NOT_FOUND, fieldValue.getStatusesOrThrow(key)); + } + } + + @Test + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + public void shouldAllowUnauthenticatedAccessToMetricsEndpoint() throws IOException { + Request request = + new Request.Builder() + .url(String.format("http://localhost:%d/metrics", metricsPort)) + .get() + .build(); + Response response = new OkHttpClient().newCall(request).execute(); + assertTrue(response.isSuccessful()); + assertFalse(response.body().string().isEmpty()); + } +} diff --git a/java/serving/src/test/java/feast/serving/it/TestUtils.java b/java/serving/src/test/java/feast/serving/it/TestUtils.java new file mode 100644 index 00000000000..fb88b2fb372 --- /dev/null +++ b/java/serving/src/test/java/feast/serving/it/TestUtils.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.it; + +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingServiceGrpc; +import io.grpc.Channel; +import io.grpc.ManagedChannelBuilder; +import java.util.*; + +public class TestUtils { + + public static ServingServiceGrpc.ServingServiceBlockingStub getServingServiceStub( + boolean isSecure, int feastServingPort, Map options) { + Channel secureChannel = + ManagedChannelBuilder.forAddress("localhost", feastServingPort).usePlaintext().build(); + return ServingServiceGrpc.newBlockingStub(secureChannel); + } + + public static GetOnlineFeaturesRequestV2 createOnlineFeatureRequest( + String projectName, + List featureReferences, + List entityRows) { + return GetOnlineFeaturesRequestV2.newBuilder() + .setProject(projectName) + .addAllFeatures(featureReferences) + .addAllEntityRows(entityRows) + .build(); + } +} diff --git a/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java b/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java new file mode 100644 index 00000000000..d64cd07fb4c --- /dev/null +++ b/java/serving/src/test/java/feast/serving/service/OnlineServingServiceTest.java @@ -0,0 +1,395 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2019 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.service; + +import static feast.serving.util.DataGenerator.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import com.google.protobuf.Duration; +import com.google.protobuf.Timestamp; +import feast.proto.core.FeatureProto; +import feast.proto.core.FeatureViewProto; +import feast.proto.serving.ServingAPIProto; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldStatus; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesResponse.FieldValues; +import feast.proto.types.ValueProto; +import feast.serving.registry.LocalRegistryRepo; +import feast.serving.specs.FeatureSpecRetriever; +import feast.serving.specs.RegistryFeatureSpecRetriever; +import feast.storage.api.retriever.Feature; +import feast.storage.api.retriever.ProtoFeature; +import feast.storage.connectors.redis.retriever.OnlineRetriever; +import io.opentracing.Tracer; +import io.opentracing.Tracer.SpanBuilder; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.Mockito; + +public class OnlineServingServiceTest { + + @Mock LocalRegistryRepo registryRepo; + @Mock Tracer tracer; + @Mock OnlineRetriever retrieverV2; + private String transformationServiceEndpoint; + + private OnlineServingServiceV2 onlineServingServiceV2; + + List mockedFeatureRows; + List featureSpecs; + + @Before + public void setUp() { + initMocks(this); + FeatureSpecRetriever featureSpecRetriever = new RegistryFeatureSpecRetriever(registryRepo); + OnlineTransformationService onlineTransformationService = + new OnlineTransformationService(transformationServiceEndpoint, featureSpecRetriever); + onlineServingServiceV2 = + new OnlineServingServiceV2( + retrieverV2, tracer, featureSpecRetriever, onlineTransformationService); + + mockedFeatureRows = new ArrayList<>(); + mockedFeatureRows.add( + new ProtoFeature( + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_1") + .build(), + Timestamp.newBuilder().setSeconds(100).build(), + createStrValue("1"))); + mockedFeatureRows.add( + new ProtoFeature( + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_2") + .build(), + Timestamp.newBuilder().setSeconds(100).build(), + createStrValue("2"))); + mockedFeatureRows.add( + new ProtoFeature( + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_1") + .build(), + Timestamp.newBuilder().setSeconds(100).build(), + createStrValue("3"))); + mockedFeatureRows.add( + new ProtoFeature( + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_2") + .build(), + Timestamp.newBuilder().setSeconds(100).build(), + createStrValue("4"))); + mockedFeatureRows.add( + new ProtoFeature( + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_3") + .build(), + Timestamp.newBuilder().setSeconds(100).build(), + createStrValue("5"))); + mockedFeatureRows.add( + new ProtoFeature( + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_1") + .build(), + Timestamp.newBuilder().setSeconds(50).build(), + createStrValue("6"))); + + featureSpecs = new ArrayList<>(); + featureSpecs.add( + FeatureProto.FeatureSpecV2.newBuilder() + .setName("feature_1") + .setValueType(ValueProto.ValueType.Enum.STRING) + .build()); + featureSpecs.add( + FeatureProto.FeatureSpecV2.newBuilder() + .setName("feature_2") + .setValueType(ValueProto.ValueType.Enum.STRING) + .build()); + } + + @Test + public void shouldReturnResponseWithValuesAndMetadataIfKeysPresent() { + String projectName = "default"; + ServingAPIProto.FeatureReferenceV2 featureReference1 = + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_1") + .build(); + ServingAPIProto.FeatureReferenceV2 featureReference2 = + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_2") + .build(); + List featureReferences = + List.of(featureReference1, featureReference2); + GetOnlineFeaturesRequestV2 request = getOnlineFeaturesRequestV2(projectName, featureReferences); + + List entityKeyList1 = new ArrayList<>(); + List entityKeyList2 = new ArrayList<>(); + entityKeyList1.add(mockedFeatureRows.get(0)); + entityKeyList1.add(mockedFeatureRows.get(1)); + entityKeyList2.add(mockedFeatureRows.get(2)); + entityKeyList2.add(mockedFeatureRows.get(3)); + + List> featureRows = List.of(entityKeyList1, entityKeyList2); + + when(retrieverV2.getOnlineFeatures(any(), any(), any(), any())).thenReturn(featureRows); + when(registryRepo.getFeatureViewSpec(any(), any())).thenReturn(getFeatureViewSpec()); + when(registryRepo.getFeatureSpec(projectName, mockedFeatureRows.get(0).getFeatureReference())) + .thenReturn(featureSpecs.get(0)); + when(registryRepo.getFeatureSpec(projectName, mockedFeatureRows.get(1).getFeatureReference())) + .thenReturn(featureSpecs.get(1)); + when(registryRepo.getFeatureSpec(projectName, mockedFeatureRows.get(2).getFeatureReference())) + .thenReturn(featureSpecs.get(0)); + when(registryRepo.getFeatureSpec(projectName, mockedFeatureRows.get(3).getFeatureReference())) + .thenReturn(featureSpecs.get(1)); + + when(tracer.buildSpan(ArgumentMatchers.any())).thenReturn(Mockito.mock(SpanBuilder.class)); + + GetOnlineFeaturesResponse expected = + GetOnlineFeaturesResponse.newBuilder() + .addFieldValues( + FieldValues.newBuilder() + .putFields("entity1", createInt64Value(1)) + .putStatuses("entity1", FieldStatus.PRESENT) + .putFields("entity2", createStrValue("a")) + .putStatuses("entity2", FieldStatus.PRESENT) + .putFields("featuretable_1:feature_1", createStrValue("1")) + .putStatuses("featuretable_1:feature_1", FieldStatus.PRESENT) + .putFields("featuretable_1:feature_2", createStrValue("2")) + .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) + .build()) + .addFieldValues( + FieldValues.newBuilder() + .putFields("entity1", createInt64Value(2)) + .putStatuses("entity1", FieldStatus.PRESENT) + .putFields("entity2", createStrValue("b")) + .putStatuses("entity2", FieldStatus.PRESENT) + .putFields("featuretable_1:feature_1", createStrValue("3")) + .putStatuses("featuretable_1:feature_1", FieldStatus.PRESENT) + .putFields("featuretable_1:feature_2", createStrValue("4")) + .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) + .build()) + .build(); + GetOnlineFeaturesResponse actual = onlineServingServiceV2.getOnlineFeatures(request); + assertThat(actual, equalTo(expected)); + } + + @Test + public void shouldReturnResponseWithUnsetValuesAndMetadataIfKeysNotPresent() { + String projectName = "default"; + ServingAPIProto.FeatureReferenceV2 featureReference1 = + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_1") + .build(); + ServingAPIProto.FeatureReferenceV2 featureReference2 = + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_2") + .build(); + List featureReferences = + List.of(featureReference1, featureReference2); + GetOnlineFeaturesRequestV2 request = getOnlineFeaturesRequestV2(projectName, featureReferences); + + List entityKeyList1 = new ArrayList<>(); + List entityKeyList2 = new ArrayList<>(); + entityKeyList1.add(mockedFeatureRows.get(0)); + entityKeyList1.add(mockedFeatureRows.get(1)); + entityKeyList2.add(mockedFeatureRows.get(4)); + + List> featureRows = List.of(entityKeyList1, entityKeyList2); + + when(retrieverV2.getOnlineFeatures(any(), any(), any(), any())).thenReturn(featureRows); + when(registryRepo.getFeatureViewSpec(any(), any())).thenReturn(getFeatureViewSpec()); + when(registryRepo.getFeatureSpec(projectName, mockedFeatureRows.get(0).getFeatureReference())) + .thenReturn(featureSpecs.get(0)); + when(registryRepo.getFeatureSpec(projectName, mockedFeatureRows.get(1).getFeatureReference())) + .thenReturn(featureSpecs.get(1)); + + when(tracer.buildSpan(ArgumentMatchers.any())).thenReturn(Mockito.mock(SpanBuilder.class)); + + GetOnlineFeaturesResponse expected = + GetOnlineFeaturesResponse.newBuilder() + .addFieldValues( + FieldValues.newBuilder() + .putFields("entity1", createInt64Value(1)) + .putStatuses("entity1", FieldStatus.PRESENT) + .putFields("entity2", createStrValue("a")) + .putStatuses("entity2", FieldStatus.PRESENT) + .putFields("featuretable_1:feature_1", createStrValue("1")) + .putStatuses("featuretable_1:feature_1", FieldStatus.PRESENT) + .putFields("featuretable_1:feature_2", createStrValue("2")) + .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) + .build()) + .addFieldValues( + FieldValues.newBuilder() + .putFields("entity1", createInt64Value(2)) + .putStatuses("entity1", FieldStatus.PRESENT) + .putFields("entity2", createStrValue("b")) + .putStatuses("entity2", FieldStatus.PRESENT) + .putFields("featuretable_1:feature_1", createEmptyValue()) + .putStatuses("featuretable_1:feature_1", FieldStatus.NOT_FOUND) + .putFields("featuretable_1:feature_2", createEmptyValue()) + .putStatuses("featuretable_1:feature_2", FieldStatus.NOT_FOUND) + .build()) + .build(); + GetOnlineFeaturesResponse actual = onlineServingServiceV2.getOnlineFeatures(request); + assertThat(actual, equalTo(expected)); + } + + @Test + public void shouldReturnResponseWithValuesAndMetadataIfMaxAgeIsExceeded() { + String projectName = "default"; + ServingAPIProto.FeatureReferenceV2 featureReference1 = + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_1") + .build(); + ServingAPIProto.FeatureReferenceV2 featureReference2 = + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_2") + .build(); + List featureReferences = + List.of(featureReference1, featureReference2); + GetOnlineFeaturesRequestV2 request = getOnlineFeaturesRequestV2(projectName, featureReferences); + + List entityKeyList1 = new ArrayList<>(); + List entityKeyList2 = new ArrayList<>(); + entityKeyList1.add(mockedFeatureRows.get(5)); + entityKeyList1.add(mockedFeatureRows.get(1)); + entityKeyList2.add(mockedFeatureRows.get(5)); + entityKeyList2.add(mockedFeatureRows.get(1)); + + List> featureRows = List.of(entityKeyList1, entityKeyList2); + + when(retrieverV2.getOnlineFeatures(any(), any(), any(), any())).thenReturn(featureRows); + when(registryRepo.getFeatureViewSpec(any(), any())) + .thenReturn( + FeatureViewProto.FeatureViewSpec.newBuilder() + .setName("featuretable_1") + .addEntities("entity1") + .addEntities("entity2") + .addFeatures( + FeatureProto.FeatureSpecV2.newBuilder() + .setName("feature_1") + .setValueType(ValueProto.ValueType.Enum.STRING) + .build()) + .addFeatures( + FeatureProto.FeatureSpecV2.newBuilder() + .setName("feature_2") + .setValueType(ValueProto.ValueType.Enum.STRING) + .build()) + .setTtl(Duration.newBuilder().setSeconds(1)) + .build()); + when(registryRepo.getFeatureSpec(projectName, mockedFeatureRows.get(1).getFeatureReference())) + .thenReturn(featureSpecs.get(1)); + when(registryRepo.getFeatureSpec(projectName, mockedFeatureRows.get(5).getFeatureReference())) + .thenReturn(featureSpecs.get(0)); + + when(tracer.buildSpan(ArgumentMatchers.any())).thenReturn(Mockito.mock(SpanBuilder.class)); + + GetOnlineFeaturesResponse expected = + GetOnlineFeaturesResponse.newBuilder() + .addFieldValues( + FieldValues.newBuilder() + .putFields("entity1", createInt64Value(1)) + .putStatuses("entity1", FieldStatus.PRESENT) + .putFields("entity2", createStrValue("a")) + .putStatuses("entity2", FieldStatus.PRESENT) + .putFields("featuretable_1:feature_1", createStrValue("6")) + .putStatuses("featuretable_1:feature_1", FieldStatus.OUTSIDE_MAX_AGE) + .putFields("featuretable_1:feature_2", createStrValue("2")) + .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) + .build()) + .addFieldValues( + FieldValues.newBuilder() + .putFields("entity1", createInt64Value(2)) + .putStatuses("entity1", FieldStatus.PRESENT) + .putFields("entity2", createStrValue("b")) + .putStatuses("entity2", FieldStatus.PRESENT) + .putFields("featuretable_1:feature_1", createStrValue("6")) + .putStatuses("featuretable_1:feature_1", FieldStatus.OUTSIDE_MAX_AGE) + .putFields("featuretable_1:feature_2", createStrValue("2")) + .putStatuses("featuretable_1:feature_2", FieldStatus.PRESENT) + .build()) + .build(); + GetOnlineFeaturesResponse actual = onlineServingServiceV2.getOnlineFeatures(request); + assertThat(actual, equalTo(expected)); + } + + private FeatureViewProto.FeatureViewSpec getFeatureViewSpec() { + return FeatureViewProto.FeatureViewSpec.newBuilder() + .setName("featuretable_1") + .addEntities("entity1") + .addEntities("entity2") + .addFeatures( + FeatureProto.FeatureSpecV2.newBuilder() + .setName("feature_1") + .setValueType(ValueProto.ValueType.Enum.STRING) + .build()) + .addFeatures( + FeatureProto.FeatureSpecV2.newBuilder() + .setName("feature_2") + .setValueType(ValueProto.ValueType.Enum.STRING) + .build()) + .setTtl(Duration.newBuilder().setSeconds(120)) + .build(); + } + + private GetOnlineFeaturesRequestV2 getOnlineFeaturesRequestV2( + String projectName, List featureReferences) { + return GetOnlineFeaturesRequestV2.newBuilder() + .setProject(projectName) + .addAllFeatures(featureReferences) + .addEntityRows( + GetOnlineFeaturesRequestV2.EntityRow.newBuilder() + .setTimestamp(Timestamp.newBuilder().setSeconds(100)) + .putFields("entity1", createInt64Value(1)) + .putFields("entity2", createStrValue("a"))) + .addEntityRows( + GetOnlineFeaturesRequestV2.EntityRow.newBuilder() + .setTimestamp(Timestamp.newBuilder().setSeconds(100)) + .putFields("entity1", createInt64Value(2)) + .putFields("entity2", createStrValue("b"))) + .addFeatures( + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_1") + .build()) + .addFeatures( + ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretable_1") + .setName("feature_2") + .build()) + .build(); + } +} diff --git a/java/serving/src/test/java/feast/serving/util/DataGenerator.java b/java/serving/src/test/java/feast/serving/util/DataGenerator.java new file mode 100644 index 00000000000..ab537fa6f9b --- /dev/null +++ b/java/serving/src/test/java/feast/serving/util/DataGenerator.java @@ -0,0 +1,317 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.util; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.protobuf.Duration; +import com.google.protobuf.Timestamp; +import feast.proto.core.DataFormatProto.FileFormat; +import feast.proto.core.DataFormatProto.FileFormat.ParquetFormat; +import feast.proto.core.DataFormatProto.StreamFormat; +import feast.proto.core.DataFormatProto.StreamFormat.AvroFormat; +import feast.proto.core.DataFormatProto.StreamFormat.ProtoFormat; +import feast.proto.core.DataSourceProto.DataSource; +import feast.proto.core.DataSourceProto.DataSource.FileOptions; +import feast.proto.core.DataSourceProto.DataSource.KafkaOptions; +import feast.proto.core.DataSourceProto.DataSource.KinesisOptions; +import feast.proto.core.EntityProto; +import feast.proto.core.FeatureProto; +import feast.proto.core.FeatureProto.FeatureSpecV2; +import feast.proto.core.FeatureTableProto.FeatureTableSpec; +import feast.proto.core.StoreProto; +import feast.proto.serving.ServingAPIProto; +import feast.proto.types.ValueProto; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.commons.lang3.tuple.Triple; + +public class DataGenerator { + // projectName, featureName, exclude + static Triple defaultSubscription = Triple.of("*", "*", false); + + static StoreProto.Store defaultStore = + createStore( + "test-store", StoreProto.Store.StoreType.REDIS, ImmutableList.of(defaultSubscription)); + + public static Triple getDefaultSubscription() { + return defaultSubscription; + } + + public static String valueToString(ValueProto.Value v) { + String stringRepr; + switch (v.getValCase()) { + case STRING_VAL: + stringRepr = v.getStringVal(); + break; + case INT64_VAL: + stringRepr = String.valueOf(v.getInt64Val()); + break; + case INT32_VAL: + stringRepr = String.valueOf(v.getInt32Val()); + break; + case BYTES_VAL: + stringRepr = v.getBytesVal().toString(); + break; + default: + throw new RuntimeException("Type is not supported to be entity"); + } + + return stringRepr; + } + + public static StoreProto.Store getDefaultStore() { + return defaultStore; + } + + public static StoreProto.Store createStore( + String name, + StoreProto.Store.StoreType type, + List> subscriptions) { + StoreProto.Store.Builder builder = + StoreProto.Store.newBuilder() + .addAllSubscriptions( + subscriptions.stream() + .map( + s -> + StoreProto.Store.Subscription.newBuilder() + .setProject(s.getLeft()) + .setName(s.getMiddle()) + .setExclude(s.getRight()) + .build()) + .collect(Collectors.toList())) + .setName(name) + .setType(type); + + switch (type) { + case REDIS: + StoreProto.Store.RedisConfig redisConfig = + StoreProto.Store.RedisConfig.newBuilder().build(); + return builder.setRedisConfig(redisConfig).build(); + case REDIS_CLUSTER: + StoreProto.Store.RedisClusterConfig redisClusterConfig = + StoreProto.Store.RedisClusterConfig.newBuilder().build(); + return builder.setRedisClusterConfig(redisClusterConfig).build(); + default: + throw new RuntimeException("Unrecognized Store type"); + } + } + + public static EntityProto.EntitySpecV2 createEntitySpecV2( + String name, + String description, + ValueProto.ValueType.Enum valueType, + Map labels) { + return EntityProto.EntitySpecV2.newBuilder() + .setName(name) + .setDescription(description) + .setValueType(valueType) + .putAllLabels(labels) + .build(); + } + + public static FeatureProto.FeatureSpecV2 createFeatureSpecV2( + String name, ValueProto.ValueType.Enum valueType, Map labels) { + return FeatureProto.FeatureSpecV2.newBuilder() + .setName(name) + .setValueType(valueType) + .putAllLabels(labels) + .build(); + } + + // Create a Feature Table spec without DataSources configured. + public static FeatureTableSpec createFeatureTableSpec( + String name, + List entities, + Map features, + int maxAgeSecs, + Map labels) { + + return FeatureTableSpec.newBuilder() + .setName(name) + .addAllEntities(entities) + .addAllFeatures( + features.entrySet().stream() + .map( + entry -> + FeatureSpecV2.newBuilder() + .setName(entry.getKey()) + .setValueType(entry.getValue()) + .putAllLabels(labels) + .build()) + .collect(Collectors.toList())) + .setMaxAge(Duration.newBuilder().setSeconds(3600).build()) + .setBatchSource( + DataSource.newBuilder() + .setEventTimestampColumn("ts") + .setType(DataSource.SourceType.BATCH_FILE) + .setFileOptions( + FileOptions.newBuilder() + .setFileFormat( + FileFormat.newBuilder() + .setParquetFormat(ParquetFormat.newBuilder().build()) + .build()) + .setFileUrl("/dev/null") + .build()) + .build()) + .putAllLabels(labels) + .build(); + } + + public static FeatureTableSpec createFeatureTableSpec( + String name, + List entities, + ImmutableMap features, + int maxAgeSecs, + Map labels) { + + return FeatureTableSpec.newBuilder() + .setName(name) + .addAllEntities(entities) + .addAllFeatures( + features.entrySet().stream() + .map( + entry -> + FeatureSpecV2.newBuilder() + .setName(entry.getKey()) + .setValueType(entry.getValue()) + .putAllLabels(labels) + .build()) + .collect(Collectors.toList())) + .setMaxAge(Duration.newBuilder().setSeconds(maxAgeSecs).build()) + .putAllLabels(labels) + .build(); + } + + public static DataSource createFileDataSourceSpec( + String fileURL, String timestampColumn, String datePartitionColumn) { + return DataSource.newBuilder() + .setType(DataSource.SourceType.BATCH_FILE) + .setFileOptions( + FileOptions.newBuilder() + .setFileFormat(createParquetFormat()) + .setFileUrl(fileURL) + .build()) + .setEventTimestampColumn(timestampColumn) + .setDatePartitionColumn(datePartitionColumn) + .build(); + } + + public static DataSource createBigQueryDataSourceSpec( + String bigQueryTableRef, String timestampColumn, String datePartitionColumn) { + return DataSource.newBuilder() + .setType(DataSource.SourceType.BATCH_BIGQUERY) + .setBigqueryOptions( + DataSource.BigQueryOptions.newBuilder().setTableRef(bigQueryTableRef).build()) + .setEventTimestampColumn(timestampColumn) + .setDatePartitionColumn(datePartitionColumn) + .build(); + } + + public static DataSource createKafkaDataSourceSpec( + String servers, String topic, String classPath, String timestampColumn) { + return DataSource.newBuilder() + .setType(DataSource.SourceType.STREAM_KAFKA) + .setKafkaOptions( + KafkaOptions.newBuilder() + .setTopic(topic) + .setBootstrapServers(servers) + .setMessageFormat(createProtoFormat("class.path")) + .build()) + .setEventTimestampColumn(timestampColumn) + .build(); + } + + public static ValueProto.Value createEmptyValue() { + return ValueProto.Value.newBuilder().build(); + } + + public static ValueProto.Value createStrValue(String val) { + return ValueProto.Value.newBuilder().setStringVal(val).build(); + } + + public static ValueProto.Value createDoubleValue(double value) { + return ValueProto.Value.newBuilder().setDoubleVal(value).build(); + } + + public static ValueProto.Value createInt32Value(int value) { + return ValueProto.Value.newBuilder().setInt32Val(value).build(); + } + + public static ValueProto.Value createInt64Value(long value) { + return ValueProto.Value.newBuilder().setInt64Val(value).build(); + } + + public static ServingAPIProto.FeatureReferenceV2 createFeatureReference( + String featureTableName, String featureName) { + return ServingAPIProto.FeatureReferenceV2.newBuilder() + .setFeatureTable(featureTableName) + .setName(featureName) + .build(); + } + + public static ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow createEntityRow( + String entityName, ValueProto.Value entityValue, long seconds) { + return ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow.newBuilder() + .setTimestamp(Timestamp.newBuilder().setSeconds(seconds)) + .putFields(entityName, entityValue) + .build(); + } + + public static ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow createCompoundEntityRow( + ImmutableMap entityNameValues, long seconds) { + ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow.Builder entityRow = + ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow.newBuilder() + .setTimestamp(Timestamp.newBuilder().setSeconds(seconds)); + + entityNameValues.entrySet().stream() + .forEach(entry -> entityRow.putFields(entry.getKey(), entry.getValue())); + + return entityRow.build(); + } + + public static DataSource createKinesisDataSourceSpec( + String region, String streamName, String classPath, String timestampColumn) { + return DataSource.newBuilder() + .setType(DataSource.SourceType.STREAM_KINESIS) + .setKinesisOptions( + KinesisOptions.newBuilder() + .setRegion("ap-nowhere1") + .setStreamName("stream") + .setRecordFormat(createProtoFormat(classPath)) + .build()) + .setEventTimestampColumn(timestampColumn) + .build(); + } + + public static FileFormat createParquetFormat() { + return FileFormat.newBuilder().setParquetFormat(ParquetFormat.getDefaultInstance()).build(); + } + + public static StreamFormat createAvroFormat(String schemaJSON) { + return StreamFormat.newBuilder() + .setAvroFormat(AvroFormat.newBuilder().setSchemaJson(schemaJSON).build()) + .build(); + } + + public static StreamFormat createProtoFormat(String classPath) { + return StreamFormat.newBuilder() + .setProtoFormat(ProtoFormat.newBuilder().setClassPath(classPath).build()) + .build(); + } +} diff --git a/java/serving/src/test/java/feast/serving/util/RequestHelperTest.java b/java/serving/src/test/java/feast/serving/util/RequestHelperTest.java new file mode 100644 index 00000000000..140d46cd569 --- /dev/null +++ b/java/serving/src/test/java/feast/serving/util/RequestHelperTest.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.serving.util; + +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2; +import org.junit.Test; + +public class RequestHelperTest { + + @Test(expected = IllegalArgumentException.class) + public void shouldErrorIfEntityRowEmpty() { + FeatureReferenceV2 featureReference = + FeatureReferenceV2.newBuilder() + .setFeatureTable("featuretablename") + .setName("featurename") + .build(); + GetOnlineFeaturesRequestV2 getOnlineFeaturesRequestV2 = + GetOnlineFeaturesRequestV2.newBuilder().addFeatures(featureReference).build(); + RequestHelper.validateOnlineRequest(getOnlineFeaturesRequestV2); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldErrorIfFeatureReferenceTableEmpty() { + FeatureReferenceV2 featureReference = + FeatureReferenceV2.newBuilder().setName("featurename").build(); + GetOnlineFeaturesRequestV2 getOnlineFeaturesRequestV2 = + GetOnlineFeaturesRequestV2.newBuilder().addFeatures(featureReference).build(); + RequestHelper.validateOnlineRequest(getOnlineFeaturesRequestV2); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldErrorIfFeatureReferenceNameEmpty() { + FeatureReferenceV2 featureReference = + FeatureReferenceV2.newBuilder().setFeatureTable("featuretablename").build(); + GetOnlineFeaturesRequestV2 getOnlineFeaturesRequestV2 = + GetOnlineFeaturesRequestV2.newBuilder().addFeatures(featureReference).build(); + RequestHelper.validateOnlineRequest(getOnlineFeaturesRequestV2); + } +} diff --git a/java/serving/src/test/resources/application-it.properties b/java/serving/src/test/resources/application-it.properties new file mode 100644 index 00000000000..6b56408d439 --- /dev/null +++ b/java/serving/src/test/resources/application-it.properties @@ -0,0 +1,14 @@ +# +# Copyright 2018 The Feast Authors +# +# Licensed 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 +# +# https://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. diff --git a/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml b/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml new file mode 100644 index 00000000000..08a50233df1 --- /dev/null +++ b/java/serving/src/test/resources/docker-compose/docker-compose-redis-it.yml @@ -0,0 +1,12 @@ +version: '3' + +services: + redis: + image: redis:5-alpine + ports: + - "6379:6379" + materialize: + build: feast10 + links: + - redis + diff --git a/java/serving/src/test/resources/docker-compose/feast10/Dockerfile b/java/serving/src/test/resources/docker-compose/feast10/Dockerfile new file mode 100644 index 00000000000..bde9f11592f --- /dev/null +++ b/java/serving/src/test/resources/docker-compose/feast10/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.7 + +WORKDIR /usr/src/ + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD [ "python", "./materialize.py" ] diff --git a/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml b/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml new file mode 100644 index 00000000000..7554725004f --- /dev/null +++ b/java/serving/src/test/resources/docker-compose/feast10/feature_store.yaml @@ -0,0 +1,10 @@ +project: feast_project +registry: registry.db +provider: local +online_store: + type: redis + connection_string: "redis:6379" +offline_store: {} +flags: + alpha_features: true + on_demand_transforms: true diff --git a/java/serving/src/test/resources/docker-compose/feast10/materialize.py b/java/serving/src/test/resources/docker-compose/feast10/materialize.py new file mode 100644 index 00000000000..c347728c68b --- /dev/null +++ b/java/serving/src/test/resources/docker-compose/feast10/materialize.py @@ -0,0 +1,64 @@ +# This is an example feature definition file + +import pandas as pd +import numpy as np + +from google.protobuf.duration_pb2 import Duration + +from datetime import datetime, timedelta +from feast import Entity, Feature, FeatureView, FileSource, ValueType, FeatureService, FeatureStore + +print("Running materialize.py") + +# Fill our temporary data source +start = datetime.now() - timedelta(days=10) + +df = pd.DataFrame() +df["driver_id"] = np.arange(1000, 1010) +df["created"] = datetime.now() +df["conv_rate"] = np.arange(0, 1, 0.1) +df["acc_rate"] = np.arange(0.5, 1, 0.05) +df["avg_daily_trips"] = np.arange(0, 1000, 100) + +# some of rows are beyond 7 days to test OUTSIDE_MAX_AGE status +df["event_timestamp"] = start + pd.Series(np.arange(0, 10)).map(lambda days: timedelta(days=days)) + +df.to_parquet("driver_stats.parquet") + +# Read data from parquet files. Parquet is convenient for local development mode. For +# production, you can use your favorite DWH, such as BigQuery. See Feast documentation +# for more info. +file_path = "driver_stats.parquet" +driver_hourly_stats = FileSource( + path=file_path, + event_timestamp_column="event_timestamp", + created_timestamp_column="created", +) + +# Define an entity for the driver. You can think of entity as a primary key used to +# fetch features. +driver = Entity(name="driver_id", value_type=ValueType.INT64, description="driver id",) + +# Our parquet files contain sample data that includes a driver_id column, timestamps and +# three feature column. Here we define a Feature View that will allow us to serve this +# data to our model online. +driver_hourly_stats_view = FeatureView( + name="driver_hourly_stats", + entities=["driver_id"], + ttl=Duration(seconds=86400 * 7), + features=[ + Feature(name="conv_rate", dtype=ValueType.DOUBLE), + Feature(name="acc_rate", dtype=ValueType.FLOAT), + Feature(name="avg_daily_trips", dtype=ValueType.INT64), + ], + online=True, + batch_source=driver_hourly_stats, + tags={}, +) + +fs = FeatureStore(".") +fs.apply([driver_hourly_stats_view, driver]) + +now = datetime.now() +fs.materialize(start, now) +print("Materialization finished") diff --git a/java/serving/src/test/resources/docker-compose/feast10/registry.db b/java/serving/src/test/resources/docker-compose/feast10/registry.db new file mode 100644 index 00000000000..b9a19475af0 Binary files /dev/null and b/java/serving/src/test/resources/docker-compose/feast10/registry.db differ diff --git a/java/serving/src/test/resources/docker-compose/feast10/requirements.txt b/java/serving/src/test/resources/docker-compose/feast10/requirements.txt new file mode 100644 index 00000000000..447f126392b --- /dev/null +++ b/java/serving/src/test/resources/docker-compose/feast10/requirements.txt @@ -0,0 +1,8 @@ +feast[redis]>=0.13,<1 + +# for source generation +pyarrow==6.0.0 + +# temp fixes +proto-plus +Jinja2>=2.0.0 \ No newline at end of file diff --git a/java/serving/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/java/serving/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..ca6ee9cea8e --- /dev/null +++ b/java/serving/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/java/storage/api/pom.xml b/java/storage/api/pom.xml new file mode 100644 index 00000000000..6bce6cec4eb --- /dev/null +++ b/java/storage/api/pom.xml @@ -0,0 +1,78 @@ + + + + dev.feast + feast-parent + ${revision} + ../.. + + + 4.0.0 + feast-storage-api + + Feast Storage API + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + + javax.annotation + + + + + + + + + + dev.feast + datatypes-java + ${project.version} + + + + + + + + + + com.google.auto.value + auto-value-annotations + 1.6.6 + + + + com.google.auto.value + auto-value + 1.6.6 + provided + + + + org.apache.commons + commons-lang3 + 3.9 + + + + org.apache.avro + avro + 1.10.2 + + + + junit + junit + 4.13.2 + test + + + + diff --git a/java/storage/api/src/main/java/feast/storage/api/retriever/AvroFeature.java b/java/storage/api/src/main/java/feast/storage/api/retriever/AvroFeature.java new file mode 100644 index 00000000000..96f19ccdbe1 --- /dev/null +++ b/java/storage/api/src/main/java/feast/storage/api/retriever/AvroFeature.java @@ -0,0 +1,171 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.api.retriever; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Timestamp; +import feast.proto.serving.ServingAPIProto; +import feast.proto.types.ValueProto; +import java.nio.ByteBuffer; +import java.util.stream.Collectors; +import org.apache.avro.generic.GenericData; +import org.apache.avro.util.Utf8; + +public class AvroFeature implements Feature { + private final ServingAPIProto.FeatureReferenceV2 featureReference; + + private final Timestamp eventTimestamp; + + private final Object featureValue; + + public AvroFeature( + ServingAPIProto.FeatureReferenceV2 featureReference, + Timestamp eventTimestamp, + Object featureValue) { + this.featureReference = featureReference; + this.eventTimestamp = eventTimestamp; + this.featureValue = featureValue; + } + + /** + * Casts feature value of Object type based on Feast valueType. Empty object i.e new Object() is + * interpreted as VAL_NOT_SET Feast valueType. + * + * @param valueType Feast valueType of feature as specified in FeatureSpec + * @return ValueProto.Value representation of feature + */ + @Override + public ValueProto.Value getFeatureValue(ValueProto.ValueType.Enum valueType) { + ValueProto.Value finalValue; + + try { + switch (valueType) { + case STRING: + finalValue = + ValueProto.Value.newBuilder().setStringVal(((Utf8) featureValue).toString()).build(); + break; + case INT32: + finalValue = ValueProto.Value.newBuilder().setInt32Val((Integer) featureValue).build(); + break; + case INT64: + finalValue = ValueProto.Value.newBuilder().setInt64Val((Long) featureValue).build(); + break; + case DOUBLE: + finalValue = ValueProto.Value.newBuilder().setDoubleVal((Double) featureValue).build(); + break; + case FLOAT: + finalValue = ValueProto.Value.newBuilder().setFloatVal((Float) featureValue).build(); + break; + case BYTES: + finalValue = + ValueProto.Value.newBuilder() + .setBytesVal(ByteString.copyFrom(((ByteBuffer) featureValue).array())) + .build(); + break; + case BOOL: + finalValue = ValueProto.Value.newBuilder().setBoolVal((Boolean) featureValue).build(); + break; + case STRING_LIST: + finalValue = + ValueProto.Value.newBuilder() + .setStringListVal( + ValueProto.StringList.newBuilder() + .addAllVal( + ((GenericData.Array) featureValue) + .stream().map(Utf8::toString).collect(Collectors.toList())) + .build()) + .build(); + break; + case INT64_LIST: + finalValue = + ValueProto.Value.newBuilder() + .setInt64ListVal( + ValueProto.Int64List.newBuilder() + .addAllVal(((GenericData.Array) featureValue)) + .build()) + .build(); + break; + case INT32_LIST: + finalValue = + ValueProto.Value.newBuilder() + .setInt32ListVal( + ValueProto.Int32List.newBuilder() + .addAllVal(((GenericData.Array) featureValue)) + .build()) + .build(); + break; + case FLOAT_LIST: + finalValue = + ValueProto.Value.newBuilder() + .setFloatListVal( + ValueProto.FloatList.newBuilder() + .addAllVal(((GenericData.Array) featureValue)) + .build()) + .build(); + break; + case DOUBLE_LIST: + finalValue = + ValueProto.Value.newBuilder() + .setDoubleListVal( + ValueProto.DoubleList.newBuilder() + .addAllVal(((GenericData.Array) featureValue)) + .build()) + .build(); + break; + case BOOL_LIST: + finalValue = + ValueProto.Value.newBuilder() + .setBoolListVal( + ValueProto.BoolList.newBuilder() + .addAllVal(((GenericData.Array) featureValue)) + .build()) + .build(); + break; + case BYTES_LIST: + finalValue = + ValueProto.Value.newBuilder() + .setBytesListVal( + ValueProto.BytesList.newBuilder() + .addAllVal( + ((GenericData.Array) featureValue) + .stream() + .map(byteBuffer -> ByteString.copyFrom(byteBuffer.array())) + .collect(Collectors.toList())) + .build()) + .build(); + break; + default: + throw new RuntimeException("FeatureType is not supported"); + } + } catch (ClassCastException e) { + // Feature type has changed + finalValue = ValueProto.Value.newBuilder().build(); + } + + return finalValue; + } + + @Override + public ServingAPIProto.FeatureReferenceV2 getFeatureReference() { + return this.featureReference; + } + + @Override + public Timestamp getEventTimestamp() { + return this.eventTimestamp; + } +} diff --git a/java/storage/api/src/main/java/feast/storage/api/retriever/Feature.java b/java/storage/api/src/main/java/feast/storage/api/retriever/Feature.java new file mode 100644 index 00000000000..92ae1f31fb1 --- /dev/null +++ b/java/storage/api/src/main/java/feast/storage/api/retriever/Feature.java @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.api.retriever; + +import com.google.protobuf.Timestamp; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.types.ValueProto; +import feast.proto.types.ValueProto.Value; +import java.util.HashMap; + +public interface Feature { + + HashMap TYPE_TO_VAL_CASE = + new HashMap() { + { + put(ValueProto.ValueType.Enum.BYTES, ValueProto.Value.ValCase.BYTES_VAL); + put(ValueProto.ValueType.Enum.STRING, ValueProto.Value.ValCase.STRING_VAL); + put(ValueProto.ValueType.Enum.INT32, ValueProto.Value.ValCase.INT32_VAL); + put(ValueProto.ValueType.Enum.INT64, ValueProto.Value.ValCase.INT64_VAL); + put(ValueProto.ValueType.Enum.DOUBLE, ValueProto.Value.ValCase.DOUBLE_VAL); + put(ValueProto.ValueType.Enum.FLOAT, ValueProto.Value.ValCase.FLOAT_VAL); + put(ValueProto.ValueType.Enum.BOOL, ValueProto.Value.ValCase.BOOL_VAL); + put(ValueProto.ValueType.Enum.BYTES_LIST, ValueProto.Value.ValCase.BYTES_LIST_VAL); + put(ValueProto.ValueType.Enum.STRING_LIST, ValueProto.Value.ValCase.STRING_LIST_VAL); + put(ValueProto.ValueType.Enum.INT32_LIST, ValueProto.Value.ValCase.INT32_LIST_VAL); + put(ValueProto.ValueType.Enum.INT64_LIST, ValueProto.Value.ValCase.INT64_LIST_VAL); + put(ValueProto.ValueType.Enum.DOUBLE_LIST, ValueProto.Value.ValCase.DOUBLE_LIST_VAL); + put(ValueProto.ValueType.Enum.FLOAT_LIST, ValueProto.Value.ValCase.FLOAT_LIST_VAL); + put(ValueProto.ValueType.Enum.BOOL_LIST, ValueProto.Value.ValCase.BOOL_LIST_VAL); + } + }; + + Value getFeatureValue(ValueProto.ValueType.Enum valueType); + + FeatureReferenceV2 getFeatureReference(); + + Timestamp getEventTimestamp(); +} diff --git a/java/storage/api/src/main/java/feast/storage/api/retriever/FeatureTableRequest.java b/java/storage/api/src/main/java/feast/storage/api/retriever/FeatureTableRequest.java new file mode 100644 index 00000000000..6188a270c40 --- /dev/null +++ b/java/storage/api/src/main/java/feast/storage/api/retriever/FeatureTableRequest.java @@ -0,0 +1,61 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.api.retriever; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableSet; +import feast.proto.core.FeatureTableProto.FeatureTableSpec; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@AutoValue +public abstract class FeatureTableRequest { + public abstract FeatureTableSpec getSpec(); + + public abstract ImmutableSet getFeatureReferences(); + + public static Builder newBuilder() { + return new AutoValue_FeatureTableRequest.Builder(); + } + + @AutoValue.Builder + public abstract static class Builder { + public abstract Builder setSpec(FeatureTableSpec spec); + + abstract ImmutableSet.Builder featureReferencesBuilder(); + + public Builder addAllFeatureReferences(List featureReferenceList) { + featureReferencesBuilder().addAll(featureReferenceList); + return this; + } + + public Builder addFeatureReference(FeatureReferenceV2 featureReference) { + featureReferencesBuilder().add(featureReference); + return this; + } + + public abstract FeatureTableRequest build(); + } + + public Map getFeatureRefsByName() { + return getFeatureReferences().stream() + .collect( + Collectors.toMap(FeatureReferenceV2::getName, featureReference -> featureReference)); + } +} diff --git a/java/storage/api/src/main/java/feast/storage/api/retriever/OnlineRetrieverV2.java b/java/storage/api/src/main/java/feast/storage/api/retriever/OnlineRetrieverV2.java new file mode 100644 index 00000000000..a49ab3fccd0 --- /dev/null +++ b/java/storage/api/src/main/java/feast/storage/api/retriever/OnlineRetrieverV2.java @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.api.retriever; + +import feast.proto.serving.ServingAPIProto; +import java.util.List; + +public interface OnlineRetrieverV2 { + /** + * Get online features for the given entity rows using data retrieved from the Feature references + * specified in FeatureTable request. + * + *

Each {@link Feature} optional in the returned list then corresponds to an {@link + * ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow} provided by the user. If feature for a + * given entity row is not found, will return an empty optional instead. The no. of {@link + * Feature} returned should match the no. of given {@link + * ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow}s + * + * @param project name of project to request features from. + * @param entityRows list of entity rows to request features for. + * @param featureReferences specifies the FeatureTable to retrieve data from + * @param entityNames name of entities + * @return list of {@link Feature}s corresponding to data retrieved for each entity row from + * FeatureTable specified in FeatureTable request. + */ + List> getOnlineFeatures( + String project, + List entityRows, + List featureReferences, + List entityNames); +} diff --git a/java/storage/api/src/main/java/feast/storage/api/retriever/ProtoFeature.java b/java/storage/api/src/main/java/feast/storage/api/retriever/ProtoFeature.java new file mode 100644 index 00000000000..09f6b75f49c --- /dev/null +++ b/java/storage/api/src/main/java/feast/storage/api/retriever/ProtoFeature.java @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.api.retriever; + +import com.google.protobuf.Timestamp; +import feast.proto.serving.ServingAPIProto; +import feast.proto.types.ValueProto; + +public class ProtoFeature implements Feature { + private final ServingAPIProto.FeatureReferenceV2 featureReference; + + private final Timestamp eventTimestamp; + + private final ValueProto.Value featureValue; + + public ProtoFeature( + ServingAPIProto.FeatureReferenceV2 featureReference, + Timestamp eventTimestamp, + ValueProto.Value featureValue) { + this.featureReference = featureReference; + this.eventTimestamp = eventTimestamp; + this.featureValue = featureValue; + } + + /** + * Returns Feast valueType if type matches, otherwise null. + * + * @param valueType Feast valueType of feature as specified in FeatureSpec + * @return ValueProto.Value representation of feature + */ + @Override + public ValueProto.Value getFeatureValue(ValueProto.ValueType.Enum valueType) { + if (TYPE_TO_VAL_CASE.get(valueType) != this.featureValue.getValCase()) { + return null; + } + + return this.featureValue; + } + + @Override + public ServingAPIProto.FeatureReferenceV2 getFeatureReference() { + return this.featureReference; + } + + @Override + public Timestamp getEventTimestamp() { + return this.eventTimestamp; + } +} diff --git a/java/storage/connectors/bigtable/pom.xml b/java/storage/connectors/bigtable/pom.xml new file mode 100644 index 00000000000..81cd4507450 --- /dev/null +++ b/java/storage/connectors/bigtable/pom.xml @@ -0,0 +1,45 @@ + + + + dev.feast + feast-storage-connectors + ${revision} + + + 4.0.0 + feast-storage-connector-bigtable + + + 11 + 11 + + + + + com.google.cloud + google-cloud-bigtable + 1.21.2 + + + + + org.apache.avro + avro + 1.10.2 + + + + dev.feast + feast-storage-connector-sstable + ${project.version} + + + + com.google.guava + guava + + + + \ No newline at end of file diff --git a/java/storage/connectors/bigtable/src/main/java/feast/storage/connectors/bigtable/retriever/BigTableOnlineRetriever.java b/java/storage/connectors/bigtable/src/main/java/feast/storage/connectors/bigtable/retriever/BigTableOnlineRetriever.java new file mode 100644 index 00000000000..6e67782b2ac --- /dev/null +++ b/java/storage/connectors/bigtable/src/main/java/feast/storage/connectors/bigtable/retriever/BigTableOnlineRetriever.java @@ -0,0 +1,211 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.bigtable.retriever; + +import com.google.cloud.bigtable.data.v2.BigtableDataClient; +import com.google.cloud.bigtable.data.v2.models.Filters; +import com.google.cloud.bigtable.data.v2.models.Query; +import com.google.cloud.bigtable.data.v2.models.Row; +import com.google.cloud.bigtable.data.v2.models.RowCell; +import com.google.protobuf.ByteString; +import com.google.protobuf.Timestamp; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow; +import feast.storage.api.retriever.AvroFeature; +import feast.storage.api.retriever.Feature; +import feast.storage.connectors.sstable.retriever.SSTableOnlineRetriever; +import java.io.IOException; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.apache.avro.AvroRuntimeException; +import org.apache.avro.generic.GenericDatumReader; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.io.*; + +public class BigTableOnlineRetriever implements SSTableOnlineRetriever { + + private BigtableDataClient client; + private BigTableSchemaRegistry schemaRegistry; + + public BigTableOnlineRetriever(BigtableDataClient client) { + this.client = client; + this.schemaRegistry = new BigTableSchemaRegistry(client); + } + + /** + * Generate BigTable key in the form of entity values joined by #. + * + * @param entityRow Single EntityRow representation in feature retrieval call + * @param entityNames List of entities related to feature references in retrieval call + * @return BigTable key for retrieval + */ + @Override + public ByteString convertEntityValueToKey(EntityRow entityRow, List entityNames) { + return ByteString.copyFrom( + entityNames.stream() + .map(entity -> entityRow.getFieldsMap().get(entity)) + .map(this::valueToString) + .collect(Collectors.joining("#")) + .getBytes()); + } + + /** + * Converts rowCell feature value into @NativeFeature type. + * + * @param tableName Name of BigTable table + * @param rowKeys List of keys of rows to retrieve + * @param rows Map of rowKey to Row related to it + * @param featureReferences List of feature references + * @return List of List of Features associated with respective rowKey + */ + @Override + public List> convertRowToFeature( + String tableName, + List rowKeys, + Map rows, + List featureReferences) { + + BinaryDecoder reusedDecoder = DecoderFactory.get().binaryDecoder(new byte[0], null); + + return rowKeys.stream() + .map( + rowKey -> { + if (!rows.containsKey(rowKey)) { + return Collections.emptyList(); + } else { + Row row = rows.get(rowKey); + return featureReferences.stream() + .map(FeatureReferenceV2::getFeatureTable) + .distinct() + .map(cf -> row.getCells(cf, "")) + .filter(ls -> !ls.isEmpty()) + .flatMap( + rowCells -> { + RowCell rowCell = rowCells.get(0); // Latest cell + String family = rowCell.getFamily(); + ByteString value = rowCell.getValue(); + + List features; + List localFeatureReferences = + featureReferences.stream() + .filter( + featureReference -> + featureReference.getFeatureTable().equals(family)) + .collect(Collectors.toList()); + + try { + features = + decodeFeatures( + tableName, + value, + localFeatureReferences, + reusedDecoder, + rowCell.getTimestamp()); + } catch (IOException e) { + throw new RuntimeException("Failed to decode features from BigTable"); + } + + return features.stream(); + }) + .collect(Collectors.toList()); + } + }) + .collect(Collectors.toList()); + } + + /** + * Retrieve rows for each row entity key by generating BigTable rowQuery with filters based on + * column families. + * + * @param tableName Name of BigTable table + * @param rowKeys List of keys of rows to retrieve + * @param columnFamilies List of FeatureTable names + * @return Map of retrieved features for each rowKey + */ + @Override + public Map getFeaturesFromSSTable( + String tableName, List rowKeys, List columnFamilies) { + Query rowQuery = Query.create(tableName); + Filters.InterleaveFilter familyFilter = Filters.FILTERS.interleave(); + columnFamilies.forEach(cf -> familyFilter.filter(Filters.FILTERS.family().exactMatch(cf))); + + for (ByteString rowKey : rowKeys) { + rowQuery.rowKey(rowKey); + } + + return StreamSupport.stream(client.readRows(rowQuery).spliterator(), false) + .collect(Collectors.toMap(Row::getKey, Function.identity())); + } + + /** + * AvroRuntimeException is thrown if feature name does not exist in avro schema. Empty Object is + * returned when null is retrieved from BigTable RowCell. + * + * @param tableName Name of BigTable table + * @param value Value of BigTable cell where first 4 bytes represent the schema reference and + * remaining bytes represent avro-serialized features + * @param featureReferences List of feature references + * @param reusedDecoder Decoder for decoding feature values + * @param timestamp Timestamp of rowCell + * @return @NativeFeature with retrieved value stored in BigTable RowCell + * @throws IOException + */ + private List decodeFeatures( + String tableName, + ByteString value, + List featureReferences, + BinaryDecoder reusedDecoder, + long timestamp) + throws IOException { + ByteString schemaReferenceBytes = value.substring(0, 4); + byte[] featureValueBytes = value.substring(4).toByteArray(); + + BigTableSchemaRegistry.SchemaReference schemaReference = + new BigTableSchemaRegistry.SchemaReference(tableName, schemaReferenceBytes); + + GenericDatumReader reader = schemaRegistry.getReader(schemaReference); + + reusedDecoder = DecoderFactory.get().binaryDecoder(featureValueBytes, reusedDecoder); + GenericRecord record = reader.read(null, reusedDecoder); + + return featureReferences.stream() + .map( + featureReference -> { + Object featureValue; + try { + featureValue = record.get(featureReference.getName()); + } catch (AvroRuntimeException e) { + // Feature is not found in schema + return null; + } + if (featureValue != null) { + return new AvroFeature( + featureReference, + Timestamp.newBuilder().setSeconds(timestamp / 1000).build(), + featureValue); + } + return new AvroFeature( + featureReference, + Timestamp.newBuilder().setSeconds(timestamp / 1000).build(), + new Object()); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } +} diff --git a/java/storage/connectors/bigtable/src/main/java/feast/storage/connectors/bigtable/retriever/BigTableSchemaRegistry.java b/java/storage/connectors/bigtable/src/main/java/feast/storage/connectors/bigtable/retriever/BigTableSchemaRegistry.java new file mode 100644 index 00000000000..64989f0f8dd --- /dev/null +++ b/java/storage/connectors/bigtable/src/main/java/feast/storage/connectors/bigtable/retriever/BigTableSchemaRegistry.java @@ -0,0 +1,107 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.bigtable.retriever; + +import com.google.cloud.bigtable.data.v2.BigtableDataClient; +import com.google.cloud.bigtable.data.v2.models.Filters; +import com.google.cloud.bigtable.data.v2.models.Row; +import com.google.cloud.bigtable.data.v2.models.RowCell; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.collect.Iterables; +import com.google.protobuf.ByteString; +import java.util.concurrent.ExecutionException; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericDatumReader; +import org.apache.avro.generic.GenericRecord; + +public class BigTableSchemaRegistry { + private final BigtableDataClient client; + private final LoadingCache> cache; + + private static String COLUMN_FAMILY = "metadata"; + private static String QUALIFIER = "avro"; + private static String KEY_PREFIX = "schema#"; + + public static class SchemaReference { + private final String tableName; + private final ByteString schemaHash; + + public SchemaReference(String tableName, ByteString schemaHash) { + this.tableName = tableName; + this.schemaHash = schemaHash; + } + + public String getTableName() { + return tableName; + } + + public ByteString getSchemaHash() { + return schemaHash; + } + + @Override + public int hashCode() { + int result = tableName.hashCode(); + result = 31 * result + schemaHash.hashCode(); + return result; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + SchemaReference that = (SchemaReference) o; + + if (!tableName.equals(that.tableName)) return false; + return schemaHash.equals(that.schemaHash); + } + } + + public BigTableSchemaRegistry(BigtableDataClient client) { + this.client = client; + + CacheLoader> schemaCacheLoader = + CacheLoader.from(this::loadReader); + + cache = CacheBuilder.newBuilder().build(schemaCacheLoader); + } + + public GenericDatumReader getReader(SchemaReference reference) { + GenericDatumReader reader; + try { + reader = this.cache.get(reference); + } catch (ExecutionException | CacheLoader.InvalidCacheLoadException e) { + throw new RuntimeException(String.format("Unable to find Schema"), e); + } + return reader; + } + + private GenericDatumReader loadReader(SchemaReference reference) { + Row row = + client.readRow( + reference.getTableName(), + ByteString.copyFrom(KEY_PREFIX.getBytes()).concat(reference.getSchemaHash()), + Filters.FILTERS.family().exactMatch(COLUMN_FAMILY)); + RowCell last = Iterables.getLast(row.getCells(COLUMN_FAMILY, QUALIFIER)); + + Schema schema = new Schema.Parser().parse(last.getValue().toStringUtf8()); + return new GenericDatumReader<>(schema); + } +} diff --git a/java/storage/connectors/bigtable/src/main/java/feast/storage/connectors/bigtable/retriever/BigTableStoreConfig.java b/java/storage/connectors/bigtable/src/main/java/feast/storage/connectors/bigtable/retriever/BigTableStoreConfig.java new file mode 100644 index 00000000000..11a0445006d --- /dev/null +++ b/java/storage/connectors/bigtable/src/main/java/feast/storage/connectors/bigtable/retriever/BigTableStoreConfig.java @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.bigtable.retriever; + +public class BigTableStoreConfig { + private final String projectId; + private final String instanceId; + private final String appProfileId; + + public BigTableStoreConfig(String projectId, String instanceId, String appProfileId) { + this.projectId = projectId; + this.instanceId = instanceId; + this.appProfileId = appProfileId; + } + + public String getProjectId() { + return this.projectId; + } + + public String getInstanceId() { + return this.instanceId; + } + + public String getAppProfileId() { + return this.appProfileId; + } +} diff --git a/java/storage/connectors/cassandra/pom.xml b/java/storage/connectors/cassandra/pom.xml new file mode 100644 index 00000000000..32e4d73bca3 --- /dev/null +++ b/java/storage/connectors/cassandra/pom.xml @@ -0,0 +1,45 @@ + + + + feast-storage-connectors + dev.feast + ${revision} + + + 4.0.0 + feast-storage-connector-cassandra + + + 11 + 11 + + + + + org.apache.avro + avro + 1.10.2 + + + + dev.feast + feast-storage-connector-sstable + ${project.version} + + + + com.datastax.oss + java-driver-core + 4.11.0 + + + + com.datastax.oss + java-driver-query-builder + 4.11.0 + + + + \ No newline at end of file diff --git a/java/storage/connectors/cassandra/src/main/java/feast/storage/connectors/cassandra/retriever/CassandraOnlineRetriever.java b/java/storage/connectors/cassandra/src/main/java/feast/storage/connectors/cassandra/retriever/CassandraOnlineRetriever.java new file mode 100644 index 00000000000..9b9de7bdb9b --- /dev/null +++ b/java/storage/connectors/cassandra/src/main/java/feast/storage/connectors/cassandra/retriever/CassandraOnlineRetriever.java @@ -0,0 +1,271 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.cassandra.retriever; + +import com.datastax.oss.driver.api.core.AsyncPagingIterable; +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.AsyncResultSet; +import com.datastax.oss.driver.api.core.cql.PreparedStatement; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.querybuilder.QueryBuilder; +import com.datastax.oss.driver.api.querybuilder.select.Select; +import com.google.protobuf.Timestamp; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow; +import feast.storage.api.retriever.AvroFeature; +import feast.storage.api.retriever.Feature; +import feast.storage.connectors.sstable.retriever.SSTableOnlineRetriever; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.avro.AvroRuntimeException; +import org.apache.avro.generic.GenericDatumReader; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.io.BinaryDecoder; +import org.apache.avro.io.DecoderFactory; + +public class CassandraOnlineRetriever implements SSTableOnlineRetriever { + + private final CqlSession session; + private final CassandraSchemaRegistry schemaRegistry; + + private static final String ENTITY_KEY = "key"; + private static final String SCHEMA_REF_SUFFIX = "__schema_ref"; + private static final String EVENT_TIMESTAMP_SUFFIX = "__event_timestamp"; + private static final int MAX_TABLE_NAME_LENGTH = 48; + + public CassandraOnlineRetriever(CqlSession session) { + this.session = session; + this.schemaRegistry = new CassandraSchemaRegistry(session); + } + + /** + * Generate Cassandra key in the form of entity values joined by #. + * + * @param entityRow Single EntityRow representation in feature retrieval call + * @param entityNames List of entities related to feature references in retrieval call + * @return Cassandra key for retrieval + */ + @Override + public ByteBuffer convertEntityValueToKey(EntityRow entityRow, List entityNames) { + return ByteBuffer.wrap( + entityNames.stream() + .map(entity -> entityRow.getFieldsMap().get(entity)) + .map(this::valueToString) + .collect(Collectors.joining("#")) + .getBytes()); + } + + /** + * Generate Cassandra table name, with limit of 48 characters. + * + * @param project Name of Feast project + * @param entityNames List of entities used in retrieval call + * @return Cassandra table name for retrieval + */ + @Override + public String getSSTable(String project, List entityNames) { + String tableName = String.format("%s__%s", project, String.join("__", entityNames)); + return trimAndHash(tableName, MAX_TABLE_NAME_LENGTH); + } + + /** + * Converts Cassandra rows into @NativeFeature type. + * + * @param tableName Name of Cassandra table + * @param rowKeys List of keys of rows to retrieve + * @param rows Map of rowKey to Row related to it + * @param featureReferences List of feature references + * @return List of List of Features associated with respective rowKey + */ + @Override + public List> convertRowToFeature( + String tableName, + List rowKeys, + Map rows, + List featureReferences) { + + BinaryDecoder reusedDecoder = DecoderFactory.get().binaryDecoder(new byte[0], null); + + return rowKeys.stream() + .map( + rowKey -> { + if (!rows.containsKey(rowKey)) { + return Collections.emptyList(); + } else { + Row row = rows.get(rowKey); + return featureReferences.stream() + .map(FeatureReferenceV2::getFeatureTable) + .distinct() + .flatMap( + featureTableColumn -> { + ByteBuffer featureValues = row.getByteBuffer(featureTableColumn); + ByteBuffer schemaRefKey = + row.getByteBuffer(featureTableColumn + SCHEMA_REF_SUFFIX); + + // Prevent retrieval of features from incorrect FeatureTable + List localFeatureReferences = + featureReferences.stream() + .filter( + featureReference -> + featureReference + .getFeatureTable() + .equals(featureTableColumn)) + .collect(Collectors.toList()); + + List features; + try { + features = + decodeFeatures( + schemaRefKey, + featureValues, + localFeatureReferences, + reusedDecoder, + row.getLong(featureTableColumn + EVENT_TIMESTAMP_SUFFIX)); + } catch (IOException e) { + throw new RuntimeException("Failed to decode features from Cassandra"); + } + + return features.stream(); + }) + .collect(Collectors.toList()); + } + }) + .collect(Collectors.toList()); + } + + /** + * Retrieve rows for each row entity key by generating Cassandra Query with filters based on + * columns. + * + * @param tableName Name of Cassandra table + * @param rowKeys List of keys of rows to retrieve + * @param columnFamilies List of FeatureTable names + * @return Map of retrieved features for each rowKey + */ + @Override + public Map getFeaturesFromSSTable( + String tableName, List rowKeys, List columnFamilies) { + List schemaRefColumns = + columnFamilies.stream().map(c -> c + SCHEMA_REF_SUFFIX).collect(Collectors.toList()); + Select query = + QueryBuilder.selectFrom(tableName) + .columns(columnFamilies) + .columns(schemaRefColumns) + .column(ENTITY_KEY); + for (String columnFamily : columnFamilies) { + query = query.writeTime(columnFamily).as(columnFamily + EVENT_TIMESTAMP_SUFFIX); + } + query = query.whereColumn(ENTITY_KEY).isEqualTo(QueryBuilder.bindMarker()); + + PreparedStatement preparedStatement = session.prepare(query.build()); + + List> completableAsyncResultSets = + rowKeys.stream() + .map(preparedStatement::bind) + .map(session::executeAsync) + .map(CompletionStage::toCompletableFuture) + .collect(Collectors.toList()); + + CompletableFuture allResultComputed = + CompletableFuture.allOf(completableAsyncResultSets.toArray(new CompletableFuture[0])); + + Map resultMap; + try { + resultMap = + allResultComputed + .thenApply( + v -> + completableAsyncResultSets.stream() + .map(CompletableFuture::join) + .filter(result -> result.remaining() != 0) + .map(AsyncPagingIterable::one) + .filter(Objects::nonNull) + .collect( + Collectors.toMap( + (Row row) -> row.getByteBuffer(ENTITY_KEY), Function.identity()))) + .get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e.getMessage()); + } + + return resultMap; + } + + /** + * AvroRuntimeException is thrown if feature name does not exist in avro schema. + * + * @param schemaRefKey Schema reference key + * @param value Value of Cassandra cell where bytes represent avro-serialized features + * @param featureReferences List of feature references + * @param reusedDecoder Decoder for decoding feature values + * @param timestamp Timestamp of rowCell + * @return @NativeFeature with retrieved value stored in Cassandra cell + * @throws IOException + */ + private List decodeFeatures( + ByteBuffer schemaRefKey, + ByteBuffer value, + List featureReferences, + BinaryDecoder reusedDecoder, + long timestamp) + throws IOException { + + if (value == null || schemaRefKey == null) { + return Collections.emptyList(); + } + + CassandraSchemaRegistry.SchemaReference schemaReference = + new CassandraSchemaRegistry.SchemaReference(schemaRefKey); + + // Convert ByteBuffer to ByteArray + byte[] bytesArray = new byte[value.remaining()]; + value.get(bytesArray, 0, bytesArray.length); + GenericDatumReader reader = schemaRegistry.getReader(schemaReference); + reusedDecoder = DecoderFactory.get().binaryDecoder(bytesArray, reusedDecoder); + GenericRecord record = reader.read(null, reusedDecoder); + + return featureReferences.stream() + .map( + featureReference -> { + Object featureValue; + try { + featureValue = record.get(featureReference.getName()); + } catch (AvroRuntimeException e) { + // Feature is not found in schema + return null; + } + if (featureValue != null) { + return new AvroFeature( + featureReference, + Timestamp.newBuilder().setSeconds(timestamp / 1000).build(), + featureValue); + } + return new AvroFeature( + featureReference, + Timestamp.newBuilder().setSeconds(timestamp / 1000).build(), + new Object()); + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } +} diff --git a/java/storage/connectors/cassandra/src/main/java/feast/storage/connectors/cassandra/retriever/CassandraSchemaRegistry.java b/java/storage/connectors/cassandra/src/main/java/feast/storage/connectors/cassandra/retriever/CassandraSchemaRegistry.java new file mode 100644 index 00000000000..7915b370a43 --- /dev/null +++ b/java/storage/connectors/cassandra/src/main/java/feast/storage/connectors/cassandra/retriever/CassandraSchemaRegistry.java @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.cassandra.retriever; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.BoundStatement; +import com.datastax.oss.driver.api.core.cql.PreparedStatement; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.querybuilder.QueryBuilder; +import com.datastax.oss.driver.api.querybuilder.select.Select; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericDatumReader; +import org.apache.avro.generic.GenericRecord; + +public class CassandraSchemaRegistry { + private final CqlSession session; + private final PreparedStatement preparedStatement; + private final LoadingCache> cache; + + private static String SCHEMA_REF_TABLE = "feast_schema_reference"; + private static String SCHEMA_REF_COLUMN = "schema_ref"; + private static String SCHEMA_COLUMN = "avro_schema"; + + public static class SchemaReference { + private final ByteBuffer schemaHash; + + public SchemaReference(ByteBuffer schemaHash) { + this.schemaHash = schemaHash; + } + + public ByteBuffer getSchemaHash() { + return schemaHash; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SchemaReference that = (SchemaReference) o; + return Objects.equals(schemaHash, that.schemaHash); + } + + @Override + public int hashCode() { + return Objects.hash(schemaHash); + } + } + + public CassandraSchemaRegistry(CqlSession session) { + this.session = session; + String tableName = String.format("\"%s\"", SCHEMA_REF_TABLE); + Select query = + QueryBuilder.selectFrom(tableName) + .column(SCHEMA_COLUMN) + .whereColumn(SCHEMA_REF_COLUMN) + .isEqualTo(QueryBuilder.bindMarker()); + this.preparedStatement = session.prepare(query.build()); + + CacheLoader> schemaCacheLoader = + CacheLoader.from(this::loadReader); + + cache = CacheBuilder.newBuilder().build(schemaCacheLoader); + } + + public GenericDatumReader getReader(SchemaReference reference) { + GenericDatumReader reader; + try { + reader = this.cache.get(reference); + } catch (ExecutionException | CacheLoader.InvalidCacheLoadException e) { + throw new RuntimeException("Unable to find Schema"); + } + return reader; + } + + private GenericDatumReader loadReader(SchemaReference reference) { + BoundStatement statement = preparedStatement.bind(reference.getSchemaHash()); + + Row row = session.execute(statement).one(); + + Schema schema = + new Schema.Parser() + .parse(StandardCharsets.UTF_8.decode(row.getByteBuffer(SCHEMA_COLUMN)).toString()); + return new GenericDatumReader<>(schema); + } +} diff --git a/java/storage/connectors/cassandra/src/main/java/feast/storage/connectors/cassandra/retriever/CassandraStoreConfig.java b/java/storage/connectors/cassandra/src/main/java/feast/storage/connectors/cassandra/retriever/CassandraStoreConfig.java new file mode 100644 index 00000000000..3ee25dfe8a7 --- /dev/null +++ b/java/storage/connectors/cassandra/src/main/java/feast/storage/connectors/cassandra/retriever/CassandraStoreConfig.java @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.cassandra.retriever; + +public class CassandraStoreConfig { + + private final String connectionString; + private final String dataCenter; + private final String keySpace; + + public CassandraStoreConfig(String connectionString, String dataCenter, String keySpace) { + this.connectionString = connectionString; + this.dataCenter = dataCenter; + this.keySpace = keySpace; + } + + public String getConnectionString() { + return this.connectionString; + } + + public String getDataCenter() { + return this.dataCenter; + } + + public String getKeySpace() { + return this.keySpace; + } +} diff --git a/java/storage/connectors/pom.xml b/java/storage/connectors/pom.xml new file mode 100644 index 00000000000..1c4b75afa98 --- /dev/null +++ b/java/storage/connectors/pom.xml @@ -0,0 +1,64 @@ + + + + dev.feast + feast-parent + ${revision} + ../.. + + + 4.0.0 + feast-storage-connectors + pom + + Feast Storage Connectors + + + redis + bigtable + cassandra + sstable + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + + javax.annotation + + + + + + org.jacoco + jacoco-maven-plugin + + + + + + + dev.feast + datatypes-java + ${project.version} + + + + dev.feast + feast-common + ${project.version} + + + + dev.feast + feast-storage-api + ${project.version} + + + + diff --git a/java/storage/connectors/redis/pom.xml b/java/storage/connectors/redis/pom.xml new file mode 100644 index 00000000000..7b0c944a66e --- /dev/null +++ b/java/storage/connectors/redis/pom.xml @@ -0,0 +1,100 @@ + + + + dev.feast + feast-storage-connectors + ${revision} + + + 4.0.0 + feast-storage-connector-redis + + Feast Storage Connector for Redis + + + + io.lettuce + lettuce-core + 6.0.2.RELEASE + + + + io.netty + netty-transport-native-epoll + 4.1.52.Final + linux-x86_64 + + + + org.apache.commons + commons-lang3 + 3.9 + + + + com.google.auto.value + auto-value-annotations + 1.6.6 + + + + com.google.auto.value + auto-value + 1.6.6 + provided + + + + com.google.guava + guava + + + + org.mockito + mockito-core + ${mockito.version} + test + + + + + com.github.kstyrc + embedded-redis + test + + + + org.hamcrest + hamcrest-core + test + + + + org.hamcrest + hamcrest-library + test + + + + net.ishiis.redis + redis-unit + 1.0.3 + test + + + + + junit + junit + 4.13.2 + test + + + org.slf4j + slf4j-simple + 1.7.30 + test + + + diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisHashDecoder.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisHashDecoder.java new file mode 100644 index 00000000000..e24b3bd5ab9 --- /dev/null +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisHashDecoder.java @@ -0,0 +1,97 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.common; + +import com.google.common.hash.Hashing; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Timestamp; +import feast.proto.serving.ServingAPIProto; +import feast.proto.types.ValueProto; +import feast.storage.api.retriever.Feature; +import feast.storage.api.retriever.ProtoFeature; +import io.lettuce.core.KeyValue; +import java.nio.charset.StandardCharsets; +import java.util.*; + +public class RedisHashDecoder { + + /** + * Converts all retrieved Redis Hash values based on EntityRows into {@link Feature} + * + * @param redisHashValues retrieved Redis Hash values based on EntityRows + * @param byteToFeatureReferenceMap map to decode bytes back to FeatureReference + * @param timestampPrefix timestamp prefix + * @return List of {@link Feature} + * @throws InvalidProtocolBufferException if a protocol buffer exception occurs + */ + public static List retrieveFeature( + List> redisHashValues, + Map byteToFeatureReferenceMap, + String timestampPrefix) + throws InvalidProtocolBufferException { + List allFeatures = new ArrayList<>(); + HashMap featureMap = new HashMap<>(); + Map featureTableTimestampMap = new HashMap<>(); + + for (KeyValue entity : redisHashValues) { + if (entity.hasValue()) { + byte[] redisValueK = entity.getKey(); + byte[] redisValueV = entity.getValue(); + + // Decode data from Redis into Feature object fields + if (new String(redisValueK).startsWith(timestampPrefix)) { + Timestamp eventTimestamp = Timestamp.parseFrom(redisValueV); + featureTableTimestampMap.put(new String(redisValueK), eventTimestamp); + } else { + ServingAPIProto.FeatureReferenceV2 featureReference = + byteToFeatureReferenceMap.get(redisValueK); + ValueProto.Value featureValue = ValueProto.Value.parseFrom(redisValueV); + + featureMap.put(featureReference, featureValue); + } + } + } + + // Add timestamp to features + for (Map.Entry entry : + featureMap.entrySet()) { + String timestampRedisHashKeyStr = timestampPrefix + ":" + entry.getKey().getFeatureTable(); + Timestamp curFeatureTimestamp = featureTableTimestampMap.get(timestampRedisHashKeyStr); + + ProtoFeature curFeature = + new ProtoFeature(entry.getKey(), curFeatureTimestamp, entry.getValue()); + allFeatures.add(curFeature); + } + + return allFeatures; + } + + public static byte[] getTimestampRedisHashKeyBytes( + ServingAPIProto.FeatureReferenceV2 featureReference, String timestampPrefix) { + String timestampRedisHashKeyStr = timestampPrefix + ":" + featureReference.getFeatureTable(); + return timestampRedisHashKeyStr.getBytes(); + } + + public static byte[] getFeatureReferenceRedisHashKeyBytes( + ServingAPIProto.FeatureReferenceV2 featureReference) { + String delimitedFeatureReference = + featureReference.getFeatureTable() + ":" + featureReference.getName(); + return Hashing.murmur3_32() + .hashString(delimitedFeatureReference, StandardCharsets.UTF_8) + .asBytes(); + } +} diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisKeyGenerator.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisKeyGenerator.java new file mode 100644 index 00000000000..797dd522151 --- /dev/null +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/common/RedisKeyGenerator.java @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.common; + +import feast.proto.serving.ServingAPIProto; +import feast.proto.storage.RedisProto; +import feast.proto.types.ValueProto; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class RedisKeyGenerator { + + public static List buildRedisKeys( + String project, List entityRows) { + List redisKeys = + entityRows.stream() + .map(entityRow -> makeRedisKey(project, entityRow)) + .collect(Collectors.toList()); + + return redisKeys; + } + + /** + * Create {@link RedisProto.RedisKeyV2} + * + * @param project Project where request for features was called from + * @param entityRow {@link ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow} + * @return {@link RedisProto.RedisKeyV2} + */ + private static RedisProto.RedisKeyV2 makeRedisKey( + String project, ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow entityRow) { + RedisProto.RedisKeyV2.Builder builder = RedisProto.RedisKeyV2.newBuilder().setProject(project); + Map fieldsMap = entityRow.getFieldsMap(); + List entityNames = new ArrayList<>(new HashSet<>(fieldsMap.keySet())); + + // Sort entity names by alphabetical order + entityNames.sort(String::compareTo); + + for (String entityName : entityNames) { + builder.addEntityNames(entityName); + builder.addEntityValues(fieldsMap.get(entityName)); + } + return builder.build(); + } +} diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializer.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializer.java new file mode 100644 index 00000000000..6220dd29d45 --- /dev/null +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializer.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.retriever; + +import feast.proto.storage.RedisProto; + +@FunctionalInterface +public interface EntityKeySerializer { + byte[] serialize(final RedisProto.RedisKeyV2 entityKey); +} diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializerV2.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializerV2.java new file mode 100644 index 00000000000..922a09d3f55 --- /dev/null +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/EntityKeySerializerV2.java @@ -0,0 +1,123 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.retriever; + +import com.google.common.primitives.UnsignedBytes; +import com.google.protobuf.ProtocolStringList; +import feast.proto.storage.RedisProto; +import feast.proto.types.ValueProto; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import org.apache.commons.lang3.tuple.Pair; + +// This is derived from +// https://github.com/feast-dev/feast/blob/b1ccf8dd1535f721aee8bea937ee38feff80bec5/sdk/python/feast/infra/key_encoding_utils.py#L22 +// and must be kept up to date with any changes in that logic. +public class EntityKeySerializerV2 implements EntityKeySerializer { + + @Override + public byte[] serialize(RedisProto.RedisKeyV2 entityKey) { + final ProtocolStringList joinKeys = entityKey.getEntityNamesList(); + final List values = entityKey.getEntityValuesList(); + + assert joinKeys.size() == values.size(); + + final List buffer = new ArrayList<>(); + + final List> tuples = new ArrayList<>(joinKeys.size()); + for (int i = 0; i < joinKeys.size(); i++) { + tuples.add(Pair.of(joinKeys.get(i), values.get(i))); + } + tuples.sort(Comparator.comparing(Pair::getLeft)); + + ByteBuffer stringBytes = ByteBuffer.allocate(Integer.BYTES); + stringBytes.order(ByteOrder.LITTLE_ENDIAN); + stringBytes.putInt(ValueProto.ValueType.Enum.STRING.getNumber()); + + for (Pair pair : tuples) { + for (final byte b : stringBytes.array()) { + buffer.add(b); + } + for (final byte b : pair.getLeft().getBytes(StandardCharsets.UTF_8)) { + buffer.add(b); + } + } + + for (Pair pair : tuples) { + final ValueProto.Value val = pair.getRight(); + switch (val.getValCase()) { + case STRING_VAL: + buffer.add(UnsignedBytes.checkedCast(ValueProto.ValueType.Enum.STRING.getNumber())); + buffer.add( + UnsignedBytes.checkedCast( + val.getStringVal().getBytes(StandardCharsets.UTF_8).length)); + for (final byte b : val.getStringVal().getBytes(StandardCharsets.UTF_8)) { + buffer.add(b); + } + break; + case BYTES_VAL: + buffer.add(UnsignedBytes.checkedCast(ValueProto.ValueType.Enum.BYTES.getNumber())); + for (final byte b : val.getBytesVal().toByteArray()) { + buffer.add(b); + } + break; + case INT32_VAL: + ByteBuffer int32ByteBuffer = + ByteBuffer.allocate(Integer.BYTES + Integer.BYTES + Integer.BYTES); + int32ByteBuffer.order(ByteOrder.LITTLE_ENDIAN); + int32ByteBuffer.putInt(ValueProto.ValueType.Enum.INT32.getNumber()); + int32ByteBuffer.putInt(Integer.BYTES); + int32ByteBuffer.putInt(val.getInt32Val()); + for (final byte b : int32ByteBuffer.array()) { + buffer.add(b); + } + break; + case INT64_VAL: + ByteBuffer int64ByteBuffer = + ByteBuffer.allocate(Integer.BYTES + Integer.BYTES + Integer.BYTES); + int64ByteBuffer.order(ByteOrder.LITTLE_ENDIAN); + int64ByteBuffer.putInt(ValueProto.ValueType.Enum.INT64.getNumber()); + int64ByteBuffer.putInt(Integer.BYTES); + /* This is super dumb - but in https://github.com/feast-dev/feast/blob/dcae1606f53028ce5413567fb8b66f92cfef0f8e/sdk/python/feast/infra/key_encoding_utils.py#L9 + we use `struct.pack("> getOnlineFeatures( + String project, + List entityRows, + List featureReferences, + List entityNames) { + + List redisKeys = RedisKeyGenerator.buildRedisKeys(project, entityRows); + List> features = getFeaturesFromRedis(redisKeys, featureReferences); + + return features; + } + + private List> getFeaturesFromRedis( + List redisKeys, + List featureReferences) { + List> features = new ArrayList<>(); + // To decode bytes back to Feature Reference + Map byteToFeatureReferenceMap = new HashMap<>(); + + // Serialize using proto + List binaryRedisKeys = + redisKeys.stream().map(this.keySerializer::serialize).collect(Collectors.toList()); + + List featureReferenceWithTsByteList = new ArrayList<>(); + featureReferences.stream() + .forEach( + featureReference -> { + + // eg. murmur() + byte[] featureReferenceBytes = + RedisHashDecoder.getFeatureReferenceRedisHashKeyBytes(featureReference); + featureReferenceWithTsByteList.add(featureReferenceBytes); + byteToFeatureReferenceMap.put(featureReferenceBytes, featureReference); + + // eg. <_ts:featuretable_name> + byte[] featureTableTsBytes = + RedisHashDecoder.getTimestampRedisHashKeyBytes(featureReference, timestampPrefix); + featureReferenceWithTsByteList.add(featureTableTsBytes); + }); + + // Perform a series of independent calls + List>>> futures = Lists.newArrayList(); + for (byte[] binaryRedisKey : binaryRedisKeys) { + byte[][] featureReferenceWithTsByteArrays = + featureReferenceWithTsByteList.toArray(new byte[0][]); + // Access redis keys and extract features + futures.add(redisClientAdapter.hmget(binaryRedisKey, featureReferenceWithTsByteArrays)); + } + + // Write all commands to the transport layer + redisClientAdapter.flushCommands(); + + futures.forEach( + future -> { + try { + List> redisValuesList = future.get(); + + List curRedisKeyFeatures = + RedisHashDecoder.retrieveFeature( + redisValuesList, byteToFeatureReferenceMap, timestampPrefix); + features.add(curRedisKeyFeatures); + } catch (InterruptedException | ExecutionException | InvalidProtocolBufferException e) { + throw Status.UNKNOWN + .withDescription("Unexpected error when pulling data from from Redis.") + .withCause(e) + .asRuntimeException(); + } + }); + return features; + } +} diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClient.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClient.java new file mode 100644 index 00000000000..e1699bbde0a --- /dev/null +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClient.java @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.retriever; + +import io.lettuce.core.KeyValue; +import io.lettuce.core.RedisFuture; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.async.RedisAsyncCommands; +import io.lettuce.core.codec.ByteArrayCodec; +import java.util.List; + +public class RedisClient implements RedisClientAdapter { + + private final RedisAsyncCommands asyncCommands; + + @Override + public RedisFuture>> hmget(byte[] key, byte[]... fields) { + return asyncCommands.hmget(key, fields); + } + + @Override + public void flushCommands() { + asyncCommands.flushCommands(); + } + + private RedisClient(StatefulRedisConnection connection) { + this.asyncCommands = connection.async(); + + // Disable auto-flushing + this.asyncCommands.setAutoFlushCommands(false); + } + + public static RedisClientAdapter create(RedisStoreConfig config) { + + RedisURI uri = RedisURI.create(config.getHost(), config.getPort()); + + if (config.getSsl()) { + uri.setSsl(true); + } + + if (!config.getPassword().isEmpty()) { + uri.setPassword(config.getPassword()); + } + + StatefulRedisConnection connection = + io.lettuce.core.RedisClient.create(uri).connect(new ByteArrayCodec()); + + return new RedisClient(connection); + } +} diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClientAdapter.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClientAdapter.java new file mode 100644 index 00000000000..65e730ae930 --- /dev/null +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClientAdapter.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.retriever; + +import io.lettuce.core.*; +import java.util.List; + +public interface RedisClientAdapter { + RedisFuture>> hmget(byte[] key, byte[]... fields); + + void flushCommands(); +} diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterClient.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterClient.java new file mode 100644 index 00000000000..5395b72e5f7 --- /dev/null +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterClient.java @@ -0,0 +1,92 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.retriever; + +import io.lettuce.core.*; +import io.lettuce.core.cluster.ClusterClientOptions; +import io.lettuce.core.cluster.ClusterTopologyRefreshOptions; +import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; +import io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands; +import io.lettuce.core.codec.ByteArrayCodec; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class RedisClusterClient implements RedisClientAdapter { + + private final RedisAdvancedClusterAsyncCommands asyncCommands; + + @Override + public RedisFuture>> hmget(byte[] key, byte[]... fields) { + return asyncCommands.hmget(key, fields); + } + + @Override + public void flushCommands() { + asyncCommands.flushCommands(); + } + + static class Builder { + private final StatefulRedisClusterConnection connection; + + Builder(StatefulRedisClusterConnection connection) { + this.connection = connection; + } + + RedisClusterClient build() { + return new RedisClusterClient(this); + } + } + + private RedisClusterClient(Builder builder) { + this.asyncCommands = builder.connection.async(); + + // allows reading from replicas + this.asyncCommands.readOnly(); + + // Disable auto-flushing + this.asyncCommands.setAutoFlushCommands(false); + } + + public static RedisClientAdapter create(RedisClusterStoreConfig config) { + List redisURIList = + Arrays.stream(config.getConnectionString().split(",")) + .map( + hostPort -> { + String[] hostPortSplit = hostPort.trim().split(":"); + return RedisURI.create(hostPortSplit[0], Integer.parseInt(hostPortSplit[1])); + }) + .collect(Collectors.toList()); + + io.lettuce.core.cluster.RedisClusterClient client = + io.lettuce.core.cluster.RedisClusterClient.create(redisURIList); + client.setOptions( + ClusterClientOptions.builder() + .socketOptions(SocketOptions.builder().keepAlive(true).tcpNoDelay(true).build()) + .timeoutOptions(TimeoutOptions.enabled(config.getTimeout())) + .pingBeforeActivateConnection(true) + .topologyRefreshOptions( + ClusterTopologyRefreshOptions.builder().enableAllAdaptiveRefreshTriggers().build()) + .build()); + + StatefulRedisClusterConnection connection = + client.connect(new ByteArrayCodec()); + connection.setReadFrom(config.getReadFrom()); + + return new Builder(connection).build(); + } +} diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterStoreConfig.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterStoreConfig.java new file mode 100644 index 00000000000..c179ffe9645 --- /dev/null +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisClusterStoreConfig.java @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.retriever; + +import io.lettuce.core.ReadFrom; +import java.time.Duration; + +public class RedisClusterStoreConfig { + private final String connectionString; + private final ReadFrom readFrom; + private final Duration timeout; + + public RedisClusterStoreConfig(String connectionString, ReadFrom readFrom, Duration timeout) { + this.connectionString = connectionString; + this.readFrom = readFrom; + this.timeout = timeout; + } + + public String getConnectionString() { + return this.connectionString; + } + + public ReadFrom getReadFrom() { + return this.readFrom; + } + + public Duration getTimeout() { + return this.timeout; + } +} diff --git a/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisStoreConfig.java b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisStoreConfig.java new file mode 100644 index 00000000000..30452358833 --- /dev/null +++ b/java/storage/connectors/redis/src/main/java/feast/storage/connectors/redis/retriever/RedisStoreConfig.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.retriever; + +public class RedisStoreConfig { + private final String host; + private final Integer port; + private final Boolean ssl; + private final String password; + + public RedisStoreConfig(String host, Integer port, Boolean ssl, String password) { + this.host = host; + this.port = port; + this.ssl = ssl; + this.password = password; + } + + public String getHost() { + return this.host; + } + + public Integer getPort() { + return this.port; + } + + public Boolean getSsl() { + return this.ssl; + } + + public String getPassword() { + return this.password; + } +} diff --git a/java/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/test/TestUtil.java b/java/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/test/TestUtil.java new file mode 100644 index 00000000000..66aba44bc20 --- /dev/null +++ b/java/storage/connectors/redis/src/test/java/feast/storage/connectors/redis/test/TestUtil.java @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2020 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.redis.test; + +import java.io.IOException; +import redis.embedded.RedisServer; + +public class TestUtil { + public static class LocalRedis { + + private static RedisServer server; + + /** + * Start local Redis for used in testing at "localhost" + * + * @param port port number + * @throws IOException if Redis failed to start + */ + public static void start(int port) throws IOException { + server = new RedisServer(port); + server.start(); + } + + public static void stop() { + if (server != null) { + server.stop(); + } + } + } +} diff --git a/java/storage/connectors/sstable/pom.xml b/java/storage/connectors/sstable/pom.xml new file mode 100644 index 00000000000..8c7a271aace --- /dev/null +++ b/java/storage/connectors/sstable/pom.xml @@ -0,0 +1,19 @@ + + + + feast-storage-connectors + dev.feast + ${revision} + + + 4.0.0 + feast-storage-connector-sstable + + + 11 + 11 + + + \ No newline at end of file diff --git a/java/storage/connectors/sstable/src/main/java/feast/storage/connectors/sstable/retriever/SSTableOnlineRetriever.java b/java/storage/connectors/sstable/src/main/java/feast/storage/connectors/sstable/retriever/SSTableOnlineRetriever.java new file mode 100644 index 00000000000..c86923a2ec9 --- /dev/null +++ b/java/storage/connectors/sstable/src/main/java/feast/storage/connectors/sstable/retriever/SSTableOnlineRetriever.java @@ -0,0 +1,163 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2018-2021 The Feast Authors + * + * Licensed 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 + * + * https://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 feast.storage.connectors.sstable.retriever; + +import com.google.common.hash.Hashing; +import feast.proto.serving.ServingAPIProto.FeatureReferenceV2; +import feast.proto.serving.ServingAPIProto.GetOnlineFeaturesRequestV2.EntityRow; +import feast.proto.types.ValueProto; +import feast.storage.api.retriever.Feature; +import feast.storage.api.retriever.OnlineRetrieverV2; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * @param Decoded value type of the partition key + * @param Type of the SSTable row + */ +public interface SSTableOnlineRetriever extends OnlineRetrieverV2 { + + int MAX_TABLE_NAME_LENGTH = 50; + + @Override + default List> getOnlineFeatures( + String project, + List entityRows, + List featureReferences, + List entityNames) { + + List columnFamilies = getSSTableColumns(featureReferences); + String tableName = getSSTable(project, entityNames); + + List rowKeys = + entityRows.stream() + .map(row -> convertEntityValueToKey(row, entityNames)) + .collect(Collectors.toList()); + + Map rowsFromSSTable = getFeaturesFromSSTable(tableName, rowKeys, columnFamilies); + + return convertRowToFeature(tableName, rowKeys, rowsFromSSTable, featureReferences); + } + + /** + * Generate SSTable key. + * + * @param entityRow Single EntityRow representation in feature retrieval call + * @param entityNames List of entities related to feature references in retrieval call + * @return SSTable key for retrieval + */ + K convertEntityValueToKey(EntityRow entityRow, List entityNames); + + /** + * Converts SSTable rows into @NativeFeature type. + * + * @param tableName Name of SSTable + * @param rowKeys List of keys of rows to retrieve + * @param rows Map of rowKey to Row related to it + * @param featureReferences List of feature references + * @return List of List of Features associated with respective rowKey + */ + List> convertRowToFeature( + String tableName, + List rowKeys, + Map rows, + List featureReferences); + + /** + * Retrieve rows for each row entity key. + * + * @param tableName Name of SSTable + * @param rowKeys List of keys of rows to retrieve + * @param columnFamilies List of column names + * @return Map of retrieved features for each rowKey + */ + Map getFeaturesFromSSTable(String tableName, List rowKeys, List columnFamilies); + + /** + * Retrieve name of SSTable corresponding to entities in retrieval call + * + * @param project Name of Feast project + * @param entityNames List of entities used in retrieval call + * @return Name of Cassandra table + */ + default String getSSTable(String project, List entityNames) { + return trimAndHash( + String.format("%s__%s", project, String.join("__", entityNames)), MAX_TABLE_NAME_LENGTH); + } + + /** + * Convert Entity value from Feast valueType to String type. Currently only supports STRING_VAL, + * INT64_VAL, INT32_VAL and BYTES_VAL. + * + * @param v Entity value of Feast valueType + * @return String representation of Entity value + */ + default String valueToString(ValueProto.Value v) { + String stringRepr; + switch (v.getValCase()) { + case STRING_VAL: + stringRepr = v.getStringVal(); + break; + case INT64_VAL: + stringRepr = String.valueOf(v.getInt64Val()); + break; + case INT32_VAL: + stringRepr = String.valueOf(v.getInt32Val()); + break; + case BYTES_VAL: + stringRepr = v.getBytesVal().toString(); + break; + default: + throw new RuntimeException("Type is not supported to be entity"); + } + + return stringRepr; + } + + /** + * Retrieve SSTable columns based on Feature references. + * + * @param featureReferences List of feature references in retrieval call + * @return List of String of column names + */ + default List getSSTableColumns(List featureReferences) { + return featureReferences.stream() + .map(FeatureReferenceV2::getFeatureTable) + .distinct() + .collect(Collectors.toList()); + } + + /** + * Trims long SSTable table names and appends hash suffix for uniqueness. + * + * @param expr Original SSTable table name + * @param maxLength Maximum length allowed for SSTable + * @return Hashed suffix SSTable table name + */ + default String trimAndHash(String expr, int maxLength) { + // Length 8 as derived from murmurhash_32 implementation + int maxPrefixLength = maxLength - 8; + String finalName = expr; + if (expr.length() > maxLength) { + String hashSuffix = + Hashing.murmur3_32().hashBytes(expr.substring(maxPrefixLength).getBytes()).toString(); + finalName = expr.substring(0, Math.min(expr.length(), maxPrefixLength)).concat(hashSuffix); + } + return finalName; + } +} diff --git a/sdk/python/feast/constants.py b/sdk/python/feast/constants.py index efcea6d8f17..1e88e60435b 100644 --- a/sdk/python/feast/constants.py +++ b/sdk/python/feast/constants.py @@ -17,7 +17,7 @@ # Maximum interval(secs) to wait between retries for retry function MAX_WAIT_INTERVAL: str = "60" -AWS_LAMBDA_FEATURE_SERVER_IMAGE = "feastdev/feature-server:aws" +AWS_LAMBDA_FEATURE_SERVER_IMAGE = "feastdev/feature-server" AWS_LAMBDA_FEATURE_SERVER_REPOSITORY = "feast-python-server" # feature_store.yaml environment variable name for remote feature server diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index 27f77124d98..6eb27894fab 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -7,6 +7,7 @@ import feast from feast import proto_json from feast.protos.feast.serving.ServingService_pb2 import GetOnlineFeaturesRequest +from feast.type_map import feast_value_type_to_python_type def get_app(store: "feast.FeatureStore"): @@ -36,7 +37,10 @@ async def get_online_features(request: Request): raise HTTPException(status_code=500, detail="Uneven number of columns") entity_rows = [ - {k: v.val[idx] for k, v in request_proto.entities.items()} + { + k: feast_value_type_to_python_type(v.val[idx]) + for k, v in request_proto.entities.items() + } for idx in range(num_entities) ] @@ -45,7 +49,9 @@ async def get_online_features(request: Request): ).proto # Convert the Protobuf object to JSON and return it - return MessageToDict(response_proto, preserving_proto_field_name=True) + return MessageToDict( # type: ignore + response_proto, preserving_proto_field_name=True, float_precision=18 + ) except Exception as e: # Print the original exception on the server side logger.exception(e) diff --git a/sdk/python/feast/infra/aws.py b/sdk/python/feast/infra/aws.py index f0348d29014..2ff38de9f98 100644 --- a/sdk/python/feast/infra/aws.py +++ b/sdk/python/feast/infra/aws.py @@ -1,6 +1,8 @@ import base64 +import hashlib import logging import os +import subprocess import uuid from datetime import datetime from pathlib import Path @@ -10,6 +12,7 @@ from colorama import Fore, Style +from feast import flags_helper from feast.constants import ( AWS_LAMBDA_FEATURE_SERVER_IMAGE, AWS_LAMBDA_FEATURE_SERVER_REPOSITORY, @@ -88,18 +91,18 @@ def update_infra( ) ecr_client = boto3.client("ecr") + docker_image_version = _get_docker_image_version() repository_uri = self._create_or_get_repository_uri(ecr_client) - version = _get_version_for_aws() # Only download & upload the docker image if it doesn't already exist in ECR if not ecr_client.batch_get_image( repositoryName=AWS_LAMBDA_FEATURE_SERVER_REPOSITORY, - imageIds=[{"imageTag": version}], + imageIds=[{"imageTag": docker_image_version}], ).get("images"): image_uri = self._upload_docker_image( - ecr_client, repository_uri, version + ecr_client, repository_uri, docker_image_version ) else: - image_uri = f"{repository_uri}:{version}" + image_uri = f"{repository_uri}:{docker_image_version}" self._deploy_feature_server(project, image_uri) @@ -154,11 +157,10 @@ def _deploy_feature_server(self, project: str, image_uri: str): # feature views, feature services, and other definitions does not update lambda). _logger.info(" Updating AWS Lambda...") - lambda_client.update_function_configuration( - FunctionName=resource_name, - Environment={ - "Variables": {FEATURE_STORE_YAML_ENV_NAME: config_base64} - }, + aws_utils.update_lambda_function_environment( + lambda_client, + resource_name, + {"Variables": {FEATURE_STORE_YAML_ENV_NAME: config_base64}}, ) api = aws_utils.get_first_api_gateway(api_gateway_client, resource_name) @@ -235,7 +237,7 @@ def get_feature_server_endpoint(self) -> Optional[str]: return f"https://{api_id}.execute-api.{region}.amazonaws.com" def _upload_docker_image( - self, ecr_client, repository_uri: str, version: str + self, ecr_client, repository_uri: str, docker_image_version: str ) -> str: """ Pulls the AWS Lambda docker image from Dockerhub and uploads it to AWS ECR. @@ -258,12 +260,11 @@ def _upload_docker_image( raise DockerDaemonNotRunning() + dockerhub_image = f"{AWS_LAMBDA_FEATURE_SERVER_IMAGE}:{docker_image_version}" _logger.info( - f"Pulling remote image {Style.BRIGHT + Fore.GREEN}{AWS_LAMBDA_FEATURE_SERVER_IMAGE}{Style.RESET_ALL}" + f"Pulling remote image {Style.BRIGHT + Fore.GREEN}{dockerhub_image}{Style.RESET_ALL}" ) - for line in docker_client.api.pull( - AWS_LAMBDA_FEATURE_SERVER_IMAGE, stream=True, decode=True - ): + for line in docker_client.api.pull(dockerhub_image, stream=True, decode=True): _logger.debug(f" {line}") auth_token = ecr_client.get_authorization_token()["authorizationData"][0][ @@ -280,14 +281,14 @@ def _upload_docker_image( ) _logger.debug(f" {login_status}") - image = docker_client.images.get(AWS_LAMBDA_FEATURE_SERVER_IMAGE) - image_remote_name = f"{repository_uri}:{version}" + image = docker_client.images.get(dockerhub_image) + image_remote_name = f"{repository_uri}:{docker_image_version}" _logger.info( f"Pushing local image to remote {Style.BRIGHT + Fore.GREEN}{image_remote_name}{Style.RESET_ALL}" ) image.tag(image_remote_name) for line in docker_client.api.push( - repository_uri, tag=version, stream=True, decode=True + repository_uri, tag=docker_image_version, stream=True, decode=True ): _logger.debug(f" {line}") @@ -310,21 +311,53 @@ def _create_or_get_repository_uri(self, ecr_client): def _get_lambda_name(project: str): lambda_prefix = AWS_LAMBDA_FEATURE_SERVER_REPOSITORY - lambda_suffix = f"{project}-{_get_version_for_aws()}" + lambda_suffix = f"{project}-{_get_docker_image_version()}" # AWS Lambda name can't have the length greater than 64 bytes. - # This usually occurs during integration tests or when feast is - # installed in editable mode (pip install -e), where feast version is long + # This usually occurs during integration tests where feast version is long if len(lambda_prefix) + len(lambda_suffix) >= 63: - lambda_suffix = base64.b64encode(lambda_suffix.encode()).decode()[:40] + lambda_suffix = hashlib.md5(lambda_suffix.encode()).hexdigest() return f"{lambda_prefix}-{lambda_suffix}" -def _get_version_for_aws(): - """Returns Feast version with certain characters replaced. +def _get_docker_image_version() -> str: + """Returns a version for the feature server Docker image. + + For public Feast releases this equals to the Feast SDK version modified by replacing "." with "_". + For example, Feast SDK version "0.14.1" would correspond to Docker image version "0_14_1". + + For integration tests this equals to the git commit hash of HEAD. This is necessary, + because integration tests need to use images built from the same commit hash. + + During development (when Feast is installed in editable mode) this equals to the Feast SDK version + modified by removing the "dev..." suffix and replacing "." with "_". For example, Feast SDK version + "0.14.1.dev41+g1cbfa225.d20211103" would correspond to Docker image version "0_14_1". This way, + Feast SDK will use an already existing Docker image built during the previous public release. - This allows the version to be included in names for AWS resources. """ - return get_version().replace(".", "_").replace("+", "_") + if flags_helper.is_test(): + # Note: this should be in sync with https://github.com/feast-dev/feast/blob/6fbe01b6e9a444dc77ec3328a54376f4d9387664/.github/workflows/pr_integration_tests.yml#L41 + return ( + subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=Path(__file__).resolve().parent + ) + .decode() + .strip() + ) + else: + version = get_version() + if "dev" in version: + version = version[: version.find("dev") - 1].replace(".", "_") + _logger.warning( + "You are trying to use AWS Lambda feature server while Feast is in a development mode. " + f"Feast will use a docker image version {version} derived from Feast SDK " + f"version {get_version()}. If you want to update the Feast SDK version, make " + "sure to first fetch all new release tags from Github and then reinstall the library:\n" + "> git fetch --all --tags\n" + "> pip install -e sdk/python" + ) + else: + version = version.replace(".", "_") + return version class S3RegistryStore(RegistryStore): diff --git a/sdk/python/feast/infra/feature_servers/aws_lambda/Dockerfile b/sdk/python/feast/infra/feature_servers/aws_lambda/Dockerfile index 4d46abd3db2..3384bca293a 100644 --- a/sdk/python/feast/infra/feature_servers/aws_lambda/Dockerfile +++ b/sdk/python/feast/infra/feature_servers/aws_lambda/Dockerfile @@ -9,7 +9,7 @@ COPY protos protos COPY README.md README.md # Install Feast for AWS with Lambda dependencies -RUN pip3 install -e 'sdk/python[aws,redis]' +RUN pip3 install -e 'sdk/python[aws]' RUN pip3 install -r sdk/python/feast/infra/feature_servers/aws_lambda/requirements.txt --target "${LAMBDA_TASK_ROOT}" # Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile) diff --git a/sdk/python/feast/infra/utils/aws_utils.py b/sdk/python/feast/infra/utils/aws_utils.py index 28d48a489e6..6211c75e375 100644 --- a/sdk/python/feast/infra/utils/aws_utils.py +++ b/sdk/python/feast/infra/utils/aws_utils.py @@ -2,7 +2,7 @@ import os import tempfile import uuid -from typing import Dict, Iterator, Optional, Tuple +from typing import Any, Dict, Iterator, Optional, Tuple import pandas as pd import pyarrow as pa @@ -60,6 +60,7 @@ def get_bucket_and_key(s3_path: str) -> Tuple[str, str]: wait=wait_exponential(multiplier=1, max=4), retry=retry_if_exception_type(ConnectionClosedError), stop=stop_after_attempt(5), + reraise=True, ) def execute_redshift_statement_async( redshift_data_client, cluster_id: str, database: str, user: str, query: str @@ -96,6 +97,7 @@ class RedshiftStatementNotFinishedError(Exception): wait=wait_exponential(multiplier=1, max=30), retry=retry_if_exception_type(RedshiftStatementNotFinishedError), stop=stop_after_delay(300), # 300 seconds + reraise=True, ) def wait_for_redshift_statement(redshift_data_client, statement: dict) -> None: """Waits for the Redshift statement to finish. Raises RedshiftQueryError if the statement didn't succeed. @@ -426,6 +428,29 @@ def delete_lambda_function(lambda_client, function_name: str) -> Dict: return lambda_client.delete_function(FunctionName=function_name) +@retry( + wait=wait_exponential(multiplier=1, max=4), + retry=retry_if_exception_type(ClientError), + stop=stop_after_attempt(5), + reraise=True, +) +def update_lambda_function_environment( + lambda_client, function_name: str, environment: Dict[str, Any] +) -> None: + """ + Update AWS Lambda function environment. The function is retried multiple times in case another action is + currently being run on the lambda (e.g. it's being created or being updated in parallel). + Args: + lambda_client: AWS Lambda client. + function_name: Name of the AWS Lambda function. + environment: The desired lambda environment. + + """ + lambda_client.update_function_configuration( + FunctionName=function_name, Environment=environment + ) + + def get_first_api_gateway(api_gateway_client, api_gateway_name: str) -> Optional[Dict]: """ Get the first API Gateway with the given name. Note, that API Gateways can have the same name. diff --git a/sdk/python/feast/online_response.py b/sdk/python/feast/online_response.py index 070c86abbcb..62bd86ae9e7 100644 --- a/sdk/python/feast/online_response.py +++ b/sdk/python/feast/online_response.py @@ -45,6 +45,12 @@ def __init__(self, online_response_proto: GetOnlineFeaturesResponse): online_response_proto: GetOnlineResponse proto object to construct from. """ self.proto = online_response_proto + # Delete DUMMY_ENTITY_ID from proto if it exists + for item in self.proto.field_values: + if DUMMY_ENTITY_ID in item.statuses: + del item.statuses[DUMMY_ENTITY_ID] + if DUMMY_ENTITY_ID in item.fields: + del item.fields[DUMMY_ENTITY_ID] @property def field_values(self): @@ -57,13 +63,9 @@ def to_dict(self) -> Dict[str, Any]: """ Converts GetOnlineFeaturesResponse features into a dictionary form. """ - fields = [ - k - for row in self.field_values - for k, _ in row.statuses.items() - if k != DUMMY_ENTITY_ID - ] - features_dict: Dict[str, List[Any]] = {k: list() for k in fields} + features_dict: Dict[str, List[Any]] = { + k: list() for row in self.field_values for k, _ in row.statuses.items() + } for row in self.field_values: for feature in features_dict.keys(): @@ -77,9 +79,7 @@ def to_df(self) -> pd.DataFrame: Converts GetOnlineFeaturesResponse features into Panda dataframe form. """ - return pd.DataFrame(self.to_dict()).drop( - DUMMY_ENTITY_ID, axis=1, errors="ignore" - ) + return pd.DataFrame(self.to_dict()) def _infer_online_entity_rows( diff --git a/sdk/python/feast/repo_config.py b/sdk/python/feast/repo_config.py index 5bbe09d8e06..70e64c845c5 100644 --- a/sdk/python/feast/repo_config.py +++ b/sdk/python/feast/repo_config.py @@ -44,6 +44,11 @@ "gcp_cloudrun": "feast.infra.feature_servers.gcp_cloudrun.config.GcpCloudRunFeatureServerConfig", } +FEATURE_SERVER_TYPE_FOR_PROVIDER = { + "aws": "aws_lambda", + "gcp": "gcp_cloudrun", +} + class FeastBaseModel(BaseModel): """ Feast Pydantic Configuration Class """ @@ -226,15 +231,12 @@ def _validate_feature_server_config(cls, values): if "provider" not in values: raise FeastProviderNotSetError() - # Make sure that the type is not set, since we will set it based on the provider. - if "type" in values["feature_server"]: - raise FeastFeatureServerTypeSetError(values["feature_server"]["type"]) - - # Set the default type. We only support AWS Lambda for now. - if values["provider"] == "aws": - values["feature_server"]["type"] = "aws_lambda" - - feature_server_type = values["feature_server"]["type"] + feature_server_type = FEATURE_SERVER_TYPE_FOR_PROVIDER.get(values["provider"]) + defined_type = values["feature_server"].get("type") + # Make sure that the type is either not set, or set correctly, since it's defined by the provider + if defined_type not in (None, feature_server_type): + raise FeastFeatureServerTypeSetError(defined_type) + values["feature_server"]["type"] = feature_server_type # Validate the dict to ensure one of the union types match try: diff --git a/sdk/python/feast/type_map.py b/sdk/python/feast/type_map.py index c615d2f50e9..9992517ba6a 100644 --- a/sdk/python/feast/type_map.py +++ b/sdk/python/feast/type_map.py @@ -47,7 +47,11 @@ def feast_value_type_to_python_type(field_value_proto: ProtoValue) -> Any: Returns: Python native type representation/version of the given field_value_proto """ - field_value_dict = MessageToDict(field_value_proto) + field_value_dict = MessageToDict(field_value_proto, float_precision=18) # type: ignore + + # This can happen when proto_json.patch() has been called before this call, which is true for a feature server + if not isinstance(field_value_dict, dict): + return field_value_dict for k, v in field_value_dict.items(): if "List" in k: @@ -90,6 +94,8 @@ def feast_value_type_to_pandas_type(value_type: ValueType) -> Any: ValueType.BOOL: "bool", ValueType.UNIX_TIMESTAMP: "datetime", } + if value_type.name.endswith("_LIST"): + return "object" if value_type in value_type_to_pandas_type: return value_type_to_pandas_type[value_type] raise TypeError( @@ -451,6 +457,9 @@ def pa_to_redshift_value_type(pa_type: pyarrow.DataType) -> str: # PyArrow decimal types (e.g. "decimal(38,37)") luckily directly map to the Redshift type. return pa_type_as_str + if pa_type_as_str.startswith("list"): + return "super" + # We have to take into account how arrow types map to parquet types as well. # For example, null type maps to int32 in parquet, so we have to use int4 in Redshift. # Other mappings have also been adjusted accordingly. diff --git a/sdk/python/setup.py b/sdk/python/setup.py index b5eeeecedfc..58ae08e6ff2 100644 --- a/sdk/python/setup.py +++ b/sdk/python/setup.py @@ -47,13 +47,14 @@ "google-api-core>=1.23.0", "googleapis-common-protos==1.52.*", "grpcio>=1.34.0", - "grpcio-reflection>=1.34.0" + "grpcio-reflection>=1.34.0", "Jinja2>=2.0.0", "jsonschema", "mmh3", "pandas>=1.0.0", "pandavro==1.5.*", "protobuf>=3.10", + "proto-plus<1.19.7", "pyarrow>=4.0.0", "pydantic>=1.0.0", "PyYAML>=5.4.*", @@ -63,10 +64,10 @@ "tqdm==4.*", "fastapi>=0.68.0", "uvicorn[standard]>=0.14.0", + "proto-plus<1.19.7", ] GCP_REQUIRED = [ - "proto-plus<1.19.7", "google-cloud-bigquery>=2.28.1", "google-cloud-bigquery-storage >= 2.0.0", "google-cloud-datastore>=2.1.*", @@ -113,15 +114,10 @@ "firebase-admin==4.5.2", "pre-commit", "assertpy==1.1", - "proto-plus<1.19.7", - "google-cloud-bigquery>=2.28.1", - "google-cloud-bigquery-storage >= 2.0.0", - "google-cloud-datastore>=2.1.*", - "google-cloud-storage>=1.20.*,<1.41", - "google-cloud-core==1.4.*", - "redis-py-cluster==2.1.2", - "boto3==1.17.*", -] + "pip-tools", +] + GCP_REQUIRED + REDIS_REQUIRED + AWS_REQUIRED + +DEV_REQUIRED = ["mypy-protobuf==1.*", "grpcio-testing==1.*"] + CI_REQUIRED # Get git repo root directory repo_root = str(pathlib.Path(__file__).resolve().parent.parent.parent) @@ -215,7 +211,7 @@ def run(self): # https://stackoverflow.com/questions/28509965/setuptools-development-requirements # Install dev requirements with: pip install -e .[dev] extras_require={ - "dev": ["mypy-protobuf==1.*", "grpcio-testing==1.*"], + "dev": DEV_REQUIRED, "ci": CI_REQUIRED, "gcp": GCP_REQUIRED, "aws": AWS_REQUIRED, diff --git a/sdk/python/tests/integration/e2e/test_usage_e2e.py b/sdk/python/tests/integration/e2e/test_usage_e2e.py index c05ce8daf4e..f55fbce55cf 100644 --- a/sdk/python/tests/integration/e2e/test_usage_e2e.py +++ b/sdk/python/tests/integration/e2e/test_usage_e2e.py @@ -19,7 +19,7 @@ import pytest -from feast import Entity, RepoConfig, ValueType, usage +from feast import Entity, RepoConfig, ValueType from feast.infra.online_stores.sqlite import SqliteOnlineStoreConfig @@ -31,10 +31,18 @@ def dummy_exporter(): yield event_log -@pytest.mark.integration -def test_usage_on(dummy_exporter): - usage._is_enabled = True +@pytest.fixture(scope="function") +def enabling_toggle(): + with patch("feast.usage._is_enabled") as p: + p.__bool__.return_value = True + yield p + # return to initial state + _reload_feast() + + +@pytest.mark.integration +def test_usage_on(dummy_exporter, enabling_toggle): _reload_feast() from feast.feature_store import FeatureStore @@ -65,8 +73,8 @@ def test_usage_on(dummy_exporter): @pytest.mark.integration -def test_usage_off(dummy_exporter): - usage._is_enabled = False +def test_usage_off(dummy_exporter, enabling_toggle): + enabling_toggle.__bool__.return_value = False _reload_feast() from feast.feature_store import FeatureStore @@ -94,9 +102,7 @@ def test_usage_off(dummy_exporter): @pytest.mark.integration -def test_exception_usage_on(dummy_exporter): - usage._is_enabled = True - +def test_exception_usage_on(dummy_exporter, enabling_toggle): _reload_feast() from feast.feature_store import FeatureStore @@ -111,8 +117,8 @@ def test_exception_usage_on(dummy_exporter): @pytest.mark.integration -def test_exception_usage_off(dummy_exporter): - usage._is_enabled = False +def test_exception_usage_off(dummy_exporter, enabling_toggle): + enabling_toggle.__bool__.return_value = False _reload_feast() from feast.feature_store import FeatureStore diff --git a/sdk/python/tests/integration/feature_repos/integration_test_repo_config.py b/sdk/python/tests/integration/feature_repos/integration_test_repo_config.py index 6e4f42cff81..e4ff6677648 100644 --- a/sdk/python/tests/integration/feature_repos/integration_test_repo_config.py +++ b/sdk/python/tests/integration/feature_repos/integration_test_repo_config.py @@ -22,6 +22,7 @@ class IntegrationTestRepoConfig: full_feature_names: bool = True infer_features: bool = False + python_feature_server: bool = False def __repr__(self) -> str: return "-".join( diff --git a/sdk/python/tests/integration/feature_repos/repo_configuration.py b/sdk/python/tests/integration/feature_repos/repo_configuration.py index 67b330be491..67f8b04f64f 100644 --- a/sdk/python/tests/integration/feature_repos/repo_configuration.py +++ b/sdk/python/tests/integration/feature_repos/repo_configuration.py @@ -1,4 +1,5 @@ import importlib +import json import os import tempfile import uuid @@ -9,6 +10,7 @@ from typing import Any, Dict, List, Optional import pandas as pd +import yaml from feast import FeatureStore, FeatureView, RepoConfig, driver_test_data from feast.constants import FULL_REPO_CONFIGS_MODULE_ENV_NAME @@ -51,10 +53,10 @@ IntegrationTestRepoConfig(), ] if os.getenv("FEAST_IS_LOCAL_TEST", "False") != "True": - IntegrationTestRepoConfig(online_store=REDIS_CONFIG), - # GCP configurations DEFAULT_FULL_REPO_CONFIGS.extend( [ + IntegrationTestRepoConfig(online_store=REDIS_CONFIG), + # GCP configurations IntegrationTestRepoConfig( provider="gcp", offline_store_creator=BigQueryDataSourceCreator, @@ -70,6 +72,7 @@ provider="aws", offline_store_creator=RedshiftDataSourceCreator, online_store=DYNAMO_CONFIG, + python_feature_server=True, ), IntegrationTestRepoConfig( provider="aws", @@ -211,9 +214,10 @@ class Environment: test_repo_config: IntegrationTestRepoConfig feature_store: FeatureStore data_source_creator: DataSourceCreator + python_feature_server: bool end_date: datetime = field( - default=datetime.now().replace(microsecond=0, second=0, minute=0) + default=datetime.utcnow().replace(microsecond=0, second=0, minute=0) ) def __post_init__(self): @@ -241,15 +245,36 @@ def construct_test_environment( online_store = test_repo_config.online_store with tempfile.TemporaryDirectory() as repo_dir_name: + if test_repo_config.python_feature_server: + from feast.infra.feature_servers.aws_lambda.config import ( + AwsLambdaFeatureServerConfig, + ) + + feature_server = AwsLambdaFeatureServerConfig( + enabled=True, + execution_role_name="arn:aws:iam::402087665549:role/lambda_execution_role", + ) + + registry = f"s3://feast-integration-tests/registries/{project}/registry.db" + else: + feature_server = None + registry = str(Path(repo_dir_name) / "registry.db") + config = RepoConfig( - registry=str(Path(repo_dir_name) / "registry.db"), + registry=registry, project=project, provider=test_repo_config.provider, offline_store=offline_store_config, online_store=online_store, repo_path=repo_dir_name, + feature_server=feature_server, ) - fs = FeatureStore(config=config) + + # Create feature_store.yaml out of the config + with open(Path(repo_dir_name) / "feature_store.yaml", "w") as f: + yaml.safe_dump(json.loads(config.json()), f) + + fs = FeatureStore(repo_dir_name) # We need to initialize the registry, because if nothing is applied in the test before tearing down # the feature store, that will cause the teardown method to blow up. fs.registry._initialize_registry() @@ -258,6 +283,7 @@ def construct_test_environment( test_repo_config=test_repo_config, feature_store=fs, data_source_creator=offline_creator, + python_feature_server=test_repo_config.python_feature_server, ) try: diff --git a/sdk/python/tests/integration/feature_repos/universal/entities.py b/sdk/python/tests/integration/feature_repos/universal/entities.py index 3b4ec05f5d9..e8e90a6af62 100644 --- a/sdk/python/tests/integration/feature_repos/universal/entities.py +++ b/sdk/python/tests/integration/feature_repos/universal/entities.py @@ -16,3 +16,7 @@ def customer(): def location(): return Entity(name="location_id", value_type=ValueType.INT64) + + +def item(): + return Entity(name="item_id", value_type=ValueType.INT64) diff --git a/sdk/python/tests/integration/feature_repos/universal/feature_views.py b/sdk/python/tests/integration/feature_repos/universal/feature_views.py index 23ce1b4cb19..3d19212f485 100644 --- a/sdk/python/tests/integration/feature_repos/universal/feature_views.py +++ b/sdk/python/tests/integration/feature_repos/universal/feature_views.py @@ -1,6 +1,7 @@ from datetime import timedelta from typing import Dict, List, Optional, Union +import numpy as np import pandas as pd from feast import Feature, FeatureView, OnDemandFeatureView, ValueType @@ -68,6 +69,40 @@ def conv_rate_plus_100_feature_view( ) +def similarity(features_df: pd.DataFrame) -> pd.DataFrame: + if features_df.size == 0: + # give hint to Feast about return type + df = pd.DataFrame({"cos_double": [0.0]}) + df["cos_float"] = df["cos_double"].astype(np.float32) + return df + vectors_a = features_df["embedding_double"].apply(np.array) + vectors_b = features_df["vector_double"].apply(np.array) + dot_products = vectors_a.mul(vectors_b).apply(sum) + norms_q = vectors_a.apply(np.linalg.norm) + norms_doc = vectors_b.apply(np.linalg.norm) + df = pd.DataFrame() + df["cos_double"] = dot_products / (norms_q * norms_doc) + df["cos_float"] = df["cos_double"].astype(np.float32) + return df + + +def similarity_feature_view( + inputs: Dict[str, Union[RequestDataSource, FeatureView]], + infer_features: bool = False, + features: Optional[List[Feature]] = None, +) -> OnDemandFeatureView: + _features = features or [ + Feature("cos_double", ValueType.DOUBLE), + Feature("cos_float", ValueType.FLOAT), + ] + return OnDemandFeatureView( + name=similarity.__name__, + inputs=inputs, + features=[] if infer_features else _features, + udf=similarity, + ) + + def create_driver_age_request_feature_view(): return RequestFeatureView( name="driver_age", @@ -83,6 +118,32 @@ def create_conv_rate_request_data_source(): ) +def create_similarity_request_data_source(): + return RequestDataSource( + name="similarity_input", + schema={ + "vector_double": ValueType.DOUBLE_LIST, + "vector_float": ValueType.FLOAT_LIST, + }, + ) + + +def create_item_embeddings_feature_view(source, infer_features: bool = False): + item_embeddings_feature_view = FeatureView( + name="item_embeddings", + entities=["item"], + features=None + if infer_features + else [ + Feature(name="embedding_double", dtype=ValueType.DOUBLE_LIST), + Feature(name="embedding_float", dtype=ValueType.FLOAT_LIST), + ], + batch_source=source, + ttl=timedelta(hours=2), + ) + return item_embeddings_feature_view + + def create_driver_hourly_stats_feature_view(source, infer_features: bool = False): driver_stats_feature_view = FeatureView( name="driver_stats", diff --git a/sdk/python/tests/integration/online_store/test_universal_online.py b/sdk/python/tests/integration/online_store/test_universal_online.py index c90021f9ce9..c47f2bbfd07 100644 --- a/sdk/python/tests/integration/online_store/test_universal_online.py +++ b/sdk/python/tests/integration/online_store/test_universal_online.py @@ -1,12 +1,16 @@ import datetime import itertools import os +import time import unittest from datetime import timedelta +from typing import Any, Dict, List, Union +import assertpy import numpy as np import pandas as pd import pytest +import requests from feast import Entity, Feature, FeatureService, FeatureView, ValueType from feast.errors import ( @@ -14,6 +18,7 @@ RequestDataNotFoundInEntityRowsException, ) from tests.integration.feature_repos.repo_configuration import ( + Environment, construct_universal_feature_views, ) from tests.integration.feature_repos.universal.entities import ( @@ -165,16 +170,97 @@ def test_write_to_online_store(environment, universal_data_sources): ], entity_rows=[{"driver": 123}], ).to_df() - assert df["avg_daily_trips"].iloc[0] == 14 - assert df["acc_rate"].iloc[0] == 0.91 - assert df["conv_rate"].iloc[0] == 0.85 + assertpy.assert_that(df["avg_daily_trips"].iloc[0]).is_equal_to(14) + assertpy.assert_that(df["acc_rate"].iloc[0]).is_close_to(0.91, 1e-6) + assertpy.assert_that(df["conv_rate"].iloc[0]).is_close_to(0.85, 1e-6) + + +def _get_online_features_dict_remotely( + endpoint: str, + features: Union[List[str], FeatureService], + entity_rows: List[Dict[str, Any]], + full_feature_names: bool = False, +) -> Dict[str, List[Any]]: + """Sends the online feature request to a remote feature server (through endpoint) and returns the feature dict. + + The output should be identical to: + + >>> fs.get_online_features(features=features, entity_rows=entity_rows, full_feature_names=full_feature_names).to_dict() + + This makes it easy to test the remote feature server by comparing the output to the local method. + + """ + request = { + # Convert list of dicts (entity_rows) into dict of lists (entities) for json request + "entities": {key: [row[key] for row in entity_rows] for key in entity_rows[0]}, + "full_feature_names": full_feature_names, + } + # Either set features of feature_service depending on the parameter + if isinstance(features, list): + request["features"] = features + else: + request["feature_service"] = features.name + for _ in range(25): + # Send the request to the remote feature server and get the response in JSON format + response = requests.post( + f"{endpoint}/get-online-features", json=request, timeout=30 + ).json() + # Retry if the response is internal server error, which can happen when lambda is being restarted + if response.get("message") != "Internal Server Error": + break + # Sleep between retries to give the server some time to start + time.sleep(1) + else: + raise Exception("Failed to get online features from remote feature server") + keys = response["field_values"][0]["statuses"].keys() + # Get rid of unnecessary structure in the response, leaving list of dicts + response = [row["fields"] for row in response["field_values"]] + # Convert list of dicts (response) into dict of lists which is the format of the return value + return {key: [row.get(key) for row in response] for key in keys} + + +def get_online_features_dict( + environment: Environment, + features: Union[List[str], FeatureService], + entity_rows: List[Dict[str, Any]], + full_feature_names: bool = False, +) -> Dict[str, List[Any]]: + """Get the online feature values from both SDK and remote feature servers, assert equality and return values. + + Always use this method instead of fs.get_online_features(...) in this test file. + + """ + online_features = environment.feature_store.get_online_features( + features=features, + entity_rows=entity_rows, + full_feature_names=full_feature_names, + ) + assertpy.assert_that(online_features).is_not_none() + dict1 = online_features.to_dict() + + endpoint = environment.feature_store.get_feature_server_endpoint() + # If endpoint is None, it means that the remote feature server isn't configured + if endpoint is not None: + dict2 = _get_online_features_dict_remotely( + endpoint=endpoint, + features=features, + entity_rows=entity_rows, + full_feature_names=full_feature_names, + ) + + # Make sure that the two dicts are equal + assertpy.assert_that(dict1).is_equal_to(dict2) + elif environment.python_feature_server: + raise ValueError( + "feature_store.get_feature_server_endpoint() is None while python feature server is enabled" + ) + return dict1 @pytest.mark.integration @pytest.mark.universal @pytest.mark.parametrize("full_feature_names", [True, False], ids=lambda v: str(v)) def test_online_retrieval(environment, universal_data_sources, full_feature_names): - fs = environment.feature_store entities, datasets, data_sources = universal_data_sources feature_views = construct_universal_feature_views(data_sources) @@ -237,7 +323,9 @@ def test_online_retrieval(environment, universal_data_sources, full_feature_name ] location_pairs = np.array(list(itertools.permutations(entities["location"], 2))) - sample_location_pairs = location_pairs[np.random.choice(len(location_pairs), 10)].T + sample_location_pairs = location_pairs[ + np.random.choice(len(location_pairs), 10) + ].T.tolist() origins_df = datasets["location"][ datasets["location"]["location_id"].isin(sample_location_pairs[0]) ] @@ -270,23 +358,24 @@ def test_online_retrieval(environment, universal_data_sources, full_feature_name unprefixed_feature_refs.remove("conv_rate_plus_100") unprefixed_feature_refs.remove("conv_rate_plus_val_to_add") - online_features = fs.get_online_features( + online_features_dict = get_online_features_dict( + environment=environment, features=feature_refs, entity_rows=entity_rows, full_feature_names=full_feature_names, ) - assert online_features is not None # Test that the on demand feature views compute properly even if the dependent conv_rate # feature isn't requested. - online_features_no_conv_rate = fs.get_online_features( + online_features_no_conv_rate = get_online_features_dict( + environment=environment, features=[ref for ref in feature_refs if ref != "driver_stats:conv_rate"], entity_rows=entity_rows, full_feature_names=full_feature_names, ) + assert online_features_no_conv_rate is not None - online_features_dict = online_features.to_dict() keys = online_features_dict.keys() assert ( len(keys) == len(feature_refs) + 2 @@ -340,13 +429,14 @@ def test_online_retrieval(environment, universal_data_sources, full_feature_name ) # Check what happens for missing values - missing_responses_dict = fs.get_online_features( + missing_responses_dict = get_online_features_dict( + environment=environment, features=feature_refs, entity_rows=[ {"driver": 0, "customer_id": 0, "val_to_add": 100, "driver_age": 125} ], full_feature_names=full_feature_names, - ).to_dict() + ) assert missing_responses_dict is not None for unprefixed_feature_ref in unprefixed_feature_refs: if unprefixed_feature_ref not in {"num_rides", "avg_ride_length", "driver_age"}: @@ -358,22 +448,24 @@ def test_online_retrieval(environment, universal_data_sources, full_feature_name # Check what happens for missing request data with pytest.raises(RequestDataNotFoundInEntityRowsException): - fs.get_online_features( + get_online_features_dict( + environment=environment, features=feature_refs, entity_rows=[{"driver": 0, "customer_id": 0}], full_feature_names=full_feature_names, - ).to_dict() + ) # Also with request data with pytest.raises(RequestDataNotFoundInEntityRowsException): - fs.get_online_features( + get_online_features_dict( + environment=environment, features=feature_refs, entity_rows=[{"driver": 0, "customer_id": 0, "val_to_add": 20}], full_feature_names=full_feature_names, - ).to_dict() + ) assert_feature_service_correctness( - fs, + environment, feature_service, entity_rows, full_feature_names, @@ -395,7 +487,7 @@ def test_online_retrieval(environment, universal_data_sources, full_feature_name ) ] assert_feature_service_entity_mapping_correctness( - fs, + environment, feature_service_entity_mapping, entity_rows, full_feature_names, @@ -511,7 +603,7 @@ def get_latest_feature_values_from_dataframes( def assert_feature_service_correctness( - fs, + environment, feature_service, entity_rows, full_feature_names, @@ -520,14 +612,12 @@ def assert_feature_service_correctness( orders_df, global_df, ): - feature_service_response = fs.get_online_features( + feature_service_online_features_dict = get_online_features_dict( + environment=environment, features=feature_service, entity_rows=entity_rows, full_feature_names=full_feature_names, ) - assert feature_service_response is not None - - feature_service_online_features_dict = feature_service_response.to_dict() feature_service_keys = feature_service_online_features_dict.keys() assert ( @@ -560,7 +650,7 @@ def assert_feature_service_correctness( def assert_feature_service_entity_mapping_correctness( - fs, + environment, feature_service, entity_rows, full_feature_names, @@ -571,14 +661,12 @@ def assert_feature_service_entity_mapping_correctness( destinations_df, ): if full_feature_names: - feature_service_response = fs.get_online_features( + feature_service_online_features_dict = get_online_features_dict( + environment=environment, features=feature_service, entity_rows=entity_rows, full_feature_names=full_feature_names, ) - assert feature_service_response is not None - - feature_service_online_features_dict = feature_service_response.to_dict() feature_service_keys = feature_service_online_features_dict.keys() assert ( @@ -609,7 +697,8 @@ def assert_feature_service_entity_mapping_correctness( else: # using 2 of the same FeatureView without full_feature_names=True will result in collision with pytest.raises(FeatureNameCollisionError): - feature_service_response = fs.get_online_features( + get_online_features_dict( + environment=environment, features=feature_service, entity_rows=entity_rows, full_feature_names=full_feature_names, diff --git a/sdk/python/tests/integration/registration/test_universal_odfv_feature_inference.py b/sdk/python/tests/integration/registration/test_universal_odfv_feature_inference.py index 6d6750081bc..ee3180f863e 100644 --- a/sdk/python/tests/integration/registration/test_universal_odfv_feature_inference.py +++ b/sdk/python/tests/integration/registration/test_universal_odfv_feature_inference.py @@ -1,12 +1,19 @@ +from datetime import datetime + +import pandas as pd import pytest from feast import Feature, ValueType from feast.errors import SpecifiedFeaturesNotPresentError -from tests.integration.feature_repos.universal.entities import customer, driver +from feast.infra.offline_stores.file_source import FileSource +from tests.integration.feature_repos.universal.entities import customer, driver, item from tests.integration.feature_repos.universal.feature_views import ( conv_rate_plus_100_feature_view, create_conv_rate_request_data_source, create_driver_hourly_stats_feature_view, + create_item_embeddings_feature_view, + create_similarity_request_data_source, + similarity_feature_view, ) @@ -33,6 +40,37 @@ def test_infer_odfv_features(environment, universal_data_sources, infer_features assert len(odfv.features) == 3 +@pytest.mark.integration +@pytest.mark.parametrize("infer_features", [True, False], ids=lambda v: str(v)) +def test_infer_odfv_list_features(environment, infer_features, tmp_path): + fake_embedding = [1.0, 1.0] + items_df = pd.DataFrame( + data={ + "item_id": [0], + "embedding_float": [fake_embedding], + "embedding_double": [fake_embedding], + "event_timestamp": [pd.Timestamp(datetime.utcnow())], + "created": [pd.Timestamp(datetime.utcnow())], + } + ) + output_path = f"{tmp_path}/items.parquet" + items_df.to_parquet(output_path) + fake_items_src = FileSource( + path=output_path, + event_timestamp_column="event_timestamp", + created_timestamp_column="created", + ) + items = create_item_embeddings_feature_view(fake_items_src) + sim_odfv = similarity_feature_view( + {"items": items, "input_request": create_similarity_request_data_source()}, + infer_features=infer_features, + ) + store = environment.feature_store + store.apply([item(), items, sim_odfv]) + odfv = store.get_on_demand_feature_view("similarity") + assert len(odfv.features) == 2 + + @pytest.mark.integration @pytest.mark.universal def test_infer_odfv_features_with_error(environment, universal_data_sources):