From c6a8f9ed234c72bad256de019b43fa9e606ebac5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:33:54 +0000 Subject: [PATCH 1/2] chore: Migrate java-server-sdk-dynamodb-store to java-core Move launchdarkly/java-server-sdk-dynamodb into lib/java-server-sdk-dynamodb-store and release it with release-please, following the redis-store migration. - Copy the sources, tests, Gradle wrapper (7.6), checkstyle config and CHANGELOG unchanged. - Replace the de.marcphilipp.nexus-publish and io.codearte.nexus-staging plugins with io.github.gradle-nexus.publish-plugin 1.3.0 and publish through the Central Portal OSSRH staging API, as the other packages do. - Drop the retired oss.sonatype.org repository and the unused ossrh credential properties. - Point the POM url and scm at java-core. - Add the package to release-please, the release workflow, the manual publish workflows, the shared CI action (DynamoDB Local on Linux) and a package workflow (Linux Java 8/11/17/19, Windows Java 11/17). - Add the package to the root README and add issue templates. Generated-by: termag --- .../package-dynamodb-store--bug_report.md | 36 ++ ...package-dynamodb-store--feature_request.md | 20 ++ .github/actions/ci/action.yml | 6 + .../java-server-sdk-dynamodb-store.yml | 72 ++++ .github/workflows/manual-publish-docs.yml | 1 + .github/workflows/manual-publish.yml | 1 + .github/workflows/release-please.yml | 34 ++ .release-please-manifest.json | 1 + README.md | 10 + lib/java-server-sdk-dynamodb-store/.gitignore | 6 + .../CHANGELOG.md | 69 ++++ lib/java-server-sdk-dynamodb-store/README.md | 94 +++++ .../build.gradle | 144 ++++++++ .../checkstyle.xml | 14 + .../gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59536 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + lib/java-server-sdk-dynamodb-store/gradlew | 234 ++++++++++++ .../gradlew.bat | 89 +++++ .../settings.gradle | 1 + .../integrations/CollectionHelpers.java | 39 ++ .../sdk/server/integrations/DynamoDb.java | 110 ++++++ .../DynamoDbBigSegmentStoreImpl.java | 67 ++++ .../integrations/DynamoDbDataStoreImpl.java | 333 ++++++++++++++++++ .../integrations/DynamoDbStoreBuilder.java | 188 ++++++++++ .../integrations/DynamoDbStoreImplBase.java | 60 ++++ .../sdk/server/integrations/package-info.java | 9 + .../DynamoDbBigSegmentStoreImplTest.java | 98 ++++++ .../DynamoDbDataStoreImplTest.java | 204 +++++++++++ .../sdk/server/integrations/TestUtils.java | 120 +++++++ release-please-config.json | 8 + 31 files changed, 2079 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/package-dynamodb-store--bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/package-dynamodb-store--feature_request.md create mode 100644 .github/workflows/java-server-sdk-dynamodb-store.yml create mode 100644 lib/java-server-sdk-dynamodb-store/.gitignore create mode 100644 lib/java-server-sdk-dynamodb-store/CHANGELOG.md create mode 100644 lib/java-server-sdk-dynamodb-store/README.md create mode 100644 lib/java-server-sdk-dynamodb-store/build.gradle create mode 100644 lib/java-server-sdk-dynamodb-store/checkstyle.xml create mode 100644 lib/java-server-sdk-dynamodb-store/gradle.properties create mode 100644 lib/java-server-sdk-dynamodb-store/gradle/wrapper/gradle-wrapper.jar create mode 100644 lib/java-server-sdk-dynamodb-store/gradle/wrapper/gradle-wrapper.properties create mode 100755 lib/java-server-sdk-dynamodb-store/gradlew create mode 100644 lib/java-server-sdk-dynamodb-store/gradlew.bat create mode 100644 lib/java-server-sdk-dynamodb-store/settings.gradle create mode 100644 lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/CollectionHelpers.java create mode 100644 lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDb.java create mode 100644 lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbBigSegmentStoreImpl.java create mode 100644 lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbDataStoreImpl.java create mode 100644 lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbStoreBuilder.java create mode 100644 lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbStoreImplBase.java create mode 100644 lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java create mode 100644 lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/DynamoDbBigSegmentStoreImplTest.java create mode 100644 lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/DynamoDbDataStoreImplTest.java create mode 100644 lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/TestUtils.java diff --git a/.github/ISSUE_TEMPLATE/package-dynamodb-store--bug_report.md b/.github/ISSUE_TEMPLATE/package-dynamodb-store--bug_report.md new file mode 100644 index 00000000..b1c91af8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-dynamodb-store--bug_report.md @@ -0,0 +1,36 @@ +--- +name: 'Bug report for the java-server-sdk-dynamodb-store package' +about: Create a report to help us improve +title: '' +labels: 'package: java-server-sdk-dynamodb-store, bug' +assignees: '' +--- + +**Is this a support request?** +This issue tracker is maintained by LaunchDarkly SDK developers and is intended for feedback on the code in this library. If you're not sure whether the problem you are having is specifically related to this library, or to the LaunchDarkly service overall, it may be more appropriate to contact the LaunchDarkly support team; they can help to investigate the problem and will consult the SDK team if necessary. You can submit a support request by going [here](https://support.launchdarkly.com/) and clicking "submit a request", or by emailing support@launchdarkly.com. + +Note that issues filed on this issue tracker are publicly accessible. Do not provide any private account information on your issues. If your problem is specific to your account, you should submit a support request as described above. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, add any log output related to your problem. + +**SDK version** +The version of this SDK that you are using. + +**Language version, developer tools** +For instance, Go 1.11 or Ruby 2.5.3. If you are using a language that requires a separate compiler, such as C, please include the name and version of the compiler too. + +**OS/platform** +For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the browser type and version. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/package-dynamodb-store--feature_request.md b/.github/ISSUE_TEMPLATE/package-dynamodb-store--feature_request.md new file mode 100644 index 00000000..488ec304 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-dynamodb-store--feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request for the java-server-sdk-dynamodb-store package +about: Suggest an idea for this project +title: '' +labels: 'package: java-server-sdk-dynamodb-store, enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I would love to see the SDK [...does something new...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context about the feature request here. \ No newline at end of file diff --git a/.github/actions/ci/action.yml b/.github/actions/ci/action.yml index 910fe8ed..28371468 100644 --- a/.github/actions/ci/action.yml +++ b/.github/actions/ci/action.yml @@ -35,6 +35,12 @@ runs: sudo apt-get install redis-server -y sudo service redis-server start + - name: Setup DynamoDB Service + shell: bash + if: ${{ inputs.workspace_path == 'lib/java-server-sdk-dynamodb-store' }} + run: | + sudo docker run -d -p 8000:8000 amazon/dynamodb-local + - name: Restore dependencies shell: bash id: restore diff --git a/.github/workflows/java-server-sdk-dynamodb-store.yml b/.github/workflows/java-server-sdk-dynamodb-store.yml new file mode 100644 index 00000000..5df8dc59 --- /dev/null +++ b/.github/workflows/java-server-sdk-dynamodb-store.yml @@ -0,0 +1,72 @@ +name: java-server-sdk-dynamodb-store + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' + +jobs: + build-test-java-server-sdk-dynamodb-store: + strategy: + matrix: + java-version: [8, 11, 17, 19] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Shared CI Steps + uses: ./.github/actions/ci + with: + workspace_path: 'lib/java-server-sdk-dynamodb-store' + java_version: ${{ matrix.java-version }} + + build-test-java-server-sdk-dynamodb-store-windows: + strategy: + matrix: + java-version: [11, 17] + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ matrix.java-version }} + + - name: Setup DynamoDB Service + shell: pwsh + run: | + $ProgressPreference = "SilentlyContinue" + iwr -outf dynamo.zip https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.zip + mkdir dynamo + Expand-Archive -Path dynamo.zip -DestinationPath dynamo + cd dynamo + Start-Process -FilePath "java" -ArgumentList "-D`"java.library.path=./DynamoDBLocal_lib`"","-jar","DynamoDBLocal.jar" + + - name: Restore dependencies + shell: bash + run: lib/java-server-sdk-dynamodb-store/gradlew dependencies -p lib/java-server-sdk-dynamodb-store + + - name: Build + shell: bash + id: build + run: lib/java-server-sdk-dynamodb-store/gradlew build -p lib/java-server-sdk-dynamodb-store + + - name: Build Jar + shell: bash + run: lib/java-server-sdk-dynamodb-store/gradlew jar -p lib/java-server-sdk-dynamodb-store + + - name: Run Tests + if: steps.build.outcome == 'success' + shell: bash + run: lib/java-server-sdk-dynamodb-store/gradlew test -p lib/java-server-sdk-dynamodb-store + + - name: Build Documentation + shell: bash + run: lib/java-server-sdk-dynamodb-store/gradlew javadoc -p lib/java-server-sdk-dynamodb-store diff --git a/.github/workflows/manual-publish-docs.yml b/.github/workflows/manual-publish-docs.yml index 3e59fe92..d0a862af 100644 --- a/.github/workflows/manual-publish-docs.yml +++ b/.github/workflows/manual-publish-docs.yml @@ -13,6 +13,7 @@ on: - lib/sdk/server-ai - lib/java-server-sdk-otel - lib/java-server-sdk-redis-store + - lib/java-server-sdk-dynamodb-store dry_run: description: 'Is this a dry run. If so no docs will be published.' type: boolean diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index c85f948a..fa5bfab8 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -14,6 +14,7 @@ on: - lib/sdk/server-ai - lib/java-server-sdk-otel - lib/java-server-sdk-redis-store + - lib/java-server-sdk-dynamodb-store prerelease: description: 'Is this a prerelease.' type: boolean diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b448b30f..68e613ff 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -20,6 +20,7 @@ jobs: package-server-sdk-ai-released: ${{ steps.release.outputs['lib/sdk/server-ai--release_created'] }} package-server-sdk-otel-released: ${{ steps.release.outputs['lib/java-server-sdk-otel--release_created'] }} package-server-sdk-redis-store-released: ${{ steps.release.outputs['lib/java-server-sdk-redis-store--release_created'] }} + package-server-sdk-dynamodb-store-released: ${{ steps.release.outputs['lib/java-server-sdk-dynamodb-store--release_created'] }} steps: - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 @@ -160,6 +161,39 @@ jobs: aws_role: ${{ vars.AWS_ROLE_ARN }} token: ${{ secrets.GITHUB_TOKEN }} + release-server-sdk-dynamodb-store: + runs-on: ubuntu-latest + needs: release-please + permissions: + id-token: write + contents: write + pull-requests: write + if: ${{ needs.release-please.outputs.package-server-sdk-dynamodb-store-released == 'true'}} + steps: + - uses: actions/checkout@v4 + + - uses: launchdarkly/gh-actions/actions/release-secrets@release-secrets-v1.1.0 + name: Get secrets + with: + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + ssm_parameter_pairs: '/production/common/releasing/sonatype/central/username = SONATYPE_USER_NAME, + /production/common/releasing/sonatype/central/password = SONATYPE_PASSWORD, + /production/common/releasing/java/keyId = SIGNING_KEY_ID' + s3_path_pairs: 'launchdarkly-releaser/java/code-signing-keyring.gpg = code-signing-keyring.gpg' + + - uses: ./.github/actions/full-release + with: + workspace_path: lib/java-server-sdk-dynamodb-store + dry_run: false + prerelease: false + code_signing_keyring: ${{ github.workspace }}/code-signing-keyring.gpg + signing_key_id: ${{ env.SIGNING_KEY_ID }} + signing_key_passphrase: '' + sonatype_username: ${{ env.SONATYPE_USER_NAME }} + sonatype_password: ${{ env.SONATYPE_PASSWORD }} + aws_role: ${{ vars.AWS_ROLE_ARN }} + token: ${{ secrets.GITHUB_TOKEN }} + release-sdk-internal: runs-on: ubuntu-latest needs: release-please diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 50fbe0d5..72bf15aa 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,6 +2,7 @@ "lib/sdk/server-ai": "0.3.0", "lib/java-server-sdk-otel": "0.2.0", "lib/java-server-sdk-redis-store": "3.2.0", + "lib/java-server-sdk-dynamodb-store": "5.0.0", "lib/shared/common": "2.5.0", "lib/shared/internal": "1.12.2", "lib/shared/test-helpers": "2.1.0", diff --git a/README.md b/README.md index 2a05665a..1aae81f7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. | ---------------------------------------------------------------------------- |--------------------------------------------------------------| ---------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------- | | [@launchdarkly/java-server-sdk-otel](lib/java-server-sdk-otel/README.md) | [![Documentation][server-otel-docs-badge]][server-otel-docs-link] | [![maven][server-otel-maven-badge]][server-otel-maven-link] | [Issues][server-otel-issues] | [![Actions Status][server-otel-ci-badge]][server-otel-ci-link] | | [@launchdarkly/java-server-sdk-redis-store](lib/java-server-sdk-redis-store/README.md) | [![Documentation][server-redis-docs-badge]][server-redis-docs-link] | [![maven][server-redis-maven-badge]][server-redis-maven-link] | [Issues][server-redis-issues] | [![Actions Status][server-redis-ci-badge]][server-redis-ci-link] | +| [@launchdarkly/java-server-sdk-dynamodb-store](lib/java-server-sdk-dynamodb-store/README.md) | [![Documentation][server-dynamodb-docs-badge]][server-dynamodb-docs-link] | [![maven][server-dynamodb-maven-badge]][server-dynamodb-maven-link] | [Issues][server-dynamodb-issues] | [![Actions Status][server-dynamodb-ci-badge]][server-dynamodb-ci-link] | ## Organization @@ -81,6 +82,15 @@ We encourage pull requests and other contributions from the community. Check out [server-redis-docs-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 [server-redis-docs-link]: https://launchdarkly.github.io/java-core/lib/java-server-sdk-redis-store/ +[//]: # 'java-server-sdk-dynamodb-store' +[server-dynamodb-issues]: https://github.com/launchdarkly/java-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+java-server-sdk-dynamodb-store%22+ +[server-dynamodb-maven-badge]: https://img.shields.io/maven-central/v/com.launchdarkly/launchdarkly-java-server-sdk-dynamodb-store +[server-dynamodb-maven-link]: https://central.sonatype.com/artifact/com.launchdarkly/launchdarkly-java-server-sdk-dynamodb-store +[server-dynamodb-ci-badge]: https://github.com/launchdarkly/java-core/actions/workflows/java-server-sdk-dynamodb-store.yml/badge.svg +[server-dynamodb-ci-link]: https://github.com/launchdarkly/java-core/actions/workflows/java-server-sdk-dynamodb-store.yml +[server-dynamodb-docs-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 +[server-dynamodb-docs-link]: https://launchdarkly.github.io/java-core/lib/java-server-sdk-dynamodb-store/ + [//]: # 'java-sdk-internal' [sdk-internal-issues]: https://github.com/launchdarkly/java-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+java-sdk-internal%22+ [sdk-internal-maven-badge]: https://img.shields.io/maven-central/v/com.launchdarkly/launchdarkly-java-sdk-internal diff --git a/lib/java-server-sdk-dynamodb-store/.gitignore b/lib/java-server-sdk-dynamodb-store/.gitignore new file mode 100644 index 00000000..d67a5bc6 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/.gitignore @@ -0,0 +1,6 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build +bin diff --git a/lib/java-server-sdk-dynamodb-store/CHANGELOG.md b/lib/java-server-sdk-dynamodb-store/CHANGELOG.md new file mode 100644 index 00000000..c0114e32 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/CHANGELOG.md @@ -0,0 +1,69 @@ +# Change log + +All notable changes to the LaunchDarkly Java SDK DynamoDB integration will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). + +## [5.0.0] - 2022-12-07 +This release corresponds to the 6.0.0 release of the LaunchDarkly Java SDK. Any application code that is being updated to use the 6.0.0 SDK, and was using a 4.x version of `launchdarkly-java-server-sdk-dynamodb`, should now use a 5.x version instead. + +There are no functional differences in the behavior of the DynamoDB integration; the differences are only related to changes in the usage of interface types for configuration in the SDK. + +### Added: +- `DynamoDb.bigSegmentStore()`, which creates a configuration builder for use with Big Segments. Previously, the `DynamoDb.dataStore()` builder was used for both regular data stores and Big Segment stores. + +### Changed: +- The type `DynamoDbDataStoreBuilder` has been removed, replaced by a generic type `DynamoDbStoreBuilder`. Application code would not normally need to reference these types by name, but if necessary, use either `DynamoDbStoreBuilder` or `DynamoDbStoreBuilder` depending on whether you are configuring a regular data store or a Big Segment store. + +## [4.0.0] - 2022-10-06 +This release updates the package to use the new logging mechanism that was introduced in version [5.10.0](https://github.com/launchdarkly/java-server-sdk/releases/tag/5.10.0) of the LaunchDarkly Java SDK, so that log output from the DynamoDB integration is handled in whatever way was specified by the SDK's logging configuration, instead of always using SLF4J. + +This version of the package will not work with SDK versions earlier than 5.10.0; that is the only reason for the 4.0.0 major version increment. The functionality of the package is otherwise unchanged, and there are no API changes. + +## [3.1.1] - 2022-04-15 +### Fixed: +- If the SDK attempts to store a feature flag or segment whose total data size is over the 400KB limit for DynamoDB items, this integration will now log (at `Error` level) a message like `The item "my-flag-key" in "features" was too large to store in DynamoDB and was dropped` but will still process all other data updates. Previously, it would cause the SDK to enter an error state in which the oversized item would be pointlessly retried and other updates might be lost. + +## [3.1.0] - 2022-02-04 +### Added: +- Added support for Big Segments. An Early Access Program for creating and syncing Big Segments from customer data platforms is available to enterprise customers. + +## [3.0.0] - 2020-06-02 +This release is for use with versions 5.0.0 and higher of [`launchdarkly-java-server-sdk`](https://github.com/launchdarkly/java-server-sdk). + +For more information about changes in the SDK database integrations, see the [4.x to 5.0 migration guide](https://docs-stg.launchdarkly.com/252/sdk/server-side/java/migration-4-to-5/). + +### Changed: +- The entry point is now `com.launchdarkly.sdk.server.integrations.DynamoDb` rather than `com.launchdarkly.client.integrations.DynamoDb`. +- The SLF4J logger name is now `com.launchdarkly.sdk.server.LDClient.DataStore.DynamoDb` rather than `com.launchdarkly.client.integrations.DynamoDbDataStoreImpl`. + +### Removed: +- Removed the deprecated entry point `com.launchdarkly.client.dynamodb.DynamoDbComponents`. + + +## [2.1.0] - 2020-01-30 +### Added: +- New classes `com.launchdarkly.client.integrations.DynamoDb` and `com.launchdarkly.client.integrations.DynamoDbDataStoreBuilder`, which serve the same purpose as the previous classes but are designed to work with the newer persistent data store API introduced in [Java SDK 4.12.0](https://github.com/launchdarkly/java-server-sdk/releases/tag/4.12.0). + +### Deprecated: +- The old interface in the `com.launchdarkly.client.integrations.dynamodb` package. + +## [2.0.2] - 2019-12-11 +### Changed: +- Updated the AWS SDK DynamoDB dependency to version 2.10.32. Your application can always specify its own desired version of the dependency; this is just the version that is used for building the library. + +## [2.0.1] - 2019-05-13 +### Changed: +- Corresponding to the SDK package name change from `com.launchdarkly:launchdarkly-client` to `com.launchdarkly:launchdarkly-java-server-sdk`, this package is now called `com.launchdarkly:launchdarkly-java-server-sdk-dynamodb-store`. The functionality of the package, including the package names and class names in the code, has not changed. + +## [1.0.1] - 2019-05-13 +### Changed: +- Corresponding to the SDK package name change from `com.launchdarkly:launchdarkly-client` to `com.launchdarkly:launchdarkly-java-server-sdk`, this package is now called `com.launchdarkly:launchdarkly-java-server-sdk-dynamodb-store`. The functionality of the package, including the package names and class names in the code, has not changed. + +## [2.0.0] - 2018-12-12 + +Initial release of the implementation that is based on AWS SDK 2.x. This has the same functionality as version 1.0.0, but uses the DynamoDB client classes from the more recent AWS API. + +Note that AWS SDK 2.x has a minimum Java version of 8, so if you require Java 7 compatibility you should use the 1.x releases of this library. + +## [1.0.0] - 2018-12-12 + +Initial release of the implementation that is based on AWS SDK 1.x. diff --git a/lib/java-server-sdk-dynamodb-store/README.md b/lib/java-server-sdk-dynamodb-store/README.md new file mode 100644 index 00000000..2409bce8 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/README.md @@ -0,0 +1,94 @@ +# LaunchDarkly SDK for Java - DynamoDB integration + +This library provides a DynamoDB-backed persistence mechanism (data store) for the [LaunchDarkly Java SDK](https://github.com/launchdarkly/java-server-sdk), replacing the default in-memory data store. + +This version of the library requires at least version 6.0.0 of the LaunchDarkly Java SDK, and at least version 2.1 of the AWS SDK for Java. For versions of the library to use with earlier SDK versions, see the changelog. The minimum Java version is 8. + +See the [API documentation](https://launchdarkly.github.io/java-core/lib/java-server-sdk-dynamodb-store/) for details on classes and methods. + +For more information, see also: [Using DynamoDB as a persistent feature store](https://docs.launchdarkly.com/sdk/features/storing-data/dynamodb#java). + +## Quick setup + +This assumes that you have already installed the LaunchDarkly Java SDK. + +1. In DynamoDB, create a table which has the following schema: a partition key called "namespace" and a sort key called "key", both with a string type. The LaunchDarkly library does not create the table automatically, because it has no way of knowing what additional properties (such as permissions and throughput) you would want it to have. + +2. Add this library to your project (substitute the [latest version](https://central.sonatype.com/artifact/com.launchdarkly/launchdarkly-java-server-sdk-dynamodb-store) number for `XXX`): + + + com.launchdarkly + launchdarkly-java-server-sdk-dynamodb-store + XXX + + +3. If you do not already have the AWS SDK in your project, add the DynamoDB part of it. (This needs to be added separately, rather than being included in the LaunchDarkly jar, because AWS classes are exposed in the public interface.) + + + software.amazon.awssdk + dynamodb + 2.1.4 + + +4. Import the LaunchDarkly package and the package for this library: + + import com.launchdarkly.sdk.server.*; + import com.launchdarkly.sdk.server.integrations.*; + +5. When configuring your SDK client, add the DynamoDB feature store: + + LDConfig config = new LDConfig.Builder() + .dataStore( + Components.persistentDataStore( + DynamoDb.dataStore("my-table-name") + ) + ) + .build(); + +By default, the DynamoDB client will try to get your AWS credentials and region name from environment variables and/or local configuration files, as described in the AWS SDK documentation. There are methods in `DynamoDbDataStoreBuilder` for changing the configuration options. Alternatively, if you already have a fully configured DynamoDB client object, you can tell LaunchDarkly to use that: + + Components.persistentDataStore( + DynamoDb.dataStore("my-table-name").existingClient(myDynamoDbClientInstance) + ) + +## Caching behavior + +The LaunchDarkly SDK has a standard caching mechanism for any persistent data store, to reduce database traffic. This is configured through the SDK's `PersistentDataStoreBuilder` class as described the SDK documentation. For instance, to specify a cache TTL of 5 minutes: + + LDConfig config = new LDConfig.Builder() + .dataStore( + Components.persistentDataStore( + DynamoDb.dataStore("my-table-name") + ).cacheTime(Duration.ofMinutes(5)) + ) + .build(); + +## Data size limitation + +DynamoDB has [a 400KB limit](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ServiceQuotas.html#limits-items) on the size of any data item. For the LaunchDarkly SDK, a data item consists of the JSON representation of an individual feature flag or segment configuration, plus a few smaller attributes. You can see the format and size of these representations by querying `https://sdk.launchdarkly.com/flags/latest-all` and setting the `Authorization` header to your SDK key. + +Most flags and segments won't be nearly as big as 400KB, but they could be if for instance you have a long list of user keys for individual user targeting. If the flag or segment representation is too large, it cannot be stored in DynamoDB. To avoid disrupting storage and evaluation of other unrelated feature flags, the SDK will simply skip storing that individual flag or segment, and will log a message (at ERROR level) describing the problem. For example: + +``` + The item "my-flag-key" in "features" was too large to store in DynamoDB and was dropped +``` + +If caching is enabled in your configuration, the flag or segment may still be available in the SDK from the in-memory cache, but do not rely on this. If you see this message, consider redesigning your flag/segment configurations, or else do not use DynamoDB for the environment that contains this data item. + +This limitation does not apply to target lists in [Big Segments](https://docs.launchdarkly.com/home/users/big-segments/). + +A future version of the LaunchDarkly DynamoDB integration may use different strategies to work around this limitation, such as compressing the data or dividing it into multiple items. However, this integration is required to be interoperable with the DynamoDB integrations used by all the other LaunchDarkly SDKs and by the Relay Proxy, so any such change will only be made as part of a larger cross-platform release. + +## About LaunchDarkly + +* LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +* Explore LaunchDarkly + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates diff --git a/lib/java-server-sdk-dynamodb-store/build.gradle b/lib/java-server-sdk-dynamodb-store/build.gradle new file mode 100644 index 00000000..26dfa9d8 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/build.gradle @@ -0,0 +1,144 @@ + +buildscript { + repositories { + mavenCentral() + mavenLocal() + } +} + +plugins { + id "java" + id "java-library" + id "checkstyle" + id "signing" + id "maven-publish" + id "io.github.gradle-nexus.publish-plugin" version "1.3.0" + id "idea" +} + +configurations.all { + // check for updates every build for dependencies with: 'changing: true' + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + +repositories { + mavenLocal() + mavenCentral() +} + +allprojects { + group = 'com.launchdarkly' + version = "${version}" + archivesBaseName = 'launchdarkly-java-server-sdk-dynamodb-store' + sourceCompatibility = 1.8 + targetCompatibility = 1.8 +} + +ext.versions = [ + "sdk": "6.0.0", // the *lowest* version we're compatible with + "dynamodb": "2.10.32" +] + +ext.libraries = [:] + +dependencies { + api "com.launchdarkly:launchdarkly-java-server-sdk:${versions.sdk}" + api "software.amazon.awssdk:dynamodb:${versions.dynamodb}" + testImplementation "org.hamcrest:hamcrest-all:1.3" + testImplementation "junit:junit:4.13.2" + testImplementation "com.launchdarkly:launchdarkly-java-server-sdk:${versions.sdk}:test" // our unit tests use helper classes from the SDK + testImplementation "com.google.guava:guava:33.6.0-jre" // required by SDK tests, not used in this library itself + testImplementation "com.google.code.gson:gson:2.14.0" // same as above +} + +task sourcesJar(type: Jar, dependsOn: classes) { + classifier = 'sources' + from sourceSets.main.allSource +} + +task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir +} + +artifacts { + archives sourcesJar, javadocJar +} + +test { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + showStandardStreams = true + exceptionFormat = 'full' + } +} + +checkstyle { + toolVersion = "9.3" + configFile = file("${project.rootDir}/checkstyle.xml") +} + +idea { + module { + downloadJavadoc = true + downloadSources = true + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + + groupId = 'com.launchdarkly' + artifactId = project.archivesBaseName + + artifact sourcesJar + artifact javadocJar + + pom { + name = project.archivesBaseName + description = 'LaunchDarkly Java SDK DynamoDB integration' + url = 'https://github.com/launchdarkly/java-core' + licenses { + license { + name = 'The Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + developers { + developer { + name = 'LaunchDarkly' + email = 'team@launchdarkly.com' + } + } + scm { + connection = 'scm:git:git://github.com/launchdarkly/java-core.git' + developerConnection = 'scm:git:ssh:git@github.com:launchdarkly/java-core.git' + url = 'https://github.com/launchdarkly/java-core' + } + } + } + } + repositories { + mavenLocal() + } +} + +nexusPublishing { + clientTimeout = java.time.Duration.ofMinutes(3) // we've seen extremely long delays in creating repositories + repositories { + sonatype{ + nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) + snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) + } + } +} + +signing { + sign publishing.publications.mavenJava +} + +tasks.withType(Sign) { + onlyIf { !"1".equals(project.findProperty("LD_SKIP_SIGNING")) } // so we can build jars for testing in CI +} diff --git a/lib/java-server-sdk-dynamodb-store/checkstyle.xml b/lib/java-server-sdk-dynamodb-store/checkstyle.xml new file mode 100644 index 00000000..01019561 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/checkstyle.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/java-server-sdk-dynamodb-store/gradle.properties b/lib/java-server-sdk-dynamodb-store/gradle.properties new file mode 100644 index 00000000..c596561e --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/gradle.properties @@ -0,0 +1,6 @@ +#x-release-please-start-version +version=5.0.0 +#x-release-please-end + +# See https://github.com/gradle/gradle/issues/11308 regarding the following property +systemProp.org.gradle.internal.publish.checksums.insecure=true diff --git a/lib/java-server-sdk-dynamodb-store/gradle/wrapper/gradle-wrapper.jar b/lib/java-server-sdk-dynamodb-store/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7454180f2ae8848c63b8b4dea2cb829da983f2fa GIT binary patch literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL literal 0 HcmV?d00001 diff --git a/lib/java-server-sdk-dynamodb-store/gradle/wrapper/gradle-wrapper.properties b/lib/java-server-sdk-dynamodb-store/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..070cb702 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/lib/java-server-sdk-dynamodb-store/gradlew b/lib/java-server-sdk-dynamodb-store/gradlew new file mode 100755 index 00000000..1b6c7873 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original 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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/lib/java-server-sdk-dynamodb-store/gradlew.bat b/lib/java-server-sdk-dynamodb-store/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/java-server-sdk-dynamodb-store/settings.gradle b/lib/java-server-sdk-dynamodb-store/settings.gradle new file mode 100644 index 00000000..d08a8dc6 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'launchdarkly-java-server-sdk-dynamodb-store' diff --git a/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/CollectionHelpers.java b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/CollectionHelpers.java new file mode 100644 index 00000000..4450f23d --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/CollectionHelpers.java @@ -0,0 +1,39 @@ +package com.launchdarkly.sdk.server.integrations; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Simple factory methods that allow us to do without Guava in this library. We don't need immutable collections + * for our simple use cases. + */ +final class CollectionHelpers { + static Map mapOf(K k1, V v1) { + return Collections.singletonMap(k1, v1); + } + + static Map mapOf(K k1, V v1, K k2, V v2) { + Map map = new HashMap<>(2); + map.put(k1, v1); + map.put(k2, v2); + return map; + } + + static Map mapOf(K k1, V v1, K k2, V v2, K k3, V v3) { + Map map = new HashMap<>(2); + map.put(k1, v1); + map.put(k2, v2); + map.put(k3, v3); + return map; + } + + static Map mapOf(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4) { + Map map = new HashMap<>(2); + map.put(k1, v1); + map.put(k2, v2); + map.put(k3, v3); + map.put(k4, v4); + return map; + } +} diff --git a/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDb.java b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDb.java new file mode 100644 index 00000000..81f730a9 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDb.java @@ -0,0 +1,110 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.subsystems.BigSegmentStore; +import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.PersistentDataStore; + +/** + * Integration between the LaunchDarkly SDK and DynamoDB. + * + * @since 2.1.0 + */ +public abstract class DynamoDb { + /** + * Returns a builder object for creating a DynamoDB-backed data store. + *

+ * This is for the main data store that holds feature flag data. To configure a + * Big Segment store, use {@link #bigSegmentStore(String)} instead. + *

+ * You can use methods of the builder to specify any non-default DynamoDB options + * you may want, before passing the builder to {@link Components#persistentDataStore(ComponentConfigurer)}. + * In this example, the data store uses a table called "table1" and a namespace prefix of "prefix1": + * + *


+   *   LDConfig config = new LDConfig.Builder()
+   *       .dataStore(
+   *           Components.persistentDataStore(
+   *               DynamoDb.dataStore("table1").prefix("prefix1")
+   *           )
+   *       )
+   *       .build();
+   * 
+ * + * Note that the SDK also has its own options related to data storage that are configured + * at a different level, because they are independent of what database is being used. For + * instance, the builder returned by {@link Components#persistentDataStore(ComponentConfigurer)} + * has options for caching: + * + *

+   *     LDConfig config = new LDConfig.Builder()
+   *         .dataStore(
+   *             Components.persistentDataStore(
+   *                 DynamoDb.dataStore("table1")
+   *             ).cacheSeconds(15)
+   *         )
+   *         .build();
+   * 
+ * + * Note that the specified table must already exist in DynamoDB. It must have a partition key + * of "namespace", and a sort key of "key". + *

+ * By default, the data store uses a basic DynamoDB client configuration that takes its + * AWS credentials and region from AWS environment variables and/or local configuration files. + * There are options in the builder for changing some configuration options, or you can + * configure the DynamoDB client yourself and pass it to the builder with + * {@link DynamoDbStoreBuilder#existingClient(software.amazon.awssdk.services.dynamodb.DynamoDbClient)}. + *

+ * If you are using the same DynamoDB table as a data store for multiple LaunchDarkly + * environments, use the {@link DynamoDbStoreBuilder#prefix(String)} option and choose a + * different prefix string for each, so they will not interfere with each other's data. + * + * @param tableName the table name in DynamoDB (must already exist) + * @return a data store configuration object + */ + public static DynamoDbStoreBuilder dataStore(String tableName) { + return new DynamoDbStoreBuilder.ForDataStore(tableName); + } + + /** + * Returns a builder object for creating a DynamoDB-backed Big Segment store. + *

+ * You can use methods of the builder to specify any non-default DynamoDB options + * you may want, before passing the builder to {@link Components#bigSegments(ComponentConfigurer)}. + * In this example, the store is configured to use a table called "table2" and a + * namespace prefix of "prefix2": + *


+   *     LDConfig config = new LDConfig.Builder()
+   *         .bigSegments(
+   *             Components.bigSegments(
+   *                 DynamoDb.bigSegmentStore("table2").prefix("prefix2")
+   *             )
+   *         )
+   *         .build();
+   * 
+ *

+ * Note that the SDK also has its own options related to Big Segments that are configured + * at a different level, because they are independent of what database is being used. For + * instance, the builder returned by {@link Components#bigSegments(ComponentConfigurer)} + * has an option for the status polling interval: + *


+   *     LDConfig config = new LDConfig.Builder()
+   *         .dataStore(
+   *             Components.bigSegments(
+   *                 DynamoDb.bigSegmentStore("table2")
+   *             ).statusPollInterval(Duration.ofSeconds(30))
+   *         )
+   *         .build();
+   * 
+ * + * Note that the specified table must already exist in DynamoDB. It must have a partition key + * of "namespace", and a sort key of "key". + * + * @param tableName the table name in DynamoDB (must already exist) + * @return a Big Segment store configuration object + * @since 3.0.0 + */ + public static DynamoDbStoreBuilder bigSegmentStore(String tableName) { + return new DynamoDbStoreBuilder.ForBigSegments(tableName); + } +} diff --git a/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbBigSegmentStoreImpl.java b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbBigSegmentStoreImpl.java new file mode 100644 index 00000000..5476daca --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbBigSegmentStoreImpl.java @@ -0,0 +1,67 @@ +package com.launchdarkly.sdk.server.integrations; + +import static com.launchdarkly.sdk.server.subsystems.BigSegmentStoreTypes.createMembershipFromSegmentRefs; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.subsystems.BigSegmentStore; +import com.launchdarkly.sdk.server.subsystems.BigSegmentStoreTypes; + +import java.util.List; + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; + +final class DynamoDbBigSegmentStoreImpl extends DynamoDbStoreImplBase implements BigSegmentStore { + private final static String MEMBERSHIP_KEY = "big_segments_user"; + private final static String INCLUDED_ATTR = "included"; + private final static String EXCLUDED_ATTR = "excluded"; + + private final static String METADATA_KEY = "big_segments_metadata"; + private final static String SYNC_TIME_ATTR = "synchronizedOn"; + + DynamoDbBigSegmentStoreImpl( + DynamoDbClient client, + boolean wasExistingClient, + String tableName, + String prefix, + LDLogger baseLogger + ) { + super(client, wasExistingClient, tableName, prefix, + baseLogger.subLogger("BigSegments").subLogger("DynamoDb")); + } + + @Override + public BigSegmentStoreTypes.Membership getMembership(String userHash) { + String namespaceKey = prefixedNamespace(MEMBERSHIP_KEY); + GetItemResponse response = getItemByKeys(namespaceKey, userHash); + if (response == null || response.item() == null || response.item().isEmpty()) { + return null; + } + List includedRefs = stringListFromAttrValue(response.item().get(INCLUDED_ATTR)); + List excludedRefs = stringListFromAttrValue(response.item().get(EXCLUDED_ATTR)); + return createMembershipFromSegmentRefs(includedRefs, excludedRefs); + } + + private static List stringListFromAttrValue(AttributeValue attrValue) { + return attrValue == null ? null : attrValue.ss(); + } + + @Override + public BigSegmentStoreTypes.StoreMetadata getMetadata() { + String key = prefixedNamespace(METADATA_KEY); + GetItemResponse response = getItemByKeys(key, key); + if (response == null || response.item() == null) { + return null; + } + AttributeValue syncTimeValue = response.item().get(SYNC_TIME_ATTR); + if (syncTimeValue == null) { + return null; + } + String syncTimeString = syncTimeValue.n(); + if (syncTimeString == null) { + return null; + } + return new BigSegmentStoreTypes.StoreMetadata(Long.parseLong(syncTimeString)); + } +} diff --git a/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbDataStoreImpl.java b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbDataStoreImpl.java new file mode 100644 index 00000000..61ea3975 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbDataStoreImpl.java @@ -0,0 +1,333 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.PersistentDataStore; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.launchdarkly.sdk.server.integrations.CollectionHelpers.mapOf; + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ComparisonOperator; +import software.amazon.awssdk.services.dynamodb.model.Condition; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; +import software.amazon.awssdk.services.dynamodb.model.WriteRequest; +import software.amazon.awssdk.services.dynamodb.paginators.QueryIterable; + +/** + * Internal implementation of the DynamoDB data store. + *

+ * Implementation notes: + *

    + * + *
  • Feature flags, segments, and any other kind of entity the LaunchDarkly client may wish + * to store, are all put in the same table. The only two required attributes are "key" (which + * is present in all storeable entities) and "namespace" (a parameter from the client that is + * used to disambiguate between flags and segments). + * + *
  • Because of DynamoDB's restrictions on attribute values (e.g. empty strings are not + * allowed), the standard DynamoDB marshaling mechanism with one attribute per object property + * is not used. Instead, the entire object is serialized to JSON and stored in a single + * attribute, "item". The "version" property is also stored as a separate attribute since it + * is used for updates. + * + *
  • Since DynamoDB doesn't have transactions, the init() method - which replaces the entire data + * store - is not atomic, so there can be a race condition if another process is adding new data + * via upsert(). To minimize this, we don't delete all the data at the start; instead, we update + * the items we've received, and then delete all other items. That could potentially result in + * deleting new data from another process, but that would be the case anyway if the init() + * happened to execute later than the upsert(); we are relying on the fact that normally the + * process that did the init() will also receive the new data shortly and do its own upsert(). + * + *
  • DynamoDB has a maximum item size of 400KB. Since each feature flag or user segment is + * stored as a single item, this mechanism will not work for extremely large flags or segments. + *
+ */ +final class DynamoDbDataStoreImpl extends DynamoDbStoreImplBase implements PersistentDataStore { + private static final String versionAttribute = "version"; + private static final String itemJsonAttribute = "item"; + private static final String deletedItemPlaceholder = "null"; // DynamoDB doesn't allow empty strings + + // We won't try to store items whose total size exceeds this. The DynamoDB documentation says + // only "400KB", which probably means 400*1024, but to avoid any chance of trying to store a + // too-large item we are rounding it down. + private static final int DYNAMO_DB_MAX_ITEM_SIZE = 400000; + + private Runnable updateHook; + + DynamoDbDataStoreImpl( + DynamoDbClient client, + boolean wasExistingClient, + String tableName, + String prefix, + LDLogger baseLogger + ) { + super(client, wasExistingClient, tableName, prefix, + baseLogger.subLogger("DataStore").subLogger("DynamoDb")); + } + + @Override + public SerializedItemDescriptor get(DataKind kind, String key) { + GetItemResponse resp = getItemByKeys(namespaceForKind(kind), key); + return unmarshalItem(kind, resp.item()); + } + + @Override + public KeyedItems getAll(DataKind kind) { + List> itemsOut = new ArrayList<>(); + for (QueryResponse resp: client.queryPaginator(makeQueryForKind(kind).build())) { + for (Map item: resp.items()) { + AttributeValue keyAttr = item.get(SORT_KEY); + if (keyAttr != null && keyAttr.s() != null) { + SerializedItemDescriptor itemOut = unmarshalItem(kind, item); + if (itemOut != null) { + itemsOut.add(new AbstractMap.SimpleEntry<>(keyAttr.s(), itemOut)); + } + } + } + } + return new KeyedItems<>(itemsOut); + } + + @Override + public void init(FullDataSet allData) { + // Start by reading the existing keys; we will later delete any of these that weren't in allData. + Set> unusedOldKeys = readExistingKeys(allData); + + List requests = new ArrayList<>(); + int numItems = 0; + + // Insert or update every provided item + for (Map.Entry> entry: allData.getData()) { + DataKind kind = entry.getKey(); + for (Map.Entry itemEntry: entry.getValue().getItems()) { + String key = itemEntry.getKey(); + Map encodedItem = marshalItem(kind, key, itemEntry.getValue()); + + if (!checkSizeLimit(encodedItem)) { + continue; + } + + requests.add(WriteRequest.builder().putRequest(builder -> builder.item(encodedItem)).build()); + + Map.Entry combinedKey = new AbstractMap.SimpleEntry<>(namespaceForKind(kind), key); + unusedOldKeys.remove(combinedKey); + + numItems++; + } + } + + // Now delete any previously existing items whose keys were not in the current data + for (Map.Entry combinedKey: unusedOldKeys) { + if (!combinedKey.getKey().equals(initedKey())) { + requests.add(WriteRequest.builder() + .deleteRequest(builder -> + builder.key(makeKeysMap(combinedKey.getKey(), combinedKey.getValue()))) + .build()); + } + } + + // Now set the special key that we check in initializedInternal() + requests.add(WriteRequest.builder() + .putRequest(builder -> builder.item(makeKeysMap(initedKey(), initedKey()))) + .build()); + + batchWriteRequests(client, tableName, requests); + + logger.info("Initialized table {} with {} items", tableName, numItems); + } + + @Override + public boolean upsert(DataKind kind, String key, SerializedItemDescriptor newItem) { + Map encodedItem = marshalItem(kind, key, newItem); + if (!checkSizeLimit(encodedItem)) { + return false; + } + + if (updateHook != null) { // instrumentation for tests + updateHook.run(); + } + + try { + client.putItem(builder -> builder.tableName(tableName) + .item(encodedItem) + .conditionExpression("attribute_not_exists(#namespace) or attribute_not_exists(#key) or :version > #version") + .expressionAttributeNames(mapOf( + "#namespace", PARTITION_KEY, + "#key", SORT_KEY, + "#version", versionAttribute)) + .expressionAttributeValues(mapOf( + ":version", AttributeValue.builder().n(String.valueOf(newItem.getVersion())).build())) + ); + } catch (ConditionalCheckFailedException e) { + // The item was not updated because there's a newer item in the database. + return false; + } + + return true; + } + + @Override + public boolean isInitialized() { + GetItemResponse resp = getItemByKeys(initedKey(), initedKey()); + return resp.item() != null && resp.item().size() > 0; + } + + //@Override + public boolean isStoreAvailable() { + try { + isInitialized(); // don't care about the return value, just that it doesn't throw an exception + return true; + } catch (Exception e) { // don't care about exception class, since any exception means the DynamoDB request couldn't be made + return false; + } + } + + public void setUpdateHook(Runnable updateHook) { + this.updateHook = updateHook; + } + + private String namespaceForKind(DataKind kind) { + return prefixedNamespace(kind.getName()); + } + + private String initedKey() { + return prefixedNamespace("$inited"); + } + + private QueryRequest.Builder makeQueryForKind(DataKind kind) { + Map keyConditions = mapOf( + PARTITION_KEY, + Condition.builder() + .comparisonOperator(ComparisonOperator.EQ) + .attributeValueList(AttributeValue.builder().s(namespaceForKind(kind)).build()) + .build() + ); + return QueryRequest.builder() + .tableName(tableName) + .consistentRead(true) + .keyConditions(keyConditions); + } + + private Set> readExistingKeys(FullDataSet kindsFromThisDataSet) { + Set> keys = new HashSet<>(); + for (Map.Entry e: kindsFromThisDataSet.getData()) { + DataKind kind = e.getKey(); + QueryRequest req = makeQueryForKind(kind) + .projectionExpression("#namespace, #key") + .expressionAttributeNames(mapOf( + "#namespace", PARTITION_KEY, "#key", SORT_KEY)) + .build(); + QueryIterable queryResults = client.queryPaginator(req); + for (QueryResponse resp: queryResults) { + for (Map item: resp.items()) { + String namespace = item.get(PARTITION_KEY).s(); + String key = item.get(SORT_KEY).s(); + keys.add(new AbstractMap.SimpleEntry<>(namespace, key)); + } + } + } + return keys; + } + + private Map marshalItem(DataKind kind, String key, SerializedItemDescriptor item) { + String json = item.isDeleted() ? deletedItemPlaceholder : item.getSerializedItem(); + return mapOf( + PARTITION_KEY, AttributeValue.builder().s(namespaceForKind(kind)).build(), + SORT_KEY, AttributeValue.builder().s(key).build(), + versionAttribute, AttributeValue.builder().n(String.valueOf(item.getVersion())).build(), + itemJsonAttribute, AttributeValue.builder().s(json).build() + ); + } + + private SerializedItemDescriptor unmarshalItem(DataKind kind, Map item) { + if (item == null || item.size() == 0) { + return null; + } + AttributeValue jsonAttr = item.get(itemJsonAttribute); + if (jsonAttr == null || jsonAttr.s() == null) { + throw new IllegalStateException("DynamoDB map did not contain expected item string"); + } + String jsonValue = jsonAttr.s(); + AttributeValue versionAttr = item.get(versionAttribute); + if (versionAttr == null || versionAttr.n() == null) { + throw new IllegalStateException("DynamoDB map did not contain expected version attribute"); + } + int version; + try { + version = Integer.parseInt(versionAttr.n()); + } catch (NumberFormatException e) { + throw new IllegalStateException("DynamoDB version attribute had a non-numeric value"); + } + if (jsonValue.equals(deletedItemPlaceholder)) { + return new SerializedItemDescriptor(version, true, null); + } + return new SerializedItemDescriptor(version, false, jsonValue); + } + + static void batchWriteRequests(DynamoDbClient client, String tableName, List requests) { + int batchSize = 25; + for (int i = 0; i < requests.size(); i += batchSize) { + int limit = (i + batchSize < requests.size()) ? (i + batchSize) : requests.size(); + List batch = requests.subList(i, limit); + Map> batchMap = mapOf(tableName, batch); + client.batchWriteItem(builder -> builder.requestItems(batchMap)); + } + } + + private boolean checkSizeLimit(Map item) { + // see: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/CapacityUnitCalculations.html + int size = 100; // fixed overhead for index data + for (Map.Entry kv: item.entrySet()) { + size += utf8Length(kv.getKey()); + if (kv.getValue().s() != null) { + size += utf8Length(kv.getValue().s()); + } else if (kv.getValue().n() != null) { + size += utf8Length(kv.getValue().n()); + } + } + if (size <= DYNAMO_DB_MAX_ITEM_SIZE) { + return true; + } + logger.error("The item \"{}\" in \"{}\" was too large to store in DynamoDB and was dropped", + item.get(SORT_KEY).s(), item.get(PARTITION_KEY).s()); + return false; + } + + private static int utf8Length(String s) { + // Unfortunately Java (at least Java 8) doesn't have a built-in way to determine the UTF8 encoding + // length without actually creating a new byte array, which we would rather not do. Guava does + // support this, but we don't want a dependency on Guava (except in test code). + if (s == null) { + return 0; + } + int count = 0; + for (int i = 0, len = s.length(); i < len; i++) { + char ch = s.charAt(i); + if (ch <= 0x7F) { + count++; + } else if (ch <= 0x7FF) { + count += 2; + } else if (Character.isHighSurrogate(ch)) { + count += 4; + ++i; + } else { + count += 3; + } + } + return count; + } +} diff --git a/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbStoreBuilder.java b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbStoreBuilder.java new file mode 100644 index 00000000..1c68fe3c --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbStoreBuilder.java @@ -0,0 +1,188 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.server.Components; +import com.launchdarkly.sdk.server.subsystems.BigSegmentStore; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.DiagnosticDescription; +import com.launchdarkly.sdk.server.subsystems.PersistentDataStore; + +import java.net.URI; + +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClientBuilder; + +/** + * A
builder for configuring the + * DynamoDB-based persistent data store and/or Big Segment store. + *

+ * Both {@link DynamoDb#dataStore(String)} and {@link DynamoDb#bigSegmentStore(String)} return instances of + * this class. You can use methods of the builder to specify any non-default DynamoDB options + * you may want, before passing the builder to either {@link Components#persistentDataStore(ComponentConfigurer)} + * or {@link Components#bigSegments(ComponentConfigurer)} as appropriate. The two types of + * stores are independent of each other; you do not need a Big Segment store if you are not + * using the Big Segments feature, and you do not need to use the same database for both. + * + * In this example, the main data store uses a DynamoDB table called "table1", and the Big + * Segment store uses a DynamoDB table called "table2": + *


+ *     LDConfig config = new LDConfig.Builder()
+ *         .dataStore(
+ *             Components.persistentDataStore(
+ *                 DynamoDb.dataStore("table1")
+ *             )
+ *         )
+ *         .bigSegments(
+ *             Components.bigSegments(
+ *                 DynamoDb.dataStore("table2")
+ *             )
+ *         )
+ *         .build();
+ * 
+ *

+ * Note that the SDK also has its own options related to data storage that are configured + * at a different level, because they are independent of what database is being used. For + * instance, the builder returned by {@link Components#persistentDataStore(ComponentConfigurer)} + * has options for caching: + *


+ *     LDConfig config = new LDConfig.Builder()
+ *         .dataStore(
+ *             Components.persistentDataStore(
+ *                 DynamoDb.dataStore("table1")
+ *             ).cacheSeconds(15)
+ *         )
+ *         .build();
+ * 
+ * + * @param the component type that this builder is being used for + *

+ * The AWS SDK provides many configuration options for a DynamoDB client. This class has + * corresponding methods for some of the most commonly used ones. If you need more sophisticated + * control over the DynamoDB client, you can construct one of your own and pass it in with the + * {@link #existingClient(DynamoDbClient)} method. + * + * @since 2.1.0 + */ +public abstract class DynamoDbStoreBuilder implements ComponentConfigurer, DiagnosticDescription { + final String tableName; + + String prefix; + DynamoDbClient existingClient; + DynamoDbClientBuilder clientBuilder; + + private DynamoDbStoreBuilder(String tableName) { + this.tableName = tableName; + clientBuilder = DynamoDbClient.builder(); + } + + /** + * Sets the main AWS client configuration options for the DynamoDB client. + * + * @param config an AWS client configuration object + * @return the builder + */ + public DynamoDbStoreBuilder clientOverrideConfiguration(ClientOverrideConfiguration config) { + clientBuilder.overrideConfiguration(config); + return this; + } + + /** + * Sets the AWS client credentials. If you do not set them programmatically, the AWS SDK will + * attempt to find them in environment variables and/or local configuration files. + * + * @param credentialsProvider a source of credentials + * @return the builder + */ + public DynamoDbStoreBuilder credentials(AwsCredentialsProvider credentialsProvider) { + clientBuilder.credentialsProvider(credentialsProvider); + return this; + } + + /** + * Sets the service endpoint to use. Normally, you will not use this, as AWS determines the + * service endpoint based on your region. However, you can set it explicitly if you are + * running your own DynamoDB instance. + * + * @param endpointUri the custom endpoint URI + * @return the builder + */ + public DynamoDbStoreBuilder endpoint(URI endpointUri) { + clientBuilder.endpointOverride(endpointUri); + return this; + } + + /** + * Sets the AWS region to use. If you do not set this, AWS will attempt to determine it from + * environment variables and/or local configuration files. + * + * @param region the AWS region + * @return the builder + */ + public DynamoDbStoreBuilder region(Region region) { + clientBuilder.region(region); + return this; + } + + /** + * Sets an optional namespace prefix for all keys stored in DynamoDB. Use this if you are sharing + * the same database table between multiple clients that are for different LaunchDarkly + * environments, to avoid key collisions. + * + * @param prefix the namespace prefix + * @return the builder + */ + public DynamoDbStoreBuilder prefix(String prefix) { + this.prefix = prefix; + return this; + } + + /** + * Specifies an existing, already-configured DynamoDB client instance that the data store + * should use rather than creating one of its own. If you specify an existing client, then the + * other builder methods for configuring DynamoDB are ignored. + * + * @param existingClient an existing DynamoDB client instance + * @return the builder + */ + public DynamoDbStoreBuilder existingClient(DynamoDbClient existingClient) { + this.existingClient = existingClient; + return this; + } + + @Override + public LDValue describeConfiguration(ClientContext context) { + return LDValue.of("DynamoDB"); + } + + DynamoDbClient makeClient() { + return existingClient != null ? existingClient : clientBuilder.build(); + } + + final static class ForDataStore extends DynamoDbStoreBuilder { + ForDataStore(String tableName) { + super(tableName); + } + + @Override + public PersistentDataStore build(ClientContext clientContext) { + return new DynamoDbDataStoreImpl(existingClient != null ? existingClient : clientBuilder.build(), + existingClient != null, tableName, prefix, clientContext.getBaseLogger()); + } + } + + final static class ForBigSegments extends DynamoDbStoreBuilder { + ForBigSegments(String tableName) { + super(tableName); + } + + @Override + public BigSegmentStore build(ClientContext clientContext) { + return new DynamoDbBigSegmentStoreImpl(existingClient != null ? existingClient : clientBuilder.build(), + existingClient != null, tableName, prefix, clientContext.getBaseLogger()); + } + } +} diff --git a/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbStoreImplBase.java b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbStoreImplBase.java new file mode 100644 index 00000000..d05b9230 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/DynamoDbStoreImplBase.java @@ -0,0 +1,60 @@ +package com.launchdarkly.sdk.server.integrations; + +import static com.launchdarkly.sdk.server.integrations.CollectionHelpers.mapOf; + +import com.launchdarkly.logging.LDLogger; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Map; + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; + +abstract class DynamoDbStoreImplBase implements Closeable { + static final String PARTITION_KEY = "namespace"; + static final String SORT_KEY = "key"; + + protected final DynamoDbClient client; + protected final boolean wasExistingClient; + protected final String tableName; + protected final String prefix; + protected final LDLogger logger; + + public DynamoDbStoreImplBase( + DynamoDbClient client, + boolean wasExistingClient, + String tableName, + String prefix, + LDLogger logger + ) { + this.client = client; + this.wasExistingClient = wasExistingClient; + this.tableName = tableName; + this.prefix = "".equals(prefix) ? null : prefix; + this.logger = logger; + } + + protected String prefixedNamespace(String base) { + return prefix == null ? base : (prefix + ":" + base); + } + + protected Map makeKeysMap(String partitionKey, String sortKey) { + return mapOf( + PARTITION_KEY, AttributeValue.builder().s(partitionKey).build(), + SORT_KEY, AttributeValue.builder().s(sortKey).build()); + } + + protected GetItemResponse getItemByKeys(String namespace, String key) { + return client.getItem(builder -> builder.tableName(tableName) + .consistentRead(true) + .key(makeKeysMap(namespace, key)) + ); + } + public void close() throws IOException { + if (!wasExistingClient) { + client.close(); + } + } +} diff --git a/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java new file mode 100644 index 00000000..df096bd8 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/main/java/com/launchdarkly/sdk/server/integrations/package-info.java @@ -0,0 +1,9 @@ +/** + * This package provides a DynamoDB-backed data store for the LaunchDarkly Java SDK. + *

+ * For more details about how and why you can use a persistent data store, see: + * https://docs.launchdarkly.com/sdk/features/storing-data/dynamodb#java + *

+ * For details on usage, see {@link com.launchdarkly.sdk.server.integrations.DynamoDb}. + */ +package com.launchdarkly.sdk.server.integrations; \ No newline at end of file diff --git a/lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/DynamoDbBigSegmentStoreImplTest.java b/lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/DynamoDbBigSegmentStoreImplTest.java new file mode 100644 index 00000000..128c5446 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/DynamoDbBigSegmentStoreImplTest.java @@ -0,0 +1,98 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.subsystems.BigSegmentStore; +import com.launchdarkly.sdk.server.subsystems.BigSegmentStoreTypes; +import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; + +import org.junit.BeforeClass; + +import static com.launchdarkly.sdk.server.integrations.CollectionHelpers.mapOf; +import static com.launchdarkly.sdk.server.integrations.DynamoDbStoreImplBase.PARTITION_KEY; +import static com.launchdarkly.sdk.server.integrations.DynamoDbStoreImplBase.SORT_KEY; +import static com.launchdarkly.sdk.server.integrations.TestUtils.TABLE_NAME; +import static com.launchdarkly.sdk.server.integrations.TestUtils.clearEverything; +import static com.launchdarkly.sdk.server.integrations.TestUtils.createTableIfNecessary; +import static com.launchdarkly.sdk.server.integrations.TestUtils.createTestClient; + +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; + +/** + * Runs the standard database Big Segment store test suite that's defined in the Java SDK. + *

+ * Note that you must be running a local DynamoDB instance on port 8000 to run these tests. + * The simplest way to do this is: + *

+ *     docker run -p 8000:8000 amazon/dynamodb-local
+ * 
+ */ +@SuppressWarnings("javadoc") +public class DynamoDbBigSegmentStoreImplTest extends BigSegmentStoreTestBase { + + @BeforeClass + public static void setUpAll() { + createTableIfNecessary(); + } + + @Override + protected ComponentConfigurer makeStore(String prefix) { + return TestUtils.baseBigSegmentStoreBuilder().prefix(prefix); + } + + @Override + protected void clearData(String prefix) { + clearEverything(prefix); + } + + @Override + protected void setMetadata(String prefix, BigSegmentStoreTypes.StoreMetadata metadata) { + DynamoDbClient client = createTestClient(); + String key = prefix + ":big_segments_metadata"; + String syncTimeString = String.valueOf(metadata.getLastUpToDate()); + client.putItem(PutItemRequest.builder() + .tableName(TABLE_NAME) + .item( + mapOf( + PARTITION_KEY, AttributeValue.builder().s(key).build(), + SORT_KEY, AttributeValue.builder().s(key).build(), + "synchronizedOn", AttributeValue.builder().n(syncTimeString).build() + ) + ) + .build()); + } + + @Override + protected void setSegments(String prefix, + String userHashKey, + Iterable includedSegmentRefs, + Iterable excludedSegmentRefs) { + DynamoDbClient client = createTestClient(); + if (includedSegmentRefs != null) { + for (String includedRef: includedSegmentRefs) { + addToSet(client, prefix, userHashKey, "included", includedRef); + } + } + if (excludedSegmentRefs != null) { + for (String excludedRef: excludedSegmentRefs) { + addToSet(client, prefix, userHashKey, "excluded", excludedRef); + } + } + } + + private void addToSet(DynamoDbClient client, String prefix, String userHash, String attrName, String value) { + String namespaceKey = prefix + ":big_segments_user"; + client.updateItem(UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key( + mapOf( + PARTITION_KEY, AttributeValue.builder().s(namespaceKey).build(), + SORT_KEY, AttributeValue.builder().s(userHash).build() + ) + ) + .updateExpression(String.format("ADD %s :value", attrName)) + .expressionAttributeValues(mapOf(":value", AttributeValue.builder().ss(value).build())) + .build()); + } +} diff --git a/lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/DynamoDbDataStoreImplTest.java b/lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/DynamoDbDataStoreImplTest.java new file mode 100644 index 00000000..365de38a --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/DynamoDbDataStoreImplTest.java @@ -0,0 +1,204 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.launchdarkly.sdk.server.DataModel; +import com.launchdarkly.sdk.server.subsystems.ClientContext; +import com.launchdarkly.sdk.server.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.DataKind; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.FullDataSet; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.KeyedItems; +import com.launchdarkly.sdk.server.subsystems.DataStoreTypes.SerializedItemDescriptor; +import com.launchdarkly.sdk.server.subsystems.PersistentDataStore; + +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.AbstractMap.SimpleEntry; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static com.launchdarkly.sdk.server.TestComponents.clientContext; +import static com.launchdarkly.sdk.server.integrations.TestUtils.baseDataStoreBuilder; +import static com.launchdarkly.sdk.server.integrations.TestUtils.clearEverything; +import static com.launchdarkly.sdk.server.integrations.TestUtils.createTableIfNecessary; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; + +/** + * Runs the standard database data store test suite that's defined in the Java SDK. + *

+ * Note that you must be running a local DynamoDB instance on port 8000 to run these tests. + * The simplest way to do this is: + *

+ *     docker run -p 8000:8000 amazon/dynamodb-local
+ * 
+ */ +@SuppressWarnings("javadoc") +public class DynamoDbDataStoreImplTest extends PersistentDataStoreTestBase { + private static final String BAD_ITEM_KEY = "baditem"; + + @BeforeClass + public static void setUpAll() { + createTableIfNecessary(); + } + + @Override + protected ComponentConfigurer buildStore(String prefix) { + return baseDataStoreBuilder().prefix(prefix); + } + + @Override + protected void clearAllData() { + clearEverything(null); + } + + @Override + protected boolean setUpdateHook(DynamoDbDataStoreImpl storeUnderTest, final Runnable hook) { + storeUnderTest.setUpdateHook(hook); + return true; + } + + @Test + public void dataStoreSkipsAndLogsTooLargeItemOnInitForFlag() throws Exception { + dataStoreSkipsAndLogsTooLargeItemOnInit(DataModel.FEATURES, 0); + } + + @Test + public void dataStoreSkipsAndLogsTooLargeItemOnInitForSegment() throws Exception { + dataStoreSkipsAndLogsTooLargeItemOnInit(DataModel.SEGMENTS, 1); + } + + @Test + public void dataStoreSkipsAndLogsTooLargeItemOnUpsertForFlag() throws Exception { + dataStoreSkipsAndLogsTooLargeItemOnUpsert(DataModel.FEATURES); + } + + @Test + public void dataStoreSkipsAndLogsTooLargeItemOnUpsertForSegment() throws Exception { + dataStoreSkipsAndLogsTooLargeItemOnUpsert(DataModel.SEGMENTS); + } + + private void dataStoreSkipsAndLogsTooLargeItemOnInit( + DataKind dataKind, + int collIndex + ) throws Exception { + SerializedItemDescriptor badItem = makeTooBigItem(dataKind); + + // Construct a data set that is based on the hard-coded data from makeGoodData(), but with one + // oversized item inserted in either the flags collection or the segments collection. + FullDataSet goodData = makeGoodData(); + List>> dataPlusBadItem = + Lists.newArrayList(goodData.getData()); // converting the data set to a mutable list + List> items = Lists.newArrayList( + dataPlusBadItem.get(collIndex).getValue().getItems()); // this is now either list of flags or list of segments + items.add(0, new SimpleEntry<>(BAD_ITEM_KEY, badItem)); + // putting the bad item first to prove that items after that one are still stored + dataPlusBadItem.set(collIndex, new SimpleEntry<>(dataKind, new KeyedItems<>(items))); + + // Initialize the store with this data set. It should not throw an exception, but instead just + // log an error and store all the *other* items-- so the resulting state should be the same as + // makeGoodData(). + try (PersistentDataStore store = buildStore(null).build(makeClientContext())) { + store.init(new FullDataSet<>(dataPlusBadItem)); + + assertDataSetsEqual(goodData, getAllData(store)); + } + } + + private void dataStoreSkipsAndLogsTooLargeItemOnUpsert( + DataKind dataKind + ) throws Exception { + FullDataSet goodData = makeGoodData(); + + // Initialize the store with valid data. + try (PersistentDataStore store = buildStore(null).build(makeClientContext())) { + store.init(goodData); + + assertDataSetsEqual(goodData, getAllData(store)); + + // Now try to store an oversized item. It should not throw an exception, but should not do + // the update-- so the resulting state should be the same valid data as before. + store.upsert(dataKind, BAD_ITEM_KEY, makeTooBigItem(dataKind)); + + assertDataSetsEqual(goodData, getAllData(store)); + } + } + + private ClientContext makeClientContext() { + return clientContext("", baseConfig().build()); + } + + private static FullDataSet makeGoodData() { + return new FullDataSet(ImmutableList.of( + new SimpleEntry<>(DataModel.FEATURES, + new KeyedItems(ImmutableList.of( + new SimpleEntry<>("flag1", + new SerializedItemDescriptor(1, false, "{\"key\": \"flag1\", \"version\": 1}")), + new SimpleEntry<>("flag2", + new SerializedItemDescriptor(1, false, "{\"key\": \"flag2\", \"version\": 1}")) + ))), + new SimpleEntry<>(DataModel.SEGMENTS, + new KeyedItems(ImmutableList.of( + new SimpleEntry<>("segment1", + new SerializedItemDescriptor(1, false, "{\"key\": \"segment1\", \"version\": 1}")), + new SimpleEntry<>("segment2", + new SerializedItemDescriptor(1, false, "{\"key\": \"segment2\", \"version\": 1}")) + ))) + )); + } + + private static SerializedItemDescriptor makeTooBigItem(DataKind dataKind) { + StringBuilder tooBigKeysListJson = new StringBuilder().append('['); + for (int i = 0; i < 40000; i++) { + if (i != 0 ) { + tooBigKeysListJson.append(','); + } + tooBigKeysListJson.append("\"key").append(i).append('"'); + } + tooBigKeysListJson.append(']'); + assertThat(tooBigKeysListJson.length(), greaterThan(400 * 1024)); + + String serializedItem; + if (dataKind == DataModel.SEGMENTS) { + serializedItem = "{\":key\":\"" + BAD_ITEM_KEY + "\", \"version\": 1, \"included\": " + + tooBigKeysListJson.toString() + "}"; + } else { + serializedItem = "{\":key\":\"" + BAD_ITEM_KEY + "\", \"version\": 1, \"targets\": [{\"variation\": 0, \"values\":" + + tooBigKeysListJson.toString() + "}]}"; + } + return new SerializedItemDescriptor(1, false, serializedItem); + } + + private static FullDataSet getAllData(PersistentDataStore store) { + List>> colls = new ArrayList<>(); + for (DataKind kind: new DataKind[] { DataModel.FEATURES, DataModel.SEGMENTS }) { + KeyedItems items = store.getAll(kind); + colls.add(new SimpleEntry<>(kind, items)); + } + return new FullDataSet<>(colls); + } + + private static void assertDataSetsEqual(FullDataSet expected, + FullDataSet actual) { + if (!actual.equals(expected)) { + throw new AssertionError("expected " + describeDataSet(expected) + " but got " + + describeDataSet(actual)); + } + } + + private static String describeDataSet(FullDataSet data) { + return Joiner.on(", ").join( + Iterables.transform(data.getData(), entry -> { + DataKind kind = entry.getKey(); + return "{" + kind + ": [" + + Joiner.on(", ").join( + Iterables.transform(entry.getValue().getItems(), item -> item.getValue().getSerializedItem()) + ) + + "]}"; + })); + } +} diff --git a/lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/TestUtils.java b/lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/TestUtils.java new file mode 100644 index 00000000..0b12b037 --- /dev/null +++ b/lib/java-server-sdk-dynamodb-store/src/test/java/com/launchdarkly/sdk/server/integrations/TestUtils.java @@ -0,0 +1,120 @@ +package com.launchdarkly.sdk.server.integrations; + +import com.launchdarkly.sdk.server.subsystems.BigSegmentStore; +import com.launchdarkly.sdk.server.subsystems.PersistentDataStore; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static com.launchdarkly.sdk.server.integrations.CollectionHelpers.mapOf; +import static com.launchdarkly.sdk.server.integrations.DynamoDbStoreImplBase.PARTITION_KEY; +import static com.launchdarkly.sdk.server.integrations.DynamoDbStoreImplBase.SORT_KEY; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest; +import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest; +import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; +import software.amazon.awssdk.services.dynamodb.model.KeyType; +import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; +import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException; +import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; +import software.amazon.awssdk.services.dynamodb.model.ScanResponse; +import software.amazon.awssdk.services.dynamodb.model.WriteRequest; +import software.amazon.awssdk.services.dynamodb.paginators.ScanIterable; + +@SuppressWarnings("javadoc") +public class TestUtils { + static final String TABLE_NAME = "LD_DYNAMODB_TEST_TABLE"; + static final URI DYNAMODB_ENDPOINT = URI.create("http://localhost:8000"); + private static DynamoDbClient client; + + static AwsCredentialsProvider getTestCredentials() { + // The values here don't matter, it just expects us to provide something (since there may not be AWS + // environment variables or config files where the tests are running) + return StaticCredentialsProvider.create(AwsBasicCredentials.create("key", "secret")); + } + + static DynamoDbStoreBuilder baseDataStoreBuilder() { + return DynamoDb.dataStore(TABLE_NAME) + .endpoint(DYNAMODB_ENDPOINT) + .region(Region.US_EAST_1) + .credentials(getTestCredentials()); + } + + static DynamoDbStoreBuilder baseBigSegmentStoreBuilder() { + return DynamoDb.bigSegmentStore(TABLE_NAME) + .endpoint(DYNAMODB_ENDPOINT) + .region(Region.US_EAST_1) + .credentials(getTestCredentials()); + } + + static DynamoDbClient createTestClient() { + if (client == null) { + client = DynamoDbClient.builder() + .endpointOverride(DYNAMODB_ENDPOINT) + .region(Region.US_EAST_1) + .credentialsProvider(getTestCredentials()) + .build(); + } + return client; + } + + static void createTableIfNecessary() { + DynamoDbClient client = createTestClient(); + try { + client.describeTable(DescribeTableRequest.builder().tableName(TABLE_NAME).build()); + return; // table exists + } catch (ResourceNotFoundException e) { + // fall through to code below - we'll create the table + } + + CreateTableRequest request = CreateTableRequest.builder() + .tableName(TABLE_NAME) + .keySchema( + KeySchemaElement.builder().attributeName(PARTITION_KEY).keyType(KeyType.HASH).build(), + KeySchemaElement.builder().attributeName(SORT_KEY).keyType(KeyType.RANGE).build() + ).attributeDefinitions( + AttributeDefinition.builder().attributeName(PARTITION_KEY).attributeType(ScalarAttributeType.S).build(), + AttributeDefinition.builder().attributeName(SORT_KEY).attributeType(ScalarAttributeType.S).build() + ).provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(1L).writeCapacityUnits(1L).build()) + .build(); + + client.createTable(request); + } + + static void clearEverything(String prefix) { + String keyPrefix = prefix == null ? "" : (prefix + ":"); + DynamoDbClient client = createTestClient(); + + ScanIterable results = client.scanPaginator(builder -> builder.tableName(TABLE_NAME) + .consistentRead(true) + .projectionExpression("#namespace, #key") + .expressionAttributeNames(mapOf( + "#namespace", PARTITION_KEY, + "#key", SORT_KEY))); + + List> itemsToDelete = new ArrayList<>(); + for (ScanResponse resp: results) { + for (Map item: resp.items()) { + if (item.get(PARTITION_KEY).s().startsWith(keyPrefix)) { + itemsToDelete.add(item); + } + } + } + + List requests = new ArrayList<>(); + for (Map item: itemsToDelete) { + requests.add(WriteRequest.builder().deleteRequest(builder -> builder.key(item)).build()); + } + + DynamoDbDataStoreImpl.batchWriteRequests(client, TABLE_NAME, requests); + } +} diff --git a/release-please-config.json b/release-please-config.json index 24054078..20402e9d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -28,6 +28,14 @@ "gradle.properties" ] }, + "lib/java-server-sdk-dynamodb-store": { + "package-name": "launchdarkly-java-server-sdk-dynamodb-store", + "bump-minor-pre-major": true, + "include-v-in-tag": false, + "extra-files": [ + "gradle.properties" + ] + }, "lib/shared/internal": { "package-name": "launchdarkly-java-sdk-internal", "bump-minor-pre-major": true, From 843ebc7fe9a4e8ad26b228eb40b54974254c2738 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:55:23 +0000 Subject: [PATCH 2/2] ci: Run current DynamoDB Local on Java 17 in the Windows job The Windows job downloaded DynamoDB Local from the legacy S3 URL, which is frozen at the deprecated 1.25.1 release, and ran it with the matrix JDK. Download the current release (3.x, the same version as the Linux Docker image) and run it on its own Java 17, since DynamoDB Local 2.6.0+ requires Java 17. Fail the setup step if DynamoDB Local exits or is not listening on port 8000 within 60 seconds. Generated-by: termag --- .../java-server-sdk-dynamodb-store.yml | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/java-server-sdk-dynamodb-store.yml b/.github/workflows/java-server-sdk-dynamodb-store.yml index 5df8dc59..cf920d97 100644 --- a/.github/workflows/java-server-sdk-dynamodb-store.yml +++ b/.github/workflows/java-server-sdk-dynamodb-store.yml @@ -33,6 +33,14 @@ jobs: steps: - uses: actions/checkout@v3 + # DynamoDB Local 2.6.0+ requires Java 17, so it gets its own JDK independent of the version under test. + - name: Setup Java for DynamoDB Local + id: dynamodb-java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: 17 + - name: Setup Java uses: actions/setup-java@v4 with: @@ -41,13 +49,23 @@ jobs: - name: Setup DynamoDB Service shell: pwsh + env: + DYNAMODB_JAVA_HOME: ${{ steps.dynamodb-java.outputs.path }} run: | $ProgressPreference = "SilentlyContinue" - iwr -outf dynamo.zip https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.zip + iwr -outf dynamo.zip https://d1ni2b6xgvw0s0.cloudfront.net/v2.x/dynamodb_local_latest.zip mkdir dynamo Expand-Archive -Path dynamo.zip -DestinationPath dynamo cd dynamo - Start-Process -FilePath "java" -ArgumentList "-D`"java.library.path=./DynamoDBLocal_lib`"","-jar","DynamoDBLocal.jar" + $java = Join-Path $env:DYNAMODB_JAVA_HOME "bin\java.exe" + $proc = Start-Process -PassThru -FilePath $java -ArgumentList "-D`"java.library.path=./DynamoDBLocal_lib`"","-jar","DynamoDBLocal.jar" + $deadline = (Get-Date).AddSeconds(60) + while ($true) { + try { (New-Object System.Net.Sockets.TcpClient("127.0.0.1", 8000)).Close(); break } catch {} + if ($proc.HasExited) { throw "DynamoDB Local exited before it started listening on port 8000" } + if ((Get-Date) -gt $deadline) { throw "DynamoDB Local did not start listening on port 8000 within 60 seconds" } + Start-Sleep -Seconds 1 + } - name: Restore dependencies shell: bash