From 94939a6ddb11db25881f2aea17797dcae54794ca Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 5 Jun 2023 10:35:58 -0700 Subject: [PATCH 01/41] chore: Scaffold node redis store package. --- ...tores-node-server-sdk-redis--bug_report.md | 37 ++++++++ ...-node-server-sdk-redis--feature_request.md | 20 +++++ .github/workflows/node-redis.yml | 27 ++++++ README.md | 10 +++ package.json | 3 +- .../store/node-server-sdk-redis/CHANGELOG.md | 0 .../store/node-server-sdk-redis/README.md | 87 +++++++++++++++++++ .../node-server-sdk-redis/jest.config.js | 7 ++ .../store/node-server-sdk-redis/package.json | 49 +++++++++++ .../store/node-server-sdk-redis/src/index.ts | 0 .../store/node-server-sdk-redis/tsconfig.json | 21 +++++ tsconfig.json | 3 + 12 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--feature_request.md create mode 100644 .github/workflows/node-redis.yml create mode 100644 packages/store/node-server-sdk-redis/CHANGELOG.md create mode 100644 packages/store/node-server-sdk-redis/README.md create mode 100644 packages/store/node-server-sdk-redis/jest.config.js create mode 100644 packages/store/node-server-sdk-redis/package.json create mode 100644 packages/store/node-server-sdk-redis/src/index.ts create mode 100644 packages/store/node-server-sdk-redis/tsconfig.json diff --git a/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--bug_report.md b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--bug_report.md new file mode 100644 index 0000000000..179eb988fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--bug_report.md @@ -0,0 +1,37 @@ +--- +name: '@launchdarkly/node-server-sdk-redis Bug Report' +about: Create a report to help us improve +title: '' +labels: 'package: store/node-server-sdk-redis, 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. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--feature_request.md b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--feature_request.md new file mode 100644 index 0000000000..7a82edb540 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--feature_request.md @@ -0,0 +1,20 @@ +--- +name: '@launchdarkly/node-server-sdk-redis Feature Request' +about: Suggest an idea for this project +title: '' +labels: 'package: store/node-server-sdk-redis, feature' +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/workflows/node-redis.yml b/.github/workflows/node-redis.yml new file mode 100644 index 0000000000..22295a3153 --- /dev/null +++ b/.github/workflows/node-redis.yml @@ -0,0 +1,27 @@ +name: store/node-server-sdk-redis + +on: + push: + branches: [main] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main] + paths-ignore: + - '**.md' + +jobs: + build-test-common: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + registry-url: 'https://registry.npmjs.org' + - id: shared + name: Shared CI Steps + uses: ./actions/ci + with: + workspace_name: '@launchdarkly/node-server-sdk-redis' + workspace_path: packages/store/node-server-sdk-redis diff --git a/README.md b/README.md index 4e7ca4afd6..8db2f454e9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. | [@launchdarkly/js-server-sdk-common](packages/shared/sdk-server/README.md) | [![NPM][js-server-sdk-common-npm-badge]][js-server-sdk-common-npm-link] | [Common Server][package-shared-sdk-server-issues] | [![Actions Status][shared-sdk-server-ci-badge]][shared-sdk-server-ci] | | [@launchdarkly/js-server-sdk-common-edge](packages/shared/sdk-server-edge/README.md) | [![NPM][js-server-sdk-common-edge-badge]][js-server-sdk-common-edge-link] | [Common Server Edge][package-shared-sdk-server-edge-issues] | [![Actions Status][shared-sdk-server-edge-ci-badge]][shared-sdk-server-edge-ci] | +| Store Packages | npm | issues | tests | +| ------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------- | ------------------------------------------------------- | +| [@launchdarkly/node-server-sdk-redis](packages/store/node-server-sdk-redis/README.md) | [![NPM][node-redis-npm-badge]][node-redis-npm-link] | [Node Redis][node-redis-issues] | [![Actions Status][node-redis-ci-badge]][node-redis-ci] | + ## Organization `packages` Top level directory containing package implementations. @@ -108,3 +112,9 @@ We encourage pull requests and other contributions from the community. Check out [sdk-akamai-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/akamai-server-base-sdk.svg?style=flat-square [sdk-akamai-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/akamai-server-base-sdk.svg?style=flat-square [package-sdk-akamai-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+sdk%2Fakamai-base%22+ +[//]: # 'store/node-server-sdk-redis' +[node-redis-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/node-redis.yml/badge.svg +[node-redis-ci]: https://github.com/launchdarkly/js-core/actions/workflows/node-redis.yml +[node-redis-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-redis.svg?style=flat-square +[node-redis-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-redis +[node-redis-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+store%2Fnode-server-sdk-redis%22+ \ No newline at end of file diff --git a/package.json b/package.json index 81556bdfce..85c3be3f60 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "packages/sdk/akamai-base", "packages/sdk/akamai-base/example", "packages/sdk/akamai-edgekv", - "packages/sdk/akamai-edgekv/example" + "packages/sdk/akamai-edgekv/example", + "packages/store/node-server-sdk-redis" ], "private": true, "scripts": { diff --git a/packages/store/node-server-sdk-redis/CHANGELOG.md b/packages/store/node-server-sdk-redis/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/store/node-server-sdk-redis/README.md b/packages/store/node-server-sdk-redis/README.md new file mode 100644 index 0000000000..8c7d2ea757 --- /dev/null +++ b/packages/store/node-server-sdk-redis/README.md @@ -0,0 +1,87 @@ +# LaunchDarkly Server-Side SDK for Node.js + +[![NPM][node-redis-npm-badge]][node-redis-npm-link] +[![Actions Status][node-redis-ci-badge]][node-redis-ci] +[![Documentation](https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8)](https://launchdarkly.github.io/js-core/packages/store/node-server-sdk-redis/docs/) + +This library provides a Redis-backed persistence mechanism (feature store) for the [LaunchDarkly Node.js SDK](https://github.com/launchdarkly/js-core/packages/sdk/server-node), replacing the default in-memory feature store. The underlying Redis client implementation is [ioredis](https://github.com/luin/ioredis). + +The minimum version of the LaunchDarkly Server-Side SDK for Node for use with this library is 8.0.0. + +This SDK is a beta version and should not be considered ready for production use while this message is visible. + +## LaunchDarkly overview + +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +## Supported Node versions + +This package is compatible with Node.js versions 14 and above. + +## Getting started + +Refer to [Using Redis as a persistent feature store](https://docs.launchdarkly.com/sdk/features/storing-data/redis#nodejs-server-side). + +## Quick setup + +This assumes that you have already installed the LaunchDarkly Node.js SDK. + +1. Install this package with `npm` or `yarn`: + +```shell +npm install @launchdarkly/node-server-sdk-redis --save +``` + +2. If your application does not already have its own dependency on the `ioredis` package, add `ioredis` as well: + +```shell +npm install ioredis --save +``` + +3. Require the package: + +```typescript +import { RedisFeatureStore } = from '@launchdarkly/node-server-sdk-redis'; +``` + +4. When configuring your SDK client, add the Redis feature store: + +``` +const store = RedisFeatureStore(); +const config = { featureStore: store }; +const client = LaunchDarkly.init('YOUR SDK KEY', config); +``` + + By default, the store will try to connect to a local Redis instance on port 6379. You may specify an alternate configuration as described in the API documentation for `RedisFeatureStore`. + +## Caching behavior + +To reduce traffic to Redis, there is an optional in-memory cache that retains the last known data for a configurable amount of time. This is on by default; to turn it off (and guarantee that the latest feature flag data will always be retrieved from Redis for every flag evaluation), configure the store as follows: + + const store = RedisFeatureStore({ cacheTTL: 0 }); + +## Contributing + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +## 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. Check out [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 + +[node-redis-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/node-redis.yml/badge.svg +[node-redis-ci]: https://github.com/launchdarkly/js-core/actions/workflows/node-redis.yml + +[node-redis-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-redis.svg?style=flat-square +[node-redis-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-redis diff --git a/packages/store/node-server-sdk-redis/jest.config.js b/packages/store/node-server-sdk-redis/jest.config.js new file mode 100644 index 0000000000..f106eb3bc9 --- /dev/null +++ b/packages/store/node-server-sdk-redis/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + transform: { '^.+\\.ts?$': 'ts-jest' }, + testMatch: ['**/__tests__/**/*test.ts?(x)'], + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: ['src/**/*.ts'], +}; diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json new file mode 100644 index 0000000000..09c034bb1a --- /dev/null +++ b/packages/store/node-server-sdk-redis/package.json @@ -0,0 +1,49 @@ +{ + "name": "@launchdarkly/node-server-sdk-redis", + "version": "0.0.0", + "description": "Redis-backed feature store for the LaunchDarkly Server-Side SDK for Node.js", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/store/node-server-sdk-redis", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "type": "commonjs", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "files": [ + "dist" + ], + "keywords": [ + "launchdarkly", + "analytics", + "client" + ], + "license": "Apache-2.0", + "scripts": { + "clean": "npx tsc --build --clean", + "test": "npx jest --ci --runInBand", + "build": "npx tsc", + "lint": "npx eslint . --ext .ts", + "lint:fix": "yarn run lint -- --fix" + }, + "dependencies": { + "@launchdarkly/node-server-sdk": "0.4.2" + }, + "devDependencies": { + "@types/jest": "^29.4.0", + "@typescript-eslint/eslint-plugin": "^5.22.0", + "@typescript-eslint/parser": "^5.22.0", + "eslint": "^8.14.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-prettier": "^8.7.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^29.5.0", + "launchdarkly-js-test-helpers": "^2.2.0", + "prettier": "^2.8.4", + "ts-jest": "^29.0.5", + "typedoc": "0.23.26", + "typescript": "^4.6.3" + } +} diff --git a/packages/store/node-server-sdk-redis/src/index.ts b/packages/store/node-server-sdk-redis/src/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/store/node-server-sdk-redis/tsconfig.json b/packages/store/node-server-sdk-redis/tsconfig.json new file mode 100644 index 0000000000..1b9ac79b8c --- /dev/null +++ b/packages/store/node-server-sdk-redis/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + // Uses "." so it can load package.json. + "rootDir": ".", + "outDir": "dist", + "target": "es6", + "lib": ["es6"], + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + // Needed for CommonJS modules: markdown-it, fs-extra + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "resolveJsonModule": true, + "stripInternal": true, + "moduleResolution": "node" + }, + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] +} diff --git a/tsconfig.json b/tsconfig.json index 5fe56a01aa..1c1ece914b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,9 @@ }, { "path": "./packages/sdk/akamai-base/tsconfig.ref.json" + }, + { + "path": "./packages/stores/node-server-sdk-redis/tsconfig.ref.json" } ] } From f9c2bcd181fa2e0855f52e11e1aa080cc3347196 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 5 Jun 2023 10:38:06 -0700 Subject: [PATCH 02/41] Add note on store organization. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 8db2f454e9..42aeb6adb9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. `packages/sdk` SDK packages intended for use by application developers. +`packages/store` Persistent store packages for use with SDKs in this repository. + ## LaunchDarkly overview [LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! From a6a391811396170e01c66135ee916505dadb1004 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 5 Jun 2023 10:45:11 -0700 Subject: [PATCH 03/41] Better text. --- packages/store/node-server-sdk-redis/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/store/node-server-sdk-redis/README.md b/packages/store/node-server-sdk-redis/README.md index 8c7d2ea757..5a9697ea86 100644 --- a/packages/store/node-server-sdk-redis/README.md +++ b/packages/store/node-server-sdk-redis/README.md @@ -40,7 +40,7 @@ npm install @launchdarkly/node-server-sdk-redis --save npm install ioredis --save ``` -3. Require the package: +3. Import the package: ```typescript import { RedisFeatureStore } = from '@launchdarkly/node-server-sdk-redis'; @@ -48,13 +48,13 @@ import { RedisFeatureStore } = from '@launchdarkly/node-server-sdk-redis'; 4. When configuring your SDK client, add the Redis feature store: -``` +```typescript const store = RedisFeatureStore(); const config = { featureStore: store }; const client = LaunchDarkly.init('YOUR SDK KEY', config); ``` - By default, the store will try to connect to a local Redis instance on port 6379. You may specify an alternate configuration as described in the API documentation for `RedisFeatureStore`. +By default, the store will try to connect to a local Redis instance on port 6379. You may specify an alternate configuration as described in the API documentation for `RedisFeatureStore`. ## Caching behavior From e57b38e89ad7d6fa684da7f27892e1fb46b99a0f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 5 Jun 2023 10:45:54 -0700 Subject: [PATCH 04/41] new --- packages/store/node-server-sdk-redis/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/store/node-server-sdk-redis/README.md b/packages/store/node-server-sdk-redis/README.md index 5a9697ea86..0e21f64796 100644 --- a/packages/store/node-server-sdk-redis/README.md +++ b/packages/store/node-server-sdk-redis/README.md @@ -49,7 +49,7 @@ import { RedisFeatureStore } = from '@launchdarkly/node-server-sdk-redis'; 4. When configuring your SDK client, add the Redis feature store: ```typescript -const store = RedisFeatureStore(); +const store = new RedisFeatureStore(); const config = { featureStore: store }; const client = LaunchDarkly.init('YOUR SDK KEY', config); ``` From c3263d8262aa36c095370ab58f7627624660114f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 5 Jun 2023 10:47:02 -0700 Subject: [PATCH 05/41] Update disable cache example. --- packages/store/node-server-sdk-redis/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/store/node-server-sdk-redis/README.md b/packages/store/node-server-sdk-redis/README.md index 0e21f64796..9ec198e422 100644 --- a/packages/store/node-server-sdk-redis/README.md +++ b/packages/store/node-server-sdk-redis/README.md @@ -60,7 +60,9 @@ By default, the store will try to connect to a local Redis instance on port 6379 To reduce traffic to Redis, there is an optional in-memory cache that retains the last known data for a configurable amount of time. This is on by default; to turn it off (and guarantee that the latest feature flag data will always be retrieved from Redis for every flag evaluation), configure the store as follows: - const store = RedisFeatureStore({ cacheTTL: 0 }); +```typescriot +const store = new RedisFeatureStore({ cacheTTL: 0 }); +``` ## Contributing From 73ac3bd7eef127caed7c430df8b6e692ec7cdc55 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 5 Jun 2023 10:48:54 -0700 Subject: [PATCH 06/41] Add LICENSE. --- packages/store/node-server-sdk-redis/LICENSE | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/store/node-server-sdk-redis/LICENSE diff --git a/packages/store/node-server-sdk-redis/LICENSE b/packages/store/node-server-sdk-redis/LICENSE new file mode 100644 index 0000000000..f33486934f --- /dev/null +++ b/packages/store/node-server-sdk-redis/LICENSE @@ -0,0 +1,13 @@ +Copyright 2023 Catamorphic, Co. + +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 + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file From e721f0cd0924f69e9584f880af047bbd0bfd39b9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 5 Jun 2023 11:23:33 -0700 Subject: [PATCH 07/41] chore: Scaffold node redis store package. (#136) --- ...tores-node-server-sdk-redis--bug_report.md | 37 ++++++++ ...-node-server-sdk-redis--feature_request.md | 20 +++++ .github/workflows/node-redis.yml | 27 ++++++ README.md | 12 +++ package.json | 3 +- .../store/node-server-sdk-redis/CHANGELOG.md | 0 packages/store/node-server-sdk-redis/LICENSE | 13 +++ .../store/node-server-sdk-redis/README.md | 89 +++++++++++++++++++ .../node-server-sdk-redis/jest.config.js | 7 ++ .../store/node-server-sdk-redis/package.json | 49 ++++++++++ .../store/node-server-sdk-redis/src/index.ts | 0 .../store/node-server-sdk-redis/tsconfig.json | 21 +++++ tsconfig.json | 3 + 13 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--feature_request.md create mode 100644 .github/workflows/node-redis.yml create mode 100644 packages/store/node-server-sdk-redis/CHANGELOG.md create mode 100644 packages/store/node-server-sdk-redis/LICENSE create mode 100644 packages/store/node-server-sdk-redis/README.md create mode 100644 packages/store/node-server-sdk-redis/jest.config.js create mode 100644 packages/store/node-server-sdk-redis/package.json create mode 100644 packages/store/node-server-sdk-redis/src/index.ts create mode 100644 packages/store/node-server-sdk-redis/tsconfig.json diff --git a/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--bug_report.md b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--bug_report.md new file mode 100644 index 0000000000..179eb988fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--bug_report.md @@ -0,0 +1,37 @@ +--- +name: '@launchdarkly/node-server-sdk-redis Bug Report' +about: Create a report to help us improve +title: '' +labels: 'package: store/node-server-sdk-redis, 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. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--feature_request.md b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--feature_request.md new file mode 100644 index 0000000000..7a82edb540 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-redis--feature_request.md @@ -0,0 +1,20 @@ +--- +name: '@launchdarkly/node-server-sdk-redis Feature Request' +about: Suggest an idea for this project +title: '' +labels: 'package: store/node-server-sdk-redis, feature' +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/workflows/node-redis.yml b/.github/workflows/node-redis.yml new file mode 100644 index 0000000000..22295a3153 --- /dev/null +++ b/.github/workflows/node-redis.yml @@ -0,0 +1,27 @@ +name: store/node-server-sdk-redis + +on: + push: + branches: [main] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main] + paths-ignore: + - '**.md' + +jobs: + build-test-common: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + registry-url: 'https://registry.npmjs.org' + - id: shared + name: Shared CI Steps + uses: ./actions/ci + with: + workspace_name: '@launchdarkly/node-server-sdk-redis' + workspace_path: packages/store/node-server-sdk-redis diff --git a/README.md b/README.md index 4e7ca4afd6..42aeb6adb9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. | [@launchdarkly/js-server-sdk-common](packages/shared/sdk-server/README.md) | [![NPM][js-server-sdk-common-npm-badge]][js-server-sdk-common-npm-link] | [Common Server][package-shared-sdk-server-issues] | [![Actions Status][shared-sdk-server-ci-badge]][shared-sdk-server-ci] | | [@launchdarkly/js-server-sdk-common-edge](packages/shared/sdk-server-edge/README.md) | [![NPM][js-server-sdk-common-edge-badge]][js-server-sdk-common-edge-link] | [Common Server Edge][package-shared-sdk-server-edge-issues] | [![Actions Status][shared-sdk-server-edge-ci-badge]][shared-sdk-server-edge-ci] | +| Store Packages | npm | issues | tests | +| ------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------- | ------------------------------------------------------- | +| [@launchdarkly/node-server-sdk-redis](packages/store/node-server-sdk-redis/README.md) | [![NPM][node-redis-npm-badge]][node-redis-npm-link] | [Node Redis][node-redis-issues] | [![Actions Status][node-redis-ci-badge]][node-redis-ci] | + ## Organization `packages` Top level directory containing package implementations. @@ -26,6 +30,8 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. `packages/sdk` SDK packages intended for use by application developers. +`packages/store` Persistent store packages for use with SDKs in this repository. + ## LaunchDarkly overview [LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! @@ -108,3 +114,9 @@ We encourage pull requests and other contributions from the community. Check out [sdk-akamai-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/akamai-server-base-sdk.svg?style=flat-square [sdk-akamai-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/akamai-server-base-sdk.svg?style=flat-square [package-sdk-akamai-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+sdk%2Fakamai-base%22+ +[//]: # 'store/node-server-sdk-redis' +[node-redis-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/node-redis.yml/badge.svg +[node-redis-ci]: https://github.com/launchdarkly/js-core/actions/workflows/node-redis.yml +[node-redis-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-redis.svg?style=flat-square +[node-redis-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-redis +[node-redis-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+store%2Fnode-server-sdk-redis%22+ \ No newline at end of file diff --git a/package.json b/package.json index 81556bdfce..85c3be3f60 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "packages/sdk/akamai-base", "packages/sdk/akamai-base/example", "packages/sdk/akamai-edgekv", - "packages/sdk/akamai-edgekv/example" + "packages/sdk/akamai-edgekv/example", + "packages/store/node-server-sdk-redis" ], "private": true, "scripts": { diff --git a/packages/store/node-server-sdk-redis/CHANGELOG.md b/packages/store/node-server-sdk-redis/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/store/node-server-sdk-redis/LICENSE b/packages/store/node-server-sdk-redis/LICENSE new file mode 100644 index 0000000000..f33486934f --- /dev/null +++ b/packages/store/node-server-sdk-redis/LICENSE @@ -0,0 +1,13 @@ +Copyright 2023 Catamorphic, Co. + +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 + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/packages/store/node-server-sdk-redis/README.md b/packages/store/node-server-sdk-redis/README.md new file mode 100644 index 0000000000..9ec198e422 --- /dev/null +++ b/packages/store/node-server-sdk-redis/README.md @@ -0,0 +1,89 @@ +# LaunchDarkly Server-Side SDK for Node.js + +[![NPM][node-redis-npm-badge]][node-redis-npm-link] +[![Actions Status][node-redis-ci-badge]][node-redis-ci] +[![Documentation](https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8)](https://launchdarkly.github.io/js-core/packages/store/node-server-sdk-redis/docs/) + +This library provides a Redis-backed persistence mechanism (feature store) for the [LaunchDarkly Node.js SDK](https://github.com/launchdarkly/js-core/packages/sdk/server-node), replacing the default in-memory feature store. The underlying Redis client implementation is [ioredis](https://github.com/luin/ioredis). + +The minimum version of the LaunchDarkly Server-Side SDK for Node for use with this library is 8.0.0. + +This SDK is a beta version and should not be considered ready for production use while this message is visible. + +## LaunchDarkly overview + +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +## Supported Node versions + +This package is compatible with Node.js versions 14 and above. + +## Getting started + +Refer to [Using Redis as a persistent feature store](https://docs.launchdarkly.com/sdk/features/storing-data/redis#nodejs-server-side). + +## Quick setup + +This assumes that you have already installed the LaunchDarkly Node.js SDK. + +1. Install this package with `npm` or `yarn`: + +```shell +npm install @launchdarkly/node-server-sdk-redis --save +``` + +2. If your application does not already have its own dependency on the `ioredis` package, add `ioredis` as well: + +```shell +npm install ioredis --save +``` + +3. Import the package: + +```typescript +import { RedisFeatureStore } = from '@launchdarkly/node-server-sdk-redis'; +``` + +4. When configuring your SDK client, add the Redis feature store: + +```typescript +const store = new RedisFeatureStore(); +const config = { featureStore: store }; +const client = LaunchDarkly.init('YOUR SDK KEY', config); +``` + +By default, the store will try to connect to a local Redis instance on port 6379. You may specify an alternate configuration as described in the API documentation for `RedisFeatureStore`. + +## Caching behavior + +To reduce traffic to Redis, there is an optional in-memory cache that retains the last known data for a configurable amount of time. This is on by default; to turn it off (and guarantee that the latest feature flag data will always be retrieved from Redis for every flag evaluation), configure the store as follows: + +```typescriot +const store = new RedisFeatureStore({ cacheTTL: 0 }); +``` + +## Contributing + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +## 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. Check out [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 + +[node-redis-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/node-redis.yml/badge.svg +[node-redis-ci]: https://github.com/launchdarkly/js-core/actions/workflows/node-redis.yml + +[node-redis-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-redis.svg?style=flat-square +[node-redis-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-redis diff --git a/packages/store/node-server-sdk-redis/jest.config.js b/packages/store/node-server-sdk-redis/jest.config.js new file mode 100644 index 0000000000..f106eb3bc9 --- /dev/null +++ b/packages/store/node-server-sdk-redis/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + transform: { '^.+\\.ts?$': 'ts-jest' }, + testMatch: ['**/__tests__/**/*test.ts?(x)'], + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: ['src/**/*.ts'], +}; diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json new file mode 100644 index 0000000000..09c034bb1a --- /dev/null +++ b/packages/store/node-server-sdk-redis/package.json @@ -0,0 +1,49 @@ +{ + "name": "@launchdarkly/node-server-sdk-redis", + "version": "0.0.0", + "description": "Redis-backed feature store for the LaunchDarkly Server-Side SDK for Node.js", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/store/node-server-sdk-redis", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "type": "commonjs", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "files": [ + "dist" + ], + "keywords": [ + "launchdarkly", + "analytics", + "client" + ], + "license": "Apache-2.0", + "scripts": { + "clean": "npx tsc --build --clean", + "test": "npx jest --ci --runInBand", + "build": "npx tsc", + "lint": "npx eslint . --ext .ts", + "lint:fix": "yarn run lint -- --fix" + }, + "dependencies": { + "@launchdarkly/node-server-sdk": "0.4.2" + }, + "devDependencies": { + "@types/jest": "^29.4.0", + "@typescript-eslint/eslint-plugin": "^5.22.0", + "@typescript-eslint/parser": "^5.22.0", + "eslint": "^8.14.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-prettier": "^8.7.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^29.5.0", + "launchdarkly-js-test-helpers": "^2.2.0", + "prettier": "^2.8.4", + "ts-jest": "^29.0.5", + "typedoc": "0.23.26", + "typescript": "^4.6.3" + } +} diff --git a/packages/store/node-server-sdk-redis/src/index.ts b/packages/store/node-server-sdk-redis/src/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/store/node-server-sdk-redis/tsconfig.json b/packages/store/node-server-sdk-redis/tsconfig.json new file mode 100644 index 0000000000..1b9ac79b8c --- /dev/null +++ b/packages/store/node-server-sdk-redis/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + // Uses "." so it can load package.json. + "rootDir": ".", + "outDir": "dist", + "target": "es6", + "lib": ["es6"], + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + // Needed for CommonJS modules: markdown-it, fs-extra + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "resolveJsonModule": true, + "stripInternal": true, + "moduleResolution": "node" + }, + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] +} diff --git a/tsconfig.json b/tsconfig.json index 5fe56a01aa..1c1ece914b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,9 @@ }, { "path": "./packages/sdk/akamai-base/tsconfig.ref.json" + }, + { + "path": "./packages/stores/node-server-sdk-redis/tsconfig.ref.json" } ] } From df189817c6e36c78efd613cebd64fe7484cb53db Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 5 Jun 2023 13:18:24 -0700 Subject: [PATCH 08/41] feat: Implement redis persistent store. --- .../api/interfaces/persistent_store/index.ts | 13 ++- packages/shared/sdk-server/src/index.ts | 2 + .../sdk-server/src/options/Configuration.ts | 4 - .../src/store/PersistentDataStoreWrapper.ts | 2 - .../store/node-server-sdk-redis/README.md | 10 +-- .../store/node-server-sdk-redis/package.json | 3 + .../src/LDRedisOptions.ts | 39 +++++++++ .../node-server-sdk-redis/src/RedisCore.ts | 54 ++++++++++++ .../src/RedisFeatureStore.ts | 86 +++++++++++++++++++ .../src/RedisFeatureStoreFactory.ts | 9 ++ tsconfig.json | 2 +- 11 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 packages/store/node-server-sdk-redis/src/LDRedisOptions.ts create mode 100644 packages/store/node-server-sdk-redis/src/RedisCore.ts create mode 100644 packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts create mode 100644 packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts diff --git a/packages/shared/sdk-server/src/api/interfaces/persistent_store/index.ts b/packages/shared/sdk-server/src/api/interfaces/persistent_store/index.ts index 50e13ce091..f86340793f 100644 --- a/packages/shared/sdk-server/src/api/interfaces/persistent_store/index.ts +++ b/packages/shared/sdk-server/src/api/interfaces/persistent_store/index.ts @@ -1,6 +1,15 @@ import ItemDescriptor from './ItemDescriptor'; -import PersistentDataStore from './PersistentDataStore'; +import KeyedItem from './KeyedItem'; +import PersistentDataStore, { KindKeyedStore } from './PersistentDataStore'; + import PersistentStoreDataKind from './PersistentStoreDataKind'; import SerializedItemDescriptor from './SerializedItemDescriptor'; -export { ItemDescriptor, PersistentDataStore, PersistentStoreDataKind, SerializedItemDescriptor }; +export { + ItemDescriptor, + PersistentDataStore, + PersistentStoreDataKind, + SerializedItemDescriptor, + KeyedItem, + KindKeyedStore, +}; diff --git a/packages/shared/sdk-server/src/index.ts b/packages/shared/sdk-server/src/index.ts index b538e7a8cb..d0592fd0eb 100644 --- a/packages/shared/sdk-server/src/index.ts +++ b/packages/shared/sdk-server/src/index.ts @@ -8,4 +8,6 @@ export * from './store'; export * from './events'; export * from '@launchdarkly/js-sdk-common'; +export { default as PersistentDataStoreWrapper } from './store/PersistentDataStoreWrapper'; + export { LDClientImpl, BigSegmentStoreStatusProviderImpl }; diff --git a/packages/shared/sdk-server/src/options/Configuration.ts b/packages/shared/sdk-server/src/options/Configuration.ts index 43b139e9fa..a60f8989cc 100644 --- a/packages/shared/sdk-server/src/options/Configuration.ts +++ b/packages/shared/sdk-server/src/options/Configuration.ts @@ -199,12 +199,8 @@ export default class Configuration { public readonly diagnosticRecordingInterval: number; - // public readonly featureStore: LDFeatureStore; - public readonly featureStoreFactory: (clientContext: LDClientContext) => LDFeatureStore; - // public readonly updateProcessor?: LDStreamProcessor; - public readonly updateProcessorFactory?: ( clientContext: LDClientContext, dataSourceUpdates: LDDataSourceUpdates diff --git a/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts b/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts index b4add9174b..1bac5f0f75 100644 --- a/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts +++ b/packages/shared/sdk-server/src/store/PersistentDataStoreWrapper.ts @@ -86,8 +86,6 @@ function deserialize( * instance of {@link PersistentDataStore}. It provides optional caching behavior and other logic * that would otherwise be repeated in every data store implementation. This makes it easier to * create new database integrations by implementing only the database-specific logic. - * - * @internal */ export default class PersistentDataStoreWrapper implements LDFeatureStore { private isInitialized = false; diff --git a/packages/store/node-server-sdk-redis/README.md b/packages/store/node-server-sdk-redis/README.md index 9ec198e422..8c5e3c4d81 100644 --- a/packages/store/node-server-sdk-redis/README.md +++ b/packages/store/node-server-sdk-redis/README.md @@ -43,25 +43,25 @@ npm install ioredis --save 3. Import the package: ```typescript -import { RedisFeatureStore } = from '@launchdarkly/node-server-sdk-redis'; +import { RedisFeatureStoreFactory } = from '@launchdarkly/node-server-sdk-redis'; ``` 4. When configuring your SDK client, add the Redis feature store: ```typescript -const store = new RedisFeatureStore(); -const config = { featureStore: store }; +const storeFactory = RedisFeatureStoreFactory(); +const config = { featureStore: storeFactory }; const client = LaunchDarkly.init('YOUR SDK KEY', config); ``` -By default, the store will try to connect to a local Redis instance on port 6379. You may specify an alternate configuration as described in the API documentation for `RedisFeatureStore`. +By default, the store will try to connect to a local Redis instance on port 6379. You may specify an alternate configuration as described in the API documentation for `RedisFeatureStoreFactory`. ## Caching behavior To reduce traffic to Redis, there is an optional in-memory cache that retains the last known data for a configurable amount of time. This is on by default; to turn it off (and guarantee that the latest feature flag data will always be retrieved from Redis for every flag evaluation), configure the store as follows: ```typescriot -const store = new RedisFeatureStore({ cacheTTL: 0 }); +const factory = RedisFeatureStoreFactory({ cacheTTL: 0 }); ``` ## Contributing diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json index 09c034bb1a..81c282a242 100644 --- a/packages/store/node-server-sdk-redis/package.json +++ b/packages/store/node-server-sdk-redis/package.json @@ -27,6 +27,9 @@ "lint:fix": "yarn run lint -- --fix" }, "dependencies": { + "ioredis": "^5.3.2" + }, + "peerDependencies": { "@launchdarkly/node-server-sdk": "0.4.2" }, "devDependencies": { diff --git a/packages/store/node-server-sdk-redis/src/LDRedisOptions.ts b/packages/store/node-server-sdk-redis/src/LDRedisOptions.ts new file mode 100644 index 0000000000..e85fb841cc --- /dev/null +++ b/packages/store/node-server-sdk-redis/src/LDRedisOptions.ts @@ -0,0 +1,39 @@ +import { RedisOptions, Redis } from 'ioredis'; + +/** + * The standard options supported for the LaunchDarkly Redis integration. + */ +export default interface LDRedisOptions { + /** + * Optional configuration parameters to be passed to the `redis` package that handles communication + * with the Redis server. + * + * This includes properties such as `host` and `port`. For more details, see: + * https://github.com/luin/ioredis + * + * If you leave this property empty, the default is to connect to `localhost:6379`. + */ + redisOpts?: RedisOptions; + + /** + * A string that will be prepended to all Redis keys used by the SDK. + */ + prefix?: string; + + /** + * Set this property if you already have a Redis client instance that you wish to reuse. In this + * case, `redisOpts` will be ignored. + */ + client?: Redis; + + /** + * The amount of time, in seconds, that recently read or updated items should remain in an + * in-memory cache. If it is zero, there will be no in-memory caching. The default TTL will be + * 30 seconds if one is not set. + * + * This parameter applies only to RedisFeatureStore. It is ignored for RedisBigSegmentStore. + * Caching for RedisBigSegmentStore is configured separately, in the SDK's + * `LDBigSegmentsOptions` type, since it is independent of what database implementation is used. + */ + cacheTTL?: number; +} diff --git a/packages/store/node-server-sdk-redis/src/RedisCore.ts b/packages/store/node-server-sdk-redis/src/RedisCore.ts new file mode 100644 index 0000000000..79f8262171 --- /dev/null +++ b/packages/store/node-server-sdk-redis/src/RedisCore.ts @@ -0,0 +1,54 @@ +import { interfaces } from '@launchdarkly/node-server-sdk'; +import { Redis } from 'ioredis'; + +export default class RedisCore implements interfaces.PersistentDataStore { + constructor(private readonly client: Redis) {} + + init( + allData: interfaces.KindKeyedStore, + callback: () => void + ): void { + throw new Error('Method not implemented.'); + } + + get( + kind: interfaces.PersistentStoreDataKind, + key: string, + callback: (descriptor: interfaces.SerializedItemDescriptor | undefined) => void + ): void { + throw new Error('Method not implemented.'); + } + + getAll( + kind: interfaces.PersistentStoreDataKind, + callback: ( + descriptors: interfaces.KeyedItem[] | undefined + ) => void + ): void { + throw new Error('Method not implemented.'); + } + + upsert( + kind: interfaces.PersistentStoreDataKind, + key: string, + descriptor: interfaces.SerializedItemDescriptor, + callback: ( + err?: Error | undefined, + updatedDescriptor?: interfaces.SerializedItemDescriptor | undefined + ) => void + ): void { + throw new Error('Method not implemented.'); + } + + initialized(callback: (isInitialized: boolean) => void): void { + throw new Error('Method not implemented.'); + } + + close(): void { + throw new Error('Method not implemented.'); + } + + getDescription(): string { + return 'Redis'; + } +} diff --git a/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts b/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts new file mode 100644 index 0000000000..4ff7be2901 --- /dev/null +++ b/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts @@ -0,0 +1,86 @@ +import { + interfaces, + LDFeatureStore, + LDFeatureStoreDataStorage, + LDFeatureStoreItem, + LDFeatureStoreKindData, + LDKeyedFeatureStoreItem, + LDLogger, + PersistentDataStoreWrapper, +} from '@launchdarkly/node-server-sdk'; +import Redis from 'ioredis'; +import LDRedisOptions from './LDRedisOptions'; +import RedisCore from './RedisCore'; + +/** + * The default TTL cache time in seconds. + */ +const DEFAULT_CACHE_TTL_S = 30; + +function ClientFromOptions(options?: LDRedisOptions): Redis { + // If a pre-configured client is provided, then use it. + if (options?.client) { + return options.client; + } + // If there are options for redis, then make a client using those options. + if (options?.redisOpts) { + return new Redis(options!.redisOpts); + } + // There was no client, and there were no options. + return new Redis(); +} + +function TtlFromOptions(options?: LDRedisOptions): number { + // 0 is a valid option. So we need a null/undefined check. + if (options?.cacheTTL === undefined || options.cacheTTL === null) { + return DEFAULT_CACHE_TTL_S; + } + return options!.cacheTTL; +} + +export default class RedisFeatureStore implements LDFeatureStore { + private wrapper: PersistentDataStoreWrapper; + + constructor(options?: LDRedisOptions, private readonly logger?: LDLogger) { + this.wrapper = new PersistentDataStoreWrapper( + new RedisCore(ClientFromOptions(options)), + TtlFromOptions(options) + ); + } + + get( + kind: interfaces.DataKind, + key: string, + callback: (res: LDFeatureStoreItem | null) => void + ): void { + this.wrapper.get(kind, key, callback); + } + + all(kind: interfaces.DataKind, callback: (res: LDFeatureStoreKindData) => void): void { + this.wrapper.all(kind, callback); + } + + init(allData: LDFeatureStoreDataStorage, callback: () => void): void { + this.wrapper.init(allData, callback); + } + + delete(kind: interfaces.DataKind, key: string, version: number, callback: () => void): void { + this.wrapper.delete(kind, key, version, callback); + } + + upsert(kind: interfaces.DataKind, data: LDKeyedFeatureStoreItem, callback: () => void): void { + this.wrapper.upsert(kind, data, callback); + } + + initialized(callback: (isInitialized: boolean) => void): void { + this.wrapper.initialized(callback); + } + + close(): void { + this.wrapper.close(); + } + + getDescription?(): string { + return this.wrapper.getDescription(); + } +} diff --git a/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts b/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts new file mode 100644 index 0000000000..13186aa345 --- /dev/null +++ b/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts @@ -0,0 +1,9 @@ +import { LDClientContext } from '@launchdarkly/node-server-sdk'; +import RedisFeatureStore from './RedisFeatureStore'; +import LDRedisOptions from './LDRedisOptions'; + +export default function RedisFeatureStoreFactory(options?: LDRedisOptions) { + return (config: LDClientContext) => { + return new RedisFeatureStore(options, config.basicConfiguration.logger); + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1c1ece914b..09b46a8da1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,7 +32,7 @@ "path": "./packages/sdk/akamai-base/tsconfig.ref.json" }, { - "path": "./packages/stores/node-server-sdk-redis/tsconfig.ref.json" + "path": "./packages/store/node-server-sdk-redis/tsconfig.ref.json" } ] } From e444fbeedf3889d204884c7202bb537b40aa871d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 5 Jun 2023 15:37:41 -0700 Subject: [PATCH 09/41] First draft of redis implementation. --- packages/shared/sdk-server/src/index.ts | 2 + .../src/RedisClientState.ts | 95 ++++++++++ .../node-server-sdk-redis/src/RedisCore.ts | 169 +++++++++++++++++- .../src/RedisFeatureStore.ts | 16 +- .../src/RedisFeatureStoreFactory.ts | 20 +++ 5 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 packages/store/node-server-sdk-redis/src/RedisClientState.ts diff --git a/packages/shared/sdk-server/src/index.ts b/packages/shared/sdk-server/src/index.ts index d0592fd0eb..def23e8c55 100644 --- a/packages/shared/sdk-server/src/index.ts +++ b/packages/shared/sdk-server/src/index.ts @@ -8,6 +8,8 @@ export * from './store'; export * from './events'; export * from '@launchdarkly/js-sdk-common'; +// TODO: This should maybe be nested in another namespace to reduce visibility +// and more clearly express the intent of use by our own libraries. export { default as PersistentDataStoreWrapper } from './store/PersistentDataStoreWrapper'; export { LDClientImpl, BigSegmentStoreStatusProviderImpl }; diff --git a/packages/store/node-server-sdk-redis/src/RedisClientState.ts b/packages/store/node-server-sdk-redis/src/RedisClientState.ts new file mode 100644 index 0000000000..bc5c33a931 --- /dev/null +++ b/packages/store/node-server-sdk-redis/src/RedisClientState.ts @@ -0,0 +1,95 @@ +import { LDLogger } from '@launchdarkly/node-server-sdk'; +import { Redis } from 'ioredis'; + +/** + * Class for managing the state of a redis connection. + * + * Used for the redis persistent store as well as the redis big segment store. + */ +export default class RedisClientState { + private connected: boolean = false; + + private attempt: number = 0; + + private initialConnection: boolean = true; + + /** + * Construct a state with the given client. + * + * @param client The client for which state is being tracked. + * @param owned Is this client owned by the store integration, or was it + * provided externally. + */ + constructor( + private readonly client: Redis, + private readonly owned: boolean, + private readonly logger?: LDLogger + ) { + // If the client is not owned, then it should already be connected. + this.connected = !owned; + // We don't want to log a message on the first connection, only when reconnecting. + this.initialConnection = !this.connected; + + client.on('error', (err) => { + logger?.error(`Redis error - ${err}`); + }); + + client.on('reconnecting', (delay: number) => { + this.attempt += 1; + logger?.info( + `Attempting to reconnect to redis (attempt # ${this.attempt}, delay: ${delay}ms)` + ); + }); + + client.on('connect', () => { + this.attempt = 0; + + if (!this.initialConnection) { + this?.logger?.warn('Reconnecting to Redis'); + } + + this.initialConnection = false; + this.connected = true; + }); + + client.on('end', () => { + this.connected = false; + }); + } + + /** + * Get the connection state. + * + * @returns True if currently connected. + */ + public isConnected(): boolean { + return this.connected; + } + + /** + * Get is the client is using its initial connection. + * + * @returns True if using the initial connection. + */ + public isInitialConnection(): boolean { + return this.initialConnection; + } + + /** + * Get the redis client. + * + * @returns The redis client. + */ + public getClient(): Redis { + return this.client; + } + + /** + * If the client is owned, then this will 'quit' the client. + */ + public close() { + if (this.owned) { + this.client.quit(); + } + } +} diff --git a/packages/store/node-server-sdk-redis/src/RedisCore.ts b/packages/store/node-server-sdk-redis/src/RedisCore.ts index 79f8262171..37c0318b93 100644 --- a/packages/store/node-server-sdk-redis/src/RedisCore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisCore.ts @@ -1,14 +1,75 @@ -import { interfaces } from '@launchdarkly/node-server-sdk'; -import { Redis } from 'ioredis'; +import { LDLogger, interfaces } from '@launchdarkly/node-server-sdk'; +import RedisClientState from './RedisClientState'; +/** + * Internal implementation of the Redis data store. + * + * Feature flags, segments, and any other kind of entity the LaunchDarkly client may wish + * to store, are stored as hash values with the main key "{prefix}:features", "{prefix}:segments", + * etc. + * + * Redis only allows a single string value per hash key, so there is no way to store the + * item metadata (version number and deletion status) separately from the value. The SDK understands + * that some data store implementations don't have that capability, so it will always pass us a + * serialized item string that contains the metadata in it, and we're allowed to return 0 as the + * version number of a queried item to indicate "you have to deserialize the item to find out the + * metadata". + * + * When doing an upsert operation we will always deserialize the item to get the version so the + * version in the updated descriptor will be correct. + * + * The special key "{prefix}:$inited" indicates that the store contains a complete data set. + */ export default class RedisCore implements interfaces.PersistentDataStore { - constructor(private readonly client: Redis) {} + private initedKey: string; + + constructor( + private readonly state: RedisClientState, + private readonly prefix: string, + private readonly logger?: LDLogger + ) { + this.initedKey = this.prefixedKey('$inited'); + } + + private prefixedKey(namespace: string): string { + return `${this.prefix}:${namespace}`; + } init( allData: interfaces.KindKeyedStore, callback: () => void ): void { - throw new Error('Method not implemented.'); + const multi = this.state.getClient().multi(); + allData.forEach((keyedItems) => { + const kind = keyedItems.key; + const items = keyedItems.item; + + const namespaceKey = this.prefixedKey(kind.namespace); + + // Delete the namespace for the kind. + multi.del(namespaceKey); + + const namespaceContent: { [key: string]: string } = {}; + items.forEach((keyedItem) => { + // For each item which exists. + if (keyedItem.item.serializedItem !== undefined) { + namespaceContent[keyedItem.key] = keyedItem.item.serializedItem; + } + }); + // Only set if there is content to set. + if (Object.keys(namespaceContent).length > 0) { + multi.hmset(namespaceKey, namespaceContent); + } + }); + + multi.set(this.initedKey, ''); + + multi.exec((err) => { + if (err) { + this.logger?.error(`Error initializing Redis store ${err}`); + } + callback(); + }); } get( @@ -16,7 +77,28 @@ export default class RedisCore implements interfaces.PersistentDataStore { key: string, callback: (descriptor: interfaces.SerializedItemDescriptor | undefined) => void ): void { - throw new Error('Method not implemented.'); + if (!this.state.isConnected() && !this.state.isInitialConnection()) { + this.logger?.warn(`Attempted to fetch key '${key}' while Redis connection is down`); + callback(undefined); + return; + } + + this.state.getClient().hget(this.prefixedKey(kind.namespace), key, (err, val) => { + if (err) { + this.logger?.error(`Error fetching key '${key}' from Redis in '${kind.namespace}' ${err}`); + callback(undefined); + } else if (val) { + // When getting we do not populate version and deleted. + // The SDK will have to deserialize to access these values. + callback({ + version: 0, + deleted: false, + serializedItem: val, + }); + } else { + callback(undefined); + } + }); } getAll( @@ -25,7 +107,28 @@ export default class RedisCore implements interfaces.PersistentDataStore { descriptors: interfaces.KeyedItem[] | undefined ) => void ): void { - throw new Error('Method not implemented.'); + if (!this.state.isConnected() && !this.state.isInitialConnection()) { + this.logger?.warn('Attempted to fetch all keys while Redis connection is down'); + callback(undefined); + return; + } + + this.state.getClient().hgetall(this.prefixedKey(kind.namespace), (err, values) => { + if (err) { + this.logger?.error(`Error fetching '${kind.namespace}' from Redis ${err}`); + } else if (values) { + const results: interfaces.KeyedItem[] = []; + Object.keys(values).forEach((key) => { + const value = values[key]; + // When getting we do not populate version and deleted. + // The SDK will have to deserialize to access these values. + results.push({ key, item: { version: 0, deleted: false, serializedItem: value } }); + }); + callback(results); + } else { + callback(undefined); + } + }); } upsert( @@ -37,15 +140,63 @@ export default class RedisCore implements interfaces.PersistentDataStore { updatedDescriptor?: interfaces.SerializedItemDescriptor | undefined ) => void ): void { - throw new Error('Method not implemented.'); + // The persistent store wrapper manages interactions with a queue, so we can use watch like + // this without concerns for overlapping transactions. + this.state.getClient().watch(this.prefixedKey(kind.namespace)); + const multi = this.state.getClient().multi(); + this.get(kind, key, (old) => { + if (old && old.serializedItem) { + // Here, unfortunately, we have to deserialize the old item just to find + // out its version number. See notes on this class. + // Do not look at the meta-data, as we do not read/write it independently + // with a redis store. + const deserializedOld = kind.deserialize(old.serializedItem); + if ((deserializedOld?.version || 0) >= descriptor.version) { + multi.discard(); + + callback(undefined, { + version: deserializedOld!.version, + deleted: !!deserializedOld?.item, // If there is no item, then it is deleted. + serializedItem: old.serializedItem, + }); + } else if (descriptor.deleted) { + multi.hset( + this.prefixedKey(kind.namespace), + key, + JSON.stringify({ version: descriptor.version, deleted: true }) + ); + } else if (descriptor.serializedItem) { + multi.hset(this.prefixedKey(kind.namespace), key, descriptor.serializedItem); + } else { + // This call violates the contract. + multi.discard(); + this.logger?.error('Attempt to write a non-deleted item without data to Redis.'); + callback(undefined, undefined); + return; + } + multi.exec((err, replies) => { + if (!err && (replies === null || replies === undefined)) { + // This means the EXEC failed because someone modified the watched key + this.logger?.debug('Concurrent modification detected, retrying'); + this.upsert(kind, key, descriptor, callback); + } else { + callback(err || undefined, descriptor); + } + }); + } + }); } initialized(callback: (isInitialized: boolean) => void): void { - throw new Error('Method not implemented.'); + this.state.getClient().exists(this.initedKey, (err, count) => { + // Initialized if there is not an error and the key does exists. + // (A count >= 1) + callback(!!(!err && count)); + }); } close(): void { - throw new Error('Method not implemented.'); + this.state.close(); } getDescription(): string { diff --git a/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts b/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts index 4ff7be2901..ba3ff9a0a8 100644 --- a/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts @@ -11,23 +11,26 @@ import { import Redis from 'ioredis'; import LDRedisOptions from './LDRedisOptions'; import RedisCore from './RedisCore'; +import RedisClientState from './RedisClientState'; /** * The default TTL cache time in seconds. */ const DEFAULT_CACHE_TTL_S = 30; -function ClientFromOptions(options?: LDRedisOptions): Redis { +const DEFAULT_PREFIX = 'launchdarkly'; + +function ClientFromOptions(options?: LDRedisOptions): RedisClientState { // If a pre-configured client is provided, then use it. if (options?.client) { - return options.client; + return new RedisClientState(options!.client, false); } // If there are options for redis, then make a client using those options. if (options?.redisOpts) { - return new Redis(options!.redisOpts); + return new RedisClientState(new Redis(options!.redisOpts), true); } // There was no client, and there were no options. - return new Redis(); + return new RedisClientState(new Redis(), true); } function TtlFromOptions(options?: LDRedisOptions): number { @@ -38,12 +41,15 @@ function TtlFromOptions(options?: LDRedisOptions): number { return options!.cacheTTL; } +/** + * Integration between the LaunchDarkly SDK and Redis. + */ export default class RedisFeatureStore implements LDFeatureStore { private wrapper: PersistentDataStoreWrapper; constructor(options?: LDRedisOptions, private readonly logger?: LDLogger) { this.wrapper = new PersistentDataStoreWrapper( - new RedisCore(ClientFromOptions(options)), + new RedisCore(ClientFromOptions(options), options?.prefix || DEFAULT_PREFIX, logger), TtlFromOptions(options) ); } diff --git a/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts b/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts index 13186aa345..1ff4f0a700 100644 --- a/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts +++ b/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts @@ -2,6 +2,26 @@ import { LDClientContext } from '@launchdarkly/node-server-sdk'; import RedisFeatureStore from './RedisFeatureStore'; import LDRedisOptions from './LDRedisOptions'; + /** + * Configures a feature store backed by a Redis instance. + * + * For more details about how and why you can use a persistent feature store, see + * the [Using Redis as a persistent feature store](https://docs.launchdarkly.com/sdk/features/storing-data/redis#nodejs-server-side). + * + * ``` + * const redisStoreFactory = RedisFeatureStoreFactory( + * { + * redisOpts: { host: 'redishost', port: 6379 }, + * prefix: 'app1', + * cacheTTL: 30 + * }); + * ``` + * + * @param options Optional configuration, please refer to {@link LDRedisOptions}. + * + * @returns + * A factory function suitable for use in the SDK configuration (LDOptions). + */ export default function RedisFeatureStoreFactory(options?: LDRedisOptions) { return (config: LDClientContext) => { return new RedisFeatureStore(options, config.basicConfiguration.logger); From 01bdc2c95b8b1f3e5f5d1eea85fb3aefd3359d20 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 6 Jun 2023 09:14:29 -0700 Subject: [PATCH 10/41] Start adding tests. --- .../__tests__/RedisFeatureStore.test.ts | 62 +++++++++++++++++++ .../src/RedisFeatureStoreFactory.ts | 47 +++++++------- .../store/node-server-sdk-redis/src/index.ts | 2 + 3 files changed, 87 insertions(+), 24 deletions(-) create mode 100644 packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts new file mode 100644 index 0000000000..db87c8f0e4 --- /dev/null +++ b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts @@ -0,0 +1,62 @@ +import RedisFeatureStore from '../src/RedisFeatureStore'; + +// +import { AsyncStoreFacade } from '@launchdarkly/node-server-sdk'; + +describe('given a feature store', () => { + let store: RedisFeatureStore; + let facade: AsyncStoreFacade; + + + beforeEach(() => { + store = new RedisFeatureStore(undefined, undefined); + facade = new AsyncStoreFacade(store); + }); + + afterEach(() => { + store.close(); + }); + + it('is initialized after calling init()', async () => { + await facade.init({}); + const initialized = await facade.initialized(); + expect(initialized).toBeTruthy(); + }); + + it('init() completely replaces previous data', async () => { + }); + + it('gets existing feature', async () => { + }); + + it('does not get nonexisting feature', async () => { + }); + + it('gets all features', async () => { + }); + + it('upserts with newer version', async () => { + }); + + it('does not upsert with older version', async () => { + }); + + it('upserts new feature', async () => { + }); + + it('handles upsert race condition within same client correctly', async () => { + }); + + it('deletes with newer version', async () => { + }); + + it('does not delete with older version', async () => { + }); + + it('allows deleting unknown feature', async () => { + }); + + it('does not upsert older version after delete', async () => { + }); + +}); \ No newline at end of file diff --git a/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts b/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts index 1ff4f0a700..212bd53782 100644 --- a/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts +++ b/packages/store/node-server-sdk-redis/src/RedisFeatureStoreFactory.ts @@ -2,28 +2,27 @@ import { LDClientContext } from '@launchdarkly/node-server-sdk'; import RedisFeatureStore from './RedisFeatureStore'; import LDRedisOptions from './LDRedisOptions'; - /** - * Configures a feature store backed by a Redis instance. - * - * For more details about how and why you can use a persistent feature store, see - * the [Using Redis as a persistent feature store](https://docs.launchdarkly.com/sdk/features/storing-data/redis#nodejs-server-side). - * - * ``` - * const redisStoreFactory = RedisFeatureStoreFactory( - * { - * redisOpts: { host: 'redishost', port: 6379 }, - * prefix: 'app1', - * cacheTTL: 30 - * }); - * ``` - * - * @param options Optional configuration, please refer to {@link LDRedisOptions}. - * - * @returns - * A factory function suitable for use in the SDK configuration (LDOptions). - */ +/** + * Configures a feature store backed by a Redis instance. + * + * For more details about how and why you can use a persistent feature store, see + * the [Using Redis as a persistent feature store](https://docs.launchdarkly.com/sdk/features/storing-data/redis#nodejs-server-side). + * + * ``` + * const redisStoreFactory = RedisFeatureStoreFactory( + * { + * redisOpts: { host: 'redishost', port: 6379 }, + * prefix: 'app1', + * cacheTTL: 30 + * }); + * ``` + * + * @param options Optional configuration, please refer to {@link LDRedisOptions}. + * + * @returns + * A factory function suitable for use in the SDK configuration (LDOptions). + */ export default function RedisFeatureStoreFactory(options?: LDRedisOptions) { - return (config: LDClientContext) => { - return new RedisFeatureStore(options, config.basicConfiguration.logger); - } -} \ No newline at end of file + return (config: LDClientContext) => + new RedisFeatureStore(options, config.basicConfiguration.logger); +} diff --git a/packages/store/node-server-sdk-redis/src/index.ts b/packages/store/node-server-sdk-redis/src/index.ts index e69de29bb2..71e7eb3a09 100644 --- a/packages/store/node-server-sdk-redis/src/index.ts +++ b/packages/store/node-server-sdk-redis/src/index.ts @@ -0,0 +1,2 @@ +export { default as RedisFeatureStoreFactory } from './RedisFeatureStoreFactory'; +export { default as LDRedisOptions } from './LDRedisOptions'; From 317f3e82896b5da76667785a521648f610948ad1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 6 Jun 2023 13:22:11 -0700 Subject: [PATCH 11/41] Work on tests --- .../__tests__/RedisFeatureStore.test.ts | 135 +++++++++++++++--- .../node-server-sdk-redis/src/RedisCore.ts | 48 ++++--- 2 files changed, 143 insertions(+), 40 deletions(-) diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts index db87c8f0e4..5e18211909 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts @@ -1,14 +1,26 @@ +import { Redis } from 'ioredis'; +import { AsyncStoreFacade } from '@launchdarkly/node-server-sdk'; import RedisFeatureStore from '../src/RedisFeatureStore'; -// -import { AsyncStoreFacade } from '@launchdarkly/node-server-sdk'; +async function clearPrefix(prefix: string) { + const client = new Redis(); + const keys = await client.keys(`${prefix}:*`); + const promises = keys.map((key) => client.del(key)); + await Promise.all(promises); + client.quit(); +} + +const dataKind = { + features: { namespace: 'features' }, + segments: { namespace: 'segments' }, +}; -describe('given a feature store', () => { +describe('given an empty store', () => { let store: RedisFeatureStore; let facade: AsyncStoreFacade; - - beforeEach(() => { + beforeEach(async () => { + await clearPrefix('launchdarkly'); store = new RedisFeatureStore(undefined, undefined); facade = new AsyncStoreFacade(store); }); @@ -23,40 +35,129 @@ describe('given a feature store', () => { expect(initialized).toBeTruthy(); }); - it('init() completely replaces previous data', async () => { + it('completely replaces previous data when calling init()', async () => { + const flags = { + first: { key: 'first', version: 1 }, + second: { key: 'second', version: 1 }, + }; + const segments = { first: { key: 'first', version: 2 } }; + const initData1 = { + features: flags, + segments, + }; + + await facade.init(initData1); + const items1 = await facade.all(dataKind.features); + expect(items1).toEqual(flags); + const items2 = await facade.all(dataKind.segments); + expect(items2).toEqual(segments); + + const newFlags = { first: { key: 'first', version: 3 } }; + const newSegments = { first: { key: 'first', version: 4 } }; + const initData2 = { + features: newFlags, + segments: newSegments, + }; + + await facade.init(initData2); + const items3 = await facade.all(dataKind.features); + expect(items3).toEqual(newFlags); + const items4 = await facade.all(dataKind.segments); + expect(items4).toEqual(newSegments); + }); +}); + +describe('given a store with basic data', () => { + let store: RedisFeatureStore; + let facade: AsyncStoreFacade; + + const feature1 = { key: 'foo', version: 10 }; + const feature2 = { key: 'bar', version: 10 }; + + beforeEach(async () => { + await clearPrefix('launchdarkly'); + store = new RedisFeatureStore(undefined, undefined); + facade = new AsyncStoreFacade(store); + await facade.init({ + features: { + foo: feature1, + bar: feature2, + }, + segments: {}, + }); + }); + + afterEach(() => { + store.close(); }); - it('gets existing feature', async () => { + it('gets a feature that exists', async () => { + const result = await facade.get(dataKind.features, feature1.key); + expect(result).toEqual(feature1); }); it('does not get nonexisting feature', async () => { + const result = await facade.get(dataKind.features, 'biz'); + expect(result).toBeNull(); }); it('gets all features', async () => { + const result = await facade.all(dataKind.features); + expect(result).toEqual({ + foo: feature1, + bar: feature2, + }); }); it('upserts with newer version', async () => { + const newVer = { key: feature1.key, version: feature1.version + 1 }; + + await facade.upsert(dataKind.features, newVer); + const result = await facade.get(dataKind.features, feature1.key); + expect(result).toEqual(newVer); }); it('does not upsert with older version', async () => { + const oldVer = { key: feature1.key, version: feature1.version - 1 }; + await facade.upsert(dataKind.features, oldVer); + const result = await facade.get(dataKind.features, feature1.key); + expect(result).toEqual(feature1); }); it('upserts new feature', async () => { + const newFeature = { key: 'biz', version: 99 }; + await facade.upsert(dataKind.features, newFeature); + const result = await facade.get(dataKind.features, newFeature.key); + expect(result).toEqual(newFeature); }); it('handles upsert race condition within same client correctly', async () => { + const ver1 = { key: feature1.key, version: feature1.version + 1 }; + const ver2 = { key: feature1.key, version: feature1.version + 2 }; + const promises: Promise[] = []; + // Deliberately do not wait for the first upsert to complete before starting the second, + // so their transactions will be interleaved unless we're correctly serializing updates + promises.push(facade.upsert(dataKind.features, ver2)); + promises.push(facade.upsert(dataKind.features, ver1)); + + // await facade.upsert(dataKind.features, ver2); + // await facade.upsert(dataKind.features, ver1); + + // Now wait until both have completed + try { + await Promise.all(promises); + } catch (e) { + console.log(e); + } + const result = await facade.get(dataKind.features, feature1.key); + expect(result).toEqual(ver2); }); - it('deletes with newer version', async () => { - }); + it('deletes with newer version', async () => {}); - it('does not delete with older version', async () => { - }); + it('does not delete with older version', async () => {}); - it('allows deleting unknown feature', async () => { - }); - - it('does not upsert older version after delete', async () => { - }); + it('allows deleting unknown feature', async () => {}); -}); \ No newline at end of file + it('does not upsert older version after delete', async () => {}); +}); diff --git a/packages/store/node-server-sdk-redis/src/RedisCore.ts b/packages/store/node-server-sdk-redis/src/RedisCore.ts index 37c0318b93..8d95629e7e 100644 --- a/packages/store/node-server-sdk-redis/src/RedisCore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisCore.ts @@ -156,34 +156,36 @@ export default class RedisCore implements interfaces.PersistentDataStore { callback(undefined, { version: deserializedOld!.version, - deleted: !!deserializedOld?.item, // If there is no item, then it is deleted. + deleted: !deserializedOld?.item, // If there is no item, then it is deleted. serializedItem: old.serializedItem, }); - } else if (descriptor.deleted) { - multi.hset( - this.prefixedKey(kind.namespace), - key, - JSON.stringify({ version: descriptor.version, deleted: true }) - ); - } else if (descriptor.serializedItem) { - multi.hset(this.prefixedKey(kind.namespace), key, descriptor.serializedItem); - } else { - // This call violates the contract. - multi.discard(); - this.logger?.error('Attempt to write a non-deleted item without data to Redis.'); - callback(undefined, undefined); return; } - multi.exec((err, replies) => { - if (!err && (replies === null || replies === undefined)) { - // This means the EXEC failed because someone modified the watched key - this.logger?.debug('Concurrent modification detected, retrying'); - this.upsert(kind, key, descriptor, callback); - } else { - callback(err || undefined, descriptor); - } - }); } + if (descriptor.deleted) { + multi.hset( + this.prefixedKey(kind.namespace), + key, + JSON.stringify({ version: descriptor.version, deleted: true }) + ); + } else if (descriptor.serializedItem) { + multi.hset(this.prefixedKey(kind.namespace), key, descriptor.serializedItem); + } else { + // This call violates the contract. + multi.discard(); + this.logger?.error('Attempt to write a non-deleted item without data to Redis.'); + callback(undefined, undefined); + return; + } + multi.exec((err, replies) => { + if (!err && (replies === null || replies === undefined)) { + // This means the EXEC failed because someone modified the watched key + this.logger?.debug('Concurrent modification detected, retrying'); + this.upsert(kind, key, descriptor, callback); + } else { + callback(err || undefined, descriptor); + } + }); }); } From b95855058d8b0d393f4be22bf2201cd708c91d5c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 6 Jun 2023 13:37:50 -0700 Subject: [PATCH 12/41] Recursive deps for build order. --- package.json | 4 ++-- packages/store/node-server-sdk-redis/package.json | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 85c3be3f60..c930b9e5ab 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ ], "private": true, "scripts": { - "clean": "yarn workspaces foreach -pt run clean", - "build": "yarn workspaces foreach -pt run build", + "clean": "yarn workspaces foreach -ptR run clean", + "build": "yarn workspaces foreach -ptR run build", "//": "When using build:doc you need to specify the workspace. 'yarn run build:doc packages/shared/common' for example.", "build:doc": "./scripts/build-doc.sh $1", "lint": "npx eslint . --ext .ts", diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json index 81c282a242..337d841b19 100644 --- a/packages/store/node-server-sdk-redis/package.json +++ b/packages/store/node-server-sdk-redis/package.json @@ -33,6 +33,7 @@ "@launchdarkly/node-server-sdk": "0.4.2" }, "devDependencies": { + "@launchdarkly/node-server-sdk": "0.4.2", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", From bf0726985fa7f2ce6063026af20f60a1bc090c8c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 6 Jun 2023 15:05:29 -0700 Subject: [PATCH 13/41] Fix update queue. --- .gitignore | 1 + package.json | 4 ++-- packages/shared/sdk-server/src/store/UpdateQueue.ts | 3 ++- .../__tests__/RedisFeatureStore.test.ts | 9 +-------- packages/store/node-server-sdk-redis/src/RedisCore.ts | 1 + 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index b1b7de61e5..655e7efc8f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ docs/ !.yarn/versions yarn-error.log .DS_Store +.vscode diff --git a/package.json b/package.json index c930b9e5ab..3e2253447b 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ ], "private": true, "scripts": { - "clean": "yarn workspaces foreach -ptR run clean", - "build": "yarn workspaces foreach -ptR run build", + "clean": "yarn workspaces foreach -pt run clean", + "build": "yarn workspaces foreach -p --topological-dev run build", "//": "When using build:doc you need to specify the workspace. 'yarn run build:doc packages/shared/common' for example.", "build:doc": "./scripts/build-doc.sh $1", "lint": "npx eslint . --ext .ts", diff --git a/packages/shared/sdk-server/src/store/UpdateQueue.ts b/packages/shared/sdk-server/src/store/UpdateQueue.ts index 1796610eec..baeda25e4b 100644 --- a/packages/shared/sdk-server/src/store/UpdateQueue.ts +++ b/packages/shared/sdk-server/src/store/UpdateQueue.ts @@ -22,12 +22,13 @@ export default class UpdateQueue { // count could hit 0, and overlapping execution chains could be started. this.queue.shift(); // There is more work to do, so schedule an update. - if (this.enqueue.length > 0) { + if (this.queue.length > 0) { setTimeout(() => this.executePendingUpdates(), 0); } // Call the original callback. cb?.(); }; + fn(newCb); } } diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts index 5e18211909..0ddf9feb7a 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts @@ -140,15 +140,8 @@ describe('given a store with basic data', () => { promises.push(facade.upsert(dataKind.features, ver2)); promises.push(facade.upsert(dataKind.features, ver1)); - // await facade.upsert(dataKind.features, ver2); - // await facade.upsert(dataKind.features, ver1); - // Now wait until both have completed - try { - await Promise.all(promises); - } catch (e) { - console.log(e); - } + await Promise.all(promises); const result = await facade.get(dataKind.features, feature1.key); expect(result).toEqual(ver2); }); diff --git a/packages/store/node-server-sdk-redis/src/RedisCore.ts b/packages/store/node-server-sdk-redis/src/RedisCore.ts index 8d95629e7e..8d8ec31df5 100644 --- a/packages/store/node-server-sdk-redis/src/RedisCore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisCore.ts @@ -144,6 +144,7 @@ export default class RedisCore implements interfaces.PersistentDataStore { // this without concerns for overlapping transactions. this.state.getClient().watch(this.prefixedKey(kind.namespace)); const multi = this.state.getClient().multi(); + this.get(kind, key, (old) => { if (old && old.serializedItem) { // Here, unfortunately, we have to deserialize the old item just to find From 0743f4f7c5af88a1f56bb518026c766523944dcb Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 6 Jun 2023 15:10:35 -0700 Subject: [PATCH 14/41] Add remainder of feature store tests. --- dump.rdb | Bin 0 -> 207 bytes .../__tests__/RedisFeatureStore.test.ts | 25 +++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 dump.rdb diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..b54986d97f9a5a3726dd57550bd289b76df2fc98 GIT binary patch literal 207 zcmWG?b@2=~Ffg$E#aWb^l3A=D8RI_Kl0lpwdb^rhX literal 0 HcmV?d00001 diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts index 0ddf9feb7a..612d1f177d 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts @@ -146,11 +146,28 @@ describe('given a store with basic data', () => { expect(result).toEqual(ver2); }); - it('deletes with newer version', async () => {}); + it('deletes with newer version', async () => { + await facade.delete(dataKind.features, feature1.key, feature1.version + 1); + const result = await facade.get(dataKind.features, feature1.key); + expect(result).toBe(null); + }); - it('does not delete with older version', async () => {}); + it('does not delete with older version', async () => { + await facade.delete(dataKind.features, feature1.key, feature1.version - 1); + const result = await facade.get(dataKind.features, feature1.key); + expect(result).not.toBe(null); + }); - it('allows deleting unknown feature', async () => {}); + it('allows deleting unknown feature', async () => { + await facade.delete(dataKind.features, 'biz', 99); + const result = await facade.get(dataKind.features, 'biz'); + expect(result).toBe(null); + }); - it('does not upsert older version after delete', async () => {}); + it('does not upsert older version after delete', async () => { + await facade.delete(dataKind.features, feature1.key, feature1.version + 1); + await facade.upsert(dataKind.features, feature1); + const result = await facade.get(dataKind.features, feature1.key); + expect(result).toBe(null); + }); }); From a44e72b1602affa91820dc55ffce84245b46df64 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 6 Jun 2023 16:17:26 -0700 Subject: [PATCH 15/41] Add redis core tests. --- dump.rdb | Bin 207 -> 0 bytes .../__tests__/RedisCore.test.ts | 224 ++++++++++++++++++ .../tsconfig.eslint.json | 5 + 3 files changed, 229 insertions(+) delete mode 100644 dump.rdb create mode 100644 packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts create mode 100644 packages/store/node-server-sdk-redis/tsconfig.eslint.json diff --git a/dump.rdb b/dump.rdb deleted file mode 100644 index b54986d97f9a5a3726dd57550bd289b76df2fc98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 207 zcmWG?b@2=~Ffg$E#aWb^l3A=D8RI_Kl0lpwdb^rhX diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts new file mode 100644 index 0000000000..e8ebfdf279 --- /dev/null +++ b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts @@ -0,0 +1,224 @@ +import { Redis } from 'ioredis'; +import { interfaces } from '@launchdarkly/node-server-sdk'; +import RedisCore from '../src/RedisCore'; +import RedisClientState from '../src/RedisClientState'; + + +async function clearPrefix(prefix: string) { + const client = new Redis(); + const keys = await client.keys(`${prefix}:*`); + const promises = keys.map((key) => client.del(key)); + await Promise.all(promises); + client.quit(); +} + +const featuresKind = { namespace: 'features', deserialize: (data: string) => JSON.parse(data) }; +const segmentsKind = { namespace: 'segments', deserialize: (data: string) => JSON.parse(data) }; + +const dataKind = { + features: featuresKind, + segments: segmentsKind, +}; + +function promisify(method: (callback: (val: T) => void) => void): Promise { + return new Promise((resolve) => { + method((val: T) => { + resolve(val); + }); + }); +} + +type UpsertResult = { err: Error | undefined, updatedDescriptor: interfaces.SerializedItemDescriptor | undefined }; + +class AsyncCoreFacade { + constructor(private readonly core: RedisCore) { } + init(allData: interfaces.KindKeyedStore): Promise { + return promisify((cb) => this.core.init(allData, cb)); + } + + get(kind: interfaces.PersistentStoreDataKind, key: string): Promise { + return promisify((cb) => this.core.get(kind, key, cb)); + } + + getAll(kind: interfaces.PersistentStoreDataKind): Promise[] | undefined> { + return promisify((cb) => this.core.getAll(kind, cb)); + } + + upsert(kind: interfaces.PersistentStoreDataKind, key: string, descriptor: interfaces.SerializedItemDescriptor): Promise { + return new Promise((resolve) => { + this.core.upsert(kind, key, descriptor, (err, updatedDescriptor) => { + resolve({ err, updatedDescriptor }); + }); + }); + } + + initialized(): Promise { + return promisify((cb) => this.core.initialized(cb)); + } + + close(): void { + this.core.close(); + } + + getDescription(): string { + return this.core.getDescription(); + } +} + +describe('given an empty store', () => { + let core: RedisCore; + let facade: AsyncCoreFacade; + + beforeEach(async () => { + await clearPrefix('launchdarkly'); + core = new RedisCore(new RedisClientState(new Redis(), true, undefined), "launchdarkly", undefined); + facade = new AsyncCoreFacade(core); + }); + + afterEach(() => { + core.close(); + }); + + it('is initialized after calling init()', async () => { + await facade.init([]); + const initialized = await facade.initialized(); + expect(initialized).toBeTruthy(); + }); + + it('completely replaces previous data when calling init()', async () => { + const flags = [ + { key: 'first', item: { version: 1, serializedItem: `{"version":1}`, deleted: false } }, + { key: 'second', item: { version: 1, serializedItem: `{"version":1}`, deleted: false } } + ] + const segments = [ + { key: 'first', item: { version: 2, serializedItem: `{"version":2}`, deleted: false } } + ]; + + await facade.init([{ key: dataKind.features, item: flags }, + { key: dataKind.segments, item: segments }]); + + const items1 = await facade.getAll(dataKind.features); + const items2 = await facade.getAll(dataKind.segments); + console.log(items1); + // Reading from the store will not maintain the version. + expect(items1).toEqual([ + { + key: 'first', + item: { version: 0, deleted: false, serializedItem: '{"version":1}' } + }, + { + key: 'second', + item: { version: 0, deleted: false, serializedItem: '{"version":1}' } + } + ]); + expect(items2).toEqual([ + { + key: 'first', + item: { version: 0, deleted: false, serializedItem: '{"version":2}' } + }, + ]); + + const newFlags = [ + { key: 'first', item: { version: 2, serializedItem: `{"version":2}`, deleted: false } }, + ] + const newSegments = [ + { key: 'first', item: { version: 3, serializedItem: `{"version":3}`, deleted: false } } + ]; + + await facade.init([{ key: dataKind.features, item: newFlags }, + { key: dataKind.segments, item: newSegments }]); + + const items3 = await facade.getAll(dataKind.features); + const items4 = await facade.getAll(dataKind.segments); + + expect(items3).toEqual([ + { + key: 'first', + item: { version: 0, deleted: false, serializedItem: '{"version":2}' } + }, + ]); + expect(items4).toEqual([ + { + key: 'first', + item: { version: 0, deleted: false, serializedItem: '{"version":3}' } + }, + ]); + }); +}); + +describe('given a store with basic data', () => { + let core: RedisCore; + let facade: AsyncCoreFacade; + + const feature1 = { key: 'foo', version: 10 }; + const feature2 = { key: 'bar', version: 10 }; + + beforeEach(async () => { + await clearPrefix('launchdarkly'); + core = new RedisCore(new RedisClientState(new Redis(), true, undefined), "launchdarkly", undefined); + const flags = [ + { key: 'foo', item: { version: 10, serializedItem: JSON.stringify(feature1), deleted: false } }, + { key: 'bar', item: { version: 10, serializedItem: JSON.stringify(feature2), deleted: false } } + ] + const segments: interfaces.KeyedItem[] = [ + ]; + + facade = new AsyncCoreFacade(core); + + await facade.init([{ key: dataKind.features, item: flags }, + { key: dataKind.segments, item: segments }]); + + }); + + afterEach(() => { + core.close(); + }); + + it('gets a feature that exists', async () => { + const result = await facade.get(dataKind.features, feature1.key); + expect(result).toEqual({ version: 0, deleted: false, serializedItem: JSON.stringify(feature1) }); + }); + + it('does not get nonexisting feature', async () => { + const result = await facade.get(dataKind.features, 'biz'); + expect(result).toBeUndefined(); + }); + + it('gets all features', async () => { + const result = await facade.getAll(dataKind.features); + expect(result).toEqual([ + { key: 'foo', item: { version: 0, serializedItem: JSON.stringify(feature1), deleted: false } }, + { key: 'bar', item: { version: 0, serializedItem: JSON.stringify(feature2), deleted: false } } + ]); + }); + + it('upserts with newer version', async () => { + const newVer = { key: feature1.key, version: feature1.version + 1 }; + const descriptor = { version: newVer.version, deleted: false, serializedItem: JSON.stringify(newVer) }; + + await facade.upsert(dataKind.features, newVer.key, descriptor); + const result = await facade.get(dataKind.features, feature1.key); + // Read version 0 with redis. + expect(result).toEqual({ ...descriptor, version: 0 }); + }); + + it('does not upsert with older version', async () => { + const oldVer = { key: feature1.key, version: feature1.version - 1 }; + const descriptor = { version: oldVer.version, deleted: false, serializedItem: JSON.stringify(oldVer) }; + await facade.upsert(dataKind.features, oldVer.key, descriptor); + const result = await facade.get(dataKind.features, feature1.key); + expect(result).toEqual({ + version: 0, + deleted: false, + serializedItem: `{"key":"foo","version":10}` + }); + }); + + it('upserts new feature', async () => { + const newFeature = { key: 'biz', version: 99 }; + const descriptor = { version: newFeature.version, deleted: false, serializedItem: JSON.stringify(newFeature) }; + await facade.upsert(dataKind.features, newFeature.key, descriptor); + const result = await facade.get(dataKind.features, newFeature.key); + expect(result).toEqual({ ...descriptor, version: 0 }); + }); +}); diff --git a/packages/store/node-server-sdk-redis/tsconfig.eslint.json b/packages/store/node-server-sdk-redis/tsconfig.eslint.json new file mode 100644 index 0000000000..56c9b38305 --- /dev/null +++ b/packages/store/node-server-sdk-redis/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} From 25eb6f9b3cc757fbac98eaf8c098f96e61d87e6e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 6 Jun 2023 16:17:54 -0700 Subject: [PATCH 16/41] Ignore redis files. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 655e7efc8f..5e1acea586 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ docs/ yarn-error.log .DS_Store .vscode +dump.rdb From 33146ecc6158243cee4be1c3b33f623ee8c43b0d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 6 Jun 2023 16:19:46 -0700 Subject: [PATCH 17/41] Lint --- .../__tests__/RedisCore.test.ts | 128 ++++++++++++------ 1 file changed, 90 insertions(+), 38 deletions(-) diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts index e8ebfdf279..051f9babc0 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts @@ -3,7 +3,6 @@ import { interfaces } from '@launchdarkly/node-server-sdk'; import RedisCore from '../src/RedisCore'; import RedisClientState from '../src/RedisClientState'; - async function clearPrefix(prefix: string) { const client = new Redis(); const keys = await client.keys(`${prefix}:*`); @@ -28,23 +27,36 @@ function promisify(method: (callback: (val: T) => void) => void): Promise }); } -type UpsertResult = { err: Error | undefined, updatedDescriptor: interfaces.SerializedItemDescriptor | undefined }; +type UpsertResult = { + err: Error | undefined; + updatedDescriptor: interfaces.SerializedItemDescriptor | undefined; +}; class AsyncCoreFacade { - constructor(private readonly core: RedisCore) { } + constructor(private readonly core: RedisCore) {} + init(allData: interfaces.KindKeyedStore): Promise { return promisify((cb) => this.core.init(allData, cb)); } - get(kind: interfaces.PersistentStoreDataKind, key: string): Promise { + get( + kind: interfaces.PersistentStoreDataKind, + key: string + ): Promise { return promisify((cb) => this.core.get(kind, key, cb)); } - getAll(kind: interfaces.PersistentStoreDataKind): Promise[] | undefined> { + getAll( + kind: interfaces.PersistentStoreDataKind + ): Promise[] | undefined> { return promisify((cb) => this.core.getAll(kind, cb)); } - upsert(kind: interfaces.PersistentStoreDataKind, key: string, descriptor: interfaces.SerializedItemDescriptor): Promise { + upsert( + kind: interfaces.PersistentStoreDataKind, + key: string, + descriptor: interfaces.SerializedItemDescriptor + ): Promise { return new Promise((resolve) => { this.core.upsert(kind, key, descriptor, (err, updatedDescriptor) => { resolve({ err, updatedDescriptor }); @@ -71,7 +83,11 @@ describe('given an empty store', () => { beforeEach(async () => { await clearPrefix('launchdarkly'); - core = new RedisCore(new RedisClientState(new Redis(), true, undefined), "launchdarkly", undefined); + core = new RedisCore( + new RedisClientState(new Redis(), true, undefined), + 'launchdarkly', + undefined + ); facade = new AsyncCoreFacade(core); }); @@ -88,45 +104,49 @@ describe('given an empty store', () => { it('completely replaces previous data when calling init()', async () => { const flags = [ { key: 'first', item: { version: 1, serializedItem: `{"version":1}`, deleted: false } }, - { key: 'second', item: { version: 1, serializedItem: `{"version":1}`, deleted: false } } - ] + { key: 'second', item: { version: 1, serializedItem: `{"version":1}`, deleted: false } }, + ]; const segments = [ - { key: 'first', item: { version: 2, serializedItem: `{"version":2}`, deleted: false } } + { key: 'first', item: { version: 2, serializedItem: `{"version":2}`, deleted: false } }, ]; - await facade.init([{ key: dataKind.features, item: flags }, - { key: dataKind.segments, item: segments }]); + await facade.init([ + { key: dataKind.features, item: flags }, + { key: dataKind.segments, item: segments }, + ]); const items1 = await facade.getAll(dataKind.features); const items2 = await facade.getAll(dataKind.segments); - console.log(items1); + // Reading from the store will not maintain the version. expect(items1).toEqual([ { key: 'first', - item: { version: 0, deleted: false, serializedItem: '{"version":1}' } + item: { version: 0, deleted: false, serializedItem: '{"version":1}' }, }, { key: 'second', - item: { version: 0, deleted: false, serializedItem: '{"version":1}' } - } + item: { version: 0, deleted: false, serializedItem: '{"version":1}' }, + }, ]); expect(items2).toEqual([ { key: 'first', - item: { version: 0, deleted: false, serializedItem: '{"version":2}' } + item: { version: 0, deleted: false, serializedItem: '{"version":2}' }, }, ]); const newFlags = [ { key: 'first', item: { version: 2, serializedItem: `{"version":2}`, deleted: false } }, - ] + ]; const newSegments = [ - { key: 'first', item: { version: 3, serializedItem: `{"version":3}`, deleted: false } } + { key: 'first', item: { version: 3, serializedItem: `{"version":3}`, deleted: false } }, ]; - await facade.init([{ key: dataKind.features, item: newFlags }, - { key: dataKind.segments, item: newSegments }]); + await facade.init([ + { key: dataKind.features, item: newFlags }, + { key: dataKind.segments, item: newSegments }, + ]); const items3 = await facade.getAll(dataKind.features); const items4 = await facade.getAll(dataKind.segments); @@ -134,13 +154,13 @@ describe('given an empty store', () => { expect(items3).toEqual([ { key: 'first', - item: { version: 0, deleted: false, serializedItem: '{"version":2}' } + item: { version: 0, deleted: false, serializedItem: '{"version":2}' }, }, ]); expect(items4).toEqual([ { key: 'first', - item: { version: 0, deleted: false, serializedItem: '{"version":3}' } + item: { version: 0, deleted: false, serializedItem: '{"version":3}' }, }, ]); }); @@ -155,19 +175,29 @@ describe('given a store with basic data', () => { beforeEach(async () => { await clearPrefix('launchdarkly'); - core = new RedisCore(new RedisClientState(new Redis(), true, undefined), "launchdarkly", undefined); + core = new RedisCore( + new RedisClientState(new Redis(), true, undefined), + 'launchdarkly', + undefined + ); const flags = [ - { key: 'foo', item: { version: 10, serializedItem: JSON.stringify(feature1), deleted: false } }, - { key: 'bar', item: { version: 10, serializedItem: JSON.stringify(feature2), deleted: false } } - ] - const segments: interfaces.KeyedItem[] = [ + { + key: 'foo', + item: { version: 10, serializedItem: JSON.stringify(feature1), deleted: false }, + }, + { + key: 'bar', + item: { version: 10, serializedItem: JSON.stringify(feature2), deleted: false }, + }, ]; + const segments: interfaces.KeyedItem[] = []; facade = new AsyncCoreFacade(core); - await facade.init([{ key: dataKind.features, item: flags }, - { key: dataKind.segments, item: segments }]); - + await facade.init([ + { key: dataKind.features, item: flags }, + { key: dataKind.segments, item: segments }, + ]); }); afterEach(() => { @@ -176,7 +206,11 @@ describe('given a store with basic data', () => { it('gets a feature that exists', async () => { const result = await facade.get(dataKind.features, feature1.key); - expect(result).toEqual({ version: 0, deleted: false, serializedItem: JSON.stringify(feature1) }); + expect(result).toEqual({ + version: 0, + deleted: false, + serializedItem: JSON.stringify(feature1), + }); }); it('does not get nonexisting feature', async () => { @@ -187,14 +221,24 @@ describe('given a store with basic data', () => { it('gets all features', async () => { const result = await facade.getAll(dataKind.features); expect(result).toEqual([ - { key: 'foo', item: { version: 0, serializedItem: JSON.stringify(feature1), deleted: false } }, - { key: 'bar', item: { version: 0, serializedItem: JSON.stringify(feature2), deleted: false } } + { + key: 'foo', + item: { version: 0, serializedItem: JSON.stringify(feature1), deleted: false }, + }, + { + key: 'bar', + item: { version: 0, serializedItem: JSON.stringify(feature2), deleted: false }, + }, ]); }); it('upserts with newer version', async () => { const newVer = { key: feature1.key, version: feature1.version + 1 }; - const descriptor = { version: newVer.version, deleted: false, serializedItem: JSON.stringify(newVer) }; + const descriptor = { + version: newVer.version, + deleted: false, + serializedItem: JSON.stringify(newVer), + }; await facade.upsert(dataKind.features, newVer.key, descriptor); const result = await facade.get(dataKind.features, feature1.key); @@ -204,19 +248,27 @@ describe('given a store with basic data', () => { it('does not upsert with older version', async () => { const oldVer = { key: feature1.key, version: feature1.version - 1 }; - const descriptor = { version: oldVer.version, deleted: false, serializedItem: JSON.stringify(oldVer) }; + const descriptor = { + version: oldVer.version, + deleted: false, + serializedItem: JSON.stringify(oldVer), + }; await facade.upsert(dataKind.features, oldVer.key, descriptor); const result = await facade.get(dataKind.features, feature1.key); expect(result).toEqual({ version: 0, deleted: false, - serializedItem: `{"key":"foo","version":10}` + serializedItem: `{"key":"foo","version":10}`, }); }); it('upserts new feature', async () => { const newFeature = { key: 'biz', version: 99 }; - const descriptor = { version: newFeature.version, deleted: false, serializedItem: JSON.stringify(newFeature) }; + const descriptor = { + version: newFeature.version, + deleted: false, + serializedItem: JSON.stringify(newFeature), + }; await facade.upsert(dataKind.features, newFeature.key, descriptor); const result = await facade.get(dataKind.features, newFeature.key); expect(result).toEqual({ ...descriptor, version: 0 }); From 708ed53deae69b11ce2c1dceaaf4a254252bdccf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 6 Jun 2023 16:43:23 -0700 Subject: [PATCH 18/41] Add architecture diagram. --- .../node-server-sdk-redis/architecture.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 packages/store/node-server-sdk-redis/architecture.md diff --git a/packages/store/node-server-sdk-redis/architecture.md b/packages/store/node-server-sdk-redis/architecture.md new file mode 100644 index 0000000000..ffd2714d37 --- /dev/null +++ b/packages/store/node-server-sdk-redis/architecture.md @@ -0,0 +1,30 @@ +# Architectural Overview + +## Components + +RedisFeatureStoreFactory: Method which generates a factory which will build RedisFeatureStore instances. This is what is used by the end user in their SDK configuration. + +LDRedisOptions: Configuration options passed to the factory to configure a RedisFeatureStore. + +RedisFeatureStore: The feature store implements LDFeatureStore, it contains a PersistentDataStoreWrapper +which it marshalls all operations to. The PeristentDataStoreWrapper uses the PersistentDataStore, implemented by the RedisCore, to read and write from persistence. The PersistentDataStoreWrapper contains common operations, such as caching, which should apply to all persistent store implementations. + +RedisCore: Implements persistent data store operations using Redis. + +RedisClientState: Manages the Redis connection and exposes the Redis client to the RedisCore. + + +## Architecture Diagram +```mermaid +classDiagram +RedisFeatureStore --|> LDFeatureStore : Implements +RedisFeatureStore --* PersistentDataStoreWrapper : Contains +PersistentDataStoreWrapper --o PersistentDataStore : Uses +PersistentDataStoreWrapper --|> LDFeatureStore : Implements +RedisFeatureStore --> RedisCore : Creates +RedisCore --|> PersistentDataStore : Implements +RedisCore --* RedisClientState : Contains + +RedisFeatureStoreFactory --> RedisFeatureStore : Builds +RedisFeatureStoreFactory --> LDRedisOptions : Uses +``` From 69e386878d30bee6c3de8db35dd635c1a138c8c3 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 09:36:33 -0700 Subject: [PATCH 19/41] Add initial big segments store. --- .../__tests__/RedisCore.test.ts | 5 +- .../src/RedisBigSegmentStore.ts | 58 +++++++++++++++++++ .../src/RedisClientState.ts | 51 ++++++++++++---- .../node-server-sdk-redis/src/RedisCore.ts | 26 ++++----- .../src/RedisFeatureStore.ts | 32 +--------- .../src/RegisBigSegmentStoreFactory.ts | 20 +++++++ .../src/TtlFromOptions.ts | 21 +++++++ 7 files changed, 154 insertions(+), 59 deletions(-) create mode 100644 packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts create mode 100644 packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts create mode 100644 packages/store/node-server-sdk-redis/src/TtlFromOptions.ts diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts index 051f9babc0..1cd342da92 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts @@ -84,7 +84,7 @@ describe('given an empty store', () => { beforeEach(async () => { await clearPrefix('launchdarkly'); core = new RedisCore( - new RedisClientState(new Redis(), true, undefined), + new RedisClientState(), 'launchdarkly', undefined ); @@ -176,8 +176,7 @@ describe('given a store with basic data', () => { beforeEach(async () => { await clearPrefix('launchdarkly'); core = new RedisCore( - new RedisClientState(new Redis(), true, undefined), - 'launchdarkly', + new RedisClientState(), undefined ); const flags = [ diff --git a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts new file mode 100644 index 0000000000..9667469b8c --- /dev/null +++ b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts @@ -0,0 +1,58 @@ +import { LDLogger, interfaces } from '@launchdarkly/node-server-sdk'; +import LDRedisOptions from './LDRedisOptions'; +import RedisClientState from './RedisClientState'; + +const KEY_LAST_SYNCHRONIZED = 'big_segments_synchronized_on'; +const KEY_USER_INCLUDE = 'big_segment_include:'; +const KEY_USER_EXCLUDE = 'big_segment_exclude:'; + +export default class RedisBigSegmentStore implements interfaces.BigSegmentStore { + private state: RedisClientState; + // Logger is not currently used, but is included to reduce the chance of a + // compatibility break to add a log. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(options?: LDRedisOptions, private readonly logger?: LDLogger) { + this.state = new RedisClientState(options); + } + + async getMetadata(): Promise { + const value = await this.state.getClient().get(this.state.prefixedKey(KEY_LAST_SYNCHRONIZED)); + // Value will be true if it is a string containing any characters, which is fine + // for this check. + if (value) { + return { lastUpToDate: parseInt(value) } + } else { + return {}; + } + } + + async getUserMembership(userHash: string): Promise { + const includedRefs = await this.state.getClient().get( + this.state.prefixedKey(`${KEY_USER_INCLUDE}:${userHash}`)); + const excludedRefs = await this.state.getClient().get( + this.state.prefixedKey(`${KEY_USER_EXCLUDE}:${userHash}`)); + + // If there are no included/excluded refs, the don't return any membership. + if ((!includedRefs || !includedRefs.length) && (!excludedRefs || !excludedRefs.length)) { + return undefined; + } + + const membership: interfaces.BigSegmentStoreMembership = {}; + if (excludedRefs) { + for (const ref of excludedRefs) { + membership[ref] = false; + } + } + + if (includedRefs) { + for (const ref of includedRefs) { + membership[ref] = true; + } + } + return membership; + } + + close(): void { + this.state.close(); + } +} diff --git a/packages/store/node-server-sdk-redis/src/RedisClientState.ts b/packages/store/node-server-sdk-redis/src/RedisClientState.ts index bc5c33a931..6b17eecdcd 100644 --- a/packages/store/node-server-sdk-redis/src/RedisClientState.ts +++ b/packages/store/node-server-sdk-redis/src/RedisClientState.ts @@ -1,10 +1,15 @@ import { LDLogger } from '@launchdarkly/node-server-sdk'; import { Redis } from 'ioredis'; +import LDRedisOptions from './LDRedisOptions'; + +const DEFAULT_PREFIX = 'launchdarkly'; /** * Class for managing the state of a redis connection. * * Used for the redis persistent store as well as the redis big segment store. + * + * @internal */ export default class RedisClientState { private connected: boolean = false; @@ -13,6 +18,12 @@ export default class RedisClientState { private initialConnection: boolean = true; + private client: Redis; + + private owned: boolean; + + private base_prefix: string; + /** * Construct a state with the given client. * @@ -20,16 +31,27 @@ export default class RedisClientState { * @param owned Is this client owned by the store integration, or was it * provided externally. */ - constructor( - private readonly client: Redis, - private readonly owned: boolean, - private readonly logger?: LDLogger - ) { + constructor(options?: LDRedisOptions, private readonly logger?: LDLogger) { + if (options?.client) { + this.client = options!.client; + this.owned = false; + } else if (options?.redisOpts) { + this.client = new Redis(options!.redisOpts); + this.owned = true; + } else { + this.client = new Redis(); + this.owned = true; + } + + this.base_prefix = options?.prefix || DEFAULT_PREFIX; + // If the client is not owned, then it should already be connected. - this.connected = !owned; + this.connected = !this.owned; // We don't want to log a message on the first connection, only when reconnecting. this.initialConnection = !this.connected; + const { client } = this; + client.on('error', (err) => { logger?.error(`Redis error - ${err}`); }); @@ -62,7 +84,7 @@ export default class RedisClientState { * * @returns True if currently connected. */ - public isConnected(): boolean { + isConnected(): boolean { return this.connected; } @@ -71,7 +93,7 @@ export default class RedisClientState { * * @returns True if using the initial connection. */ - public isInitialConnection(): boolean { + isInitialConnection(): boolean { return this.initialConnection; } @@ -80,16 +102,25 @@ export default class RedisClientState { * * @returns The redis client. */ - public getClient(): Redis { + getClient(): Redis { return this.client; } /** * If the client is owned, then this will 'quit' the client. */ - public close() { + close() { if (this.owned) { this.client.quit(); } } + + /** + * Get a key with prefix prepended. + * @param key The key to prefix. + * @returns The prefixed key. + */ + prefixedKey(key: string): string { + return `${this.base_prefix}:${key}`; + } } diff --git a/packages/store/node-server-sdk-redis/src/RedisCore.ts b/packages/store/node-server-sdk-redis/src/RedisCore.ts index 8d8ec31df5..433184a355 100644 --- a/packages/store/node-server-sdk-redis/src/RedisCore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisCore.ts @@ -19,20 +19,14 @@ import RedisClientState from './RedisClientState'; * version in the updated descriptor will be correct. * * The special key "{prefix}:$inited" indicates that the store contains a complete data set. + * + * @internal */ export default class RedisCore implements interfaces.PersistentDataStore { private initedKey: string; - constructor( - private readonly state: RedisClientState, - private readonly prefix: string, - private readonly logger?: LDLogger - ) { - this.initedKey = this.prefixedKey('$inited'); - } - - private prefixedKey(namespace: string): string { - return `${this.prefix}:${namespace}`; + constructor(private readonly state: RedisClientState, private readonly logger?: LDLogger) { + this.initedKey = this.state.prefixedKey('$inited'); } init( @@ -44,7 +38,7 @@ export default class RedisCore implements interfaces.PersistentDataStore { const kind = keyedItems.key; const items = keyedItems.item; - const namespaceKey = this.prefixedKey(kind.namespace); + const namespaceKey = this.state.prefixedKey(kind.namespace); // Delete the namespace for the kind. multi.del(namespaceKey); @@ -83,7 +77,7 @@ export default class RedisCore implements interfaces.PersistentDataStore { return; } - this.state.getClient().hget(this.prefixedKey(kind.namespace), key, (err, val) => { + this.state.getClient().hget(this.state.prefixedKey(kind.namespace), key, (err, val) => { if (err) { this.logger?.error(`Error fetching key '${key}' from Redis in '${kind.namespace}' ${err}`); callback(undefined); @@ -113,7 +107,7 @@ export default class RedisCore implements interfaces.PersistentDataStore { return; } - this.state.getClient().hgetall(this.prefixedKey(kind.namespace), (err, values) => { + this.state.getClient().hgetall(this.state.prefixedKey(kind.namespace), (err, values) => { if (err) { this.logger?.error(`Error fetching '${kind.namespace}' from Redis ${err}`); } else if (values) { @@ -142,7 +136,7 @@ export default class RedisCore implements interfaces.PersistentDataStore { ): void { // The persistent store wrapper manages interactions with a queue, so we can use watch like // this without concerns for overlapping transactions. - this.state.getClient().watch(this.prefixedKey(kind.namespace)); + this.state.getClient().watch(this.state.prefixedKey(kind.namespace)); const multi = this.state.getClient().multi(); this.get(kind, key, (old) => { @@ -165,12 +159,12 @@ export default class RedisCore implements interfaces.PersistentDataStore { } if (descriptor.deleted) { multi.hset( - this.prefixedKey(kind.namespace), + this.state.prefixedKey(kind.namespace), key, JSON.stringify({ version: descriptor.version, deleted: true }) ); } else if (descriptor.serializedItem) { - multi.hset(this.prefixedKey(kind.namespace), key, descriptor.serializedItem); + multi.hset(this.state.prefixedKey(kind.namespace), key, descriptor.serializedItem); } else { // This call violates the contract. multi.discard(); diff --git a/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts b/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts index ba3ff9a0a8..1b05c677e4 100644 --- a/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisFeatureStore.ts @@ -8,38 +8,10 @@ import { LDLogger, PersistentDataStoreWrapper, } from '@launchdarkly/node-server-sdk'; -import Redis from 'ioredis'; import LDRedisOptions from './LDRedisOptions'; import RedisCore from './RedisCore'; import RedisClientState from './RedisClientState'; - -/** - * The default TTL cache time in seconds. - */ -const DEFAULT_CACHE_TTL_S = 30; - -const DEFAULT_PREFIX = 'launchdarkly'; - -function ClientFromOptions(options?: LDRedisOptions): RedisClientState { - // If a pre-configured client is provided, then use it. - if (options?.client) { - return new RedisClientState(options!.client, false); - } - // If there are options for redis, then make a client using those options. - if (options?.redisOpts) { - return new RedisClientState(new Redis(options!.redisOpts), true); - } - // There was no client, and there were no options. - return new RedisClientState(new Redis(), true); -} - -function TtlFromOptions(options?: LDRedisOptions): number { - // 0 is a valid option. So we need a null/undefined check. - if (options?.cacheTTL === undefined || options.cacheTTL === null) { - return DEFAULT_CACHE_TTL_S; - } - return options!.cacheTTL; -} +import TtlFromOptions from './TtlFromOptions'; /** * Integration between the LaunchDarkly SDK and Redis. @@ -49,7 +21,7 @@ export default class RedisFeatureStore implements LDFeatureStore { constructor(options?: LDRedisOptions, private readonly logger?: LDLogger) { this.wrapper = new PersistentDataStoreWrapper( - new RedisCore(ClientFromOptions(options), options?.prefix || DEFAULT_PREFIX, logger), + new RedisCore(new RedisClientState(options, logger), logger), TtlFromOptions(options) ); } diff --git a/packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts b/packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts new file mode 100644 index 0000000000..203407a5ea --- /dev/null +++ b/packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts @@ -0,0 +1,20 @@ +import { LDOptions, interfaces } from '@launchdarkly/node-server-sdk'; +import LDRedisOptions from './LDRedisOptions'; +import RedisBigSegmentStore from './RedisBigSegmentStore'; + +/** + * Configures a big segment store factory backed by a Redis instance. + * + * "Big segments" are a specific type of user segments. For more information, read the + * LaunchDarkly documentation about user segments: https://docs.launchdarkly.com/home/users/segments + * + * @param options The standard options supported for all LaunchDarkly Redis features, including both + * options for Redis itself and others related to the SDK's behavior. + * + * @returns A function which creates big segment stores based on the provided config. + */ +export default function RedisBigSegmentStoreFactory( + options?: LDRedisOptions +): (config: LDOptions) => interfaces.BigSegmentStore { + return (config: LDOptions) => new RedisBigSegmentStore(options, config.logger); +} diff --git a/packages/store/node-server-sdk-redis/src/TtlFromOptions.ts b/packages/store/node-server-sdk-redis/src/TtlFromOptions.ts new file mode 100644 index 0000000000..d7343530cc --- /dev/null +++ b/packages/store/node-server-sdk-redis/src/TtlFromOptions.ts @@ -0,0 +1,21 @@ +import LDRedisOptions from './LDRedisOptions'; + +/** + * The default TTL cache time in seconds. + */ +const DEFAULT_CACHE_TTL_S = 30; + +/** + * Get a cache TTL based on LDRedisOptions. If the TTL is not specified, then + * the default of 30 seconds will be used. + * @param options The options to get a TTL for. + * @returns The TTL, in seconds. + * @internal + */ +export default function TtlFromOptions(options?: LDRedisOptions): number { + // 0 is a valid option. So we need a null/undefined check. + if (options?.cacheTTL === undefined || options.cacheTTL === null) { + return DEFAULT_CACHE_TTL_S; + } + return options!.cacheTTL; +} From b8b6f1d8f757e0e7e18908a569c60600c7e66536 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 09:42:53 -0700 Subject: [PATCH 20/41] Add big segment store to diagram. --- packages/store/node-server-sdk-redis/architecture.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/store/node-server-sdk-redis/architecture.md b/packages/store/node-server-sdk-redis/architecture.md index ffd2714d37..e403a3d55b 100644 --- a/packages/store/node-server-sdk-redis/architecture.md +++ b/packages/store/node-server-sdk-redis/architecture.md @@ -2,7 +2,9 @@ ## Components -RedisFeatureStoreFactory: Method which generates a factory which will build RedisFeatureStore instances. This is what is used by the end user in their SDK configuration. +RedisFeatureStoreFactory: Method which generates a factory which will build RedisFeatureStore instances. This is what is used by the end user in their SDK configuration to configure persistence. + +RedisBigSegmentStoreFactory: Method which generates a factory which will build RedisBigSegmentStore instances. This is what is used by the end user in their SDK configuration to configure big segments. LDRedisOptions: Configuration options passed to the factory to configure a RedisFeatureStore. @@ -27,4 +29,10 @@ RedisCore --* RedisClientState : Contains RedisFeatureStoreFactory --> RedisFeatureStore : Builds RedisFeatureStoreFactory --> LDRedisOptions : Uses + +RedisBigSegmentStoreFactory --> RedisBigSegmentStore : Builds +RedisBigSegmentStoreFactory --> LDRedisOptions : Uses + +RedisBigSegmentStore --|> BigSegmentStore : Implements +RedisBigSegmentStore --* RedisClientState : Contains ``` From 3a1f9d2607dda69513683aadc50d8cd6cdcfff9b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 09:44:22 -0700 Subject: [PATCH 21/41] Add description for BigSegmentStore. --- packages/store/node-server-sdk-redis/architecture.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/store/node-server-sdk-redis/architecture.md b/packages/store/node-server-sdk-redis/architecture.md index e403a3d55b..d3ad77504e 100644 --- a/packages/store/node-server-sdk-redis/architecture.md +++ b/packages/store/node-server-sdk-redis/architecture.md @@ -11,9 +11,11 @@ LDRedisOptions: Configuration options passed to the factory to configure a Redis RedisFeatureStore: The feature store implements LDFeatureStore, it contains a PersistentDataStoreWrapper which it marshalls all operations to. The PeristentDataStoreWrapper uses the PersistentDataStore, implemented by the RedisCore, to read and write from persistence. The PersistentDataStoreWrapper contains common operations, such as caching, which should apply to all persistent store implementations. +RedisBigSegmentStore: Implements BigSegmentStore, it contains a RedisClientState which it uses to do redis operations. + RedisCore: Implements persistent data store operations using Redis. -RedisClientState: Manages the Redis connection and exposes the Redis client to the RedisCore. +RedisClientState: Manages the Redis connection and exposes the Redis client to the RedisCore/RedisBigSegmentStore. ## Architecture Diagram From 77b5974f6be7f92ed474cdaf9750e530e2f28b9f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 10:42:51 -0700 Subject: [PATCH 22/41] Add big segments tests. Extend store tests to use multiple prefixes. --- .../__tests__/RedisBigSegmentStore.test.ts | 77 +++++++++++++++++++ .../__tests__/RedisCore.test.ts | 10 +-- .../__tests__/RedisFeatureStore.test.ts | 28 ++++--- .../__tests__/clearPrefix.ts | 9 +++ .../src/RedisBigSegmentStore.ts | 21 +++-- 5 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 packages/store/node-server-sdk-redis/__tests__/RedisBigSegmentStore.test.ts create mode 100644 packages/store/node-server-sdk-redis/__tests__/clearPrefix.ts diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisBigSegmentStore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisBigSegmentStore.test.ts new file mode 100644 index 0000000000..a8bd07a031 --- /dev/null +++ b/packages/store/node-server-sdk-redis/__tests__/RedisBigSegmentStore.test.ts @@ -0,0 +1,77 @@ +import Redis from 'ioredis'; +import RedisBigSegmentStore, { KEY_LAST_SYNCHRONIZED, KEY_USER_INCLUDE, KEY_USER_EXCLUDE } from '../src/RedisBigSegmentStore'; +import clearPrefix from './clearPrefix'; +import { interfaces } from '@launchdarkly/node-server-sdk'; + +const FAKE_HASH = 'userhash'; + +describe.each([undefined, 'app1'])('given a redis big segment store', (prefixParam) => { + let store: RedisBigSegmentStore; + const prefix = prefixParam || 'launchdarkly'; + + async function setMetadata(prefix: string, metadata: interfaces.BigSegmentStoreMetadata): Promise { + const client = new Redis(); + await client.set(`${prefix}:${KEY_LAST_SYNCHRONIZED}`, metadata.lastUpToDate ? metadata.lastUpToDate.toString() : ''); + await client.quit(); + } + + async function setSegments(prefix: string, userHashKey: string, included: string[], excluded: string[]): Promise { + const client = new Redis(); + for (const ref of included) { + await client.sadd(`${prefix}:${KEY_USER_INCLUDE}:${userHashKey}`, ref); + } + for (const ref of excluded) { + await client.sadd(`${prefix}:${KEY_USER_EXCLUDE}:${userHashKey}`, ref); + } + await client.quit(); + } + + beforeEach(async () => { + console.log("Clearing prefix", prefix); + await clearPrefix(prefix); + // Use param directly to test undefined. + store = new RedisBigSegmentStore({prefix: prefixParam}); + }); + + afterEach(async () => { + store.close(); + }); + + it('can get populated meta data', async () => { + const expected = { lastUpToDate: 1234567890 }; + await setMetadata(prefix, expected); + const meta = await store.getMetadata(); + expect(meta).toEqual(expected); + }); + + it('can get metadata when not populated', async () => { + const meta = await store.getMetadata(); + expect(meta?.lastUpToDate).toBeUndefined(); + }); + + it('can get user membership for a user which has no membership', async () => { + const membership = await store.getUserMembership(FAKE_HASH); + expect(membership).toBeUndefined(); + }); + + it('can get membership for a user that is only included', async () => { + await setSegments(prefix, FAKE_HASH, ['key1', 'key2'], []); + + const membership = await store.getUserMembership(FAKE_HASH); + expect(membership).toEqual({ key1: true, key2: true }); + }); + + it('can get membership for a user that is only excluded', async () => { + await setSegments(prefix, FAKE_HASH, [], ['key1', 'key2']); + + const membership = await store.getUserMembership(FAKE_HASH); + expect(membership).toEqual({ key1: false, key2: false }); + }); + + it('can get membership for a user that is included and excluded', async () => { + await setSegments(prefix, FAKE_HASH, ['key1', 'key2'], ['key2', 'key3']); + + const membership = await store.getUserMembership(FAKE_HASH); + expect(membership).toEqual({ key1: true, key2: true, key3: false }); // include of key2 overrides exclude + }); +}); \ No newline at end of file diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts index 1cd342da92..3250aacceb 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts @@ -2,14 +2,7 @@ import { Redis } from 'ioredis'; import { interfaces } from '@launchdarkly/node-server-sdk'; import RedisCore from '../src/RedisCore'; import RedisClientState from '../src/RedisClientState'; - -async function clearPrefix(prefix: string) { - const client = new Redis(); - const keys = await client.keys(`${prefix}:*`); - const promises = keys.map((key) => client.del(key)); - await Promise.all(promises); - client.quit(); -} +import clearPrefix from './clearPrefix'; const featuresKind = { namespace: 'features', deserialize: (data: string) => JSON.parse(data) }; const segmentsKind = { namespace: 'segments', deserialize: (data: string) => JSON.parse(data) }; @@ -85,7 +78,6 @@ describe('given an empty store', () => { await clearPrefix('launchdarkly'); core = new RedisCore( new RedisClientState(), - 'launchdarkly', undefined ); facade = new AsyncCoreFacade(core); diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts index 612d1f177d..313230737f 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisFeatureStore.test.ts @@ -1,27 +1,22 @@ -import { Redis } from 'ioredis'; import { AsyncStoreFacade } from '@launchdarkly/node-server-sdk'; import RedisFeatureStore from '../src/RedisFeatureStore'; - -async function clearPrefix(prefix: string) { - const client = new Redis(); - const keys = await client.keys(`${prefix}:*`); - const promises = keys.map((key) => client.del(key)); - await Promise.all(promises); - client.quit(); -} +import clearPrefix from './clearPrefix'; const dataKind = { features: { namespace: 'features' }, segments: { namespace: 'segments' }, }; -describe('given an empty store', () => { +describe.each([undefined, 'testing'])('given an empty store', (prefixParam) => { let store: RedisFeatureStore; let facade: AsyncStoreFacade; + const prefix = prefixParam || 'launchdarkly'; + beforeEach(async () => { - await clearPrefix('launchdarkly'); - store = new RedisFeatureStore(undefined, undefined); + await clearPrefix(prefix); + // Use param directly to test undefined. + store = new RedisFeatureStore({ prefix: prefixParam }, undefined); facade = new AsyncStoreFacade(store); }); @@ -67,16 +62,19 @@ describe('given an empty store', () => { }); }); -describe('given a store with basic data', () => { +describe.each([undefined, 'testing'])('given a store with basic data', (prefixParam) => { let store: RedisFeatureStore; let facade: AsyncStoreFacade; const feature1 = { key: 'foo', version: 10 }; const feature2 = { key: 'bar', version: 10 }; + const prefix = prefixParam || 'launchdarkly'; + beforeEach(async () => { - await clearPrefix('launchdarkly'); - store = new RedisFeatureStore(undefined, undefined); + await clearPrefix(prefix); + // Use param directly to test undefined. + store = new RedisFeatureStore({ prefix: prefixParam }); facade = new AsyncStoreFacade(store); await facade.init({ features: { diff --git a/packages/store/node-server-sdk-redis/__tests__/clearPrefix.ts b/packages/store/node-server-sdk-redis/__tests__/clearPrefix.ts new file mode 100644 index 0000000000..e3c9326a6c --- /dev/null +++ b/packages/store/node-server-sdk-redis/__tests__/clearPrefix.ts @@ -0,0 +1,9 @@ +import { Redis } from 'ioredis'; + +export default async function clearPrefix(prefix: string) { + const client = new Redis(); + const keys = await client.keys(`${prefix}:*`); + const promises = keys.map((key) => client.del(key)); + await Promise.all(promises); + client.quit(); +} diff --git a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts index 9667469b8c..f71ef3512e 100644 --- a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts @@ -2,9 +2,20 @@ import { LDLogger, interfaces } from '@launchdarkly/node-server-sdk'; import LDRedisOptions from './LDRedisOptions'; import RedisClientState from './RedisClientState'; -const KEY_LAST_SYNCHRONIZED = 'big_segments_synchronized_on'; -const KEY_USER_INCLUDE = 'big_segment_include:'; -const KEY_USER_EXCLUDE = 'big_segment_exclude:'; +/** + * @internal + */ +export const KEY_LAST_SYNCHRONIZED = 'big_segments_synchronized_on'; + +/** + * @internal + */ +export const KEY_USER_INCLUDE = 'big_segment_include:'; + +/** + * @internal + */ +export const KEY_USER_EXCLUDE = 'big_segment_exclude:'; export default class RedisBigSegmentStore implements interfaces.BigSegmentStore { private state: RedisClientState; @@ -27,9 +38,9 @@ export default class RedisBigSegmentStore implements interfaces.BigSegmentStore } async getUserMembership(userHash: string): Promise { - const includedRefs = await this.state.getClient().get( + const includedRefs = await this.state.getClient().smembers( this.state.prefixedKey(`${KEY_USER_INCLUDE}:${userHash}`)); - const excludedRefs = await this.state.getClient().get( + const excludedRefs = await this.state.getClient().smembers( this.state.prefixedKey(`${KEY_USER_EXCLUDE}:${userHash}`)); // If there are no included/excluded refs, the don't return any membership. From f6d0ab60c9b22976d51af032478bfd2c1f75f36d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 10:49:54 -0700 Subject: [PATCH 23/41] Linting --- .../__tests__/RedisBigSegmentStore.test.ts | 63 ++++++++++++------- .../__tests__/RedisCore.test.ts | 11 +--- .../store/node-server-sdk-redis/package.json | 2 +- .../src/RedisBigSegmentStore.ts | 37 +++++------ .../src/RegisBigSegmentStoreFactory.ts | 4 +- 5 files changed, 65 insertions(+), 52 deletions(-) diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisBigSegmentStore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisBigSegmentStore.test.ts index a8bd07a031..aea4fe5e02 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisBigSegmentStore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisBigSegmentStore.test.ts @@ -1,36 +1,55 @@ import Redis from 'ioredis'; -import RedisBigSegmentStore, { KEY_LAST_SYNCHRONIZED, KEY_USER_INCLUDE, KEY_USER_EXCLUDE } from '../src/RedisBigSegmentStore'; -import clearPrefix from './clearPrefix'; import { interfaces } from '@launchdarkly/node-server-sdk'; +import RedisBigSegmentStore, { + KEY_LAST_SYNCHRONIZED, + KEY_USER_INCLUDE, + KEY_USER_EXCLUDE, +} from '../src/RedisBigSegmentStore'; +import clearPrefix from './clearPrefix'; const FAKE_HASH = 'userhash'; -describe.each([undefined, 'app1'])('given a redis big segment store', (prefixParam) => { - let store: RedisBigSegmentStore; - const prefix = prefixParam || 'launchdarkly'; +async function setMetadata( + prefix: string, + metadata: interfaces.BigSegmentStoreMetadata +): Promise { + const client = new Redis(); + await client.set( + `${prefix}:${KEY_LAST_SYNCHRONIZED}`, + metadata.lastUpToDate ? metadata.lastUpToDate.toString() : '' + ); + await client.quit(); +} - async function setMetadata(prefix: string, metadata: interfaces.BigSegmentStoreMetadata): Promise { - const client = new Redis(); - await client.set(`${prefix}:${KEY_LAST_SYNCHRONIZED}`, metadata.lastUpToDate ? metadata.lastUpToDate.toString() : ''); - await client.quit(); +async function setSegments( + prefix: string, + userHashKey: string, + included: string[], + excluded: string[] +): Promise { + const client = new Redis(); + + // Generators and await in a loop, both of which eslint doesn't like. This is a test, and this + // is simpler. + /* eslint-disable */ + for (const ref of included) { + await client.sadd(`${prefix}:${KEY_USER_INCLUDE}:${userHashKey}`, ref); } - - async function setSegments(prefix: string, userHashKey: string, included: string[], excluded: string[]): Promise { - const client = new Redis(); - for (const ref of included) { - await client.sadd(`${prefix}:${KEY_USER_INCLUDE}:${userHashKey}`, ref); - } - for (const ref of excluded) { - await client.sadd(`${prefix}:${KEY_USER_EXCLUDE}:${userHashKey}`, ref); - } - await client.quit(); + for (const ref of excluded) { + await client.sadd(`${prefix}:${KEY_USER_EXCLUDE}:${userHashKey}`, ref); } + /* eslint-enable */ + await client.quit(); +} + +describe.each([undefined, 'app1'])('given a redis big segment store', (prefixParam) => { + let store: RedisBigSegmentStore; + const prefix = prefixParam || 'launchdarkly'; beforeEach(async () => { - console.log("Clearing prefix", prefix); await clearPrefix(prefix); // Use param directly to test undefined. - store = new RedisBigSegmentStore({prefix: prefixParam}); + store = new RedisBigSegmentStore({ prefix: prefixParam }); }); afterEach(async () => { @@ -74,4 +93,4 @@ describe.each([undefined, 'app1'])('given a redis big segment store', (prefixPar const membership = await store.getUserMembership(FAKE_HASH); expect(membership).toEqual({ key1: true, key2: true, key3: false }); // include of key2 overrides exclude }); -}); \ No newline at end of file +}); diff --git a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts index 3250aacceb..4c8ad71539 100644 --- a/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts +++ b/packages/store/node-server-sdk-redis/__tests__/RedisCore.test.ts @@ -1,4 +1,3 @@ -import { Redis } from 'ioredis'; import { interfaces } from '@launchdarkly/node-server-sdk'; import RedisCore from '../src/RedisCore'; import RedisClientState from '../src/RedisClientState'; @@ -76,10 +75,7 @@ describe('given an empty store', () => { beforeEach(async () => { await clearPrefix('launchdarkly'); - core = new RedisCore( - new RedisClientState(), - undefined - ); + core = new RedisCore(new RedisClientState(), undefined); facade = new AsyncCoreFacade(core); }); @@ -167,10 +163,7 @@ describe('given a store with basic data', () => { beforeEach(async () => { await clearPrefix('launchdarkly'); - core = new RedisCore( - new RedisClientState(), - undefined - ); + core = new RedisCore(new RedisClientState(), undefined); const flags = [ { key: 'foo', diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json index 337d841b19..d3e98e3aa5 100644 --- a/packages/store/node-server-sdk-redis/package.json +++ b/packages/store/node-server-sdk-redis/package.json @@ -24,7 +24,7 @@ "test": "npx jest --ci --runInBand", "build": "npx tsc", "lint": "npx eslint . --ext .ts", - "lint:fix": "yarn run lint -- --fix" + "lint:fix": "yarn run lint --fix" }, "dependencies": { "ioredis": "^5.3.2" diff --git a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts index f71ef3512e..11b1c200d1 100644 --- a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts @@ -19,6 +19,7 @@ export const KEY_USER_EXCLUDE = 'big_segment_exclude:'; export default class RedisBigSegmentStore implements interfaces.BigSegmentStore { private state: RedisClientState; + // Logger is not currently used, but is included to reduce the chance of a // compatibility break to add a log. // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -31,17 +32,20 @@ export default class RedisBigSegmentStore implements interfaces.BigSegmentStore // Value will be true if it is a string containing any characters, which is fine // for this check. if (value) { - return { lastUpToDate: parseInt(value) } - } else { - return {}; + return { lastUpToDate: parseInt(value, 10) }; } + return {}; } - async getUserMembership(userHash: string): Promise { - const includedRefs = await this.state.getClient().smembers( - this.state.prefixedKey(`${KEY_USER_INCLUDE}:${userHash}`)); - const excludedRefs = await this.state.getClient().smembers( - this.state.prefixedKey(`${KEY_USER_EXCLUDE}:${userHash}`)); + async getUserMembership( + userHash: string + ): Promise { + const includedRefs = await this.state + .getClient() + .smembers(this.state.prefixedKey(`${KEY_USER_INCLUDE}:${userHash}`)); + const excludedRefs = await this.state + .getClient() + .smembers(this.state.prefixedKey(`${KEY_USER_EXCLUDE}:${userHash}`)); // If there are no included/excluded refs, the don't return any membership. if ((!includedRefs || !includedRefs.length) && (!excludedRefs || !excludedRefs.length)) { @@ -49,17 +53,14 @@ export default class RedisBigSegmentStore implements interfaces.BigSegmentStore } const membership: interfaces.BigSegmentStoreMembership = {}; - if (excludedRefs) { - for (const ref of excludedRefs) { - membership[ref] = false; - } - } - if (includedRefs) { - for (const ref of includedRefs) { - membership[ref] = true; - } - } + excludedRefs?.forEach((ref) => { + membership[ref] = false; + }); + includedRefs?.forEach((ref) => { + membership[ref] = true; + }); + return membership; } diff --git a/packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts b/packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts index 203407a5ea..3bb12c5c7b 100644 --- a/packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts +++ b/packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts @@ -4,13 +4,13 @@ import RedisBigSegmentStore from './RedisBigSegmentStore'; /** * Configures a big segment store factory backed by a Redis instance. - * + * * "Big segments" are a specific type of user segments. For more information, read the * LaunchDarkly documentation about user segments: https://docs.launchdarkly.com/home/users/segments * * @param options The standard options supported for all LaunchDarkly Redis features, including both * options for Redis itself and others related to the SDK's behavior. - * + * * @returns A function which creates big segment stores based on the provided config. */ export default function RedisBigSegmentStoreFactory( From 1ab1ee9508b4e80d3c3ae3b4e48117a4005909f8 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 11:09:13 -0700 Subject: [PATCH 24/41] Update CI configuration. --- .github/workflows/node-redis.yml | 6 ++++++ packages/shared/sdk-server/src/index.ts | 4 ---- packages/shared/sdk-server/src/store/index.ts | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/node-redis.yml b/.github/workflows/node-redis.yml index 22295a3153..b98a5943e8 100644 --- a/.github/workflows/node-redis.yml +++ b/.github/workflows/node-redis.yml @@ -19,6 +19,12 @@ jobs: with: node-version: 16 registry-url: 'https://registry.npmjs.org' + # We may want to consider moving this build to a docker container instead of installing it + # in the image. + - run: | + apt-get update + apt-get install redis-server + service redis-server start - id: shared name: Shared CI Steps uses: ./actions/ci diff --git a/packages/shared/sdk-server/src/index.ts b/packages/shared/sdk-server/src/index.ts index def23e8c55..b538e7a8cb 100644 --- a/packages/shared/sdk-server/src/index.ts +++ b/packages/shared/sdk-server/src/index.ts @@ -8,8 +8,4 @@ export * from './store'; export * from './events'; export * from '@launchdarkly/js-sdk-common'; -// TODO: This should maybe be nested in another namespace to reduce visibility -// and more clearly express the intent of use by our own libraries. -export { default as PersistentDataStoreWrapper } from './store/PersistentDataStoreWrapper'; - export { LDClientImpl, BigSegmentStoreStatusProviderImpl }; diff --git a/packages/shared/sdk-server/src/store/index.ts b/packages/shared/sdk-server/src/store/index.ts index c8c7c0e463..047f32648f 100644 --- a/packages/shared/sdk-server/src/store/index.ts +++ b/packages/shared/sdk-server/src/store/index.ts @@ -1,4 +1,5 @@ import AsyncStoreFacade from './AsyncStoreFacade'; +import PersistentDataStoreWrapper from './PersistentDataStoreWrapper'; import { deserializePoll } from './serialization'; -export { AsyncStoreFacade, deserializePoll }; +export { AsyncStoreFacade, PersistentDataStoreWrapper, deserializePoll }; From fa2aa4b2eb7f0c50d7c122c795c347e83fc954ee Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 15:02:08 -0700 Subject: [PATCH 25/41] Enable workflow for branch for testing. --- .github/workflows/node-redis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node-redis.yml b/.github/workflows/node-redis.yml index b98a5943e8..c385001c46 100644 --- a/.github/workflows/node-redis.yml +++ b/.github/workflows/node-redis.yml @@ -2,7 +2,7 @@ name: store/node-server-sdk-redis on: push: - branches: [main] + branches: [main, rlamb/implement-redis-store] paths-ignore: - '**.md' #Do not need to run CI for markdown changes. pull_request: From a5a312957da6c1a95131ce3e927cbb45dab460a4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 15:03:04 -0700 Subject: [PATCH 26/41] Add sudo commands. --- .github/workflows/node-redis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/node-redis.yml b/.github/workflows/node-redis.yml index c385001c46..b987fe6e13 100644 --- a/.github/workflows/node-redis.yml +++ b/.github/workflows/node-redis.yml @@ -22,9 +22,9 @@ jobs: # We may want to consider moving this build to a docker container instead of installing it # in the image. - run: | - apt-get update - apt-get install redis-server - service redis-server start + sudo apt-get update + sudo apt-get install redis-server + sudo service redis-server start - id: shared name: Shared CI Steps uses: ./actions/ci From 1903d66152c31d01b1715da5c9958c411639dd87 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 15:09:50 -0700 Subject: [PATCH 27/41] Add dev dependency changes to CI build. --- actions/ci/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/ci/action.yml b/actions/ci/action.yml index c9f1f3ca6c..7ea4859aae 100644 --- a/actions/ci/action.yml +++ b/actions/ci/action.yml @@ -27,7 +27,7 @@ runs: - name: Build shell: bash # This will build the package and its dependencies. - run: yarn workspaces foreach -ptR --from '${{ inputs.workspace_name }}' run build + run: yarn workspaces foreach -pR --topological-dev --from '${{ inputs.workspace_name }}' run build - name: Lint shell: bash From 58f2415878f4a85eba828285782b96ffaccac5b7 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 15:13:20 -0700 Subject: [PATCH 28/41] Remove running on branch. --- .github/workflows/node-redis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node-redis.yml b/.github/workflows/node-redis.yml index b987fe6e13..9f848b2e99 100644 --- a/.github/workflows/node-redis.yml +++ b/.github/workflows/node-redis.yml @@ -2,7 +2,7 @@ name: store/node-server-sdk-redis on: push: - branches: [main, rlamb/implement-redis-store] + branches: [main] paths-ignore: - '**.md' #Do not need to run CI for markdown changes. pull_request: From 307c17110fa27e160e1eced6ce1dfcdde0bd121f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 7 Jun 2023 15:30:34 -0700 Subject: [PATCH 29/41] Correct typo in readme. --- packages/store/node-server-sdk-redis/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/store/node-server-sdk-redis/README.md b/packages/store/node-server-sdk-redis/README.md index 8c5e3c4d81..aad387d9b8 100644 --- a/packages/store/node-server-sdk-redis/README.md +++ b/packages/store/node-server-sdk-redis/README.md @@ -60,7 +60,7 @@ By default, the store will try to connect to a local Redis instance on port 6379 To reduce traffic to Redis, there is an optional in-memory cache that retains the last known data for a configurable amount of time. This is on by default; to turn it off (and guarantee that the latest feature flag data will always be retrieved from Redis for every flag evaluation), configure the store as follows: -```typescriot +```typescript const factory = RedisFeatureStoreFactory({ cacheTTL: 0 }); ``` From aac91420e31fcbac50e99ca5efbac02d6437cc69 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 9 Jun 2023 15:23:30 -0700 Subject: [PATCH 30/41] Fix name of big segement store factory. Update index. --- ...sBigSegmentStoreFactory.ts => RedisBigSegmentStoreFactory.ts} | 0 packages/store/node-server-sdk-redis/src/index.ts | 1 + 2 files changed, 1 insertion(+) rename packages/store/node-server-sdk-redis/src/{RegisBigSegmentStoreFactory.ts => RedisBigSegmentStoreFactory.ts} (100%) diff --git a/packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStoreFactory.ts similarity index 100% rename from packages/store/node-server-sdk-redis/src/RegisBigSegmentStoreFactory.ts rename to packages/store/node-server-sdk-redis/src/RedisBigSegmentStoreFactory.ts diff --git a/packages/store/node-server-sdk-redis/src/index.ts b/packages/store/node-server-sdk-redis/src/index.ts index 71e7eb3a09..258a3acdf3 100644 --- a/packages/store/node-server-sdk-redis/src/index.ts +++ b/packages/store/node-server-sdk-redis/src/index.ts @@ -1,2 +1,3 @@ export { default as RedisFeatureStoreFactory } from './RedisFeatureStoreFactory'; +export { default as RedisBigSegmentStoreFactory } from './RedisBigSegmentStoreFactory'; export { default as LDRedisOptions } from './LDRedisOptions'; From 628a1eff6a1588eccb9fb44d5dc375dab8010f15 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 9 Jun 2023 15:41:01 -0700 Subject: [PATCH 31/41] Change job name --- .github/workflows/node-redis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node-redis.yml b/.github/workflows/node-redis.yml index 9f848b2e99..91c5477c09 100644 --- a/.github/workflows/node-redis.yml +++ b/.github/workflows/node-redis.yml @@ -11,7 +11,7 @@ on: - '**.md' jobs: - build-test-common: + build-test-node-redis: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 From 5c319ce7ae6fd7d38681b29e47b4f62e737c2677 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jun 2023 08:30:41 -0700 Subject: [PATCH 32/41] Rename exports for greater backward compatibility. --- packages/store/node-server-sdk-redis/src/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/store/node-server-sdk-redis/src/index.ts b/packages/store/node-server-sdk-redis/src/index.ts index 258a3acdf3..beee85e311 100644 --- a/packages/store/node-server-sdk-redis/src/index.ts +++ b/packages/store/node-server-sdk-redis/src/index.ts @@ -1,3 +1,7 @@ -export { default as RedisFeatureStoreFactory } from './RedisFeatureStoreFactory'; -export { default as RedisBigSegmentStoreFactory } from './RedisBigSegmentStoreFactory'; +// Exporting the factories without the 'Factory'. This keeps them in-line with +// previous store versions. The differentiation between the factory and the store +// is not critical for consuming the SDK. +export { default as RedisFeatureStore } from './RedisFeatureStoreFactory'; +export { default as RedisBigSegmentStore } from './RedisBigSegmentStoreFactory'; + export { default as LDRedisOptions } from './LDRedisOptions'; From 3445518098318a6bed45da2144af84bd7ab7cb45 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jun 2023 09:30:47 -0700 Subject: [PATCH 33/41] Update node server dep. --- packages/store/node-server-sdk-redis/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json index d3e98e3aa5..7b4c20c877 100644 --- a/packages/store/node-server-sdk-redis/package.json +++ b/packages/store/node-server-sdk-redis/package.json @@ -30,10 +30,10 @@ "ioredis": "^5.3.2" }, "peerDependencies": { - "@launchdarkly/node-server-sdk": "0.4.2" + "@launchdarkly/node-server-sdk": "0.4.3" }, "devDependencies": { - "@launchdarkly/node-server-sdk": "0.4.2", + "@launchdarkly/node-server-sdk": "0.4.3", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", From b9d8782423064fe905edf1214d64dc68551f3c1c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jun 2023 09:41:23 -0700 Subject: [PATCH 34/41] Catch on quit. --- packages/store/node-server-sdk-redis/src/RedisClientState.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/store/node-server-sdk-redis/src/RedisClientState.ts b/packages/store/node-server-sdk-redis/src/RedisClientState.ts index 6b17eecdcd..0fbef2aaa1 100644 --- a/packages/store/node-server-sdk-redis/src/RedisClientState.ts +++ b/packages/store/node-server-sdk-redis/src/RedisClientState.ts @@ -111,7 +111,9 @@ export default class RedisClientState { */ close() { if (this.owned) { - this.client.quit(); + this.client.quit().catch(() => { + // Not any action that can be taken for an error on quit. + }); } } From 57edf2708c6a222b12c8abd0e6a8a2a856b96c05 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jun 2023 10:19:13 -0700 Subject: [PATCH 35/41] Fix big segment included/excluded keys. --- .../store/node-server-sdk-redis/src/RedisBigSegmentStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts index 11b1c200d1..14208e5d3f 100644 --- a/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisBigSegmentStore.ts @@ -10,12 +10,12 @@ export const KEY_LAST_SYNCHRONIZED = 'big_segments_synchronized_on'; /** * @internal */ -export const KEY_USER_INCLUDE = 'big_segment_include:'; +export const KEY_USER_INCLUDE = 'big_segment_include'; /** * @internal */ -export const KEY_USER_EXCLUDE = 'big_segment_exclude:'; +export const KEY_USER_EXCLUDE = 'big_segment_exclude'; export default class RedisBigSegmentStore implements interfaces.BigSegmentStore { private state: RedisClientState; From 127b6046a5df5a8be9099f65c1b6d14f0d6eb652 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jun 2023 10:27:16 -0700 Subject: [PATCH 36/41] Add doc script. --- packages/store/node-server-sdk-redis/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json index 7b4c20c877..3161d01de0 100644 --- a/packages/store/node-server-sdk-redis/package.json +++ b/packages/store/node-server-sdk-redis/package.json @@ -24,7 +24,8 @@ "test": "npx jest --ci --runInBand", "build": "npx tsc", "lint": "npx eslint . --ext .ts", - "lint:fix": "yarn run lint --fix" + "lint:fix": "yarn run lint --fix", + "doc": "../../../scripts/build-doc.sh ." }, "dependencies": { "ioredis": "^5.3.2" From a61bf369e3be90f48b34da650f3c2ada9a0656b5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 13 Jun 2023 15:46:15 -0700 Subject: [PATCH 37/41] Update node server package. --- packages/store/node-server-sdk-redis/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/store/node-server-sdk-redis/package.json b/packages/store/node-server-sdk-redis/package.json index 3161d01de0..e12df0e910 100644 --- a/packages/store/node-server-sdk-redis/package.json +++ b/packages/store/node-server-sdk-redis/package.json @@ -31,10 +31,10 @@ "ioredis": "^5.3.2" }, "peerDependencies": { - "@launchdarkly/node-server-sdk": "0.4.3" + "@launchdarkly/node-server-sdk": "0.4.4" }, "devDependencies": { - "@launchdarkly/node-server-sdk": "0.4.3", + "@launchdarkly/node-server-sdk": "0.4.4", "@types/jest": "^29.4.0", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", From b466af812f92cfccdc3eb3f5f2635f6f12f42887 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jun 2023 13:41:24 -0700 Subject: [PATCH 38/41] Update packages/store/node-server-sdk-redis/src/RedisClientState.ts Co-authored-by: Yusinto Ngadiman --- .../store/node-server-sdk-redis/src/RedisClientState.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/store/node-server-sdk-redis/src/RedisClientState.ts b/packages/store/node-server-sdk-redis/src/RedisClientState.ts index 0fbef2aaa1..28a30e3d5d 100644 --- a/packages/store/node-server-sdk-redis/src/RedisClientState.ts +++ b/packages/store/node-server-sdk-redis/src/RedisClientState.ts @@ -18,11 +18,11 @@ export default class RedisClientState { private initialConnection: boolean = true; - private client: Redis; + private readonly client: Redis; - private owned: boolean; + private readonly owned: boolean; - private base_prefix: string; + private readonly base_prefix: string; /** * Construct a state with the given client. From e7c5492603b1914720a8302c1b5a29fe6ae5ff4f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jun 2023 13:41:52 -0700 Subject: [PATCH 39/41] Update packages/store/node-server-sdk-redis/src/RedisClientState.ts Co-authored-by: Yusinto Ngadiman --- packages/store/node-server-sdk-redis/src/RedisClientState.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/store/node-server-sdk-redis/src/RedisClientState.ts b/packages/store/node-server-sdk-redis/src/RedisClientState.ts index 28a30e3d5d..509768306f 100644 --- a/packages/store/node-server-sdk-redis/src/RedisClientState.ts +++ b/packages/store/node-server-sdk-redis/src/RedisClientState.ts @@ -33,10 +33,10 @@ export default class RedisClientState { */ constructor(options?: LDRedisOptions, private readonly logger?: LDLogger) { if (options?.client) { - this.client = options!.client; + this.client = options.client; this.owned = false; } else if (options?.redisOpts) { - this.client = new Redis(options!.redisOpts); + this.client = new Redis(options.redisOpts); this.owned = true; } else { this.client = new Redis(); From 4b241c11f9170b686411d0f51e5f940c55664245 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jun 2023 13:43:03 -0700 Subject: [PATCH 40/41] Update packages/store/node-server-sdk-redis/src/RedisCore.ts Co-authored-by: Yusinto Ngadiman --- packages/store/node-server-sdk-redis/src/RedisCore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/store/node-server-sdk-redis/src/RedisCore.ts b/packages/store/node-server-sdk-redis/src/RedisCore.ts index 433184a355..e5f9d62868 100644 --- a/packages/store/node-server-sdk-redis/src/RedisCore.ts +++ b/packages/store/node-server-sdk-redis/src/RedisCore.ts @@ -140,7 +140,7 @@ export default class RedisCore implements interfaces.PersistentDataStore { const multi = this.state.getClient().multi(); this.get(kind, key, (old) => { - if (old && old.serializedItem) { + if (old?.serializedItem) { // Here, unfortunately, we have to deserialize the old item just to find // out its version number. See notes on this class. // Do not look at the meta-data, as we do not read/write it independently From 1c81d9e60ace01319d0227f60aae8ce3ffa0f4dd Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 15 Jun 2023 13:45:47 -0700 Subject: [PATCH 41/41] Log ioredis quit errors. --- packages/store/node-server-sdk-redis/src/RedisClientState.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/store/node-server-sdk-redis/src/RedisClientState.ts b/packages/store/node-server-sdk-redis/src/RedisClientState.ts index 509768306f..9a7d12421d 100644 --- a/packages/store/node-server-sdk-redis/src/RedisClientState.ts +++ b/packages/store/node-server-sdk-redis/src/RedisClientState.ts @@ -111,8 +111,9 @@ export default class RedisClientState { */ close() { if (this.owned) { - this.client.quit().catch(() => { + this.client.quit().catch((err) => { // Not any action that can be taken for an error on quit. + this.logger?.debug('Error closing ioredis client:', err); }); } }