1
0
mirror of https://github.com/redis/node-redis.git synced 2025-08-04 15:02:09 +03:00

Update doctest client with latest v4 release (#2844)

This commit is contained in:
Shaya Potter
2024-09-29 13:19:06 +03:00
committed by GitHub
parent fd7b10be6c
commit 49fdb79897
167 changed files with 8227 additions and 9599 deletions

39
.github/ISSUE_TEMPLATE/BUG-REPORT.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Bug Report
description: Tell us about something that isn't working as expected
labels: [Bug]
body:
- type: textarea
id: description
attributes:
label: Description
description: Please enter a detailed description of your issue. If possible, please provide example code to reproduce the issue.
validations:
required: true
- type: input
id: node-js-version
attributes:
label: Node.js Version
description: Please enter your Node.js version `node --version`
- type: input
id: redis-server-version
attributes:
label: Redis Server Version
description: Please enter your Redis server version ([`INFO server`](https://redis.io/commands/info/))
- type: input
id: node-redis-version
attributes:
label: Node Redis Version
description: Please enter your node redis version `npm ls redis`
- type: input
id: platform
attributes:
label: Platform
description: Please enter the platform you are using e.g. Linux, macOS, Windows
- type: textarea
id: logs
attributes:
label: Logs
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: bash
validations:
required: false

View File

@@ -0,0 +1,11 @@
name: Documentation
description: Any questions or issues relating to the project documentation.
labels: [Documentation]
body:
- type: textarea
id: description
attributes:
label: Description
description: Ask your question or describe your issue here.
validations:
required: true

View File

@@ -0,0 +1,19 @@
name: Feature Request
description: Suggest an idea for this project
labels: [Feature]
body:
- type: textarea
id: motivation
attributes:
label: Motivation
description: How would Node Redis users benefit from this feature?
validations:
required: true
- type: textarea
id: basic-code-example
attributes:
label: Basic Code Example
description: Provide examples of how you imagine the API for this feature might be implemented. This will be automatically formatted into code, so no need for backticks.
render: JavaScript
validations:
required: false

View File

@@ -1,15 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: Bug
assignees: ''
---
<!-- Describe your issue here -->
**Environment:**
- **Node.js Version**: <!-- e.g. "node --version" -->
- **Redis Server Version**: <!-- e.g. "redis-server --version" -->
- **Node Redis Version**: <!-- e.g. "npm ls redis" -->
- **Platform**: <!-- e.g. Ubuntu 20.04.3, Windows 10, Mac OS 11.6 -->

View File

@@ -1,7 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: Bug
assignees: ''
---

View File

@@ -0,0 +1,50 @@
name-template: 'bloom@$NEXT_PATCH_VERSION'
tag-template: 'bloom@$NEXT_PATCH_VERSION'
autolabeler:
- label: 'chore'
files:
- '*.md'
- '.github/*'
- label: 'bug'
branch:
- '/bug-.+'
- label: 'chore'
branch:
- '/chore-.+'
- label: 'feature'
branch:
- '/feature-.+'
categories:
- title: 'Breaking Changes'
labels:
- 'breakingchange'
- title: '🚀 New Features'
labels:
- 'feature'
- 'enhancement'
- title: '🐛 Bug Fixes'
labels:
- 'fix'
- 'bugfix'
- 'bug'
- title: '🧰 Maintenance'
label:
- 'chore'
- 'maintenance'
- 'documentation'
- 'docs'
change-template: '- $TITLE (#$NUMBER)'
include-paths:
- 'packages/bloom'
exclude-labels:
- 'skip-changelog'
template: |
## Changes
$CHANGES
## Contributors
We'd like to thank all the contributors who worked on this release!
$CONTRIBUTORS

View File

@@ -0,0 +1,49 @@
name-template: 'graph@$NEXT_PATCH_VERSION'
tag-template: 'graph@$NEXT_PATCH_VERSION'
autolabeler:
- label: 'chore'
files:
- '*.md'
- '.github/*'
- label: 'bug'
branch:
- '/bug-.+'
- label: 'chore'
branch:
- '/chore-.+'
- label: 'feature'
branch:
- '/feature-.+'
categories:
- title: 'Breaking Changes'
labels:
- 'breakingchange'
- title: '🚀 New Features'
labels:
- 'feature'
- 'enhancement'
- title: '🐛 Bug Fixes'
labels:
- 'fix'
- 'bugfix'
- 'bug'
- title: '🧰 Maintenance'
label:
- 'chore'
- 'maintenance'
- 'documentation'
- 'docs'
change-template: '- $TITLE (#$NUMBER)'
include-paths:
- 'packages/graph'
exclude-labels:
- 'skip-changelog'
template: |
## Changes
$CHANGES
## Contributors
We'd like to thank all the contributors who worked on this release!
$CONTRIBUTORS

View File

@@ -1,5 +1,5 @@
name-template: 'Version $NEXT_PATCH_VERSION' name-template: 'json@$NEXT_PATCH_VERSION'
tag-template: 'v$NEXT_PATCH_VERSION' tag-template: 'json@$NEXT_PATCH_VERSION'
autolabeler: autolabeler:
- label: 'chore' - label: 'chore'
files: files:
@@ -28,8 +28,15 @@ categories:
- 'bugfix' - 'bugfix'
- 'bug' - 'bug'
- title: '🧰 Maintenance' - title: '🧰 Maintenance'
label: 'chore' label:
- 'chore'
- 'maintenance'
- 'documentation'
- 'docs'
change-template: '- $TITLE (#$NUMBER)' change-template: '- $TITLE (#$NUMBER)'
include-paths:
- 'packages/json'
exclude-labels: exclude-labels:
- 'skip-changelog' - 'skip-changelog'
template: | template: |

View File

@@ -0,0 +1,50 @@
name-template: 'search@$NEXT_PATCH_VERSION'
tag-template: 'search@$NEXT_PATCH_VERSION'
autolabeler:
- label: 'chore'
files:
- '*.md'
- '.github/*'
- label: 'bug'
branch:
- '/bug-.+'
- label: 'chore'
branch:
- '/chore-.+'
- label: 'feature'
branch:
- '/feature-.+'
categories:
- title: 'Breaking Changes'
labels:
- 'breakingchange'
- title: '🚀 New Features'
labels:
- 'feature'
- 'enhancement'
- title: '🐛 Bug Fixes'
labels:
- 'fix'
- 'bugfix'
- 'bug'
- title: '🧰 Maintenance'
label:
- 'chore'
- 'maintenance'
- 'documentation'
- 'docs'
change-template: '- $TITLE (#$NUMBER)'
include-paths:
- 'packages/search'
exclude-labels:
- 'skip-changelog'
template: |
## Changes
$CHANGES
## Contributors
We'd like to thank all the contributors who worked on this release!
$CONTRIBUTORS

View File

@@ -0,0 +1,49 @@
name-template: 'time-series@$NEXT_PATCH_VERSION'
tag-template: 'time-series@$NEXT_PATCH_VERSION'
autolabeler:
- label: 'chore'
files:
- '*.md'
- '.github/*'
- label: 'bug'
branch:
- '/bug-.+'
- label: 'chore'
branch:
- '/chore-.+'
- label: 'feature'
branch:
- '/feature-.+'
categories:
- title: 'Breaking Changes'
labels:
- 'breakingchange'
- title: '🚀 New Features'
labels:
- 'feature'
- 'enhancement'
- title: '🐛 Bug Fixes'
labels:
- 'fix'
- 'bugfix'
- 'bug'
- title: '🧰 Maintenance'
label:
- 'chore'
- 'maintenance'
- 'documentation'
- 'docs'
change-template: '- $TITLE (#$NUMBER)'
include-paths:
- 'packages/time-series'
exclude-labels:
- 'skip-changelog'
template: |
## Changes
$CHANGES
## Contributors
We'd like to thank all the contributors who worked on this release!
$CONTRIBUTORS

View File

@@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL

View File

@@ -10,11 +10,11 @@ jobs:
documentation: documentation:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2.3.4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v2.3.0 uses: actions/setup-node@v3
- name: Install Packages - name: Install Packages
run: npm ci run: npm ci
- name: Build tests tools - name: Build tests tools

View File

@@ -0,0 +1,24 @@
name: Release Drafter
on:
push:
# branches to consider in the event; optional, defaults to all
branches:
- master
jobs:
update_release_draft:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
# Drafts your next Release notes as Pull Requests are merged into "master"
- uses: release-drafter/release-drafter@v5
with:
# (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml
config-name: release-drafter/bloom-config.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,24 @@
name: Release Drafter
on:
push:
# branches to consider in the event; optional, defaults to all
branches:
- master
jobs:
update_release_draft:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
# Drafts your next Release notes as Pull Requests are merged into "master"
- uses: release-drafter/release-drafter@v5
with:
# (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml
config-name: release-drafter/graph-config.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -7,13 +7,18 @@ on:
- master - master
jobs: jobs:
update_release_draft: update_release_draft:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Drafts your next Release notes as Pull Requests are merged into "master" # Drafts your next Release notes as Pull Requests are merged into "master"
- uses: release-drafter/release-drafter@v5 - uses: release-drafter/release-drafter@v5
with: with:
# (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml
config-name: release-drafter-config.yml config-name: release-drafter/json-config.yml
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,24 @@
name: Release Drafter
on:
push:
# branches to consider in the event; optional, defaults to all
branches:
- master
jobs:
update_release_draft:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
# Drafts your next Release notes as Pull Requests are merged into "master"
- uses: release-drafter/release-drafter@v5
with:
# (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml
config-name: release-drafter/search-config.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,24 @@
name: Release Drafter
on:
push:
# branches to consider in the event; optional, defaults to all
branches:
- master
jobs:
update_release_draft:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
# Drafts your next Release notes as Pull Requests are merged into "master"
- uses: release-drafter/release-drafter@v5
with:
# (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml
config-name: release-drafter/time-series-config.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -16,14 +16,14 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
node-version: ['14', '16', '18', '19'] node-version: ['18', '20']
redis-version: ['5', '6.0', '6.2', '7.0'] redis-version: ['5', '6.0', '6.2', '7.0', '7.2', '7.4-rc2']
steps: steps:
- uses: actions/checkout@v2.3.4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2.3.0 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- name: Update npm - name: Update npm

35
LICENSE
View File

@@ -1,24 +1,21 @@
MIT License MIT License
Copyright (c) 2016-present Node Redis contributors. Copyright (c) 2022-2023, Redis, inc.
Permission is hereby granted, free of charge, to any person Permission is hereby granted, free of charge, to any person obtaining a copy
obtaining a copy of this software and associated documentation of this software and associated documentation files (the "Software"), to deal
files (the "Software"), to deal in the Software without in the Software without restriction, including without limitation the rights
restriction, including without limitation the rights to use, to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
copies of the Software, and to permit persons to whom the furnished to do so, subject to the following conditions:
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be The above copyright notice and this permission notice shall be included in all
included in all copies or substantial portions of the Software. copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR SOFTWARE.
OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -11,6 +11,19 @@
node-redis is a modern, high performance [Redis](https://redis.io) client for Node.js. node-redis is a modern, high performance [Redis](https://redis.io) client for Node.js.
## How do I Redis?
[Learn for free at Redis University](https://university.redis.com/)
[Build faster with the Redis Launchpad](https://launchpad.redis.com/)
[Try the Redis Cloud](https://redis.com/try-free/)
[Dive in developer tutorials](https://developer.redis.com/)
[Join the Redis community](https://redis.com/community/)
[Work at Redis](https://redis.com/company/careers/jobs/)
## Packages ## Packages
@@ -51,11 +64,9 @@ Looking for a high-level library to handle object mapping? See [redis-om-node](h
```typescript ```typescript
import { createClient } from 'redis'; import { createClient } from 'redis';
const client = createClient(); const client = await createClient()
.on('error', err => console.log('Redis Client Error', err))
client.on('error', (err) => console.log('Redis Client Error', err)); .connect();
await client.connect();
await client.set('key', 'value'); await client.set('key', 'value');
const value = await client.get('key'); const value = await client.get('key');
@@ -166,47 +177,7 @@ To learn more about isolated execution, check out the [guide](./docs/isolated-ex
### Pub/Sub ### Pub/Sub
Subscribing to a channel requires a dedicated stand-alone connection. You can easily get one by `.duplicate()`ing an existing Redis connection. See the [Pub/Sub overview](./docs/pub-sub.md).
```typescript
const subscriber = client.duplicate();
await subscriber.connect();
```
Once you have one, simply subscribe and unsubscribe as needed:
```typescript
await subscriber.subscribe('channel', (message) => {
console.log(message); // 'message'
});
await subscriber.pSubscribe('channe*', (message, channel) => {
console.log(message, channel); // 'message', 'channel'
});
await subscriber.unsubscribe('channel');
await subscriber.pUnsubscribe('channe*');
```
Publish a message on a channel:
```typescript
await publisher.publish('channel', 'message');
```
There is support for buffers as well:
```typescript
await subscriber.subscribe('channel', (message) => {
console.log(message); // <Buffer 6d 65 73 73 61 67 65>
}, true);
await subscriber.pSubscribe('channe*', (message, channel) => {
console.log(message, channel); // <Buffer 6d 65 73 73 61 67 65>, <Buffer 63 68 61 6e 6e 65 6c>
}, true);
```
### Scan Iterator ### Scan Iterator
@@ -373,15 +344,18 @@ Check out the [Clustering Guide](./docs/clustering.md) when using Node Redis to
The Node Redis client class is an Nodejs EventEmitter and it emits an event each time the network status changes: The Node Redis client class is an Nodejs EventEmitter and it emits an event each time the network status changes:
| Event name | Scenes | Arguments to be passed to the listener | | Name | When | Listener arguments |
|----------------|-------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------| |-------------------------|------------------------------------------------------------------------------------|------------------------------------------------------------|
| `connect` | The client is initiating a connection to the server. | _No argument_ | | `connect` | Initiating a connection to the server | *No arguments* |
| `ready` | The client successfully initiated the connection to the server. | _No argument_ | | `ready` | Client is ready to use | *No arguments* |
| `end` | The client disconnected the connection to the server via `.quit()` or `.disconnect()`. | _No argument_ | | `end` | Connection has been closed (via `.quit()` or `.disconnect()`) | *No arguments* |
| `error` | When a network error has occurred, such as unable to connect to the server or the connection closed unexpectedly. | 1 argument: The error object, such as `SocketClosedUnexpectedlyError: Socket closed unexpectedly` or `Error: connect ECONNREFUSED [IP]:[PORT]` | | `error` | An error has occurred—usually a network issue such as "Socket closed unexpectedly" | `(error: Error)` |
| `reconnecting` | The client is trying to reconnect to the server. | _No argument_ | | `reconnecting` | Client is trying to reconnect to the server | *No arguments* |
| `sharded-channel-moved` | See [here](./docs/pub-sub.md#sharded-channel-moved-event) | See [here](./docs/pub-sub.md#sharded-channel-moved-event) |
The client will not emit [any other events](./docs/v3-to-v4.md#all-the-removed-events) beyond those listed above. > :warning: You **MUST** listen to `error` events. If a client doesn't have at least one `error` listener registered and an `error` occurs, that error will be thrown and the Node.js process will exit. See the [`EventEmitter` docs](https://nodejs.org/api/events.html#events_error_events) for more details.
> The client will not emit [any other events](./docs/v3-to-v4.md#all-the-removed-events) beyond those listed above.
## Supported Redis versions ## Supported Redis versions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@redis/client-benchmark", "name": "@redis/client-benchmark",
"lockfileVersion": 2, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
@@ -8,39 +8,9 @@
"dependencies": { "dependencies": {
"@redis/client": "../packages/client", "@redis/client": "../packages/client",
"hdr-histogram-js": "3.0.0", "hdr-histogram-js": "3.0.0",
"ioredis": "5.2.2", "ioredis": "5.3.2",
"redis-v3": "npm:redis@3.1.2", "redis-v3": "npm:redis@3.1.2",
"yargs": "17.5.1" "yargs": "17.7.2"
}
},
"../packages/client": {
"name": "@redis/client",
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.0",
"generic-pool": "3.8.2",
"yallist": "4.0.0"
},
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@redis/test-utils": "*",
"@types/node": "^18.7.10",
"@types/sinon": "^10.0.13",
"@types/yallist": "^4.0.1",
"@typescript-eslint/eslint-plugin": "^5.34.0",
"@typescript-eslint/parser": "^5.34.0",
"eslint": "^8.22.0",
"nyc": "^15.1.0",
"release-it": "^15.3.0",
"sinon": "^14.0.0",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.1",
"typedoc": "^0.23.10",
"typescript": "^4.7.4"
},
"engines": {
"node": ">=14"
} }
}, },
"node_modules/@assemblyscript/loader": { "node_modules/@assemblyscript/loader": {
@@ -49,13 +19,22 @@
"integrity": "sha512-ulkCYfFbYj01ie1MDOyxv2F6SpRN1TOj7fQxbP07D6HmeR+gr2JLSmINKjga2emB+b1L2KGrFKBTc+e00p54nw==" "integrity": "sha512-ulkCYfFbYj01ie1MDOyxv2F6SpRN1TOj7fQxbP07D6HmeR+gr2JLSmINKjga2emB+b1L2KGrFKBTc+e00p54nw=="
}, },
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.1.1", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
"integrity": "sha512-fsR4P/ROllzf/7lXYyElUJCheWdTJVJvOTps8v9IWKFATxR61ANOlnoPqhH099xYLrJGpc2ZQ28B3rMeUt5VQg==" "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="
}, },
"node_modules/@redis/client": { "node_modules/@redis/client": {
"resolved": "../packages/client", "version": "1.5.7",
"link": true "resolved": "file:../packages/client",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=14"
}
}, },
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
@@ -99,19 +78,22 @@
] ]
}, },
"node_modules/cliui": { "node_modules/cliui": {
"version": "7.0.4", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dependencies": { "dependencies": {
"string-width": "^4.2.0", "string-width": "^4.2.0",
"strip-ansi": "^6.0.0", "strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0" "wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
} }
}, },
"node_modules/cluster-key-slot": { "node_modules/cluster-key-slot": {
"version": "1.1.0", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==", "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -149,9 +131,9 @@
} }
}, },
"node_modules/denque": { "node_modules/denque": {
"version": "2.0.1", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ==", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"engines": { "engines": {
"node": ">=0.10" "node": ">=0.10"
} }
@@ -169,6 +151,14 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/generic-pool": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
"integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
"engines": {
"node": ">= 4"
}
},
"node_modules/get-caller-file": { "node_modules/get-caller-file": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -191,14 +181,14 @@
} }
}, },
"node_modules/ioredis": { "node_modules/ioredis": {
"version": "5.2.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.2.2.tgz", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz",
"integrity": "sha512-wryKc1ur8PcCmNwfcGkw5evouzpbDXxxkMkzPK8wl4xQfQf7lHe11Jotell5ikMVAtikXJEu/OJVaoV51BggRQ==", "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==",
"dependencies": { "dependencies": {
"@ioredis/commands": "^1.1.1", "@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0", "cluster-key-slot": "^1.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"denque": "^2.0.1", "denque": "^2.1.0",
"lodash.defaults": "^4.2.0", "lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0", "lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0", "redis-errors": "^1.2.0",
@@ -224,12 +214,12 @@
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
}, },
"node_modules/lodash.isarguments": { "node_modules/lodash.isarguments": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=" "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="
}, },
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
@@ -249,7 +239,7 @@
"node_modules/redis-errors": { "node_modules/redis-errors": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"engines": { "engines": {
"node": ">=4" "node": ">=4"
} }
@@ -257,7 +247,7 @@
"node_modules/redis-parser": { "node_modules/redis-parser": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"dependencies": { "dependencies": {
"redis-errors": "^1.0.0" "redis-errors": "^1.0.0"
}, },
@@ -295,7 +285,7 @@
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -353,288 +343,35 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.5.1", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dependencies": { "dependencies": {
"cliui": "^7.0.2", "cliui": "^8.0.1",
"escalade": "^3.1.1", "escalade": "^3.1.1",
"get-caller-file": "^2.0.5", "get-caller-file": "^2.0.5",
"require-directory": "^2.1.1", "require-directory": "^2.1.1",
"string-width": "^4.2.3", "string-width": "^4.2.3",
"y18n": "^5.0.5", "y18n": "^5.0.5",
"yargs-parser": "^21.0.0" "yargs-parser": "^21.1.1"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/yargs-parser": { "node_modules/yargs-parser": {
"version": "21.0.1", "version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
} }
},
"dependencies": {
"@assemblyscript/loader": {
"version": "0.19.23",
"resolved": "https://registry.npmjs.org/@assemblyscript/loader/-/loader-0.19.23.tgz",
"integrity": "sha512-ulkCYfFbYj01ie1MDOyxv2F6SpRN1TOj7fQxbP07D6HmeR+gr2JLSmINKjga2emB+b1L2KGrFKBTc+e00p54nw=="
},
"@ioredis/commands": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.1.1.tgz",
"integrity": "sha512-fsR4P/ROllzf/7lXYyElUJCheWdTJVJvOTps8v9IWKFATxR61ANOlnoPqhH099xYLrJGpc2ZQ28B3rMeUt5VQg=="
},
"@redis/client": {
"version": "file:../packages/client",
"requires": {
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@redis/test-utils": "*",
"@types/node": "^18.7.10",
"@types/sinon": "^10.0.13",
"@types/yallist": "^4.0.1",
"@typescript-eslint/eslint-plugin": "^5.34.0",
"@typescript-eslint/parser": "^5.34.0",
"cluster-key-slot": "1.1.0",
"eslint": "^8.22.0",
"generic-pool": "3.8.2",
"nyc": "^15.1.0",
"release-it": "^15.3.0",
"sinon": "^14.0.0",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.1",
"typedoc": "^0.23.10",
"typescript": "^4.7.4",
"yallist": "4.0.0"
}
},
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"requires": {
"color-convert": "^2.0.1"
}
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"requires": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"cluster-key-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
"integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw=="
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"requires": {
"ms": "2.1.2"
}
},
"denque": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz",
"integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ=="
},
"emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="
},
"hdr-histogram-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hdr-histogram-js/-/hdr-histogram-js-3.0.0.tgz",
"integrity": "sha512-/EpvQI2/Z98mNFYEnlqJ8Ogful8OpArLG/6Tf2bPnkutBVLIeMVNHjk1ZDfshF2BUweipzbk+dB1hgSB7SIakw==",
"requires": {
"@assemblyscript/loader": "^0.19.21",
"base64-js": "^1.2.0",
"pako": "^1.0.3"
}
},
"ioredis": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.2.2.tgz",
"integrity": "sha512-wryKc1ur8PcCmNwfcGkw5evouzpbDXxxkMkzPK8wl4xQfQf7lHe11Jotell5ikMVAtikXJEu/OJVaoV51BggRQ==",
"requires": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.0.1",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
}
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
},
"lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
},
"lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo="
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"redis-commands": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
"integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
},
"redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60="
},
"redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
"requires": {
"redis-errors": "^1.0.0"
}
},
"redis-v3": {
"version": "npm:redis@3.1.2",
"resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz",
"integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==",
"requires": {
"denque": "^1.5.0",
"redis-commands": "^1.7.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0"
},
"dependencies": {
"denque": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz",
"integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw=="
}
}
},
"require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I="
},
"standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
},
"string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
}
},
"strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"requires": {
"ansi-regex": "^5.0.1"
}
},
"wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
}
},
"y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="
},
"yargs": {
"version": "17.5.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz",
"integrity": "sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==",
"requires": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.0.0"
}
},
"yargs-parser": {
"version": "21.0.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz",
"integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg=="
}
} }
} }

View File

@@ -9,8 +9,8 @@
"dependencies": { "dependencies": {
"@redis/client": "../packages/client", "@redis/client": "../packages/client",
"hdr-histogram-js": "3.0.0", "hdr-histogram-js": "3.0.0",
"ioredis": "5.2.2", "ioredis": "5.3.2",
"redis-v3": "npm:redis@3.1.2", "redis-v3": "npm:redis@3.1.2",
"yargs": "17.5.1" "yargs": "17.7.2"
} }
} }

View File

@@ -10,7 +10,7 @@ If don't want to queue commands in memory until a new socket is established, set
## How are commands batched? ## How are commands batched?
Commands are pipelined using [`queueMicrotask`](https://nodejs.org/api/globals.html#globals_queuemicrotask_callback). Commands are pipelined using [`setImmediate`](https://nodejs.org/api/timers.html#setimmediatecallback-args).
If `socket.write()` returns `false`—meaning that ["all or part of the data was queued in user memory"](https://nodejs.org/api/net.html#net_socket_write_data_encoding_callback:~:text=all%20or%20part%20of%20the%20data%20was%20queued%20in%20user%20memory)—the commands will stack in memory until the [`drain`](https://nodejs.org/api/net.html#net_event_drain) event is fired. If `socket.write()` returns `false`—meaning that ["all or part of the data was queued in user memory"](https://nodejs.org/api/net.html#net_socket_write_data_encoding_callback:~:text=all%20or%20part%20of%20the%20data%20was%20queued%20in%20user%20memory)—the commands will stack in memory until the [`drain`](https://nodejs.org/api/net.html#net_event_drain) event is fired.

View File

@@ -15,7 +15,7 @@
| socket.reconnectStrategy | `retries => Math.min(retries * 50, 500)` | A function containing the [Reconnect Strategy](#reconnect-strategy) logic | | socket.reconnectStrategy | `retries => Math.min(retries * 50, 500)` | A function containing the [Reconnect Strategy](#reconnect-strategy) logic |
| username | | ACL username ([see ACL guide](https://redis.io/topics/acl)) | | username | | ACL username ([see ACL guide](https://redis.io/topics/acl)) |
| password | | ACL password or the old "--requirepass" password | | password | | ACL password or the old "--requirepass" password |
| name | | Connection name ([see `CLIENT SETNAME`](https://redis.io/commands/client-setname)) | | name | | Client name ([see `CLIENT SETNAME`](https://redis.io/commands/client-setname)) |
| database | | Redis database number (see [`SELECT`](https://redis.io/commands/select) command) | | database | | Redis database number (see [`SELECT`](https://redis.io/commands/select) command) |
| modules | | Included [Redis Modules](../README.md#packages) | | modules | | Included [Redis Modules](../README.md#packages) |
| scripts | | Script definitions (see [Lua Scripts](../README.md#lua-scripts)) | | scripts | | Script definitions (see [Lua Scripts](../README.md#lua-scripts)) |
@@ -25,16 +25,24 @@
| readonly | `false` | Connect in [`READONLY`](https://redis.io/commands/readonly) mode | | readonly | `false` | Connect in [`READONLY`](https://redis.io/commands/readonly) mode |
| legacyMode | `false` | Maintain some backwards compatibility (see the [Migration Guide](./v3-to-v4.md)) | | legacyMode | `false` | Maintain some backwards compatibility (see the [Migration Guide](./v3-to-v4.md)) |
| isolationPoolOptions | | See the [Isolated Execution Guide](./isolated-execution.md) | | isolationPoolOptions | | See the [Isolated Execution Guide](./isolated-execution.md) |
| pingInterval | | Send `PING` command at interval (in ms). Useful with "[Azure Cache for Redis](https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-best-practices-connection#idle-timeout)" | | pingInterval | | Send `PING` command at interval (in ms). Useful with ["Azure Cache for Redis"](https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-best-practices-connection#idle-timeout) |
## Reconnect Strategy ## Reconnect Strategy
You can implement a custom reconnect strategy as a function: When the socket closes unexpectedly (without calling `.quit()`/`.disconnect()`), the client uses `reconnectStrategy` to decide what to do. The following values are supported:
1. `false` -> do not reconnect, close the client and flush the command queue.
2. `number` -> wait for `X` milliseconds before reconnecting.
3. `(retries: number, cause: Error) => false | number | Error` -> `number` is the same as configuring a `number` directly, `Error` is the same as `false`, but with a custom error.
- Receives the number of retries attempted so far. By default the strategy is `Math.min(retries * 50, 500)`, but it can be overwritten like so:
- Returns `number | Error`:
- `number`: wait time in milliseconds prior to attempting a reconnect. ```javascript
- `Error`: closes the client and flushes internal command queues. createClient({
socket: {
reconnectStrategy: retries => Math.min(retries * 50, 1000)
}
});
```
## TLS ## TLS
@@ -44,7 +52,7 @@ To enable TLS, set `socket.tls` to `true`. Below are some basic examples.
### Create a SSL client ### Create a SSL client
```typescript ```javascript
createClient({ createClient({
socket: { socket: {
tls: true, tls: true,
@@ -56,7 +64,7 @@ createClient({
### Create a SSL client using a self-signed certificate ### Create a SSL client using a self-signed certificate
```typescript ```javascript
createClient({ createClient({
socket: { socket: {
tls: true, tls: true,

View File

@@ -35,35 +35,70 @@ const value = await cluster.get('key');
| rootNodes | | An array of root nodes that are part of the cluster, which will be used to get the cluster topology. Each element in the array is a client configuration object. There is no need to specify every node in the cluster, 3 should be enough to reliably connect and obtain the cluster configuration from the server | | rootNodes | | An array of root nodes that are part of the cluster, which will be used to get the cluster topology. Each element in the array is a client configuration object. There is no need to specify every node in the cluster, 3 should be enough to reliably connect and obtain the cluster configuration from the server |
| defaults | | The default configuration values for every client in the cluster. Use this for example when specifying an ACL user to connect with | | defaults | | The default configuration values for every client in the cluster. Use this for example when specifying an ACL user to connect with |
| useReplicas | `false` | When `true`, distribute load by executing readonly commands (such as `GET`, `GEOSEARCH`, etc.) across all cluster nodes. When `false`, only use master nodes | | useReplicas | `false` | When `true`, distribute load by executing readonly commands (such as `GET`, `GEOSEARCH`, etc.) across all cluster nodes. When `false`, only use master nodes |
| minimizeConnections | `false` | When `true`, `.connect()` will only discover the cluster topology, without actually connecting to all the nodes. Useful for short-term or Pub/Sub-only connections. |
| maxCommandRedirections | `16` | The maximum number of times a command will be redirected due to `MOVED` or `ASK` errors | | maxCommandRedirections | `16` | The maximum number of times a command will be redirected due to `MOVED` or `ASK` errors |
| nodeAddressMap | | Defines the [node address mapping](#node-address-map) | | nodeAddressMap | | Defines the [node address mapping](#node-address-map) |
| modules | | Included [Redis Modules](../README.md#packages) | | modules | | Included [Redis Modules](../README.md#packages) |
| scripts | | Script definitions (see [Lua Scripts](../README.md#lua-scripts)) | | scripts | | Script definitions (see [Lua Scripts](../README.md#lua-scripts)) |
| functions | | Function definitions (see [Functions](../README.md#functions)) | | functions | | Function definitions (see [Functions](../README.md#functions)) |
## Auth with password and username
## Node Address Map Specifying the password in the URL or a root node will only affect the connection to that specific node. In case you want to set the password for all the connections being created from a cluster instance, use the `defaults` option.
A node address map is required when a Redis cluster is configured with addresses that are inaccessible by the machine running the Redis client.
This is a mapping of addresses and ports, with the values being the accessible address/port combination. Example:
```javascript ```javascript
createCluster({ createCluster({
rootNodes: [{ rootNodes: [{
url: 'external-host-1.io:30001' url: 'redis://10.0.0.1:30001'
}, { }, {
url: 'external-host-2.io:30002' url: 'redis://10.0.0.2:30002'
}], }],
defaults: {
username: 'username',
password: 'password'
}
});
```
## Node Address Map
A mapping between the addresses in the cluster (see `CLUSTER SHARDS`) and the addresses the client should connect to.
Useful when the cluster is running on a different network to the client.
```javascript
const rootNodes = [{
url: 'external-host-1.io:30001'
}, {
url: 'external-host-2.io:30002'
}];
// Use either a static mapping:
createCluster({
rootNodes,
nodeAddressMap: { nodeAddressMap: {
'10.0.0.1:30001': { '10.0.0.1:30001': {
host: 'external-host-1.io', host: 'external-host.io',
port: 30001 port: 30001
}, },
'10.0.0.2:30002': { '10.0.0.2:30002': {
host: 'external-host-2.io', host: 'external-host.io',
port: 30002 port: 30002
} }
} }
}); });
// or create the mapping dynamically, as a function:
createCluster({
rootNodes,
nodeAddressMap(address) {
const indexOfDash = address.lastIndexOf('-'),
indexOfDot = address.indexOf('.', indexOfDash),
indexOfColons = address.indexOf(':', indexOfDot);
return {
host: `external-host-${address.substring(indexOfDash + 1, indexOfDot)}.io`,
port: Number(address.substring(indexOfColons + 1))
};
}
});
``` ```
> This is a common problem when using ElastiCache. See [Accessing ElastiCache from outside AWS](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/accessing-elasticache.html) for more information on that. > This is a common problem when using ElastiCache. See [Accessing ElastiCache from outside AWS](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/accessing-elasticache.html) for more information on that.

88
docs/pub-sub.md Normal file
View File

@@ -0,0 +1,88 @@
# Pub/Sub
The Pub/Sub API is implemented by `RedisClient` and `RedisCluster`.
## Pub/Sub with `RedisClient`
Pub/Sub requires a dedicated stand-alone client. You can easily get one by `.duplicate()`ing an existing `RedisClient`:
```typescript
const subscriber = client.duplicate();
subscriber.on('error', err => console.error(err));
await subscriber.connect();
```
When working with a `RedisCluster`, this is handled automatically for you.
### `sharded-channel-moved` event
`RedisClient` emits the `sharded-channel-moved` event when the ["cluster slot"](https://redis.io/docs/reference/cluster-spec/#key-distribution-model) of a subscribed [Sharded Pub/Sub](https://redis.io/docs/manual/pubsub/#sharded-pubsub) channel has been moved to another shard.
The event listener signature is as follows:
```typescript
(
channel: string,
listeners: {
buffers: Set<Listener>;
strings: Set<Listener>;
}
)
```
## Subscribing
```javascript
const listener = (message, channel) => console.log(message, channel);
await client.subscribe('channel', listener);
await client.pSubscribe('channe*', listener);
// Use sSubscribe for sharded Pub/Sub:
await client.sSubscribe('channel', listener);
```
> ⚠️ Subscribing to the same channel more than once will create multiple listeners which will each be called when a message is recieved.
## Publishing
```javascript
await client.publish('channel', 'message');
// Use sPublish for sharded Pub/Sub:
await client.sPublish('channel', 'message');
```
## Unsubscribing
The code below unsubscribes all listeners from all channels.
```javascript
await client.unsubscribe();
await client.pUnsubscribe();
// Use sUnsubscribe for sharded Pub/Sub:
await client.sUnsubscribe();
```
To unsubscribe from specific channels:
```javascript
await client.unsubscribe('channel');
await client.unsubscribe(['1', '2']);
```
To unsubscribe a specific listener:
```javascript
await client.unsubscribe('channel', listener);
```
## Buffers
Publishing and subscribing using `Buffer`s is also supported:
```javascript
await subscriber.subscribe('channel', message => {
console.log(message); // <Buffer 6d 65 73 73 61 67 65>
}, true); // true = subscribe in `Buffer` mode.
await subscriber.publish(Buffer.from('channel'), Buffer.from('message'));
```
> NOTE: Buffers and strings are supported both for the channel name and the message. You can mix and match these as desired.

View File

@@ -3,7 +3,7 @@
This folder contains example scripts showing how to use Node Redis in different scenarios. This folder contains example scripts showing how to use Node Redis in different scenarios.
| File Name | Description | | File Name | Description |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | |------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|
| `blocking-list-pop.js` | Block until an element is pushed to a list. | | `blocking-list-pop.js` | Block until an element is pushed to a list. |
| `bloom-filter.js` | Space efficient set membership checks with a [Bloom Filter](https://en.wikipedia.org/wiki/Bloom_filter) using [RedisBloom](https://redisbloom.io). | | `bloom-filter.js` | Space efficient set membership checks with a [Bloom Filter](https://en.wikipedia.org/wiki/Bloom_filter) using [RedisBloom](https://redisbloom.io). |
| `check-connection-status.js` | Check the client's connection status. | | `check-connection-status.js` | Check the client's connection status. |
@@ -12,6 +12,7 @@ This folder contains example scripts showing how to use Node Redis in different
| `connect-to-cluster.js` | Connect to a Redis cluster. | | `connect-to-cluster.js` | Connect to a Redis cluster. |
| `count-min-sketch.js` | Estimate the frequency of a given event using the [RedisBloom](https://redisbloom.io) Count-Min Sketch. | | `count-min-sketch.js` | Estimate the frequency of a given event using the [RedisBloom](https://redisbloom.io) Count-Min Sketch. |
| `cuckoo-filter.js` | Space efficient set membership checks with a [Cuckoo Filter](https://en.wikipedia.org/wiki/Cuckoo_filter) using [RedisBloom](https://redisbloom.io). | | `cuckoo-filter.js` | Space efficient set membership checks with a [Cuckoo Filter](https://en.wikipedia.org/wiki/Cuckoo_filter) using [RedisBloom](https://redisbloom.io). |
| `dump-and-restore.js` | Demonstrates the use of the [`DUMP`](https://redis.io/commands/dump/) and [`RESTORE`](https://redis.io/commands/restore/) commands |
| `get-server-time.js` | Get the time from the Redis server. | | `get-server-time.js` | Get the time from the Redis server. |
| `hyperloglog.js` | Showing use of Hyperloglog commands [PFADD, PFCOUNT and PFMERGE](https://redis.io/commands/?group=hyperloglog). | | `hyperloglog.js` | Showing use of Hyperloglog commands [PFADD, PFCOUNT and PFMERGE](https://redis.io/commands/?group=hyperloglog). |
| `lua-multi-incr.js` | Define a custom lua script that allows you to perform INCRBY on multiple keys. | | `lua-multi-incr.js` | Define a custom lua script that allows you to perform INCRBY on multiple keys. |

View File

@@ -0,0 +1,22 @@
// This example demonstrates the use of the DUMP and RESTORE commands
import { commandOptions, createClient } from 'redis';
const client = createClient();
await client.connect();
// DUMP a specific key into a local variable
const dump = await client.dump(
commandOptions({ returnBuffers: true }),
'source'
);
// RESTORE into a new key
await client.restore('destination', 0, dump);
// RESTORE and REPLACE an existing key
await client.restore('destination', 0, dump, {
REPLACE: true
});
await client.quit();

View File

@@ -13,7 +13,7 @@ try {
await client.ft.create('idx:animals', { await client.ft.create('idx:animals', {
name: { name: {
type: SchemaFieldTypes.TEXT, type: SchemaFieldTypes.TEXT,
sortable: true SORTABLE: true
}, },
species: SchemaFieldTypes.TAG, species: SchemaFieldTypes.TAG,
age: SchemaFieldTypes.NUMERIC age: SchemaFieldTypes.NUMERIC

View File

@@ -15,7 +15,7 @@ try {
await client.ft.create('idx:users', { await client.ft.create('idx:users', {
'$.name': { '$.name': {
type: SchemaFieldTypes.TEXT, type: SchemaFieldTypes.TEXT,
SORTABLE: 'UNF' SORTABLE: true
}, },
'$.age': { '$.age': {
type: SchemaFieldTypes.NUMERIC, type: SchemaFieldTypes.NUMERIC,

10969
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "redis", "name": "redis",
"description": "A modern, high performance Redis client", "description": "A modern, high performance Redis client",
"version": "4.5.1", "version": "4.7.0",
"license": "MIT", "license": "MIT",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -23,18 +23,18 @@
"gh-pages": "gh-pages -d ./documentation -e ./documentation -u 'documentation-bot <documentation@bot>'" "gh-pages": "gh-pages -d ./documentation -e ./documentation -u 'documentation-bot <documentation@bot>'"
}, },
"dependencies": { "dependencies": {
"@redis/bloom": "1.1.0", "@redis/bloom": "1.2.0",
"@redis/client": "1.4.2", "@redis/client": "1.6.0",
"@redis/graph": "1.1.0", "@redis/graph": "1.1.1",
"@redis/json": "1.0.4", "@redis/json": "1.0.7",
"@redis/search": "1.1.0", "@redis/search": "1.2.0",
"@redis/time-series": "1.0.4" "@redis/time-series": "1.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node14": "^1.0.3", "@tsconfig/node14": "^14.1.0",
"gh-pages": "^4.0.0", "gh-pages": "^6.0.0",
"release-it": "^15.3.0", "release-it": "^16.1.5",
"typescript": "^4.7.4" "typescript": "^5.2.2"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -43,5 +43,8 @@
"bugs": { "bugs": {
"url": "https://github.com/redis/node-redis/issues" "url": "https://github.com/redis/node-redis/issues"
}, },
"homepage": "https://github.com/redis/node-redis" "homepage": "https://github.com/redis/node-redis",
"keywords": [
"redis"
]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@redis/bloom", "name": "@redis/bloom",
"version": "1.1.0", "version": "1.2.0",
"license": "MIT", "license": "MIT",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,12 +18,24 @@
"devDependencies": { "devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.2", "@istanbuljs/nyc-config-typescript": "^1.0.2",
"@redis/test-utils": "*", "@redis/test-utils": "*",
"@types/node": "^18.11.6", "@types/node": "^20.6.2",
"nyc": "^15.1.0", "nyc": "^15.1.0",
"release-it": "^15.5.0", "release-it": "^16.1.5",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typedoc": "^0.23.18", "typedoc": "^0.25.1",
"typescript": "^4.8.4" "typescript": "^5.2.2"
} },
"repository": {
"type": "git",
"url": "git://github.com/redis/node-redis.git"
},
"bugs": {
"url": "https://github.com/redis/node-redis/issues"
},
"homepage": "https://github.com/redis/node-redis/tree/master/packages/bloom",
"keywords": [
"redis",
"RedisBloom"
]
} }

View File

@@ -15,8 +15,10 @@ export const createCluster = RedisCluster.create;
export { defineScript } from './lib/lua-script'; export { defineScript } from './lib/lua-script';
export { GeoReplyWith } from './lib/commands/generic-transformers';
export * from './lib/errors'; export * from './lib/errors';
export { SetOptions } from "./lib/commands/SET"; export { GeoReplyWith } from './lib/commands/generic-transformers';
export { SetOptions } from './lib/commands/SET';
export { RedisFlushModes } from './lib/commands/FLUSHALL';

View File

@@ -203,9 +203,9 @@ export default class RESP2Decoder {
this.arrayItemType = undefined; this.arrayItemType = undefined;
if (length === -1) { if (length === -1) {
return this.returnArrayReply(null, arraysToKeep); return this.returnArrayReply(null, arraysToKeep, chunk);
} else if (length === 0) { } else if (length === 0) {
return this.returnArrayReply([], arraysToKeep); return this.returnArrayReply([], arraysToKeep, chunk);
} }
this.arraysInProcess.push({ this.arraysInProcess.push({
@@ -235,20 +235,23 @@ export default class RESP2Decoder {
} }
} }
private returnArrayReply(reply: ArrayReply, arraysToKeep: number): ArrayReply | undefined { private returnArrayReply(reply: ArrayReply, arraysToKeep: number, chunk?: Buffer): ArrayReply | undefined {
if (this.arraysInProcess.length <= arraysToKeep) return reply; if (this.arraysInProcess.length <= arraysToKeep) return reply;
return this.pushArrayItem(reply, arraysToKeep); return this.pushArrayItem(reply, arraysToKeep, chunk);
} }
private pushArrayItem(item: Reply, arraysToKeep: number): ArrayReply | undefined { private pushArrayItem(item: Reply, arraysToKeep: number, chunk?: Buffer): ArrayReply | undefined {
const to = this.arraysInProcess[this.arraysInProcess.length - 1]!; const to = this.arraysInProcess[this.arraysInProcess.length - 1]!;
to.array[to.pushCounter] = item; to.array[to.pushCounter] = item;
if (++to.pushCounter === to.array.length) { if (++to.pushCounter === to.array.length) {
return this.returnArrayReply( return this.returnArrayReply(
this.arraysInProcess.pop()!.array, this.arraysInProcess.pop()!.array,
arraysToKeep arraysToKeep,
chunk
); );
} else if (chunk && chunk.length > this.cursor) {
return this.parseArray(chunk, arraysToKeep);
} }
} }
} }

View File

@@ -1,18 +1,18 @@
import * as LinkedList from 'yallist'; import * as LinkedList from 'yallist';
import { AbortError, ErrorReply } from '../errors'; import { AbortError, ErrorReply } from '../errors';
import { RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply } from '../commands'; import { RedisCommandArguments, RedisCommandRawReply } from '../commands';
import RESP2Decoder from './RESP2/decoder'; import RESP2Decoder from './RESP2/decoder';
import encodeCommand from './RESP2/encoder'; import encodeCommand from './RESP2/encoder';
import { ChannelListeners, PubSub, PubSubCommand, PubSubListener, PubSubType, PubSubTypeListeners } from './pub-sub';
export interface QueueCommandOptions { export interface QueueCommandOptions {
asap?: boolean; asap?: boolean;
chainId?: symbol; chainId?: symbol;
signal?: AbortSignal; signal?: AbortSignal;
returnBuffers?: boolean; returnBuffers?: boolean;
ignorePubSubMode?: boolean;
} }
interface CommandWaitingToBeSent extends CommandWaitingForReply { export interface CommandWaitingToBeSent extends CommandWaitingForReply {
args: RedisCommandArguments; args: RedisCommandArguments;
chainId?: symbol; chainId?: symbol;
abort?: { abort?: {
@@ -28,27 +28,9 @@ interface CommandWaitingForReply {
returnBuffers?: boolean; returnBuffers?: boolean;
} }
export enum PubSubSubscribeCommands { const PONG = Buffer.from('pong');
SUBSCRIBE = 'SUBSCRIBE',
PSUBSCRIBE = 'PSUBSCRIBE'
}
export enum PubSubUnsubscribeCommands { export type OnShardedChannelMoved = (channel: string, listeners: ChannelListeners) => void;
UNSUBSCRIBE = 'UNSUBSCRIBE',
PUNSUBSCRIBE = 'PUNSUBSCRIBE'
}
export type PubSubListener<
RETURN_BUFFERS extends boolean = false,
T = RETURN_BUFFERS extends true ? Buffer : string
> = (message: T, channel: T) => unknown;
interface PubSubListeners {
buffers: Set<PubSubListener<true>>;
strings: Set<PubSubListener<false>>;
}
type PubSubListenersMap = Map<string, PubSubListeners>;
export default class RedisCommandsQueue { export default class RedisCommandsQueue {
static #flushQueue<T extends CommandWaitingForReply>(queue: LinkedList<T>, err: Error): void { static #flushQueue<T extends CommandWaitingForReply>(queue: LinkedList<T>, err: Error): void {
@@ -57,67 +39,54 @@ export default class RedisCommandsQueue {
} }
} }
static #emitPubSubMessage(listenersMap: PubSubListenersMap, message: Buffer, channel: Buffer, pattern?: Buffer): void {
const keyString = (pattern ?? channel).toString(),
listeners = listenersMap.get(keyString);
if (!listeners) return;
for (const listener of listeners.buffers) {
listener(message, channel);
}
if (!listeners.strings.size) return;
const channelString = pattern ? channel.toString() : keyString,
messageString = channelString === '__redis__:invalidate' ?
// https://github.com/redis/redis/pull/7469
// https://github.com/redis/redis/issues/7463
(message === null ? null : (message as any as Array<Buffer>).map(x => x.toString())) as any :
message.toString();
for (const listener of listeners.strings) {
listener(messageString, channelString);
}
}
readonly #maxLength: number | null | undefined; readonly #maxLength: number | null | undefined;
readonly #waitingToBeSent = new LinkedList<CommandWaitingToBeSent>(); readonly #waitingToBeSent = new LinkedList<CommandWaitingToBeSent>();
readonly #waitingForReply = new LinkedList<CommandWaitingForReply>(); readonly #waitingForReply = new LinkedList<CommandWaitingForReply>();
readonly #onShardedChannelMoved: OnShardedChannelMoved;
readonly #pubSubState = { readonly #pubSub = new PubSub();
isActive: false,
subscribing: 0,
subscribed: 0,
unsubscribing: 0,
listeners: {
channels: new Map(),
patterns: new Map()
}
};
static readonly #PUB_SUB_MESSAGES = { get isPubSubActive() {
message: Buffer.from('message'), return this.#pubSub.isActive;
pMessage: Buffer.from('pmessage'), }
subscribe: Buffer.from('subscribe'),
pSubscribe: Buffer.from('psubscribe'),
unsubscribe: Buffer.from('unsubscribe'),
pUnsubscribe: Buffer.from('punsubscribe')
};
#chainInExecution: symbol | undefined; #chainInExecution: symbol | undefined;
#decoder = new RESP2Decoder({ #decoder = new RESP2Decoder({
returnStringsAsBuffers: () => { returnStringsAsBuffers: () => {
return !!this.#waitingForReply.head?.value.returnBuffers || return !!this.#waitingForReply.head?.value.returnBuffers ||
this.#pubSubState.isActive; this.#pubSub.isActive;
}, },
onReply: reply => { onReply: reply => {
if (this.#handlePubSubReply(reply)) { if (this.#pubSub.isActive && Array.isArray(reply)) {
return; if (this.#pubSub.handleMessageReply(reply as Array<Buffer>)) return;
} else if (!this.#waitingForReply.length) {
throw new Error('Got an unexpected reply from Redis'); const isShardedUnsubscribe = PubSub.isShardedUnsubscribe(reply as Array<Buffer>);
if (isShardedUnsubscribe && !this.#waitingForReply.length) {
const channel = (reply[1] as Buffer).toString();
this.#onShardedChannelMoved(
channel,
this.#pubSub.removeShardedListeners(channel)
);
return;
} else if (isShardedUnsubscribe || PubSub.isStatusReply(reply as Array<Buffer>)) {
const head = this.#waitingForReply.head!.value;
if (
(Number.isNaN(head.channelsCounter!) && reply[2] === 0) ||
--head.channelsCounter! === 0
) {
this.#waitingForReply.shift()!.resolve();
}
return;
}
if (PONG.equals(reply[0] as Buffer)) {
const { resolve, returnBuffers } = this.#waitingForReply.shift()!,
buffer = ((reply[1] as Buffer).length === 0 ? reply[0] : reply[1]) as Buffer;
resolve(returnBuffers ? buffer : buffer.toString());
return;
}
} }
const { resolve, reject } = this.#waitingForReply.shift()!; const { resolve, reject } = this.#waitingForReply.shift()!;
if (reply instanceof ErrorReply) { if (reply instanceof ErrorReply) {
reject(reply); reject(reply);
@@ -127,14 +96,16 @@ export default class RedisCommandsQueue {
} }
}); });
constructor(maxLength: number | null | undefined) { constructor(
maxLength: number | null | undefined,
onShardedChannelMoved: OnShardedChannelMoved
) {
this.#maxLength = maxLength; this.#maxLength = maxLength;
this.#onShardedChannelMoved = onShardedChannelMoved;
} }
addCommand<T = RedisCommandRawReply>(args: RedisCommandArguments, options?: QueueCommandOptions): Promise<T> { addCommand<T = RedisCommandRawReply>(args: RedisCommandArguments, options?: QueueCommandOptions): Promise<T> {
if (this.#pubSubState.isActive && !options?.ignorePubSubMode) { if (this.#maxLength && this.#waitingToBeSent.length + this.#waitingForReply.length >= this.#maxLength) {
return Promise.reject(new Error('Cannot send commands in PubSub mode'));
} else if (this.#maxLength && this.#waitingToBeSent.length + this.#waitingForReply.length >= this.#maxLength) {
return Promise.reject(new Error('The queue is full')); return Promise.reject(new Error('The queue is full'));
} else if (options?.signal?.aborted) { } else if (options?.signal?.aborted) {
return Promise.reject(new AbortError()); return Promise.reject(new AbortError());
@@ -173,158 +144,76 @@ export default class RedisCommandsQueue {
} }
subscribe<T extends boolean>( subscribe<T extends boolean>(
command: PubSubSubscribeCommands, type: PubSubType,
channels: RedisCommandArgument | Array<RedisCommandArgument>, channels: string | Array<string>,
listener: PubSubListener<T>, listener: PubSubListener<T>,
returnBuffers?: T returnBuffers?: T
): Promise<void> { ) {
const channelsToSubscribe: Array<RedisCommandArgument> = [], return this.#pushPubSubCommand(
listenersMap = command === PubSubSubscribeCommands.SUBSCRIBE ? this.#pubSub.subscribe(type, channels, listener, returnBuffers)
this.#pubSubState.listeners.channels : );
this.#pubSubState.listeners.patterns;
for (const channel of (Array.isArray(channels) ? channels : [channels])) {
const channelString = typeof channel === 'string' ? channel : channel.toString();
let listeners = listenersMap.get(channelString);
if (!listeners) {
listeners = {
buffers: new Set(),
strings: new Set()
};
listenersMap.set(channelString, listeners);
channelsToSubscribe.push(channel);
}
// https://github.com/microsoft/TypeScript/issues/23132
(returnBuffers ? listeners.buffers : listeners.strings).add(listener as any);
}
if (!channelsToSubscribe.length) {
return Promise.resolve();
}
return this.#pushPubSubCommand(command, channelsToSubscribe);
} }
unsubscribe<T extends boolean>( unsubscribe<T extends boolean>(
command: PubSubUnsubscribeCommands, type: PubSubType,
channels?: string | Array<string>, channels?: string | Array<string>,
listener?: PubSubListener<T>, listener?: PubSubListener<T>,
returnBuffers?: T returnBuffers?: T
): Promise<void> { ) {
const listeners = command === PubSubUnsubscribeCommands.UNSUBSCRIBE ? return this.#pushPubSubCommand(
this.#pubSubState.listeners.channels : this.#pubSub.unsubscribe(type, channels, listener, returnBuffers)
this.#pubSubState.listeners.patterns; );
if (!channels) {
const size = listeners.size;
listeners.clear();
return this.#pushPubSubCommand(command, size);
}
const channelsToUnsubscribe = [];
for (const channel of (Array.isArray(channels) ? channels : [channels])) {
const sets = listeners.get(channel);
if (!sets) continue;
let shouldUnsubscribe;
if (listener) {
// https://github.com/microsoft/TypeScript/issues/23132
(returnBuffers ? sets.buffers : sets.strings).delete(listener as any);
shouldUnsubscribe = !sets.buffers.size && !sets.strings.size;
} else {
shouldUnsubscribe = true;
}
if (shouldUnsubscribe) {
channelsToUnsubscribe.push(channel);
listeners.delete(channel);
}
}
if (!channelsToUnsubscribe.length) {
return Promise.resolve();
}
return this.#pushPubSubCommand(command, channelsToUnsubscribe);
} }
#pushPubSubCommand(command: PubSubSubscribeCommands | PubSubUnsubscribeCommands, channels: number | Array<RedisCommandArgument>): Promise<void> { resubscribe(): Promise<any> | undefined {
return new Promise((resolve, reject) => { const commands = this.#pubSub.resubscribe();
const isSubscribe = command === PubSubSubscribeCommands.SUBSCRIBE || command === PubSubSubscribeCommands.PSUBSCRIBE, if (!commands.length) return;
inProgressKey = isSubscribe ? 'subscribing' : 'unsubscribing',
commandArgs: Array<RedisCommandArgument> = [command];
let channelsCounter: number; return Promise.all(
if (typeof channels === 'number') { // unsubscribe only commands.map(command => this.#pushPubSubCommand(command))
channelsCounter = channels; );
} else { }
commandArgs.push(...channels);
channelsCounter = channels.length;
}
this.#pubSubState.isActive = true; extendPubSubChannelListeners(
this.#pubSubState[inProgressKey] += channelsCounter; type: PubSubType,
channel: string,
listeners: ChannelListeners
) {
return this.#pushPubSubCommand(
this.#pubSub.extendChannelListeners(type, channel, listeners)
);
}
extendPubSubListeners(type: PubSubType, listeners: PubSubTypeListeners) {
return this.#pushPubSubCommand(
this.#pubSub.extendTypeListeners(type, listeners)
);
}
getPubSubListeners(type: PubSubType) {
return this.#pubSub.getTypeListeners(type);
}
#pushPubSubCommand(command: PubSubCommand) {
if (command === undefined) return;
return new Promise<void>((resolve, reject) => {
this.#waitingToBeSent.push({ this.#waitingToBeSent.push({
args: commandArgs, args: command.args,
channelsCounter, channelsCounter: command.channelsCounter,
returnBuffers: true, returnBuffers: true,
resolve: () => { resolve: () => {
this.#pubSubState[inProgressKey] -= channelsCounter; command.resolve();
this.#pubSubState.subscribed += channelsCounter * (isSubscribe ? 1 : -1);
this.#updatePubSubActiveState();
resolve(); resolve();
}, },
reject: err => { reject: err => {
this.#pubSubState[inProgressKey] -= channelsCounter * (isSubscribe ? 1 : -1); command.reject?.();
this.#updatePubSubActiveState();
reject(err); reject(err);
} }
}); });
}); });
} }
#updatePubSubActiveState(): void {
if (
!this.#pubSubState.subscribed &&
!this.#pubSubState.subscribing &&
!this.#pubSubState.subscribed
) {
this.#pubSubState.isActive = false;
}
}
resubscribe(): Promise<any> | undefined {
this.#pubSubState.subscribed = 0;
this.#pubSubState.subscribing = 0;
this.#pubSubState.unsubscribing = 0;
const promises = [],
{ channels, patterns } = this.#pubSubState.listeners;
if (channels.size) {
promises.push(
this.#pushPubSubCommand(
PubSubSubscribeCommands.SUBSCRIBE,
[...channels.keys()]
)
);
}
if (patterns.size) {
promises.push(
this.#pushPubSubCommand(
PubSubSubscribeCommands.PSUBSCRIBE,
[...patterns.keys()]
)
);
}
if (promises.length) {
return Promise.all(promises);
}
}
getCommandToSend(): RedisCommandArguments | undefined { getCommandToSend(): RedisCommandArguments | undefined {
const toSend = this.#waitingToBeSent.shift(); const toSend = this.#waitingToBeSent.shift();
if (!toSend) return; if (!toSend) return;
@@ -351,39 +240,9 @@ export default class RedisCommandsQueue {
this.#decoder.write(chunk); this.#decoder.write(chunk);
} }
#handlePubSubReply(reply: any): boolean {
if (!this.#pubSubState.isActive || !Array.isArray(reply)) return false;
if (RedisCommandsQueue.#PUB_SUB_MESSAGES.message.equals(reply[0])) {
RedisCommandsQueue.#emitPubSubMessage(
this.#pubSubState.listeners.channels,
reply[2],
reply[1]
);
} else if (RedisCommandsQueue.#PUB_SUB_MESSAGES.pMessage.equals(reply[0])) {
RedisCommandsQueue.#emitPubSubMessage(
this.#pubSubState.listeners.patterns,
reply[3],
reply[2],
reply[1]
);
} else if (
RedisCommandsQueue.#PUB_SUB_MESSAGES.subscribe.equals(reply[0]) ||
RedisCommandsQueue.#PUB_SUB_MESSAGES.pSubscribe.equals(reply[0]) ||
RedisCommandsQueue.#PUB_SUB_MESSAGES.unsubscribe.equals(reply[0]) ||
RedisCommandsQueue.#PUB_SUB_MESSAGES.pUnsubscribe.equals(reply[0])
) {
if (--this.#waitingForReply.head!.value.channelsCounter! === 0) {
this.#waitingForReply.shift()!.resolve();
}
}
return true;
}
flushWaitingForReply(err: Error): void { flushWaitingForReply(err: Error): void {
this.#decoder.reset(); this.#decoder.reset();
this.#pubSubState.isActive = false; this.#pubSub.reset();
RedisCommandsQueue.#flushQueue(this.#waitingForReply, err); RedisCommandsQueue.#flushQueue(this.#waitingForReply, err);
if (!this.#chainInExecution) return; if (!this.#chainInExecution) return;
@@ -396,6 +255,8 @@ export default class RedisCommandsQueue {
} }
flushAll(err: Error): void { flushAll(err: Error): void {
this.#decoder.reset();
this.#pubSub.reset();
RedisCommandsQueue.#flushQueue(this.#waitingForReply, err); RedisCommandsQueue.#flushQueue(this.#waitingForReply, err);
RedisCommandsQueue.#flushQueue(this.#waitingToBeSent, err); RedisCommandsQueue.#flushQueue(this.#waitingToBeSent, err);
} }

View File

@@ -21,7 +21,9 @@ import * as CLIENT_GETNAME from '../commands/CLIENT_GETNAME';
import * as CLIENT_GETREDIR from '../commands/CLIENT_GETREDIR'; import * as CLIENT_GETREDIR from '../commands/CLIENT_GETREDIR';
import * as CLIENT_ID from '../commands/CLIENT_ID'; import * as CLIENT_ID from '../commands/CLIENT_ID';
import * as CLIENT_KILL from '../commands/CLIENT_KILL'; import * as CLIENT_KILL from '../commands/CLIENT_KILL';
import * as CLIENT_LIST from '../commands/CLIENT_LIST';
import * as CLIENT_NO_EVICT from '../commands/CLIENT_NO-EVICT'; import * as CLIENT_NO_EVICT from '../commands/CLIENT_NO-EVICT';
import * as CLIENT_NO_TOUCH from '../commands/CLIENT_NO-TOUCH';
import * as CLIENT_PAUSE from '../commands/CLIENT_PAUSE'; import * as CLIENT_PAUSE from '../commands/CLIENT_PAUSE';
import * as CLIENT_SETNAME from '../commands/CLIENT_SETNAME'; import * as CLIENT_SETNAME from '../commands/CLIENT_SETNAME';
import * as CLIENT_TRACKING from '../commands/CLIENT_TRACKING'; import * as CLIENT_TRACKING from '../commands/CLIENT_TRACKING';
@@ -44,6 +46,7 @@ import * as CLUSTER_KEYSLOT from '../commands/CLUSTER_KEYSLOT';
import * as CLUSTER_LINKS from '../commands/CLUSTER_LINKS'; import * as CLUSTER_LINKS from '../commands/CLUSTER_LINKS';
import * as CLUSTER_MEET from '../commands/CLUSTER_MEET'; import * as CLUSTER_MEET from '../commands/CLUSTER_MEET';
import * as CLUSTER_MYID from '../commands/CLUSTER_MYID'; import * as CLUSTER_MYID from '../commands/CLUSTER_MYID';
import * as CLUSTER_MYSHARDID from '../commands/CLUSTER_MYSHARDID';
import * as CLUSTER_NODES from '../commands/CLUSTER_NODES'; import * as CLUSTER_NODES from '../commands/CLUSTER_NODES';
import * as CLUSTER_REPLICAS from '../commands/CLUSTER_REPLICAS'; import * as CLUSTER_REPLICAS from '../commands/CLUSTER_REPLICAS';
import * as CLUSTER_REPLICATE from '../commands/CLUSTER_REPLICATE'; import * as CLUSTER_REPLICATE from '../commands/CLUSTER_REPLICATE';
@@ -83,6 +86,8 @@ import * as KEYS from '../commands/KEYS';
import * as LASTSAVE from '../commands/LASTSAVE'; import * as LASTSAVE from '../commands/LASTSAVE';
import * as LATENCY_DOCTOR from '../commands/LATENCY_DOCTOR'; import * as LATENCY_DOCTOR from '../commands/LATENCY_DOCTOR';
import * as LATENCY_GRAPH from '../commands/LATENCY_GRAPH'; import * as LATENCY_GRAPH from '../commands/LATENCY_GRAPH';
import * as LATENCY_HISTORY from '../commands/LATENCY_HISTORY';
import * as LATENCY_LATEST from '../commands/LATENCY_LATEST';
import * as LOLWUT from '../commands/LOLWUT'; import * as LOLWUT from '../commands/LOLWUT';
import * as MEMORY_DOCTOR from '../commands/MEMORY_DOCTOR'; import * as MEMORY_DOCTOR from '../commands/MEMORY_DOCTOR';
import * as MEMORY_MALLOC_STATS from '../commands/MEMORY_MALLOC-STATS'; import * as MEMORY_MALLOC_STATS from '../commands/MEMORY_MALLOC-STATS';
@@ -97,6 +102,8 @@ import * as PING from '../commands/PING';
import * as PUBSUB_CHANNELS from '../commands/PUBSUB_CHANNELS'; import * as PUBSUB_CHANNELS from '../commands/PUBSUB_CHANNELS';
import * as PUBSUB_NUMPAT from '../commands/PUBSUB_NUMPAT'; import * as PUBSUB_NUMPAT from '../commands/PUBSUB_NUMPAT';
import * as PUBSUB_NUMSUB from '../commands/PUBSUB_NUMSUB'; import * as PUBSUB_NUMSUB from '../commands/PUBSUB_NUMSUB';
import * as PUBSUB_SHARDCHANNELS from '../commands/PUBSUB_SHARDCHANNELS';
import * as PUBSUB_SHARDNUMSUB from '../commands/PUBSUB_SHARDNUMSUB';
import * as RANDOMKEY from '../commands/RANDOMKEY'; import * as RANDOMKEY from '../commands/RANDOMKEY';
import * as READONLY from '../commands/READONLY'; import * as READONLY from '../commands/READONLY';
import * as READWRITE from '../commands/READWRITE'; import * as READWRITE from '../commands/READWRITE';
@@ -164,6 +171,10 @@ export default {
clientKill: CLIENT_KILL, clientKill: CLIENT_KILL,
'CLIENT_NO-EVICT': CLIENT_NO_EVICT, 'CLIENT_NO-EVICT': CLIENT_NO_EVICT,
clientNoEvict: CLIENT_NO_EVICT, clientNoEvict: CLIENT_NO_EVICT,
'CLIENT_NO-TOUCH': CLIENT_NO_TOUCH,
clientNoTouch: CLIENT_NO_TOUCH,
CLIENT_LIST,
clientList: CLIENT_LIST,
CLIENT_PAUSE, CLIENT_PAUSE,
clientPause: CLIENT_PAUSE, clientPause: CLIENT_PAUSE,
CLIENT_SETNAME, CLIENT_SETNAME,
@@ -208,6 +219,8 @@ export default {
clusterMeet: CLUSTER_MEET, clusterMeet: CLUSTER_MEET,
CLUSTER_MYID, CLUSTER_MYID,
clusterMyId: CLUSTER_MYID, clusterMyId: CLUSTER_MYID,
CLUSTER_MYSHARDID,
clusterMyShardId: CLUSTER_MYSHARDID,
CLUSTER_NODES, CLUSTER_NODES,
clusterNodes: CLUSTER_NODES, clusterNodes: CLUSTER_NODES,
CLUSTER_REPLICAS, CLUSTER_REPLICAS,
@@ -286,6 +299,10 @@ export default {
latencyDoctor: LATENCY_DOCTOR, latencyDoctor: LATENCY_DOCTOR,
LATENCY_GRAPH, LATENCY_GRAPH,
latencyGraph: LATENCY_GRAPH, latencyGraph: LATENCY_GRAPH,
LATENCY_HISTORY,
latencyHistory: LATENCY_HISTORY,
LATENCY_LATEST,
latencyLatest: LATENCY_LATEST,
LOLWUT, LOLWUT,
lolwut: LOLWUT, lolwut: LOLWUT,
MEMORY_DOCTOR, MEMORY_DOCTOR,
@@ -314,6 +331,10 @@ export default {
pubSubNumPat: PUBSUB_NUMPAT, pubSubNumPat: PUBSUB_NUMPAT,
PUBSUB_NUMSUB, PUBSUB_NUMSUB,
pubSubNumSub: PUBSUB_NUMSUB, pubSubNumSub: PUBSUB_NUMSUB,
PUBSUB_SHARDCHANNELS,
pubSubShardChannels: PUBSUB_SHARDCHANNELS,
PUBSUB_SHARDNUMSUB,
pubSubShardNumSub: PUBSUB_SHARDNUMSUB,
RANDOMKEY, RANDOMKEY,
randomKey: RANDOMKEY, randomKey: RANDOMKEY,
READONLY, READONLY,

View File

@@ -2,14 +2,16 @@ import { strict as assert } from 'assert';
import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils'; import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils';
import RedisClient, { RedisClientType } from '.'; import RedisClient, { RedisClientType } from '.';
import { RedisClientMultiCommandType } from './multi-command'; import { RedisClientMultiCommandType } from './multi-command';
import { RedisCommandArguments, RedisCommandRawReply, RedisModules, RedisFunctions, RedisScripts } from '../commands'; import { RedisCommandRawReply, RedisModules, RedisFunctions, RedisScripts } from '../commands';
import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, SocketClosedUnexpectedlyError, WatchError } from '../errors'; import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, SocketClosedUnexpectedlyError, WatchError } from '../errors';
import { defineScript } from '../lua-script'; import { defineScript } from '../lua-script';
import { spy } from 'sinon'; import { spy } from 'sinon';
import { once } from 'events'; import { once } from 'events';
import { ClientKillFilters } from '../commands/CLIENT_KILL'; import { ClientKillFilters } from '../commands/CLIENT_KILL';
import { promisify } from 'util'; import { promisify } from 'util';
import {version} from '../../package.json';
export const SQUARE_SCRIPT = defineScript({ export const SQUARE_SCRIPT = defineScript({
SCRIPT: 'return ARGV[1] * ARGV[1];', SCRIPT: 'return ARGV[1] * ARGV[1];',
NUMBER_OF_KEYS: 0, NUMBER_OF_KEYS: 0,
@@ -107,6 +109,57 @@ describe('Client', () => {
}); });
}); });
describe('connect', () => {
testUtils.testWithClient('connect should return the client instance', async client => {
try {
assert.equal(await client.connect(), client);
} finally {
if (client.isOpen) await client.disconnect();
}
}, {
...GLOBAL.SERVERS.PASSWORD,
disableClientSetup: true
});
testUtils.testWithClient('should set default lib name and version', async client => {
const clientInfo = await client.clientInfo();
assert.equal(clientInfo.libName, 'node-redis');
assert.equal(clientInfo.libVer, version);
}, {
...GLOBAL.SERVERS.PASSWORD,
minimumDockerVersion: [7, 2]
});
testUtils.testWithClient('disable sending lib name and version', async client => {
const clientInfo = await client.clientInfo();
assert.equal(clientInfo.libName, '');
assert.equal(clientInfo.libVer, '');
}, {
...GLOBAL.SERVERS.PASSWORD,
clientOptions: {
...GLOBAL.SERVERS.PASSWORD.clientOptions,
disableClientInfo: true
},
minimumDockerVersion: [7, 2]
});
testUtils.testWithClient('send client name tag', async client => {
const clientInfo = await client.clientInfo();
assert.equal(clientInfo.libName, 'node-redis(test)');
assert.equal(clientInfo.libVer, version);
}, {
...GLOBAL.SERVERS.PASSWORD,
clientOptions: {
...GLOBAL.SERVERS.PASSWORD.clientOptions,
clientInfoTag: "test"
},
minimumDockerVersion: [7, 2]
});
});
describe('authentication', () => { describe('authentication', () => {
testUtils.testWithClient('Client should be authenticated', async client => { testUtils.testWithClient('Client should be authenticated', async client => {
assert.equal( assert.equal(
@@ -165,6 +218,28 @@ describe('Client', () => {
} }
}); });
testUtils.testWithClient('client.sendCommand should reply with error', async client => {
await assert.rejects(
promisify(client.sendCommand).call(client, '1', '2')
);
}, {
...GLOBAL.SERVERS.OPEN,
clientOptions: {
legacyMode: true
}
});
testUtils.testWithClient('client.hGetAll should reply with error', async client => {
await assert.rejects(
promisify(client.hGetAll).call(client)
);
}, {
...GLOBAL.SERVERS.OPEN,
clientOptions: {
legacyMode: true
}
});
testUtils.testWithClient('client.v4.sendCommand should return a promise', async client => { testUtils.testWithClient('client.v4.sendCommand should return a promise', async client => {
assert.equal( assert.equal(
await client.v4.sendCommand(['PING']), await client.v4.sendCommand(['PING']),
@@ -177,6 +252,18 @@ describe('Client', () => {
} }
}); });
testUtils.testWithClient('client.v4.{command} should return a promise', async client => {
assert.equal(
await client.v4.ping(),
'PONG'
);
}, {
...GLOBAL.SERVERS.OPEN,
clientOptions: {
legacyMode: true
}
});
testUtils.testWithClient('client.{command} should accept vardict arguments', async client => { testUtils.testWithClient('client.{command} should accept vardict arguments', async client => {
assert.equal( assert.equal(
await promisify(client.set).call(client, 'a', 'b'), await promisify(client.set).call(client, 'a', 'b'),
@@ -484,14 +571,23 @@ describe('Client', () => {
); );
}, GLOBAL.SERVERS.OPEN); }, GLOBAL.SERVERS.OPEN);
testUtils.testWithClient('execAsPipeline', async client => { describe('execAsPipeline', () => {
assert.deepEqual( testUtils.testWithClient('exec(true)', async client => {
await client.multi() assert.deepEqual(
.ping() await client.multi()
.exec(true), .ping()
['PONG'] .exec(true),
); ['PONG']
}, GLOBAL.SERVERS.OPEN); );
}, GLOBAL.SERVERS.OPEN);
testUtils.testWithClient('empty execAsPipeline', async client => {
assert.deepEqual(
await client.multi().execAsPipeline(),
[]
);
}, GLOBAL.SERVERS.OPEN);
});
testUtils.testWithClient('should remember selected db', async client => { testUtils.testWithClient('should remember selected db', async client => {
await client.multi() await client.multi()
@@ -506,6 +602,23 @@ describe('Client', () => {
...GLOBAL.SERVERS.OPEN, ...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [6, 2] // CLIENT INFO minimumDockerVersion: [6, 2] // CLIENT INFO
}); });
testUtils.testWithClient('should handle error replies (#2665)', async client => {
await assert.rejects(
client.multi()
.set('key', 'value')
.hGetAll('key')
.exec(),
err => {
assert.ok(err instanceof MultiErrorReply);
assert.equal(err.replies.length, 2);
assert.deepEqual(err.errorIndexes, [1]);
assert.ok(err.replies[1] instanceof ErrorReply);
assert.deepEqual([...err.errors()], [err.replies[1]]);
return true;
}
);
}, GLOBAL.SERVERS.OPEN);
}); });
testUtils.testWithClient('scripts', async client => { testUtils.testWithClient('scripts', async client => {
@@ -564,11 +677,41 @@ describe('Client', () => {
} }
}); });
testUtils.testWithClient('executeIsolated', async client => { describe('isolationPool', () => {
const id = await client.clientId(), testUtils.testWithClient('executeIsolated', async client => {
isolatedId = await client.executeIsolated(isolatedClient => isolatedClient.clientId()); const id = await client.clientId(),
assert.ok(id !== isolatedId); isolatedId = await client.executeIsolated(isolatedClient => isolatedClient.clientId());
}, GLOBAL.SERVERS.OPEN); assert.ok(id !== isolatedId);
}, GLOBAL.SERVERS.OPEN);
testUtils.testWithClient('should be able to use pool even before connect', async client => {
await client.executeIsolated(() => Promise.resolve());
// make sure to destroy isolation pool
await client.connect();
await client.disconnect();
}, {
...GLOBAL.SERVERS.OPEN,
disableClientSetup: true
});
testUtils.testWithClient('should work after reconnect (#2406)', async client => {
await client.disconnect();
await client.connect();
await client.executeIsolated(() => Promise.resolve());
}, GLOBAL.SERVERS.OPEN);
testUtils.testWithClient('should throw ClientClosedError after disconnect', async client => {
await client.connect();
await client.disconnect();
await assert.rejects(
client.executeIsolated(() => Promise.resolve()),
ClientClosedError
);
}, {
...GLOBAL.SERVERS.OPEN,
disableClientSetup: true
});
});
async function killClient< async function killClient<
M extends RedisModules, M extends RedisModules,
@@ -604,6 +747,9 @@ describe('Client', () => {
}); });
testUtils.testWithClient('should propagated errors from "isolated" clients', client => { testUtils.testWithClient('should propagated errors from "isolated" clients', client => {
client.on('error', () => {
// ignore errors
});
return client.executeIsolated(isolated => killClient(isolated, client)); return client.executeIsolated(isolated => killClient(isolated, client));
}, GLOBAL.SERVERS.OPEN); }, GLOBAL.SERVERS.OPEN);
@@ -642,6 +788,31 @@ describe('Client', () => {
assert.deepEqual(hash, results); assert.deepEqual(hash, results);
}, GLOBAL.SERVERS.OPEN); }, GLOBAL.SERVERS.OPEN);
testUtils.testWithClient('hScanNoValuesIterator', async client => {
const hash: Record<string, string> = {};
const expectedKeys: Array<string> = [];
for (let i = 0; i < 100; i++) {
hash[i.toString()] = i.toString();
expectedKeys.push(i.toString());
}
await client.hSet('key', hash);
const keys: Array<string> = [];
for await (const key of client.hScanNoValuesIterator('key')) {
keys.push(key);
}
function sort(a: string, b: string) {
return Number(a) - Number(b);
}
assert.deepEqual(keys.sort(sort), expectedKeys);
}, {
...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [7, 4]
});
testUtils.testWithClient('sScanIterator', async client => { testUtils.testWithClient('sScanIterator', async client => {
const members = new Set<string>(); const members = new Set<string>();
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
@@ -685,7 +856,7 @@ describe('Client', () => {
members.map<MemberTuple>(member => [member.value, member.score]).sort(sort) members.map<MemberTuple>(member => [member.value, member.score]).sort(sort)
); );
}, GLOBAL.SERVERS.OPEN); }, GLOBAL.SERVERS.OPEN);
describe('PubSub', () => { describe('PubSub', () => {
testUtils.testWithClient('should be able to publish and subscribe to messages', async publisher => { testUtils.testWithClient('should be able to publish and subscribe to messages', async publisher => {
function assertStringListener(message: string, channel: string) { function assertStringListener(message: string, channel: string) {

View File

@@ -1,7 +1,7 @@
import COMMANDS from './commands'; import COMMANDS from './commands';
import { RedisCommand, RedisCommandArguments, RedisCommandRawReply, RedisCommandReply, RedisFunctions, RedisModules, RedisExtensions, RedisScript, RedisScripts, RedisCommandSignature, ConvertArgumentType, RedisFunction, ExcludeMappedString, RedisCommands } from '../commands'; import { RedisCommand, RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply, RedisCommandReply, RedisFunctions, RedisModules, RedisExtensions, RedisScript, RedisScripts, RedisCommandSignature, ConvertArgumentType, RedisFunction, ExcludeMappedString, RedisCommands } from '../commands';
import RedisSocket, { RedisSocketOptions, RedisTlsSocketOptions } from './socket'; import RedisSocket, { RedisSocketOptions, RedisTlsSocketOptions } from './socket';
import RedisCommandsQueue, { PubSubListener, PubSubSubscribeCommands, PubSubUnsubscribeCommands, QueueCommandOptions } from './commands-queue'; import RedisCommandsQueue, { QueueCommandOptions } from './commands-queue';
import RedisClientMultiCommand, { RedisClientMultiCommandType } from './multi-command'; import RedisClientMultiCommand, { RedisClientMultiCommandType } from './multi-command';
import { RedisMultiQueuedCommand } from '../multi-command'; import { RedisMultiQueuedCommand } from '../multi-command';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
@@ -11,27 +11,71 @@ import { ScanCommandOptions } from '../commands/SCAN';
import { HScanTuple } from '../commands/HSCAN'; import { HScanTuple } from '../commands/HSCAN';
import { attachCommands, attachExtensions, fCallArguments, transformCommandArguments, transformCommandReply, transformLegacyCommandArguments } from '../commander'; import { attachCommands, attachExtensions, fCallArguments, transformCommandArguments, transformCommandReply, transformLegacyCommandArguments } from '../commander';
import { Pool, Options as PoolOptions, createPool } from 'generic-pool'; import { Pool, Options as PoolOptions, createPool } from 'generic-pool';
import { ClientClosedError, ClientOfflineError, DisconnectsClientError } from '../errors'; import { ClientClosedError, ClientOfflineError, DisconnectsClientError, ErrorReply } from '../errors';
import { URL } from 'url'; import { URL } from 'url';
import { TcpSocketConnectOpts } from 'net'; import { TcpSocketConnectOpts } from 'net';
import { PubSubType, PubSubListener, PubSubTypeListeners, ChannelListeners } from './pub-sub';
import {version} from '../../package.json';
export interface RedisClientOptions< export interface RedisClientOptions<
M extends RedisModules = RedisModules, M extends RedisModules = RedisModules,
F extends RedisFunctions = RedisFunctions, F extends RedisFunctions = RedisFunctions,
S extends RedisScripts = RedisScripts S extends RedisScripts = RedisScripts
> extends RedisExtensions<M, F, S> { > extends RedisExtensions<M, F, S> {
/**
* `redis[s]://[[username][:password]@][host][:port][/db-number]`
* See [`redis`](https://www.iana.org/assignments/uri-schemes/prov/redis) and [`rediss`](https://www.iana.org/assignments/uri-schemes/prov/rediss) IANA registration for more details
*/
url?: string; url?: string;
/**
* Socket connection properties
*/
socket?: RedisSocketOptions; socket?: RedisSocketOptions;
/**
* ACL username ([see ACL guide](https://redis.io/topics/acl))
*/
username?: string; username?: string;
/**
* ACL password or the old "--requirepass" password
*/
password?: string; password?: string;
/**
* Client name ([see `CLIENT SETNAME`](https://redis.io/commands/client-setname))
*/
name?: string; name?: string;
/**
* Redis database number (see [`SELECT`](https://redis.io/commands/select) command)
*/
database?: number; database?: number;
/**
* Maximum length of the client's internal command queue
*/
commandsQueueMaxLength?: number; commandsQueueMaxLength?: number;
/**
* When `true`, commands are rejected when the client is reconnecting.
* When `false`, commands are queued for execution after reconnection.
*/
disableOfflineQueue?: boolean; disableOfflineQueue?: boolean;
/**
* Connect in [`READONLY`](https://redis.io/commands/readonly) mode
*/
readonly?: boolean; readonly?: boolean;
legacyMode?: boolean; legacyMode?: boolean;
isolationPoolOptions?: PoolOptions; isolationPoolOptions?: PoolOptions;
/**
* Send `PING` command at interval (in ms).
* Useful with Redis deployments that do not use TCP Keep-Alive.
*/
pingInterval?: number; pingInterval?: number;
/**
* If set to true, disables sending client identifier (user-agent like message) to the redis server
*/
disableClientInfo?: boolean;
/**
* Tag to append to library name that is sent to the Redis server
*/
clientInfoTag?: string;
} }
type WithCommands = { type WithCommands = {
@@ -155,7 +199,7 @@ export default class RedisClient<
readonly #options?: RedisClientOptions<M, F, S>; readonly #options?: RedisClientOptions<M, F, S>;
readonly #socket: RedisSocket; readonly #socket: RedisSocket;
readonly #queue: RedisCommandsQueue; readonly #queue: RedisCommandsQueue;
readonly #isolationPool: Pool<RedisClientType<M, F, S>>; #isolationPool?: Pool<RedisClientType<M, F, S>>;
readonly #v4: Record<string, any> = {}; readonly #v4: Record<string, any> = {};
#selectedDB = 0; #selectedDB = 0;
@@ -171,6 +215,10 @@ export default class RedisClient<
return this.#socket.isReady; return this.#socket.isReady;
} }
get isPubSubActive() {
return this.#queue.isPubSubActive;
}
get v4(): Record<string, any> { get v4(): Record<string, any> {
if (!this.#options?.legacyMode) { if (!this.#options?.legacyMode) {
throw new Error('the client is not in "legacy mode"'); throw new Error('the client is not in "legacy mode"');
@@ -184,16 +232,9 @@ export default class RedisClient<
this.#options = this.#initiateOptions(options); this.#options = this.#initiateOptions(options);
this.#queue = this.#initiateQueue(); this.#queue = this.#initiateQueue();
this.#socket = this.#initiateSocket(); this.#socket = this.#initiateSocket();
this.#isolationPool = createPool({ // should be initiated in connect, not here
create: async () => { // TODO: consider breaking in v5
const duplicate = this.duplicate({ this.#isolationPool = this.#initiateIsolationPool();
isolationPoolOptions: undefined
}).on('error', err => this.emit('error', err));
await duplicate.connect();
return duplicate;
},
destroy: client => client.disconnect()
}, options?.isolationPoolOptions);
this.#legacyMode(); this.#legacyMode();
} }
@@ -215,7 +256,10 @@ export default class RedisClient<
} }
#initiateQueue(): RedisCommandsQueue { #initiateQueue(): RedisCommandsQueue {
return new RedisCommandsQueue(this.#options?.commandsQueueMaxLength); return new RedisCommandsQueue(
this.#options?.commandsQueueMaxLength,
(channel, listeners) => this.emit('sharded-channel-moved', channel, listeners)
);
} }
#initiateSocket(): RedisSocket { #initiateSocket(): RedisSocket {
@@ -240,6 +284,33 @@ export default class RedisClient<
); );
} }
if (!this.#options?.disableClientInfo) {
promises.push(
this.#queue.addCommand(
[ 'CLIENT', 'SETINFO', 'LIB-VER', version],
{ asap: true }
).catch(err => {
if (!(err instanceof ErrorReply)) {
throw err;
}
})
);
promises.push(
this.#queue.addCommand(
[
'CLIENT', 'SETINFO', 'LIB-NAME',
this.#options?.clientInfoTag ? `node-redis(${this.#options.clientInfoTag})` : 'node-redis'
],
{ asap: true }
).catch(err => {
if (!(err instanceof ErrorReply)) {
throw err;
}
})
);
}
if (this.#options?.name) { if (this.#options?.name) {
promises.push( promises.push(
this.#queue.addCommand( this.#queue.addCommand(
@@ -295,6 +366,19 @@ export default class RedisClient<
.on('end', () => this.emit('end')); .on('end', () => this.emit('end'));
} }
#initiateIsolationPool() {
return createPool({
create: async () => {
const duplicate = this.duplicate({
isolationPoolOptions: undefined
}).on('error', err => this.emit('error', err));
await duplicate.connect();
return duplicate;
},
destroy: client => client.disconnect()
}, this.#options?.isolationPoolOptions);
}
#legacyMode(): void { #legacyMode(): void {
if (!this.#options?.legacyMode) return; if (!this.#options?.legacyMode) return;
@@ -302,13 +386,15 @@ export default class RedisClient<
(this as any).sendCommand = (...args: Array<any>): void => { (this as any).sendCommand = (...args: Array<any>): void => {
const result = this.#legacySendCommand(...args); const result = this.#legacySendCommand(...args);
if (result) { if (result) {
result.promise.then(reply => result.callback(null, reply)); result.promise
.then(reply => result.callback(null, reply))
.catch(err => result.callback(err));
} }
}; };
for (const [ name, command ] of Object.entries(COMMANDS as RedisCommands)) { for (const [ name, command ] of Object.entries(COMMANDS as RedisCommands)) {
this.#defineLegacyCommand(name, command); this.#defineLegacyCommand(name, command);
(this as any)[name.toLowerCase()] = (this as any)[name]; (this as any)[name.toLowerCase()] ??= (this as any)[name];
} }
// hard coded commands // hard coded commands
@@ -339,21 +425,21 @@ export default class RedisClient<
promise.catch(err => this.emit('error', err)); promise.catch(err => this.emit('error', err));
} }
#defineLegacyCommand(this: any, name: string, command?: RedisCommand): void { #defineLegacyCommand(name: string, command?: RedisCommand): void {
this.#v4[name] = this[name].bind(this); this.#v4[name] = (this as any)[name].bind(this);
this[name] = command && command.TRANSFORM_LEGACY_REPLY && command.transformReply ? (this as any)[name] = command && command.TRANSFORM_LEGACY_REPLY && command.transformReply ?
(...args: Array<unknown>) => { (...args: Array<unknown>) => {
const result = this.#legacySendCommand(name, ...args); const result = this.#legacySendCommand(name, ...args);
if (result) { if (result) {
result.promise.then((reply: any) => { result.promise
result.callback(null, command.transformReply!(reply)); .then(reply => result.callback(null, command.transformReply!(reply)))
}); .catch(err => result.callback(err));
} }
} : } :
(...args: Array<unknown>) => this.sendCommand(name, ...args); (...args: Array<unknown>) => (this as any).sendCommand(name, ...args);
} }
#pingTimer?: NodeJS.Timer; #pingTimer?: NodeJS.Timeout;
#setPingTimer(): void { #setPingTimer(): void {
if (!this.#options?.pingInterval || !this.#socket.isReady) return; if (!this.#options?.pingInterval || !this.#socket.isReady) return;
@@ -362,7 +448,8 @@ export default class RedisClient<
this.#pingTimer = setTimeout(() => { this.#pingTimer = setTimeout(() => {
if (!this.#socket.isReady) return; if (!this.#socket.isReady) return;
(this as unknown as RedisClientType<M, F, S>).ping() // using #sendCommand to support legacy mode
this.#sendCommand(['PING'])
.then(reply => this.emit('ping-interval', reply)) .then(reply => this.emit('ping-interval', reply))
.catch(err => this.emit('error', err)) .catch(err => this.emit('error', err))
.finally(() => this.#setPingTimer()); .finally(() => this.#setPingTimer());
@@ -376,8 +463,11 @@ export default class RedisClient<
}); });
} }
async connect(): Promise<void> { async connect() {
// see comment in constructor
this.#isolationPool ??= this.#initiateIsolationPool();
await this.#socket.connect(); await this.#socket.connect();
return this as unknown as RedisClientType<M, F, S>;
} }
async commandsExecutor<C extends RedisCommand>( async commandsExecutor<C extends RedisCommand>(
@@ -415,7 +505,7 @@ export default class RedisClient<
); );
} else if (!this.#socket.isReady && this.#options?.disableOfflineQueue) { } else if (!this.#socket.isReady && this.#options?.disableOfflineQueue) {
return Promise.reject(new ClientOfflineError()); return Promise.reject(new ClientOfflineError());
} }
const promise = this.#queue.addCommand<T>(args, options); const promise = this.#queue.addCommand<T>(args, options);
this.#tick(); this.#tick();
@@ -499,18 +589,9 @@ export default class RedisClient<
select = this.SELECT; select = this.SELECT;
#subscribe<T extends boolean>( #pubSubCommand(promise: Promise<void> | undefined) {
command: PubSubSubscribeCommands, if (promise === undefined) return Promise.resolve();
channels: string | Array<string>,
listener: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
const promise = this.#queue.subscribe(
command,
channels,
listener,
bufferMode
);
this.#tick(); this.#tick();
return promise; return promise;
} }
@@ -520,77 +601,128 @@ export default class RedisClient<
listener: PubSubListener<T>, listener: PubSubListener<T>,
bufferMode?: T bufferMode?: T
): Promise<void> { ): Promise<void> {
return this.#subscribe( return this.#pubSubCommand(
PubSubSubscribeCommands.SUBSCRIBE, this.#queue.subscribe(
channels, PubSubType.CHANNELS,
listener, channels,
bufferMode listener,
bufferMode
)
); );
} }
subscribe = this.SUBSCRIBE; subscribe = this.SUBSCRIBE;
PSUBSCRIBE<T extends boolean = false>(
patterns: string | Array<string>,
listener: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
return this.#subscribe(
PubSubSubscribeCommands.PSUBSCRIBE,
patterns,
listener,
bufferMode
);
}
pSubscribe = this.PSUBSCRIBE;
#unsubscribe<T extends boolean>(
command: PubSubUnsubscribeCommands,
channels?: string | Array<string>,
listener?: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
const promise = this.#queue.unsubscribe(command, channels, listener, bufferMode);
this.#tick();
return promise;
}
UNSUBSCRIBE<T extends boolean = false>( UNSUBSCRIBE<T extends boolean = false>(
channels?: string | Array<string>, channels?: string | Array<string>,
listener?: PubSubListener<T>, listener?: PubSubListener<T>,
bufferMode?: T bufferMode?: T
): Promise<void> { ): Promise<void> {
return this.#unsubscribe( return this.#pubSubCommand(
PubSubUnsubscribeCommands.UNSUBSCRIBE, this.#queue.unsubscribe(
channels, PubSubType.CHANNELS,
listener, channels,
bufferMode listener,
bufferMode
)
); );
} }
unsubscribe = this.UNSUBSCRIBE; unsubscribe = this.UNSUBSCRIBE;
PSUBSCRIBE<T extends boolean = false>(
patterns: string | Array<string>,
listener: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
return this.#pubSubCommand(
this.#queue.subscribe(
PubSubType.PATTERNS,
patterns,
listener,
bufferMode
)
);
}
pSubscribe = this.PSUBSCRIBE;
PUNSUBSCRIBE<T extends boolean = false>( PUNSUBSCRIBE<T extends boolean = false>(
patterns?: string | Array<string>, patterns?: string | Array<string>,
listener?: PubSubListener<T>, listener?: PubSubListener<T>,
bufferMode?: T bufferMode?: T
): Promise<void> { ): Promise<void> {
return this.#unsubscribe( return this.#pubSubCommand(
PubSubUnsubscribeCommands.PUNSUBSCRIBE, this.#queue.unsubscribe(
patterns, PubSubType.PATTERNS,
listener, patterns,
bufferMode listener,
bufferMode
)
); );
} }
pUnsubscribe = this.PUNSUBSCRIBE; pUnsubscribe = this.PUNSUBSCRIBE;
SSUBSCRIBE<T extends boolean = false>(
channels: string | Array<string>,
listener: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
return this.#pubSubCommand(
this.#queue.subscribe(
PubSubType.SHARDED,
channels,
listener,
bufferMode
)
);
}
sSubscribe = this.SSUBSCRIBE;
SUNSUBSCRIBE<T extends boolean = false>(
channels?: string | Array<string>,
listener?: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
return this.#pubSubCommand(
this.#queue.unsubscribe(
PubSubType.SHARDED,
channels,
listener,
bufferMode
)
);
}
sUnsubscribe = this.SUNSUBSCRIBE;
getPubSubListeners(type: PubSubType) {
return this.#queue.getPubSubListeners(type);
}
extendPubSubChannelListeners(
type: PubSubType,
channel: string,
listeners: ChannelListeners
) {
return this.#pubSubCommand(
this.#queue.extendPubSubChannelListeners(type, channel, listeners)
);
}
extendPubSubListeners(type: PubSubType, listeners: PubSubTypeListeners) {
return this.#pubSubCommand(
this.#queue.extendPubSubListeners(type, listeners)
);
}
QUIT(): Promise<string> { QUIT(): Promise<string> {
return this.#socket.quit(async () => { return this.#socket.quit(async () => {
const quitPromise = this.#queue.addCommand<string>(['QUIT'], { if (this.#pingTimer) clearTimeout(this.#pingTimer);
ignorePubSubMode: true const quitPromise = this.#queue.addCommand<string>(['QUIT']);
});
this.#tick(); this.#tick();
const [reply] = await Promise.all([ const [reply] = await Promise.all([
quitPromise, quitPromise,
@@ -618,6 +750,7 @@ export default class RedisClient<
} }
executeIsolated<T>(fn: (client: RedisClientType<M, F, S>) => T | Promise<T>): Promise<T> { executeIsolated<T>(fn: (client: RedisClientType<M, F, S>) => T | Promise<T>): Promise<T> {
if (!this.#isolationPool) return Promise.reject(new ClientClosedError());
return this.#isolationPool.use(fn); return this.#isolationPool.use(fn);
} }
@@ -639,11 +772,14 @@ export default class RedisClient<
return Promise.reject(new ClientClosedError()); return Promise.reject(new ClientClosedError());
} }
const promise = Promise.all( const promise = chainId ?
commands.map(({ args }) => { // if `chainId` has a value, it's a `MULTI` (and not "pipeline") - need to add the `MULTI` and `EXEC` commands
return this.#queue.addCommand(args, { chainId }); Promise.all([
}) this.#queue.addCommand(['MULTI'], { chainId }),
); this.#addMultiCommands(commands, chainId),
this.#queue.addCommand(['EXEC'], { chainId })
]) :
this.#addMultiCommands(commands);
this.#tick(); this.#tick();
@@ -656,6 +792,12 @@ export default class RedisClient<
return results; return results;
} }
#addMultiCommands(commands: Array<RedisMultiQueuedCommand>, chainId?: symbol) {
return Promise.all(
commands.map(({ args }) => this.#queue.addCommand(args, { chainId }))
);
}
async* scanIterator(options?: ScanCommandOptions): AsyncIterable<string> { async* scanIterator(options?: ScanCommandOptions): AsyncIterable<string> {
let cursor = 0; let cursor = 0;
do { do {
@@ -678,6 +820,17 @@ export default class RedisClient<
} while (cursor !== 0); } while (cursor !== 0);
} }
async* hScanNoValuesIterator(key: string, options?: ScanOptions): AsyncIterable<ConvertArgumentType<RedisCommandArgument, string>> {
let cursor = 0;
do {
const reply = await (this as any).hScanNoValues(key, cursor, options);
cursor = reply.cursor;
for (const k of reply.keys) {
yield k;
}
} while (cursor !== 0);
}
async* sScanIterator(key: string, options?: ScanOptions): AsyncIterable<string> { async* sScanIterator(key: string, options?: ScanOptions): AsyncIterable<string> {
let cursor = 0; let cursor = 0;
do { do {
@@ -701,14 +854,16 @@ export default class RedisClient<
} }
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
if (this.#pingTimer) clearTimeout(this.#pingTimer);
this.#queue.flushAll(new DisconnectsClientError()); this.#queue.flushAll(new DisconnectsClientError());
this.#socket.disconnect(); this.#socket.disconnect();
await this.#destroyIsolationPool(); await this.#destroyIsolationPool();
} }
async #destroyIsolationPool(): Promise<void> { async #destroyIsolationPool(): Promise<void> {
await this.#isolationPool.drain(); await this.#isolationPool!.drain();
await this.#isolationPool.clear(); await this.#isolationPool!.clear();
this.#isolationPool = undefined;
} }
ref(): void { ref(): void {

View File

@@ -119,7 +119,7 @@ export default class RedisClientMultiCommand {
for (const [ name, command ] of Object.entries(COMMANDS as RedisCommands)) { for (const [ name, command ] of Object.entries(COMMANDS as RedisCommands)) {
this.#defineLegacyCommand(name, command); this.#defineLegacyCommand(name, command);
(this as any)[name.toLowerCase()] = (this as any)[name]; (this as any)[name.toLowerCase()] ??= (this as any)[name];
} }
} }
@@ -170,12 +170,9 @@ export default class RedisClientMultiCommand {
return this.execAsPipeline(); return this.execAsPipeline();
} }
const commands = this.#multi.exec();
if (!commands) return [];
return this.#multi.handleExecReplies( return this.#multi.handleExecReplies(
await this.#executor( await this.#executor(
commands, this.#multi.queue,
this.#selectedDB, this.#selectedDB,
RedisMultiCommand.generateChainId() RedisMultiCommand.generateChainId()
) )
@@ -185,6 +182,8 @@ export default class RedisClientMultiCommand {
EXEC = this.exec; EXEC = this.exec;
async execAsPipeline(): Promise<Array<RedisCommandRawReply>> { async execAsPipeline(): Promise<Array<RedisCommandRawReply>> {
if (this.#multi.queue.length === 0) return [];
return this.#multi.transformReplies( return this.#multi.transformReplies(
await this.#executor( await this.#executor(
this.#multi.queue, this.#multi.queue,

View File

@@ -0,0 +1,151 @@
import { strict as assert } from 'assert';
import { PubSub, PubSubType } from './pub-sub';
describe('PubSub', () => {
const TYPE = PubSubType.CHANNELS,
CHANNEL = 'channel',
LISTENER = () => {};
describe('subscribe to new channel', () => {
function createAndSubscribe() {
const pubSub = new PubSub(),
command = pubSub.subscribe(TYPE, CHANNEL, LISTENER);
assert.equal(pubSub.isActive, true);
assert.ok(command);
assert.equal(command.channelsCounter, 1);
return {
pubSub,
command
};
}
it('resolve', () => {
const { pubSub, command } = createAndSubscribe();
command.resolve();
assert.equal(pubSub.isActive, true);
});
it('reject', () => {
const { pubSub, command } = createAndSubscribe();
assert.ok(command.reject);
command.reject();
assert.equal(pubSub.isActive, false);
});
});
it('subscribe to already subscribed channel', () => {
const pubSub = new PubSub(),
firstSubscribe = pubSub.subscribe(TYPE, CHANNEL, LISTENER);
assert.ok(firstSubscribe);
const secondSubscribe = pubSub.subscribe(TYPE, CHANNEL, LISTENER);
assert.ok(secondSubscribe);
firstSubscribe.resolve();
assert.equal(
pubSub.subscribe(TYPE, CHANNEL, LISTENER),
undefined
);
});
it('unsubscribe all', () => {
const pubSub = new PubSub();
const subscribe = pubSub.subscribe(TYPE, CHANNEL, LISTENER);
assert.ok(subscribe);
subscribe.resolve();
assert.equal(pubSub.isActive, true);
const unsubscribe = pubSub.unsubscribe(TYPE);
assert.equal(pubSub.isActive, true);
assert.ok(unsubscribe);
unsubscribe.resolve();
assert.equal(pubSub.isActive, false);
});
describe('unsubscribe from channel', () => {
it('when not subscribed', () => {
const pubSub = new PubSub(),
unsubscribe = pubSub.unsubscribe(TYPE, CHANNEL);
assert.ok(unsubscribe);
unsubscribe.resolve();
assert.equal(pubSub.isActive, false);
});
it('when already subscribed', () => {
const pubSub = new PubSub(),
subscribe = pubSub.subscribe(TYPE, CHANNEL, LISTENER);
assert.ok(subscribe);
subscribe.resolve();
assert.equal(pubSub.isActive, true);
const unsubscribe = pubSub.unsubscribe(TYPE, CHANNEL);
assert.equal(pubSub.isActive, true);
assert.ok(unsubscribe);
unsubscribe.resolve();
assert.equal(pubSub.isActive, false);
});
});
describe('unsubscribe from listener', () => {
it('when it\'s the only listener', () => {
const pubSub = new PubSub(),
subscribe = pubSub.subscribe(TYPE, CHANNEL, LISTENER);
assert.ok(subscribe);
subscribe.resolve();
assert.equal(pubSub.isActive, true);
const unsubscribe = pubSub.unsubscribe(TYPE, CHANNEL, LISTENER);
assert.ok(unsubscribe);
unsubscribe.resolve();
assert.equal(pubSub.isActive, false);
});
it('when there are more listeners', () => {
const pubSub = new PubSub(),
subscribe = pubSub.subscribe(TYPE, CHANNEL, LISTENER);
assert.ok(subscribe);
subscribe.resolve();
assert.equal(pubSub.isActive, true);
assert.equal(
pubSub.subscribe(TYPE, CHANNEL, () => {}),
undefined
);
assert.equal(
pubSub.unsubscribe(TYPE, CHANNEL, LISTENER),
undefined
);
});
describe('non-existing listener', () => {
it('on subscribed channel', () => {
const pubSub = new PubSub(),
subscribe = pubSub.subscribe(TYPE, CHANNEL, LISTENER);
assert.ok(subscribe);
subscribe.resolve();
assert.equal(pubSub.isActive, true);
assert.equal(
pubSub.unsubscribe(TYPE, CHANNEL, () => {}),
undefined
);
assert.equal(pubSub.isActive, true);
});
it('on unsubscribed channel', () => {
const pubSub = new PubSub();
assert.ok(pubSub.unsubscribe(TYPE, CHANNEL, () => {}));
assert.equal(pubSub.isActive, false);
});
});
});
});

View File

@@ -0,0 +1,408 @@
import { RedisCommandArgument } from "../commands";
export enum PubSubType {
CHANNELS = 'CHANNELS',
PATTERNS = 'PATTERNS',
SHARDED = 'SHARDED'
}
const COMMANDS = {
[PubSubType.CHANNELS]: {
subscribe: Buffer.from('subscribe'),
unsubscribe: Buffer.from('unsubscribe'),
message: Buffer.from('message')
},
[PubSubType.PATTERNS]: {
subscribe: Buffer.from('psubscribe'),
unsubscribe: Buffer.from('punsubscribe'),
message: Buffer.from('pmessage')
},
[PubSubType.SHARDED]: {
subscribe: Buffer.from('ssubscribe'),
unsubscribe: Buffer.from('sunsubscribe'),
message: Buffer.from('smessage')
}
};
export type PubSubListener<
RETURN_BUFFERS extends boolean = false
> = <T extends RETURN_BUFFERS extends true ? Buffer : string>(message: T, channel: T) => unknown;
export interface ChannelListeners {
unsubscribing: boolean;
buffers: Set<PubSubListener<true>>;
strings: Set<PubSubListener<false>>;
}
export type PubSubTypeListeners = Map<string, ChannelListeners>;
type Listeners = Record<PubSubType, PubSubTypeListeners>;
export type PubSubCommand = ReturnType<
typeof PubSub.prototype.subscribe |
typeof PubSub.prototype.unsubscribe |
typeof PubSub.prototype.extendTypeListeners
>;
export class PubSub {
static isStatusReply(reply: Array<Buffer>): boolean {
return (
COMMANDS[PubSubType.CHANNELS].subscribe.equals(reply[0]) ||
COMMANDS[PubSubType.CHANNELS].unsubscribe.equals(reply[0]) ||
COMMANDS[PubSubType.PATTERNS].subscribe.equals(reply[0]) ||
COMMANDS[PubSubType.PATTERNS].unsubscribe.equals(reply[0]) ||
COMMANDS[PubSubType.SHARDED].subscribe.equals(reply[0])
);
}
static isShardedUnsubscribe(reply: Array<Buffer>): boolean {
return COMMANDS[PubSubType.SHARDED].unsubscribe.equals(reply[0]);
}
static #channelsArray(channels: string | Array<string>) {
return (Array.isArray(channels) ? channels : [channels]);
}
static #listenersSet<T extends boolean>(
listeners: ChannelListeners,
returnBuffers?: T
) {
return (returnBuffers ? listeners.buffers : listeners.strings);
}
#subscribing = 0;
#isActive = false;
get isActive() {
return this.#isActive;
}
#listeners: Listeners = {
[PubSubType.CHANNELS]: new Map(),
[PubSubType.PATTERNS]: new Map(),
[PubSubType.SHARDED]: new Map()
};
subscribe<T extends boolean>(
type: PubSubType,
channels: string | Array<string>,
listener: PubSubListener<T>,
returnBuffers?: T
) {
const args: Array<RedisCommandArgument> = [COMMANDS[type].subscribe],
channelsArray = PubSub.#channelsArray(channels);
for (const channel of channelsArray) {
let channelListeners = this.#listeners[type].get(channel);
if (!channelListeners || channelListeners.unsubscribing) {
args.push(channel);
}
}
if (args.length === 1) {
// all channels are already subscribed, add listeners without issuing a command
for (const channel of channelsArray) {
PubSub.#listenersSet(
this.#listeners[type].get(channel)!,
returnBuffers
).add(listener);
}
return;
}
this.#isActive = true;
this.#subscribing++;
return {
args,
channelsCounter: args.length - 1,
resolve: () => {
this.#subscribing--;
for (const channel of channelsArray) {
let listeners = this.#listeners[type].get(channel);
if (!listeners) {
listeners = {
unsubscribing: false,
buffers: new Set(),
strings: new Set()
};
this.#listeners[type].set(channel, listeners);
}
PubSub.#listenersSet(listeners, returnBuffers).add(listener);
}
},
reject: () => {
this.#subscribing--;
this.#updateIsActive();
}
};
}
extendChannelListeners(
type: PubSubType,
channel: string,
listeners: ChannelListeners
) {
if (!this.#extendChannelListeners(type, channel, listeners)) return;
this.#isActive = true;
this.#subscribing++;
return {
args: [
COMMANDS[type].subscribe,
channel
],
channelsCounter: 1,
resolve: () => this.#subscribing--,
reject: () => {
this.#subscribing--;
this.#updateIsActive();
}
};
}
#extendChannelListeners(
type: PubSubType,
channel: string,
listeners: ChannelListeners
) {
const existingListeners = this.#listeners[type].get(channel);
if (!existingListeners) {
this.#listeners[type].set(channel, listeners);
return true;
}
for (const listener of listeners.buffers) {
existingListeners.buffers.add(listener);
}
for (const listener of listeners.strings) {
existingListeners.strings.add(listener);
}
return false;
}
extendTypeListeners(type: PubSubType, listeners: PubSubTypeListeners) {
const args: Array<RedisCommandArgument> = [COMMANDS[type].subscribe];
for (const [channel, channelListeners] of listeners) {
if (this.#extendChannelListeners(type, channel, channelListeners)) {
args.push(channel);
}
}
if (args.length === 1) return;
this.#isActive = true;
this.#subscribing++;
return {
args,
channelsCounter: args.length - 1,
resolve: () => this.#subscribing--,
reject: () => {
this.#subscribing--;
this.#updateIsActive();
}
};
}
unsubscribe<T extends boolean>(
type: PubSubType,
channels?: string | Array<string>,
listener?: PubSubListener<T>,
returnBuffers?: T
) {
const listeners = this.#listeners[type];
if (!channels) {
return this.#unsubscribeCommand(
[COMMANDS[type].unsubscribe],
// cannot use `this.#subscribed` because there might be some `SUBSCRIBE` commands in the queue
// cannot use `this.#subscribed + this.#subscribing` because some `SUBSCRIBE` commands might fail
NaN,
() => listeners.clear()
);
}
const channelsArray = PubSub.#channelsArray(channels);
if (!listener) {
return this.#unsubscribeCommand(
[COMMANDS[type].unsubscribe, ...channelsArray],
channelsArray.length,
() => {
for (const channel of channelsArray) {
listeners.delete(channel);
}
}
);
}
const args: Array<RedisCommandArgument> = [COMMANDS[type].unsubscribe];
for (const channel of channelsArray) {
const sets = listeners.get(channel);
if (sets) {
let current,
other;
if (returnBuffers) {
current = sets.buffers;
other = sets.strings;
} else {
current = sets.strings;
other = sets.buffers;
}
const currentSize = current.has(listener) ? current.size - 1 : current.size;
if (currentSize !== 0 || other.size !== 0) continue;
sets.unsubscribing = true;
}
args.push(channel);
}
if (args.length === 1) {
// all channels has other listeners,
// delete the listeners without issuing a command
for (const channel of channelsArray) {
PubSub.#listenersSet(
listeners.get(channel)!,
returnBuffers
).delete(listener);
}
return;
}
return this.#unsubscribeCommand(
args,
args.length - 1,
() => {
for (const channel of channelsArray) {
const sets = listeners.get(channel);
if (!sets) continue;
(returnBuffers ? sets.buffers : sets.strings).delete(listener);
if (sets.buffers.size === 0 && sets.strings.size === 0) {
listeners.delete(channel);
}
}
}
);
}
#unsubscribeCommand(
args: Array<RedisCommandArgument>,
channelsCounter: number,
removeListeners: () => void
) {
return {
args,
channelsCounter,
resolve: () => {
removeListeners();
this.#updateIsActive();
},
reject: undefined // use the same structure as `subscribe`
};
}
#updateIsActive() {
this.#isActive = (
this.#listeners[PubSubType.CHANNELS].size !== 0 ||
this.#listeners[PubSubType.PATTERNS].size !== 0 ||
this.#listeners[PubSubType.SHARDED].size !== 0 ||
this.#subscribing !== 0
);
}
reset() {
this.#isActive = false;
this.#subscribing = 0;
}
resubscribe(): Array<PubSubCommand> {
const commands = [];
for (const [type, listeners] of Object.entries(this.#listeners)) {
if (!listeners.size) continue;
this.#isActive = true;
this.#subscribing++;
const callback = () => this.#subscribing--;
commands.push({
args: [
COMMANDS[type as PubSubType].subscribe,
...listeners.keys()
],
channelsCounter: listeners.size,
resolve: callback,
reject: callback
});
}
return commands;
}
handleMessageReply(reply: Array<Buffer>): boolean {
if (COMMANDS[PubSubType.CHANNELS].message.equals(reply[0])) {
this.#emitPubSubMessage(
PubSubType.CHANNELS,
reply[2],
reply[1]
);
return true;
} else if (COMMANDS[PubSubType.PATTERNS].message.equals(reply[0])) {
this.#emitPubSubMessage(
PubSubType.PATTERNS,
reply[3],
reply[2],
reply[1]
);
return true;
} else if (COMMANDS[PubSubType.SHARDED].message.equals(reply[0])) {
this.#emitPubSubMessage(
PubSubType.SHARDED,
reply[2],
reply[1]
);
return true;
}
return false;
}
removeShardedListeners(channel: string): ChannelListeners {
const listeners = this.#listeners[PubSubType.SHARDED].get(channel)!;
this.#listeners[PubSubType.SHARDED].delete(channel);
this.#updateIsActive();
return listeners;
}
#emitPubSubMessage(
type: PubSubType,
message: Buffer,
channel: Buffer,
pattern?: Buffer
): void {
const keyString = (pattern ?? channel).toString(),
listeners = this.#listeners[type].get(keyString);
if (!listeners) return;
for (const listener of listeners.buffers) {
listener(message, channel);
}
if (!listeners.strings.size) return;
const channelString = pattern ? channel.toString() : keyString,
messageString = channelString === '__redis__:invalidate' ?
// https://github.com/redis/redis/pull/7469
// https://github.com/redis/redis/issues/7463
(message === null ? null : (message as any as Array<Buffer>).map(x => x.toString())) as any :
message.toString();
for (const listener of listeners.strings) {
listener(messageString, channelString);
}
}
getTypeListeners(type: PubSubType): PubSubTypeListeners {
return this.#listeners[type];
}
}

View File

@@ -1,5 +1,6 @@
import { strict as assert } from 'assert'; import { strict as assert } from 'assert';
import { spy } from 'sinon'; import { spy } from 'sinon';
import { once } from 'events';
import RedisSocket, { RedisSocketOptions } from './socket'; import RedisSocket, { RedisSocketOptions } from './socket';
describe('Socket', () => { describe('Socket', () => {
@@ -17,16 +18,42 @@ describe('Socket', () => {
} }
describe('reconnectStrategy', () => { describe('reconnectStrategy', () => {
it('false', async () => {
const socket = createSocket({
host: 'error',
connectTimeout: 1,
reconnectStrategy: false
});
await assert.rejects(socket.connect());
assert.equal(socket.isOpen, false);
});
it('0', async () => {
const socket = createSocket({
host: 'error',
connectTimeout: 1,
reconnectStrategy: 0
});
socket.connect();
await once(socket, 'error');
assert.equal(socket.isOpen, true);
assert.equal(socket.isReady, false);
socket.disconnect();
assert.equal(socket.isOpen, false);
});
it('custom strategy', async () => { it('custom strategy', async () => {
const numberOfRetries = 10; const numberOfRetries = 3;
const reconnectStrategy = spy((retries: number) => { const reconnectStrategy = spy((retries: number) => {
assert.equal(retries + 1, reconnectStrategy.callCount); assert.equal(retries + 1, reconnectStrategy.callCount);
if (retries === numberOfRetries) return new Error(`${numberOfRetries}`); if (retries === numberOfRetries) return new Error(`${numberOfRetries}`);
const time = retries * 2; return 0;
return time;
}); });
const socket = createSocket({ const socket = createSocket({

View File

@@ -6,10 +6,26 @@ import { ConnectionTimeoutError, ClientClosedError, SocketClosedUnexpectedlyErro
import { promiseTimeout } from '../utils'; import { promiseTimeout } from '../utils';
export interface RedisSocketCommonOptions { export interface RedisSocketCommonOptions {
/**
* Connection Timeout (in milliseconds)
*/
connectTimeout?: number; connectTimeout?: number;
/**
* Toggle [`Nagle's algorithm`](https://nodejs.org/api/net.html#net_socket_setnodelay_nodelay)
*/
noDelay?: boolean; noDelay?: boolean;
/**
* Toggle [`keep-alive`](https://nodejs.org/api/net.html#net_socket_setkeepalive_enable_initialdelay)
*/
keepAlive?: number | false; keepAlive?: number | false;
reconnectStrategy?(retries: number): number | Error; /**
* When the socket closes unexpectedly (without calling `.quit()`/`.disconnect()`), the client uses `reconnectStrategy` to decide what to do. The following values are supported:
* 1. `false` -> do not reconnect, close the client and flush the command queue.
* 2. `number` -> wait for `X` milliseconds before reconnecting.
* 3. `(retries: number, cause: Error) => false | number | Error` -> `number` is the same as configuring a `number` directly, `Error` is the same as `false`, but with a custom error.
* Defaults to `retries => Math.min(retries * 50, 500)`
*/
reconnectStrategy?: false | number | ((retries: number, cause: Error) => false | Error | number);
} }
type RedisNetSocketOptions = Partial<net.SocketConnectOpts> & { type RedisNetSocketOptions = Partial<net.SocketConnectOpts> & {
@@ -83,23 +99,42 @@ export default class RedisSocket extends EventEmitter {
this.#options = RedisSocket.#initiateOptions(options); this.#options = RedisSocket.#initiateOptions(options);
} }
reconnectStrategy(retries: number): number | Error { #reconnectStrategy(retries: number, cause: Error) {
if (this.#options.reconnectStrategy) { if (this.#options.reconnectStrategy === false) {
return false;
} else if (typeof this.#options.reconnectStrategy === 'number') {
return this.#options.reconnectStrategy;
} else if (this.#options.reconnectStrategy) {
try { try {
const retryIn = this.#options.reconnectStrategy(retries); const retryIn = this.#options.reconnectStrategy(retries, cause);
if (typeof retryIn !== 'number' && !(retryIn instanceof Error)) { if (retryIn !== false && !(retryIn instanceof Error) && typeof retryIn !== 'number') {
throw new TypeError('Reconnect strategy should return `number | Error`'); throw new TypeError(`Reconnect strategy should return \`false | Error | number\`, got ${retryIn} instead`);
} }
return retryIn; return retryIn;
} catch (err) { } catch (err) {
this.emit('error', err); this.emit('error', err);
} }
} }
return Math.min(retries * 50, 500); return Math.min(retries * 50, 500);
} }
#shouldReconnect(retries: number, cause: Error) {
const retryIn = this.#reconnectStrategy(retries, cause);
if (retryIn === false) {
this.#isOpen = false;
this.emit('error', cause);
return cause;
} else if (retryIn instanceof Error) {
this.#isOpen = false;
this.emit('error', cause);
return new ReconnectStrategyError(retryIn, cause);
}
return retryIn;
}
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.#isOpen) { if (this.#isOpen) {
throw new Error('Socket already opened'); throw new Error('Socket already opened');
@@ -109,13 +144,9 @@ export default class RedisSocket extends EventEmitter {
return this.#connect(); return this.#connect();
} }
async #connect(hadError?: boolean): Promise<void> { async #connect(): Promise<void> {
let retries = 0; let retries = 0;
do { do {
if (retries > 0 || hadError) {
this.emit('reconnecting');
}
try { try {
this.#socket = await this.#createSocket(); this.#socket = await this.#createSocket();
this.#writableNeedDrain = false; this.#writableNeedDrain = false;
@@ -131,17 +162,15 @@ export default class RedisSocket extends EventEmitter {
this.#isReady = true; this.#isReady = true;
this.emit('ready'); this.emit('ready');
} catch (err) { } catch (err) {
const retryIn = this.reconnectStrategy(retries); const retryIn = this.#shouldReconnect(retries++, err as Error);
if (retryIn instanceof Error) { if (typeof retryIn !== 'number') {
this.#isOpen = false; throw retryIn;
this.emit('error', err);
throw new ReconnectStrategyError(retryIn, err);
} }
this.emit('error', err); this.emit('error', err);
await promiseTimeout(retryIn); await promiseTimeout(retryIn);
this.emit('reconnecting');
} }
retries++;
} while (this.#isOpen && !this.#isReady); } while (this.#isOpen && !this.#isReady);
} }
@@ -200,12 +229,14 @@ export default class RedisSocket extends EventEmitter {
} }
#onSocketError(err: Error): void { #onSocketError(err: Error): void {
const wasReady = this.#isReady;
this.#isReady = false; this.#isReady = false;
this.emit('error', err); this.emit('error', err);
if (!this.#isOpen) return; if (!wasReady || !this.#isOpen || typeof this.#shouldReconnect(0, err) !== 'number') return;
this.#connect(true).catch(() => { this.emit('reconnecting');
this.#connect().catch(() => {
// the error was already emitted, silently ignore it // the error was already emitted, silently ignore it
}); });
} }
@@ -261,7 +292,7 @@ export default class RedisSocket extends EventEmitter {
this.#socket.cork(); this.#socket.cork();
this.#isCorked = true; this.#isCorked = true;
queueMicrotask(() => { setImmediate(() => {
this.#socket?.uncork(); this.#socket?.uncork();
this.#isCorked = false; this.#isCorked = false;
}); });

View File

@@ -1,23 +1,17 @@
import RedisClient, { InstantiableRedisClient, RedisClientType } from '../client'; import RedisClient, { InstantiableRedisClient, RedisClientType } from '../client';
import { RedisClusterMasterNode, RedisClusterReplicaNode } from '../commands/CLUSTER_NODES';
import { RedisClusterClientOptions, RedisClusterOptions } from '.'; import { RedisClusterClientOptions, RedisClusterOptions } from '.';
import { RedisCommandArgument, RedisFunctions, RedisModules, RedisScripts } from '../commands'; import { RedisCommandArgument, RedisFunctions, RedisModules, RedisScripts } from '../commands';
import { RootNodesUnavailableError } from '../errors'; import { RootNodesUnavailableError } from '../errors';
import { ClusterSlotsNode } from '../commands/CLUSTER_SLOTS';
import { types } from 'util';
import { ChannelListeners, PubSubType, PubSubTypeListeners } from '../client/pub-sub';
import { EventEmitter } from 'stream';
// We need to use 'require', because it's not possible with Typescript to import // We need to use 'require', because it's not possible with Typescript to import
// function that are exported as 'module.exports = function`, without esModuleInterop // function that are exported as 'module.exports = function`, without esModuleInterop
// set to true. // set to true.
const calculateSlot = require('cluster-key-slot'); const calculateSlot = require('cluster-key-slot');
export interface ClusterNode<
M extends RedisModules,
F extends RedisFunctions,
S extends RedisScripts
> {
id: string;
client: RedisClientType<M, F, S>;
}
interface NodeAddress { interface NodeAddress {
host: string; host: string;
port: number; port: number;
@@ -27,133 +21,236 @@ export type NodeAddressMap = {
[address: string]: NodeAddress; [address: string]: NodeAddress;
} | ((address: string) => NodeAddress | undefined); } | ((address: string) => NodeAddress | undefined);
interface SlotNodes< type ValueOrPromise<T> = T | Promise<T>;
type ClientOrPromise<
M extends RedisModules,
F extends RedisFunctions,
S extends RedisScripts
> = ValueOrPromise<RedisClientType<M, F, S>>;
export interface Node<
M extends RedisModules, M extends RedisModules,
F extends RedisFunctions, F extends RedisFunctions,
S extends RedisScripts S extends RedisScripts
> { > {
master: ClusterNode<M, F, S>; address: string;
replicas: Array<ClusterNode<M, F, S>>; client?: ClientOrPromise<M, F, S>;
clientIterator: IterableIterator<RedisClientType<M, F, S>> | undefined;
} }
type OnError = (err: unknown) => void; export interface ShardNode<
M extends RedisModules,
F extends RedisFunctions,
S extends RedisScripts
> extends Node<M, F, S> {
id: string;
host: string;
port: number;
readonly: boolean;
}
export interface MasterNode<
M extends RedisModules,
F extends RedisFunctions,
S extends RedisScripts
> extends ShardNode<M, F, S> {
pubSubClient?: ClientOrPromise<M, F, S>;
}
export interface Shard<
M extends RedisModules,
F extends RedisFunctions,
S extends RedisScripts
> {
master: MasterNode<M, F, S>;
replicas?: Array<ShardNode<M, F, S>>;
nodesIterator?: IterableIterator<ShardNode<M, F, S>>;
}
type ShardWithReplicas<
M extends RedisModules,
F extends RedisFunctions,
S extends RedisScripts
> = Shard<M, F, S> & Required<Pick<Shard<M, F, S>, 'replicas'>>;
export type PubSubNode<
M extends RedisModules,
F extends RedisFunctions,
S extends RedisScripts
> = Required<Node<M, F, S>>;
type PubSubToResubscribe = Record<
PubSubType.CHANNELS | PubSubType.PATTERNS,
PubSubTypeListeners
>;
export type OnShardedChannelMovedError = (
err: unknown,
channel: string,
listeners?: ChannelListeners
) => void;
export default class RedisClusterSlots< export default class RedisClusterSlots<
M extends RedisModules, M extends RedisModules,
F extends RedisFunctions, F extends RedisFunctions,
S extends RedisScripts S extends RedisScripts
> { > {
static #SLOTS = 16384;
readonly #options: RedisClusterOptions<M, F, S>; readonly #options: RedisClusterOptions<M, F, S>;
readonly #Client: InstantiableRedisClient<M, F, S>; readonly #Client: InstantiableRedisClient<M, F, S>;
readonly #onError: OnError; readonly #emit: EventEmitter['emit'];
readonly #nodeByAddress = new Map<string, ClusterNode<M, F, S>>(); slots = new Array<Shard<M, F, S>>(RedisClusterSlots.#SLOTS);
readonly #slots: Array<SlotNodes<M, F, S>> = []; shards = new Array<Shard<M, F, S>>();
masters = new Array<ShardNode<M, F, S>>();
replicas = new Array<ShardNode<M, F, S>>();
readonly nodeByAddress = new Map<string, MasterNode<M, F, S> | ShardNode<M, F, S>>();
pubSubNode?: PubSubNode<M, F, S>;
constructor(options: RedisClusterOptions<M, F, S>, onError: OnError) { #isOpen = false;
this.#options = options;
this.#Client = RedisClient.extend(options); get isOpen() {
this.#onError = onError; return this.#isOpen;
} }
async connect(): Promise<void> { constructor(
for (const rootNode of this.#options.rootNodes) { options: RedisClusterOptions<M, F, S>,
if (await this.#discoverNodes(rootNode)) return; emit: EventEmitter['emit']
) {
this.#options = options;
this.#Client = RedisClient.extend(options);
this.#emit = emit;
}
async connect() {
if (this.#isOpen) {
throw new Error('Cluster already open');
}
this.#isOpen = true;
try {
await this.#discoverWithRootNodes();
} catch (err) {
this.#isOpen = false;
throw err;
}
}
async #discoverWithRootNodes() {
let start = Math.floor(Math.random() * this.#options.rootNodes.length);
for (let i = start; i < this.#options.rootNodes.length; i++) {
if (await this.#discover(this.#options.rootNodes[i])) return;
}
for (let i = 0; i < start; i++) {
if (await this.#discover(this.#options.rootNodes[i])) return;
} }
throw new RootNodesUnavailableError(); throw new RootNodesUnavailableError();
} }
async #discoverNodes(clientOptions?: RedisClusterClientOptions): Promise<boolean> { #resetSlots() {
const client = this.#initiateClient(clientOptions); this.slots = new Array(RedisClusterSlots.#SLOTS);
this.shards = [];
this.masters = [];
this.replicas = [];
this.#randomNodeIterator = undefined;
}
async #discover(rootNode?: RedisClusterClientOptions) {
const addressesInUse = new Set<string>();
try {
const shards = await this.#getShards(rootNode),
promises: Array<Promise<unknown>> = [],
eagerConnect = this.#options.minimizeConnections !== true;
this.#resetSlots();
for (const { from, to, master, replicas } of shards) {
const shard: Shard<M, F, S> = {
master: this.#initiateSlotNode(master, false, eagerConnect, addressesInUse, promises)
};
if (this.#options.useReplicas) {
shard.replicas = replicas.map(replica =>
this.#initiateSlotNode(replica, true, eagerConnect, addressesInUse, promises)
);
}
this.shards.push(shard);
for (let i = from; i <= to; i++) {
this.slots[i] = shard;
}
}
if (this.pubSubNode && !addressesInUse.has(this.pubSubNode.address)) {
if (types.isPromise(this.pubSubNode.client)) {
promises.push(
this.pubSubNode.client.then(client => client.disconnect())
);
this.pubSubNode = undefined;
} else {
promises.push(this.pubSubNode.client.disconnect());
const channelsListeners = this.pubSubNode.client.getPubSubListeners(PubSubType.CHANNELS),
patternsListeners = this.pubSubNode.client.getPubSubListeners(PubSubType.PATTERNS);
if (channelsListeners.size || patternsListeners.size) {
promises.push(
this.#initiatePubSubClient({
[PubSubType.CHANNELS]: channelsListeners,
[PubSubType.PATTERNS]: patternsListeners
})
);
}
}
}
for (const [address, node] of this.nodeByAddress.entries()) {
if (addressesInUse.has(address)) continue;
if (node.client) {
promises.push(
this.#execOnNodeClient(node.client, client => client.disconnect())
);
}
const { pubSubClient } = node as MasterNode<M, F, S>;
if (pubSubClient) {
promises.push(
this.#execOnNodeClient(pubSubClient, client => client.disconnect())
);
}
this.nodeByAddress.delete(address);
}
await Promise.all(promises);
return true;
} catch (err) {
this.#emit('error', err);
return false;
}
}
async #getShards(rootNode?: RedisClusterClientOptions) {
const client = new this.#Client(
this.#clientOptionsDefaults(rootNode, true)
);
client.on('error', err => this.#emit('error', err));
await client.connect(); await client.connect();
try { try {
await this.#reset(await client.clusterNodes()); // using `CLUSTER SLOTS` and not `CLUSTER SHARDS` to support older versions
return true; return await client.clusterSlots();
} catch (err) {
this.#onError(err);
return false;
} finally { } finally {
if (client.isOpen) { await client.disconnect();
await client.disconnect();
}
} }
} }
#runningRediscoverPromise?: Promise<void>;
async rediscover(startWith: RedisClientType<M, F, S>): Promise<void> {
if (!this.#runningRediscoverPromise) {
this.#runningRediscoverPromise = this.#rediscover(startWith)
.finally(() => this.#runningRediscoverPromise = undefined);
}
return this.#runningRediscoverPromise;
}
async #rediscover(startWith: RedisClientType<M, F, S>): Promise<void> {
if (await this.#discoverNodes(startWith.options)) return;
for (const { client } of this.#nodeByAddress.values()) {
if (client === startWith) continue;
if (await this.#discoverNodes(client.options)) return;
}
throw new Error('None of the cluster nodes is available');
}
async #reset(masters: Array<RedisClusterMasterNode>): Promise<void> {
// Override this.#slots and add not existing clients to this.#nodeByAddress
const promises: Array<Promise<void>> = [],
clientsInUse = new Set<string>();
for (const master of masters) {
const slot = {
master: this.#initiateClientForNode(master, false, clientsInUse, promises),
replicas: this.#options.useReplicas ?
master.replicas.map(replica => this.#initiateClientForNode(replica, true, clientsInUse, promises)) :
[],
clientIterator: undefined // will be initiated in use
};
for (const { from, to } of master.slots) {
for (let i = from; i <= to; i++) {
this.#slots[i] = slot;
}
}
}
// Remove unused clients from this.#nodeByAddress using clientsInUse
for (const [address, { client }] of this.#nodeByAddress.entries()) {
if (clientsInUse.has(address)) continue;
promises.push(client.disconnect());
this.#nodeByAddress.delete(address);
}
await Promise.all(promises);
}
#clientOptionsDefaults(options?: RedisClusterClientOptions): RedisClusterClientOptions | undefined {
if (!this.#options.defaults) return options;
return {
...this.#options.defaults,
...options,
socket: this.#options.defaults.socket && options?.socket ? {
...this.#options.defaults.socket,
...options.socket
} : this.#options.defaults.socket ?? options?.socket
};
}
#initiateClient(options?: RedisClusterClientOptions): RedisClientType<M, F, S> {
return new this.#Client(this.#clientOptionsDefaults(options))
.on('error', this.#onError);
}
#getNodeAddress(address: string): NodeAddress | undefined { #getNodeAddress(address: string): NodeAddress | undefined {
switch (typeof this.#options.nodeAddressMap) { switch (typeof this.#options.nodeAddressMap) {
case 'object': case 'object':
@@ -164,111 +261,123 @@ export default class RedisClusterSlots<
} }
} }
#initiateClientForNode( #clientOptionsDefaults(
nodeData: RedisClusterMasterNode | RedisClusterReplicaNode, options?: RedisClusterClientOptions,
readonly: boolean, disableReconnect?: boolean
clientsInUse: Set<string>, ): RedisClusterClientOptions | undefined {
promises: Array<Promise<void>> let result: RedisClusterClientOptions | undefined;
): ClusterNode<M, F, S> { if (this.#options.defaults) {
const address = `${nodeData.host}:${nodeData.port}`; let socket;
clientsInUse.add(address); if (this.#options.defaults.socket) {
socket = {
...this.#options.defaults.socket,
...options?.socket
};
} else {
socket = options?.socket;
}
let node = this.#nodeByAddress.get(address); result = {
...this.#options.defaults,
...options,
socket
};
} else {
result = options;
}
if (disableReconnect) {
result ??= {};
result.socket ??= {};
result.socket.reconnectStrategy = false;
}
return result;
}
#initiateSlotNode(
{ id, ip, port }: ClusterSlotsNode,
readonly: boolean,
eagerConnent: boolean,
addressesInUse: Set<string>,
promises: Array<Promise<unknown>>
) {
const address = `${ip}:${port}`;
addressesInUse.add(address);
let node = this.nodeByAddress.get(address);
if (!node) { if (!node) {
node = { node = {
id: nodeData.id, id,
client: this.#initiateClient({ host: ip,
socket: this.#getNodeAddress(address) ?? { port,
host: nodeData.host, address,
port: nodeData.port readonly,
}, client: undefined
readonly
})
}; };
promises.push(node.client.connect());
this.#nodeByAddress.set(address, node); if (eagerConnent) {
promises.push(this.#createNodeClient(node));
}
this.nodeByAddress.set(address, node);
} }
(readonly ? this.replicas : this.masters).push(node);
return node; return node;
} }
getSlotMaster(slot: number): ClusterNode<M, F, S> { async #createClient(
return this.#slots[slot].master; node: ShardNode<M, F, S>,
} readonly = node.readonly
) {
*#slotClientIterator(slotNumber: number): IterableIterator<RedisClientType<M, F, S>> { const client = new this.#Client(
const slot = this.#slots[slotNumber]; this.#clientOptionsDefaults({
yield slot.master.client; socket: this.#getNodeAddress(node.address) ?? {
host: node.host,
for (const replica of slot.replicas) { port: node.port
yield replica.client; },
} readonly
} })
#getSlotClient(slotNumber: number): RedisClientType<M, F, S> {
const slot = this.#slots[slotNumber];
if (!slot.clientIterator) {
slot.clientIterator = this.#slotClientIterator(slotNumber);
}
const {done, value} = slot.clientIterator.next();
if (done) {
slot.clientIterator = undefined;
return this.#getSlotClient(slotNumber);
}
return value;
}
#randomClientIterator?: IterableIterator<ClusterNode<M, F, S>>;
#getRandomClient(): RedisClientType<M, F, S> {
if (!this.#nodeByAddress.size) {
throw new Error('Cluster is not connected');
}
if (!this.#randomClientIterator) {
this.#randomClientIterator = this.#nodeByAddress.values();
}
const {done, value} = this.#randomClientIterator.next();
if (done) {
this.#randomClientIterator = undefined;
return this.#getRandomClient();
}
return value.client;
}
getClient(firstKey?: RedisCommandArgument, isReadonly?: boolean): RedisClientType<M, F, S> {
if (!firstKey) {
return this.#getRandomClient();
}
const slot = calculateSlot(firstKey);
if (!isReadonly || !this.#options.useReplicas) {
return this.getSlotMaster(slot).client;
}
return this.#getSlotClient(slot);
}
getMasters(): Array<ClusterNode<M, F, S>> {
const masters = [];
for (const node of this.#nodeByAddress.values()) {
if (node.client.options?.readonly) continue;
masters.push(node);
}
return masters;
}
getNodeByAddress(address: string): ClusterNode<M, F, S> | undefined {
const mappedAddress = this.#getNodeAddress(address);
return this.#nodeByAddress.get(
mappedAddress ? `${mappedAddress.host}:${mappedAddress.port}` : address
); );
client.on('error', err => this.#emit('error', err));
await client.connect();
return client;
}
#createNodeClient(node: ShardNode<M, F, S>) {
const promise = this.#createClient(node)
.then(client => {
node.client = client;
return client;
})
.catch(err => {
node.client = undefined;
throw err;
});
node.client = promise;
return promise;
}
nodeClient(node: ShardNode<M, F, S>) {
return node.client ?? this.#createNodeClient(node);
}
#runningRediscoverPromise?: Promise<void>;
async rediscover(startWith: RedisClientType<M, F, S>): Promise<void> {
this.#runningRediscoverPromise ??= this.#rediscover(startWith)
.finally(() => this.#runningRediscoverPromise = undefined);
return this.#runningRediscoverPromise;
}
async #rediscover(startWith: RedisClientType<M, F, S>): Promise<void> {
if (await this.#discover(startWith.options)) return;
return this.#discoverWithRootNodes();
} }
quit(): Promise<void> { quit(): Promise<void> {
@@ -280,14 +389,233 @@ export default class RedisClusterSlots<
} }
async #destroy(fn: (client: RedisClientType<M, F, S>) => Promise<unknown>): Promise<void> { async #destroy(fn: (client: RedisClientType<M, F, S>) => Promise<unknown>): Promise<void> {
this.#isOpen = false;
const promises = []; const promises = [];
for (const { client } of this.#nodeByAddress.values()) { for (const { master, replicas } of this.shards) {
promises.push(fn(client)); if (master.client) {
promises.push(
this.#execOnNodeClient(master.client, fn)
);
}
if (master.pubSubClient) {
promises.push(
this.#execOnNodeClient(master.pubSubClient, fn)
);
}
if (replicas) {
for (const { client } of replicas) {
if (client) {
promises.push(
this.#execOnNodeClient(client, fn)
);
}
}
}
} }
await Promise.all(promises); if (this.pubSubNode) {
promises.push(this.#execOnNodeClient(this.pubSubNode.client, fn));
this.pubSubNode = undefined;
}
this.#nodeByAddress.clear(); this.#resetSlots();
this.#slots.splice(0); this.nodeByAddress.clear();
await Promise.allSettled(promises);
}
#execOnNodeClient(
client: ClientOrPromise<M, F, S>,
fn: (client: RedisClientType<M, F, S>) => Promise<unknown>
) {
return types.isPromise(client) ?
client.then(fn) :
fn(client);
}
getClient(
firstKey: RedisCommandArgument | undefined,
isReadonly: boolean | undefined
): ClientOrPromise<M, F, S> {
if (!firstKey) {
return this.nodeClient(this.getRandomNode());
}
const slotNumber = calculateSlot(firstKey);
if (!isReadonly) {
return this.nodeClient(this.slots[slotNumber].master);
}
return this.nodeClient(this.getSlotRandomNode(slotNumber));
}
*#iterateAllNodes() {
let i = Math.floor(Math.random() * (this.masters.length + this.replicas.length));
if (i < this.masters.length) {
do {
yield this.masters[i];
} while (++i < this.masters.length);
for (const replica of this.replicas) {
yield replica;
}
} else {
i -= this.masters.length;
do {
yield this.replicas[i];
} while (++i < this.replicas.length);
}
while (true) {
for (const master of this.masters) {
yield master;
}
for (const replica of this.replicas) {
yield replica;
}
}
}
#randomNodeIterator?: IterableIterator<ShardNode<M, F, S>>;
getRandomNode() {
this.#randomNodeIterator ??= this.#iterateAllNodes();
return this.#randomNodeIterator.next().value as ShardNode<M, F, S>;
}
*#slotNodesIterator(slot: ShardWithReplicas<M, F, S>) {
let i = Math.floor(Math.random() * (1 + slot.replicas.length));
if (i < slot.replicas.length) {
do {
yield slot.replicas[i];
} while (++i < slot.replicas.length);
}
while (true) {
yield slot.master;
for (const replica of slot.replicas) {
yield replica;
}
}
}
getSlotRandomNode(slotNumber: number) {
const slot = this.slots[slotNumber];
if (!slot.replicas?.length) {
return slot.master;
}
slot.nodesIterator ??= this.#slotNodesIterator(slot as ShardWithReplicas<M, F, S>);
return slot.nodesIterator.next().value as ShardNode<M, F, S>;
}
getMasterByAddress(address: string) {
const master = this.nodeByAddress.get(address);
if (!master) return;
return this.nodeClient(master);
}
getPubSubClient() {
return this.pubSubNode ?
this.pubSubNode.client :
this.#initiatePubSubClient();
}
async #initiatePubSubClient(toResubscribe?: PubSubToResubscribe) {
const index = Math.floor(Math.random() * (this.masters.length + this.replicas.length)),
node = index < this.masters.length ?
this.masters[index] :
this.replicas[index - this.masters.length];
this.pubSubNode = {
address: node.address,
client: this.#createClient(node, true)
.then(async client => {
if (toResubscribe) {
await Promise.all([
client.extendPubSubListeners(PubSubType.CHANNELS, toResubscribe[PubSubType.CHANNELS]),
client.extendPubSubListeners(PubSubType.PATTERNS, toResubscribe[PubSubType.PATTERNS])
]);
}
this.pubSubNode!.client = client;
return client;
})
.catch(err => {
this.pubSubNode = undefined;
throw err;
})
};
return this.pubSubNode.client as Promise<RedisClientType<M, F, S>>;
}
async executeUnsubscribeCommand(
unsubscribe: (client: RedisClientType<M, F, S>) => Promise<void>
): Promise<void> {
const client = await this.getPubSubClient();
await unsubscribe(client);
if (!client.isPubSubActive && client.isOpen) {
await client.disconnect();
this.pubSubNode = undefined;
}
}
getShardedPubSubClient(channel: string) {
const { master } = this.slots[calculateSlot(channel)];
return master.pubSubClient ?? this.#initiateShardedPubSubClient(master);
}
#initiateShardedPubSubClient(master: MasterNode<M, F, S>) {
const promise = this.#createClient(master, true)
.then(client => {
client.on('server-sunsubscribe', async (channel, listeners) => {
try {
await this.rediscover(client);
const redirectTo = await this.getShardedPubSubClient(channel);
redirectTo.extendPubSubChannelListeners(
PubSubType.SHARDED,
channel,
listeners
);
} catch (err) {
this.#emit('sharded-shannel-moved-error', err, channel, listeners);
}
});
master.pubSubClient = client;
return client;
})
.catch(err => {
master.pubSubClient = undefined;
throw err;
});
master.pubSubClient = promise;
return promise;
}
async executeShardedUnsubscribeCommand(
channel: string,
unsubscribe: (client: RedisClientType<M, F, S>) => Promise<void>
): Promise<void> {
const { master } = this.slots[calculateSlot(channel)];
if (!master.pubSubClient) return Promise.resolve();
const client = await master.pubSubClient;
await unsubscribe(client);
if (!client.isPubSubActive && client.isOpen) {
await client.disconnect();
master.pubSubClient = undefined;
}
} }
} }

View File

@@ -53,6 +53,9 @@ import * as GETRANGE from '../commands/GETRANGE';
import * as GETSET from '../commands/GETSET'; import * as GETSET from '../commands/GETSET';
import * as HDEL from '../commands/HDEL'; import * as HDEL from '../commands/HDEL';
import * as HEXISTS from '../commands/HEXISTS'; import * as HEXISTS from '../commands/HEXISTS';
import * as HEXPIRE from '../commands/HEXPIRE';
import * as HEXPIREAT from '../commands/HEXPIREAT';
import * as HEXPIRETIME from '../commands/HEXPIRETIME';
import * as HGET from '../commands/HGET'; import * as HGET from '../commands/HGET';
import * as HGETALL from '../commands/HGETALL'; import * as HGETALL from '../commands/HGETALL';
import * as HINCRBY from '../commands/HINCRBY'; import * as HINCRBY from '../commands/HINCRBY';
@@ -60,13 +63,20 @@ import * as HINCRBYFLOAT from '../commands/HINCRBYFLOAT';
import * as HKEYS from '../commands/HKEYS'; import * as HKEYS from '../commands/HKEYS';
import * as HLEN from '../commands/HLEN'; import * as HLEN from '../commands/HLEN';
import * as HMGET from '../commands/HMGET'; import * as HMGET from '../commands/HMGET';
import * as HPERSIST from '../commands/HPERSIST';
import * as HPEXPIRE from '../commands/HPEXPIRE';
import * as HPEXPIREAT from '../commands/HPEXPIREAT';
import * as HPEXPIRETIME from '../commands/HPEXPIRETIME';
import * as HPTTL from '../commands/HPTTL';
import * as HRANDFIELD_COUNT_WITHVALUES from '../commands/HRANDFIELD_COUNT_WITHVALUES'; import * as HRANDFIELD_COUNT_WITHVALUES from '../commands/HRANDFIELD_COUNT_WITHVALUES';
import * as HRANDFIELD_COUNT from '../commands/HRANDFIELD_COUNT'; import * as HRANDFIELD_COUNT from '../commands/HRANDFIELD_COUNT';
import * as HRANDFIELD from '../commands/HRANDFIELD'; import * as HRANDFIELD from '../commands/HRANDFIELD';
import * as HSCAN from '../commands/HSCAN'; import * as HSCAN from '../commands/HSCAN';
import * as HSCAN_NOVALUES from '../commands/HSCAN_NOVALUES';
import * as HSET from '../commands/HSET'; import * as HSET from '../commands/HSET';
import * as HSETNX from '../commands/HSETNX'; import * as HSETNX from '../commands/HSETNX';
import * as HSTRLEN from '../commands/HSTRLEN'; import * as HSTRLEN from '../commands/HSTRLEN';
import * as HTTL from '../commands/HTTL';
import * as HVALS from '../commands/HVALS'; import * as HVALS from '../commands/HVALS';
import * as INCR from '../commands/INCR'; import * as INCR from '../commands/INCR';
import * as INCRBY from '../commands/INCRBY'; import * as INCRBY from '../commands/INCRBY';
@@ -110,6 +120,7 @@ import * as PTTL from '../commands/PTTL';
import * as PUBLISH from '../commands/PUBLISH'; import * as PUBLISH from '../commands/PUBLISH';
import * as RENAME from '../commands/RENAME'; import * as RENAME from '../commands/RENAME';
import * as RENAMENX from '../commands/RENAMENX'; import * as RENAMENX from '../commands/RENAMENX';
import * as RESTORE from '../commands/RESTORE';
import * as RPOP_COUNT from '../commands/RPOP_COUNT'; import * as RPOP_COUNT from '../commands/RPOP_COUNT';
import * as RPOP from '../commands/RPOP'; import * as RPOP from '../commands/RPOP';
import * as RPOPLPUSH from '../commands/RPOPLPUSH'; import * as RPOPLPUSH from '../commands/RPOPLPUSH';
@@ -135,6 +146,7 @@ import * as SORT_RO from '../commands/SORT_RO';
import * as SORT_STORE from '../commands/SORT_STORE'; import * as SORT_STORE from '../commands/SORT_STORE';
import * as SORT from '../commands/SORT'; import * as SORT from '../commands/SORT';
import * as SPOP from '../commands/SPOP'; import * as SPOP from '../commands/SPOP';
import * as SPUBLISH from '../commands/SPUBLISH';
import * as SRANDMEMBER_COUNT from '../commands/SRANDMEMBER_COUNT'; import * as SRANDMEMBER_COUNT from '../commands/SRANDMEMBER_COUNT';
import * as SRANDMEMBER from '../commands/SRANDMEMBER'; import * as SRANDMEMBER from '../commands/SRANDMEMBER';
import * as SREM from '../commands/SREM'; import * as SREM from '../commands/SREM';
@@ -319,6 +331,12 @@ export default {
hDel: HDEL, hDel: HDEL,
HEXISTS, HEXISTS,
hExists: HEXISTS, hExists: HEXISTS,
HEXPIRE,
hExpire: HEXPIRE,
HEXPIREAT,
hExpireAt: HEXPIREAT,
HEXPIRETIME,
hExpireTime: HEXPIRETIME,
HGET, HGET,
hGet: HGET, hGet: HGET,
HGETALL, HGETALL,
@@ -333,6 +351,16 @@ export default {
hLen: HLEN, hLen: HLEN,
HMGET, HMGET,
hmGet: HMGET, hmGet: HMGET,
HPERSIST,
hPersist: HPERSIST,
HPEXPIRE,
hpExpire: HPEXPIRE,
HPEXPIREAT,
hpExpireAt: HPEXPIREAT,
HPEXPIRETIME,
hpExpireTime: HPEXPIRETIME,
HPTTL,
hpTTL: HPTTL,
HRANDFIELD_COUNT_WITHVALUES, HRANDFIELD_COUNT_WITHVALUES,
hRandFieldCountWithValues: HRANDFIELD_COUNT_WITHVALUES, hRandFieldCountWithValues: HRANDFIELD_COUNT_WITHVALUES,
HRANDFIELD_COUNT, HRANDFIELD_COUNT,
@@ -341,12 +369,16 @@ export default {
hRandField: HRANDFIELD, hRandField: HRANDFIELD,
HSCAN, HSCAN,
hScan: HSCAN, hScan: HSCAN,
HSCAN_NOVALUES,
hScanNoValues: HSCAN_NOVALUES,
HSET, HSET,
hSet: HSET, hSet: HSET,
HSETNX, HSETNX,
hSetNX: HSETNX, hSetNX: HSETNX,
HSTRLEN, HSTRLEN,
hStrLen: HSTRLEN, hStrLen: HSTRLEN,
HTTL,
hTTL: HTTL,
HVALS, HVALS,
hVals: HVALS, hVals: HVALS,
INCR, INCR,
@@ -433,6 +465,8 @@ export default {
rename: RENAME, rename: RENAME,
RENAMENX, RENAMENX,
renameNX: RENAMENX, renameNX: RENAMENX,
RESTORE,
restore: RESTORE,
RPOP_COUNT, RPOP_COUNT,
rPopCount: RPOP_COUNT, rPopCount: RPOP_COUNT,
RPOP, RPOP,
@@ -483,6 +517,8 @@ export default {
sort: SORT, sort: SORT,
SPOP, SPOP,
sPop: SPOP, sPop: SPOP,
SPUBLISH,
sPublish: SPUBLISH,
SRANDMEMBER_COUNT, SRANDMEMBER_COUNT,
sRandMemberCount: SRANDMEMBER_COUNT, sRandMemberCount: SRANDMEMBER_COUNT,
SRANDMEMBER, SRANDMEMBER,

View File

@@ -1,25 +1,30 @@
import { strict as assert } from 'assert'; import { strict as assert } from 'assert';
import testUtils, { GLOBAL } from '../test-utils'; import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils';
import RedisCluster from '.'; import RedisCluster from '.';
import { ClusterSlotStates } from '../commands/CLUSTER_SETSLOT'; import { ClusterSlotStates } from '../commands/CLUSTER_SETSLOT';
import { commandOptions } from '../command-options';
import { SQUARE_SCRIPT } from '../client/index.spec'; import { SQUARE_SCRIPT } from '../client/index.spec';
import { RootNodesUnavailableError } from '../errors'; import { RootNodesUnavailableError } from '../errors';
import { spy } from 'sinon';
// We need to use 'require', because it's not possible with Typescript to import import { promiseTimeout } from '../utils';
// function that are exported as 'module.exports = function`, without esModuleInterop import RedisClient from '../client';
// set to true.
const calculateSlot = require('cluster-key-slot');
describe('Cluster', () => { describe('Cluster', () => {
testUtils.testWithCluster('sendCommand', async cluster => { testUtils.testWithCluster('sendCommand', async cluster => {
await cluster.publish('channel', 'message'); assert.equal(
await cluster.set('a', 'b'); await cluster.sendCommand(undefined, true, ['PING']),
await cluster.set('a{a}', 'bb'); 'PONG'
await cluster.set('aa', 'bb'); );
await cluster.get('aa'); }, GLOBAL.CLUSTERS.OPEN);
await cluster.get('aa');
await cluster.get('aa'); testUtils.testWithCluster('isOpen', async cluster => {
await cluster.get('aa'); assert.equal(cluster.isOpen, true);
await cluster.disconnect();
assert.equal(cluster.isOpen, false);
}, GLOBAL.CLUSTERS.OPEN);
testUtils.testWithCluster('connect should throw if already connected', async cluster => {
await assert.rejects(cluster.connect());
}, GLOBAL.CLUSTERS.OPEN); }, GLOBAL.CLUSTERS.OPEN);
testUtils.testWithCluster('multi', async cluster => { testUtils.testWithCluster('multi', async cluster => {
@@ -64,54 +69,321 @@ describe('Cluster', () => {
}); });
testUtils.testWithCluster('should handle live resharding', async cluster => { testUtils.testWithCluster('should handle live resharding', async cluster => {
const key = 'key', const slot = 12539,
key = 'key',
value = 'value'; value = 'value';
await cluster.set(key, value); await cluster.set(key, value);
const slot = calculateSlot(key), const importing = cluster.slots[0].master,
source = cluster.getSlotMaster(slot), migrating = cluster.slots[slot].master,
destination = cluster.getMasters().find(node => node.id !== source.id)!; [ importingClient, migratingClient ] = await Promise.all([
cluster.nodeClient(importing),
cluster.nodeClient(migrating)
]);
await Promise.all([ await Promise.all([
source.client.clusterSetSlot(slot, ClusterSlotStates.MIGRATING, destination.id), importingClient.clusterSetSlot(slot, ClusterSlotStates.IMPORTING, migrating.id),
destination.client.clusterSetSlot(slot, ClusterSlotStates.IMPORTING, destination.id) migratingClient.clusterSetSlot(slot, ClusterSlotStates.MIGRATING, importing.id)
]); ]);
// should be able to get the key from the source node using "ASKING" // should be able to get the key from the migrating node
assert.equal(
await cluster.get(key),
value
);
await migratingClient.migrate(
importing.host,
importing.port,
key,
0,
10
);
// should be able to get the key from the importing node using `ASKING`
assert.equal( assert.equal(
await cluster.get(key), await cluster.get(key),
value value
); );
await Promise.all([ await Promise.all([
source.client.migrate( importingClient.clusterSetSlot(slot, ClusterSlotStates.NODE, importing.id),
'127.0.0.1', migratingClient.clusterSetSlot(slot, ClusterSlotStates.NODE, importing.id),
(<any>destination.client.options).socket.port,
key,
0,
10
)
]); ]);
// should be able to get the key from the destination node using the "ASKING" command // should handle `MOVED` errors
assert.equal(
await cluster.get(key),
value
);
await Promise.all(
cluster.getMasters().map(({ client }) => {
return client.clusterSetSlot(slot, ClusterSlotStates.NODE, destination.id);
})
);
// should handle "MOVED" errors
assert.equal( assert.equal(
await cluster.get(key), await cluster.get(key),
value value
); );
}, { }, {
serverArguments: [], serverArguments: [],
numberOfNodes: 2 numberOfMasters: 2
});
testUtils.testWithCluster('getRandomNode should spread the the load evenly', async cluster => {
const totalNodes = cluster.masters.length + cluster.replicas.length,
ids = new Set<string>();
for (let i = 0; i < totalNodes; i++) {
ids.add(cluster.getRandomNode().id);
}
assert.equal(ids.size, totalNodes);
}, GLOBAL.CLUSTERS.WITH_REPLICAS);
testUtils.testWithCluster('getSlotRandomNode should spread the the load evenly', async cluster => {
const totalNodes = 1 + cluster.slots[0].replicas!.length,
ids = new Set<string>();
for (let i = 0; i < totalNodes; i++) {
ids.add(cluster.getSlotRandomNode(0).id);
}
assert.equal(ids.size, totalNodes);
}, GLOBAL.CLUSTERS.WITH_REPLICAS);
testUtils.testWithCluster('cluster topology', async cluster => {
assert.equal(cluster.slots.length, 16384);
const { numberOfMasters, numberOfReplicas } = GLOBAL.CLUSTERS.WITH_REPLICAS;
assert.equal(cluster.shards.length, numberOfMasters);
assert.equal(cluster.masters.length, numberOfMasters);
assert.equal(cluster.replicas.length, numberOfReplicas * numberOfMasters);
assert.equal(cluster.nodeByAddress.size, numberOfMasters + numberOfMasters * numberOfReplicas);
}, GLOBAL.CLUSTERS.WITH_REPLICAS);
testUtils.testWithCluster('getMasters should be backwards competiable (without `minimizeConnections`)', async cluster => {
const masters = cluster.getMasters();
assert.ok(Array.isArray(masters));
for (const master of masters) {
assert.equal(typeof master.id, 'string');
assert.ok(master.client instanceof RedisClient);
}
}, {
...GLOBAL.CLUSTERS.OPEN,
clusterConfiguration: {
minimizeConnections: undefined // reset to default
}
});
testUtils.testWithCluster('getSlotMaster should be backwards competiable (without `minimizeConnections`)', async cluster => {
const master = cluster.getSlotMaster(0);
assert.equal(typeof master.id, 'string');
assert.ok(master.client instanceof RedisClient);
}, {
...GLOBAL.CLUSTERS.OPEN,
clusterConfiguration: {
minimizeConnections: undefined // reset to default
}
});
testUtils.testWithCluster('should throw CROSSSLOT error', async cluster => {
await assert.rejects(cluster.mGet(['a', 'b']));
}, GLOBAL.CLUSTERS.OPEN);
testUtils.testWithCluster('should send commands with commandOptions to correct cluster slot (without redirections)', async cluster => {
// 'a' and 'b' hash to different cluster slots (see previous unit test)
// -> maxCommandRedirections 0: rejects on MOVED/ASK reply
await cluster.set(commandOptions({ isolated: true }), 'a', '1'),
await cluster.set(commandOptions({ isolated: true }), 'b', '2'),
assert.equal(await cluster.get('a'), '1');
assert.equal(await cluster.get('b'), '2');
}, {
...GLOBAL.CLUSTERS.OPEN,
clusterConfiguration: {
maxCommandRedirections: 0
}
});
describe('minimizeConnections', () => {
testUtils.testWithCluster('false', async cluster => {
for (const master of cluster.masters) {
assert.ok(master.client instanceof RedisClient);
}
}, {
...GLOBAL.CLUSTERS.OPEN,
clusterConfiguration: {
minimizeConnections: false
}
});
testUtils.testWithCluster('true', async cluster => {
for (const master of cluster.masters) {
assert.equal(master.client, undefined);
}
}, {
...GLOBAL.CLUSTERS.OPEN,
clusterConfiguration: {
minimizeConnections: true
}
});
});
describe('PubSub', () => {
testUtils.testWithCluster('subscribe & unsubscribe', async cluster => {
const listener = spy();
await cluster.subscribe('channel', listener);
await Promise.all([
waitTillBeenCalled(listener),
cluster.publish('channel', 'message')
]);
assert.ok(listener.calledOnceWithExactly('message', 'channel'));
await cluster.unsubscribe('channel', listener);
assert.equal(cluster.pubSubNode, undefined);
}, GLOBAL.CLUSTERS.OPEN);
testUtils.testWithCluster('concurrent UNSUBSCRIBE does not throw an error (#2685)', async cluster => {
const listener = spy();
await Promise.all([
cluster.subscribe('1', listener),
cluster.subscribe('2', listener)
]);
await Promise.all([
cluster.unsubscribe('1', listener),
cluster.unsubscribe('2', listener)
]);
}, GLOBAL.CLUSTERS.OPEN);
testUtils.testWithCluster('psubscribe & punsubscribe', async cluster => {
const listener = spy();
await cluster.pSubscribe('channe*', listener);
await Promise.all([
waitTillBeenCalled(listener),
cluster.publish('channel', 'message')
]);
assert.ok(listener.calledOnceWithExactly('message', 'channel'));
await cluster.pUnsubscribe('channe*', listener);
assert.equal(cluster.pubSubNode, undefined);
}, GLOBAL.CLUSTERS.OPEN);
testUtils.testWithCluster('should move listeners when PubSub node disconnects from the cluster', async cluster => {
const listener = spy();
await cluster.subscribe('channel', listener);
assert.ok(cluster.pubSubNode);
const [ migrating, importing ] = cluster.masters[0].address === cluster.pubSubNode.address ?
cluster.masters :
[cluster.masters[1], cluster.masters[0]],
[ migratingClient, importingClient ] = await Promise.all([
cluster.nodeClient(migrating),
cluster.nodeClient(importing)
]);
const range = cluster.slots[0].master === migrating ? {
key: 'bar', // 5061
start: 0,
end: 8191
} : {
key: 'foo', // 12182
start: 8192,
end: 16383
};
await Promise.all([
migratingClient.clusterDelSlotsRange(range),
importingClient.clusterDelSlotsRange(range),
importingClient.clusterAddSlotsRange(range)
]);
// wait for migrating node to be notified about the new topology
while ((await migratingClient.clusterInfo()).state !== 'ok') {
await promiseTimeout(50);
}
// make sure to cause `MOVED` error
await cluster.get(range.key);
await Promise.all([
cluster.publish('channel', 'message'),
waitTillBeenCalled(listener)
]);
assert.ok(listener.calledOnceWithExactly('message', 'channel'));
}, {
serverArguments: [],
numberOfMasters: 2,
minimumDockerVersion: [7]
});
testUtils.testWithCluster('ssubscribe & sunsubscribe', async cluster => {
const listener = spy();
await cluster.sSubscribe('channel', listener);
await Promise.all([
waitTillBeenCalled(listener),
cluster.sPublish('channel', 'message')
]);
assert.ok(listener.calledOnceWithExactly('message', 'channel'));
await cluster.sUnsubscribe('channel', listener);
// 10328 is the slot of `channel`
assert.equal(cluster.slots[10328].master.pubSubClient, undefined);
}, {
...GLOBAL.CLUSTERS.OPEN,
minimumDockerVersion: [7]
});
testUtils.testWithCluster('concurrent SUNSUBCRIBE does not throw an error (#2685)', async cluster => {
const listener = spy();
await Promise.all([
await cluster.sSubscribe('1', listener),
await cluster.sSubscribe('2', listener)
]);
await Promise.all([
cluster.sUnsubscribe('1', listener),
cluster.sUnsubscribe('2', listener)
]);
}, {
...GLOBAL.CLUSTERS.OPEN,
minimumDockerVersion: [7]
});
testUtils.testWithCluster('should handle sharded-channel-moved events', async cluster => {
const SLOT = 10328,
migrating = cluster.slots[SLOT].master,
importing = cluster.masters.find(master => master !== migrating)!,
[ migratingClient, importingClient ] = await Promise.all([
cluster.nodeClient(migrating),
cluster.nodeClient(importing)
]);
await Promise.all([
migratingClient.clusterDelSlots(SLOT),
importingClient.clusterDelSlots(SLOT),
importingClient.clusterAddSlots(SLOT)
]);
// wait for migrating node to be notified about the new topology
while ((await migratingClient.clusterInfo()).state !== 'ok') {
await promiseTimeout(50);
}
const listener = spy();
// will trigger `MOVED` error
await cluster.sSubscribe('channel', listener);
await Promise.all([
waitTillBeenCalled(listener),
cluster.sPublish('channel', 'message')
]);
assert.ok(listener.calledOnceWithExactly('message', 'channel'));
}, {
serverArguments: [],
minimumDockerVersion: [7]
});
}); });
}); });

View File

@@ -1,11 +1,13 @@
import COMMANDS from './commands'; import COMMANDS from './commands';
import { RedisCommand, RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply, RedisCommandReply, RedisFunctions, RedisModules, RedisExtensions, RedisScript, RedisScripts, RedisCommandSignature, RedisFunction } from '../commands'; import { RedisCommand, RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply, RedisCommandReply, RedisFunctions, RedisModules, RedisExtensions, RedisScript, RedisScripts, RedisCommandSignature, RedisFunction } from '../commands';
import { ClientCommandOptions, RedisClientOptions, RedisClientType, WithFunctions, WithModules, WithScripts } from '../client'; import { ClientCommandOptions, RedisClientOptions, RedisClientType, WithFunctions, WithModules, WithScripts } from '../client';
import RedisClusterSlots, { ClusterNode, NodeAddressMap } from './cluster-slots'; import RedisClusterSlots, { NodeAddressMap, ShardNode } from './cluster-slots';
import { attachExtensions, transformCommandReply, attachCommands, transformCommandArguments } from '../commander'; import { attachExtensions, transformCommandReply, attachCommands, transformCommandArguments } from '../commander';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import RedisClusterMultiCommand, { InstantiableRedisClusterMultiCommandType, RedisClusterMultiCommandType } from './multi-command'; import RedisClusterMultiCommand, { InstantiableRedisClusterMultiCommandType, RedisClusterMultiCommandType } from './multi-command';
import { RedisMultiQueuedCommand } from '../multi-command'; import { RedisMultiQueuedCommand } from '../multi-command';
import { PubSubListener } from '../client/pub-sub';
import { ErrorReply } from '../errors';
export type RedisClusterClientOptions = Omit< export type RedisClusterClientOptions = Omit<
RedisClientOptions, RedisClientOptions,
@@ -17,10 +19,34 @@ export interface RedisClusterOptions<
F extends RedisFunctions = Record<string, never>, F extends RedisFunctions = Record<string, never>,
S extends RedisScripts = Record<string, never> S extends RedisScripts = Record<string, never>
> extends RedisExtensions<M, F, S> { > extends RedisExtensions<M, F, S> {
/**
* Should contain details for some of the cluster nodes that the client will use to discover
* the "cluster topology". We recommend including details for at least 3 nodes here.
*/
rootNodes: Array<RedisClusterClientOptions>; rootNodes: Array<RedisClusterClientOptions>;
/**
* Default values used for every client in the cluster. Use this to specify global values,
* for example: ACL credentials, timeouts, TLS configuration etc.
*/
defaults?: Partial<RedisClusterClientOptions>; defaults?: Partial<RedisClusterClientOptions>;
/**
* When `true`, `.connect()` will only discover the cluster topology, without actually connecting to all the nodes.
* Useful for short-term or PubSub-only connections.
*/
minimizeConnections?: boolean;
/**
* When `true`, distribute load by executing readonly commands (such as `GET`, `GEOSEARCH`, etc.) across all cluster nodes. When `false`, only use master nodes.
*/
useReplicas?: boolean; useReplicas?: boolean;
/**
* The maximum number of times a command will be redirected due to `MOVED` or `ASK` errors.
*/
maxCommandRedirections?: number; maxCommandRedirections?: number;
/**
* Mapping between the addresses in the cluster (see `CLUSTER SHARDS`) and the addresses the client should connect to
* Useful when the cluster is running on another network
*
*/
nodeAddressMap?: NodeAddressMap; nodeAddressMap?: NodeAddressMap;
} }
@@ -70,14 +96,44 @@ export default class RedisCluster<
} }
readonly #options: RedisClusterOptions<M, F, S>; readonly #options: RedisClusterOptions<M, F, S>;
readonly #slots: RedisClusterSlots<M, F, S>; readonly #slots: RedisClusterSlots<M, F, S>;
get slots() {
return this.#slots.slots;
}
get shards() {
return this.#slots.shards;
}
get masters() {
return this.#slots.masters;
}
get replicas() {
return this.#slots.replicas;
}
get nodeByAddress() {
return this.#slots.nodeByAddress;
}
get pubSubNode() {
return this.#slots.pubSubNode;
}
readonly #Multi: InstantiableRedisClusterMultiCommandType<M, F, S>; readonly #Multi: InstantiableRedisClusterMultiCommandType<M, F, S>;
get isOpen() {
return this.#slots.isOpen;
}
constructor(options: RedisClusterOptions<M, F, S>) { constructor(options: RedisClusterOptions<M, F, S>) {
super(); super();
this.#options = options; this.#options = options;
this.#slots = new RedisClusterSlots(options, err => this.emit('error', err)); this.#slots = new RedisClusterSlots(options, this.emit.bind(this));
this.#Multi = RedisClusterMultiCommand.extend(options); this.#Multi = RedisClusterMultiCommand.extend(options);
} }
@@ -88,7 +144,7 @@ export default class RedisCluster<
}); });
} }
async connect(): Promise<void> { connect() {
return this.#slots.connect(); return this.#slots.connect();
} }
@@ -96,11 +152,11 @@ export default class RedisCluster<
command: C, command: C,
args: Array<unknown> args: Array<unknown>
): Promise<RedisCommandReply<C>> { ): Promise<RedisCommandReply<C>> {
const { args: redisArgs, options } = transformCommandArguments(command, args); const { jsArgs, args: redisArgs, options } = transformCommandArguments(command, args);
return transformCommandReply( return transformCommandReply(
command, command,
await this.sendCommand( await this.sendCommand(
RedisCluster.extractFirstKey(command, args, redisArgs), RedisCluster.extractFirstKey(command, jsArgs, redisArgs),
command.IS_READ_ONLY, command.IS_READ_ONLY,
redisArgs, redisArgs,
options options
@@ -188,34 +244,33 @@ export default class RedisCluster<
executor: (client: RedisClientType<M, F, S>) => Promise<Reply> executor: (client: RedisClientType<M, F, S>) => Promise<Reply>
): Promise<Reply> { ): Promise<Reply> {
const maxCommandRedirections = this.#options.maxCommandRedirections ?? 16; const maxCommandRedirections = this.#options.maxCommandRedirections ?? 16;
let client = this.#slots.getClient(firstKey, isReadonly); let client = await this.#slots.getClient(firstKey, isReadonly);
for (let i = 0;; i++) { for (let i = 0;; i++) {
try { try {
return await executor(client); return await executor(client);
} catch (err) { } catch (err) {
if (++i > maxCommandRedirections || !(err instanceof Error)) { if (++i > maxCommandRedirections || !(err instanceof ErrorReply)) {
throw err; throw err;
} }
if (err.message.startsWith('ASK')) { if (err.message.startsWith('ASK')) {
const address = err.message.substring(err.message.lastIndexOf(' ') + 1); const address = err.message.substring(err.message.lastIndexOf(' ') + 1);
if (this.#slots.getNodeByAddress(address)?.client === client) { let redirectTo = await this.#slots.getMasterByAddress(address);
await client.asking(); if (!redirectTo) {
continue; await this.#slots.rediscover(client);
redirectTo = await this.#slots.getMasterByAddress(address);
} }
await this.#slots.rediscover(client);
const redirectTo = this.#slots.getNodeByAddress(address);
if (!redirectTo) { if (!redirectTo) {
throw new Error(`Cannot find node ${address}`); throw new Error(`Cannot find node ${address}`);
} }
await redirectTo.client.asking(); await redirectTo.asking();
client = redirectTo.client; client = redirectTo;
continue; continue;
} else if (err.message.startsWith('MOVED')) { } else if (err.message.startsWith('MOVED')) {
await this.#slots.rediscover(client); await this.#slots.rediscover(client);
client = this.#slots.getClient(firstKey, isReadonly); client = await this.#slots.getClient(firstKey, isReadonly);
continue; continue;
} }
@@ -239,14 +294,94 @@ export default class RedisCluster<
multi = this.MULTI; multi = this.MULTI;
getMasters(): Array<ClusterNode<M, F, S>> { async SUBSCRIBE<T extends boolean = false>(
return this.#slots.getMasters(); channels: string | Array<string>,
listener: PubSubListener<T>,
bufferMode?: T
) {
return (await this.#slots.getPubSubClient())
.SUBSCRIBE(channels, listener, bufferMode);
} }
getSlotMaster(slot: number): ClusterNode<M, F, S> { subscribe = this.SUBSCRIBE;
return this.#slots.getSlotMaster(slot);
async UNSUBSCRIBE<T extends boolean = false>(
channels?: string | Array<string>,
listener?: PubSubListener<boolean>,
bufferMode?: T
) {
return this.#slots.executeUnsubscribeCommand(client =>
client.UNSUBSCRIBE(channels, listener, bufferMode)
);
} }
unsubscribe = this.UNSUBSCRIBE;
async PSUBSCRIBE<T extends boolean = false>(
patterns: string | Array<string>,
listener: PubSubListener<T>,
bufferMode?: T
) {
return (await this.#slots.getPubSubClient())
.PSUBSCRIBE(patterns, listener, bufferMode);
}
pSubscribe = this.PSUBSCRIBE;
async PUNSUBSCRIBE<T extends boolean = false>(
patterns?: string | Array<string>,
listener?: PubSubListener<T>,
bufferMode?: T
) {
return this.#slots.executeUnsubscribeCommand(client =>
client.PUNSUBSCRIBE(patterns, listener, bufferMode)
);
}
pUnsubscribe = this.PUNSUBSCRIBE;
async SSUBSCRIBE<T extends boolean = false>(
channels: string | Array<string>,
listener: PubSubListener<T>,
bufferMode?: T
) {
const maxCommandRedirections = this.#options.maxCommandRedirections ?? 16,
firstChannel = Array.isArray(channels) ? channels[0] : channels;
let client = await this.#slots.getShardedPubSubClient(firstChannel);
for (let i = 0;; i++) {
try {
return await client.SSUBSCRIBE(channels, listener, bufferMode);
} catch (err) {
if (++i > maxCommandRedirections || !(err instanceof ErrorReply)) {
throw err;
}
if (err.message.startsWith('MOVED')) {
await this.#slots.rediscover(client);
client = await this.#slots.getShardedPubSubClient(firstChannel);
continue;
}
throw err;
}
}
}
sSubscribe = this.SSUBSCRIBE;
SUNSUBSCRIBE<T extends boolean = false>(
channels: string | Array<string>,
listener?: PubSubListener<T>,
bufferMode?: T
) {
return this.#slots.executeShardedUnsubscribeCommand(
Array.isArray(channels) ? channels[0] : channels,
client => client.SUNSUBSCRIBE(channels, listener, bufferMode)
);
}
sUnsubscribe = this.SUNSUBSCRIBE;
quit(): Promise<void> { quit(): Promise<void> {
return this.#slots.quit(); return this.#slots.quit();
} }
@@ -254,6 +389,32 @@ export default class RedisCluster<
disconnect(): Promise<void> { disconnect(): Promise<void> {
return this.#slots.disconnect(); return this.#slots.disconnect();
} }
nodeClient(node: ShardNode<M, F, S>) {
return this.#slots.nodeClient(node);
}
getRandomNode() {
return this.#slots.getRandomNode();
}
getSlotRandomNode(slot: number) {
return this.#slots.getSlotRandomNode(slot);
}
/**
* @deprecated use `.masters` instead
*/
getMasters() {
return this.masters;
}
/**
* @deprecated use `.slots[<SLOT>]` instead
*/
getSlotMaster(slot: number) {
return this.slots[slot].master;
}
} }
attachCommands({ attachCommands({

View File

@@ -120,11 +120,8 @@ export default class RedisClusterMultiCommand {
return this.execAsPipeline(); return this.execAsPipeline();
} }
const commands = this.#multi.exec();
if (!commands) return [];
return this.#multi.handleExecReplies( return this.#multi.handleExecReplies(
await this.#executor(commands, this.#firstKey, RedisMultiCommand.generateChainId()) await this.#executor(this.#multi.queue, this.#firstKey, RedisMultiCommand.generateChainId())
); );
} }

View File

@@ -108,6 +108,7 @@ export function transformCommandArguments<T = ClientCommandOptions>(
command: RedisCommand, command: RedisCommand,
args: Array<unknown> args: Array<unknown>
): { ): {
jsArgs: Array<unknown>;
args: RedisCommandArguments; args: RedisCommandArguments;
options: CommandOptions<T> | undefined; options: CommandOptions<T> | undefined;
} { } {
@@ -118,6 +119,7 @@ export function transformCommandArguments<T = ClientCommandOptions>(
} }
return { return {
jsArgs: args,
args: command.transformArguments(...args), args: command.transformArguments(...args),
options options
}; };

View File

@@ -13,32 +13,22 @@ describe('ACL GETUSER', () => {
}); });
testUtils.testWithClient('client.aclGetUser', async client => { testUtils.testWithClient('client.aclGetUser', async client => {
const expectedReply: any = { const reply = await client.aclGetUser('default');
passwords: [],
commands: '+@all', assert.ok(Array.isArray(reply.passwords));
}; assert.equal(typeof reply.commands, 'string');
assert.ok(Array.isArray(reply.flags));
if (testUtils.isVersionGreaterThan([7])) { if (testUtils.isVersionGreaterThan([7])) {
expectedReply.flags = ['on', 'nopass']; assert.equal(typeof reply.keys, 'string');
expectedReply.keys = '~*'; assert.equal(typeof reply.channels, 'string');
expectedReply.channels = '&*'; assert.ok(Array.isArray(reply.selectors));
expectedReply.selectors = [];
} else { } else {
expectedReply.keys = ['*']; assert.ok(Array.isArray(reply.keys));
expectedReply.selectors = undefined;
if (testUtils.isVersionGreaterThan([6, 2])) { if (testUtils.isVersionGreaterThan([6, 2])) {
expectedReply.flags = ['on', 'allkeys', 'allchannels', 'allcommands', 'nopass']; assert.ok(Array.isArray(reply.channels));
expectedReply.channels = ['*']; }
} else {
expectedReply.flags = ['on', 'allkeys', 'allcommands', 'nopass'];
expectedReply.channels = undefined;
}
} }
assert.deepEqual(
await client.aclGetUser('default'),
expectedReply
);
}, GLOBAL.SERVERS.OPEN); }, GLOBAL.SERVERS.OPEN);
}); });

View File

@@ -1,7 +1,10 @@
import { strict as assert } from 'assert'; import { strict as assert } from 'assert';
import { transformArguments, transformReply } from './CLIENT_INFO'; import { transformArguments, transformReply } from './CLIENT_INFO';
import testUtils, { GLOBAL } from '../test-utils';
describe('CLIENT INFO', () => { describe('CLIENT INFO', () => {
testUtils.isVersionGreaterThanHook([6, 2]);
it('transformArguments', () => { it('transformArguments', () => {
assert.deepEqual( assert.deepEqual(
transformArguments(), transformArguments(),
@@ -9,34 +12,39 @@ describe('CLIENT INFO', () => {
); );
}); });
it('transformReply', () => { testUtils.testWithClient('client.clientInfo', async client => {
assert.deepEqual( const reply = await client.clientInfo();
transformReply('id=526512 addr=127.0.0.1:36244 laddr=127.0.0.1:6379 fd=8 name= age=11213 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=40928 argv-mem=10 obl=0 oll=0 omem=0 tot-mem=61466 events=r cmd=client user=default redir=-1\n'), assert.equal(typeof reply.id, 'number');
{ assert.equal(typeof reply.addr, 'string');
id: 526512, assert.equal(typeof reply.laddr, 'string');
addr: '127.0.0.1:36244', assert.equal(typeof reply.fd, 'number');
laddr: '127.0.0.1:6379', assert.equal(typeof reply.name, 'string');
fd: 8, assert.equal(typeof reply.age, 'number');
name: '', assert.equal(typeof reply.idle, 'number');
age: 11213, assert.equal(typeof reply.flags, 'string');
idle: 0, assert.equal(typeof reply.db, 'number');
flags: 'N', assert.equal(typeof reply.sub, 'number');
db: 0, assert.equal(typeof reply.psub, 'number');
sub: 0, assert.equal(typeof reply.multi, 'number');
psub: 0, assert.equal(typeof reply.qbuf, 'number');
multi: -1, assert.equal(typeof reply.qbufFree, 'number');
qbuf: 26, assert.equal(typeof reply.argvMem, 'number');
qbufFree: 40928, assert.equal(typeof reply.obl, 'number');
argvMem: 10, assert.equal(typeof reply.oll, 'number');
obl: 0, assert.equal(typeof reply.omem, 'number');
oll: 0, assert.equal(typeof reply.totMem, 'number');
omem: 0, assert.equal(typeof reply.events, 'string');
totMem: 61466, assert.equal(typeof reply.cmd, 'string');
events: 'r', assert.equal(typeof reply.user, 'string');
cmd: 'client', assert.equal(typeof reply.redir, 'number');
user: 'default',
redir: -1 if (testUtils.isVersionGreaterThan([7, 0])) {
} assert.equal(typeof reply.multiMem, 'number');
); assert.equal(typeof reply.resp, 'number');
}); }
if (testUtils.isVersionGreaterThan([7, 0, 3])) {
assert.equal(typeof reply.ssub, 'number');
}
}, GLOBAL.SERVERS.OPEN);
}); });

View File

@@ -1,11 +1,13 @@
export const IS_READ_ONLY = true;
export function transformArguments(): Array<string> { export function transformArguments(): Array<string> {
return ['CLIENT', 'INFO']; return ['CLIENT', 'INFO'];
} }
interface ClientInfoReply { export interface ClientInfoReply {
id: number; id: number;
addr: string; addr: string;
laddr: string; laddr?: string; // 6.2
fd: number; fd: number;
name: string; name: string;
age: number; age: number;
@@ -14,72 +16,79 @@ interface ClientInfoReply {
db: number; db: number;
sub: number; sub: number;
psub: number; psub: number;
ssub?: number; // 7.0.3
multi: number; multi: number;
qbuf: number; qbuf: number;
qbufFree: number; qbufFree: number;
argvMem: number; argvMem?: number; // 6.0
multiMem?: number; // 7.0
obl: number; obl: number;
oll: number; oll: number;
omem: number; omem: number;
totMem: number; totMem?: number; // 6.0
events: string; events: string;
cmd: string; cmd: string;
user: string; user?: string; // 6.0
redir: number; redir?: number; // 6.2
resp?: number; // 7.0
// 7.2
libName?: string;
libVer?: string;
} }
const REGEX = /=([^\s]*)/g; const CLIENT_INFO_REGEX = /([^\s=]+)=([^\s]*)/g;
export function transformReply(reply: string): ClientInfoReply { export function transformReply(rawReply: string): ClientInfoReply {
const [ const map: Record<string, string> = {};
[, id], for (const item of rawReply.matchAll(CLIENT_INFO_REGEX)) {
[, addr], map[item[1]] = item[2];
[, laddr], }
[, fd],
[, name],
[, age],
[, idle],
[, flags],
[, db],
[, sub],
[, psub],
[, multi],
[, qbuf],
[, qbufFree],
[, argvMem],
[, obl],
[, oll],
[, omem],
[, totMem],
[, events],
[, cmd],
[, user],
[, redir]
] = [...reply.matchAll(REGEX)];
return { const reply: ClientInfoReply = {
id: Number(id), id: Number(map.id),
addr, addr: map.addr,
laddr, fd: Number(map.fd),
fd: Number(fd), name: map.name,
name, age: Number(map.age),
age: Number(age), idle: Number(map.idle),
idle: Number(idle), flags: map.flags,
flags, db: Number(map.db),
db: Number(db), sub: Number(map.sub),
sub: Number(sub), psub: Number(map.psub),
psub: Number(psub), multi: Number(map.multi),
multi: Number(multi), qbuf: Number(map.qbuf),
qbuf: Number(qbuf), qbufFree: Number(map['qbuf-free']),
qbufFree: Number(qbufFree), argvMem: Number(map['argv-mem']),
argvMem: Number(argvMem), obl: Number(map.obl),
obl: Number(obl), oll: Number(map.oll),
oll: Number(oll), omem: Number(map.omem),
omem: Number(omem), totMem: Number(map['tot-mem']),
totMem: Number(totMem), events: map.events,
events, cmd: map.cmd,
cmd, user: map.user,
user, libName: map['lib-name'],
redir: Number(redir) libVer: map['lib-ver'],
}; };
if (map.laddr !== undefined) {
reply.laddr = map.laddr;
}
if (map.redir !== undefined) {
reply.redir = Number(map.redir);
}
if (map.ssub !== undefined) {
reply.ssub = Number(map.ssub);
}
if (map['multi-mem'] !== undefined) {
reply.multiMem = Number(map['multi-mem']);
}
if (map.resp !== undefined) {
reply.resp = Number(map.resp);
}
return reply;
} }

View File

@@ -65,6 +65,16 @@ describe('CLIENT KILL', () => {
); );
}); });
it('MAXAGE', () => {
assert.deepEqual(
transformArguments({
filter: ClientKillFilters.MAXAGE,
maxAge: 10
}),
['CLIENT', 'KILL', 'MAXAGE', '10']
);
});
describe('SKIP_ME', () => { describe('SKIP_ME', () => {
it('undefined', () => { it('undefined', () => {
assert.deepEqual( assert.deepEqual(

View File

@@ -6,7 +6,8 @@ export enum ClientKillFilters {
ID = 'ID', ID = 'ID',
TYPE = 'TYPE', TYPE = 'TYPE',
USER = 'USER', USER = 'USER',
SKIP_ME = 'SKIPME' SKIP_ME = 'SKIPME',
MAXAGE = 'MAXAGE'
} }
interface KillFilter<T extends ClientKillFilters> { interface KillFilter<T extends ClientKillFilters> {
@@ -37,7 +38,11 @@ type KillSkipMe = ClientKillFilters.SKIP_ME | (KillFilter<ClientKillFilters.SKIP
skipMe: boolean; skipMe: boolean;
}); });
type KillFilters = KillAddress | KillLocalAddress | KillId | KillType | KillUser | KillSkipMe; interface KillMaxAge extends KillFilter<ClientKillFilters.MAXAGE> {
maxAge: number;
}
type KillFilters = KillAddress | KillLocalAddress | KillId | KillType | KillUser | KillSkipMe | KillMaxAge;
export function transformArguments(filters: KillFilters | Array<KillFilters>): RedisCommandArguments { export function transformArguments(filters: KillFilters | Array<KillFilters>): RedisCommandArguments {
const args = ['CLIENT', 'KILL']; const args = ['CLIENT', 'KILL'];
@@ -89,6 +94,10 @@ function pushFilter(args: RedisCommandArguments, filter: KillFilters): void {
case ClientKillFilters.SKIP_ME: case ClientKillFilters.SKIP_ME:
args.push(filter.skipMe ? 'yes' : 'no'); args.push(filter.skipMe ? 'yes' : 'no');
break; break;
case ClientKillFilters.MAXAGE:
args.push(filter.maxAge.toString());
break;
} }
} }

View File

@@ -0,0 +1,78 @@
import { strict as assert } from 'assert';
import { transformArguments, transformReply } from './CLIENT_LIST';
import testUtils, { GLOBAL } from '../test-utils';
describe('CLIENT LIST', () => {
describe('transformArguments', () => {
it('simple', () => {
assert.deepEqual(
transformArguments(),
['CLIENT', 'LIST']
);
});
it('with TYPE', () => {
assert.deepEqual(
transformArguments({
TYPE: 'NORMAL'
}),
['CLIENT', 'LIST', 'TYPE', 'NORMAL']
);
});
it('with ID', () => {
assert.deepEqual(
transformArguments({
ID: ['1', '2']
}),
['CLIENT', 'LIST', 'ID', '1', '2']
);
});
});
testUtils.testWithClient('client.clientList', async client => {
const reply = await client.clientList();
assert.ok(Array.isArray(reply));
for (const item of reply) {
assert.equal(typeof item.id, 'number');
assert.equal(typeof item.addr, 'string');
assert.equal(typeof item.fd, 'number');
assert.equal(typeof item.name, 'string');
assert.equal(typeof item.age, 'number');
assert.equal(typeof item.idle, 'number');
assert.equal(typeof item.flags, 'string');
assert.equal(typeof item.db, 'number');
assert.equal(typeof item.sub, 'number');
assert.equal(typeof item.psub, 'number');
assert.equal(typeof item.multi, 'number');
assert.equal(typeof item.qbuf, 'number');
assert.equal(typeof item.qbufFree, 'number');
assert.equal(typeof item.obl, 'number');
assert.equal(typeof item.oll, 'number');
assert.equal(typeof item.omem, 'number');
assert.equal(typeof item.events, 'string');
assert.equal(typeof item.cmd, 'string');
if (testUtils.isVersionGreaterThan([6, 0])) {
assert.equal(typeof item.argvMem, 'number');
assert.equal(typeof item.totMem, 'number');
assert.equal(typeof item.user, 'string');
}
if (testUtils.isVersionGreaterThan([6, 2])) {
assert.equal(typeof item.redir, 'number');
assert.equal(typeof item.laddr, 'string');
}
if (testUtils.isVersionGreaterThan([7, 0])) {
assert.equal(typeof item.multiMem, 'number');
assert.equal(typeof item.resp, 'number');
}
if (testUtils.isVersionGreaterThan([7, 0, 3])) {
assert.equal(typeof item.ssub, 'number');
}
}
}, GLOBAL.SERVERS.OPEN);
});

View File

@@ -0,0 +1,43 @@
import { RedisCommandArguments, RedisCommandArgument } from '.';
import { pushVerdictArguments } from './generic-transformers';
import { transformReply as transformClientInfoReply, ClientInfoReply } from './CLIENT_INFO';
interface ListFilterType {
TYPE: 'NORMAL' | 'MASTER' | 'REPLICA' | 'PUBSUB';
ID?: never;
}
interface ListFilterId {
ID: Array<RedisCommandArgument>;
TYPE?: never;
}
export type ListFilter = ListFilterType | ListFilterId;
export const IS_READ_ONLY = true;
export function transformArguments(filter?: ListFilter): RedisCommandArguments {
let args: RedisCommandArguments = ['CLIENT', 'LIST'];
if (filter) {
if (filter.TYPE !== undefined) {
args.push('TYPE', filter.TYPE);
} else {
args.push('ID');
args = pushVerdictArguments(args, filter.ID);
}
}
return args;
}
export function transformReply(rawReply: string): Array<ClientInfoReply> {
const split = rawReply.split('\n'),
length = split.length - 1,
reply: Array<ClientInfoReply> = [];
for (let i = 0; i < length; i++) {
reply.push(transformClientInfoReply(split[i]));
}
return reply;
}

View File

@@ -0,0 +1,30 @@
import { strict as assert } from 'assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './CLIENT_NO-TOUCH';
describe('CLIENT NO-TOUCH', () => {
testUtils.isVersionGreaterThanHook([7, 2]);
describe('transformArguments', () => {
it('true', () => {
assert.deepEqual(
transformArguments(true),
['CLIENT', 'NO-TOUCH', 'ON']
);
});
it('false', () => {
assert.deepEqual(
transformArguments(false),
['CLIENT', 'NO-TOUCH', 'OFF']
);
});
});
testUtils.testWithClient('client.clientNoTouch', async client => {
assert.equal(
await client.clientNoTouch(true),
'OK'
);
}, GLOBAL.SERVERS.OPEN);
});

View File

@@ -0,0 +1,11 @@
import { RedisCommandArguments } from '.';
export function transformArguments(value: boolean): RedisCommandArguments {
return [
'CLIENT',
'NO-TOUCH',
value ? 'ON' : 'OFF'
];
}
export declare function transformReply(): 'OK' | Buffer;

View File

@@ -11,8 +11,9 @@ describe('CLUSTER BUMPEPOCH', () => {
}); });
testUtils.testWithCluster('clusterNode.clusterBumpEpoch', async cluster => { testUtils.testWithCluster('clusterNode.clusterBumpEpoch', async cluster => {
const client = await cluster.nodeClient(cluster.masters[0]);
assert.equal( assert.equal(
typeof await cluster.getSlotMaster(0).client.clusterBumpEpoch(), typeof await client.clusterBumpEpoch(),
'string' 'string'
); );
}, GLOBAL.SERVERS.OPEN); }, GLOBAL.SERVERS.OPEN);

View File

@@ -11,7 +11,7 @@ describe('CLUSTER COUNT-FAILURE-REPORTS', () => {
}); });
testUtils.testWithCluster('clusterNode.clusterCountFailureReports', async cluster => { testUtils.testWithCluster('clusterNode.clusterCountFailureReports', async cluster => {
const { client } = cluster.getSlotMaster(0); const client = await cluster.nodeClient(cluster.masters[0]);
assert.equal( assert.equal(
typeof await client.clusterCountFailureReports( typeof await client.clusterCountFailureReports(
await client.clusterMyId() await client.clusterMyId()

View File

@@ -11,8 +11,9 @@ describe('CLUSTER COUNTKEYSINSLOT', () => {
}); });
testUtils.testWithCluster('clusterNode.clusterCountKeysInSlot', async cluster => { testUtils.testWithCluster('clusterNode.clusterCountKeysInSlot', async cluster => {
const client = await cluster.nodeClient(cluster.masters[0]);
assert.equal( assert.equal(
typeof await cluster.getSlotMaster(0).client.clusterCountKeysInSlot(0), typeof await client.clusterCountKeysInSlot(0),
'number' 'number'
); );
}, GLOBAL.CLUSTERS.OPEN); }, GLOBAL.CLUSTERS.OPEN);

View File

@@ -11,7 +11,8 @@ describe('CLUSTER GETKEYSINSLOT', () => {
}); });
testUtils.testWithCluster('clusterNode.clusterGetKeysInSlot', async cluster => { testUtils.testWithCluster('clusterNode.clusterGetKeysInSlot', async cluster => {
const reply = await cluster.getSlotMaster(0).client.clusterGetKeysInSlot(0, 1); const client = await cluster.nodeClient(cluster.masters[0]),
reply = await client.clusterGetKeysInSlot(0, 1);
assert.ok(Array.isArray(reply)); assert.ok(Array.isArray(reply));
for (const item of reply) { for (const item of reply) {
assert.equal(typeof item, 'string'); assert.equal(typeof item, 'string');

View File

@@ -46,8 +46,9 @@ describe('CLUSTER INFO', () => {
}); });
testUtils.testWithCluster('clusterNode.clusterInfo', async cluster => { testUtils.testWithCluster('clusterNode.clusterInfo', async cluster => {
const client = await cluster.nodeClient(cluster.masters[0]);
assert.notEqual( assert.notEqual(
await cluster.getSlotMaster(0).client.clusterInfo(), await client.clusterInfo(),
null null
); );
}, GLOBAL.CLUSTERS.OPEN); }, GLOBAL.CLUSTERS.OPEN);

View File

@@ -11,8 +11,9 @@ describe('CLUSTER KEYSLOT', () => {
}); });
testUtils.testWithCluster('clusterNode.clusterKeySlot', async cluster => { testUtils.testWithCluster('clusterNode.clusterKeySlot', async cluster => {
const client = await cluster.nodeClient(cluster.masters[0]);
assert.equal( assert.equal(
typeof await cluster.getSlotMaster(0).client.clusterKeySlot('key'), typeof await client.clusterKeySlot('key'),
'number' 'number'
); );
}, GLOBAL.CLUSTERS.OPEN); }, GLOBAL.CLUSTERS.OPEN);

View File

@@ -13,7 +13,8 @@ describe('CLUSTER LINKS', () => {
}); });
testUtils.testWithCluster('clusterNode.clusterLinks', async cluster => { testUtils.testWithCluster('clusterNode.clusterLinks', async cluster => {
const links = await cluster.getSlotMaster(0).client.clusterLinks(); const client = await cluster.nodeClient(cluster.masters[0]),
links = await client.clusterLinks();
assert.ok(Array.isArray(links)); assert.ok(Array.isArray(links));
for (const link of links) { for (const link of links) {
assert.equal(typeof link.direction, 'string'); assert.equal(typeof link.direction, 'string');

View File

@@ -11,9 +11,11 @@ describe('CLUSTER MYID', () => {
}); });
testUtils.testWithCluster('clusterNode.clusterMyId', async cluster => { testUtils.testWithCluster('clusterNode.clusterMyId', async cluster => {
const [master] = cluster.masters,
client = await cluster.nodeClient(master);
assert.equal( assert.equal(
typeof await cluster.getSlotMaster(0).client.clusterMyId(), await client.clusterMyId(),
'string' master.id
); );
}, GLOBAL.CLUSTERS.OPEN); }, GLOBAL.CLUSTERS.OPEN);
}); });

View File

@@ -0,0 +1,22 @@
import { strict as assert } from 'assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './CLUSTER_MYSHARDID';
describe('CLUSTER MYSHARDID', () => {
testUtils.isVersionGreaterThanHook([7, 2]);
it('transformArguments', () => {
assert.deepEqual(
transformArguments(),
['CLUSTER', 'MYSHARDID']
);
});
testUtils.testWithCluster('clusterNode.clusterMyShardId', async cluster => {
const client = await cluster.nodeClient(cluster.masters[0]);
assert.equal(
typeof await client.clusterMyShardId(),
'string'
);
}, GLOBAL.CLUSTERS.OPEN);
});

View File

@@ -0,0 +1,7 @@
export const IS_READ_ONLY = true;
export function transformArguments() {
return ['CLUSTER', 'MYSHARDID'];
}
export declare function transformReply(): string | Buffer;

View File

@@ -11,8 +11,9 @@ describe('CLUSTER SAVECONFIG', () => {
}); });
testUtils.testWithCluster('clusterNode.clusterSaveConfig', async cluster => { testUtils.testWithCluster('clusterNode.clusterSaveConfig', async cluster => {
const client = await cluster.nodeClient(cluster.masters[0]);
assert.equal( assert.equal(
await cluster.getSlotMaster(0).client.clusterSaveConfig(), await client.clusterSaveConfig(),
'OK' 'OK'
); );
}, GLOBAL.CLUSTERS.OPEN); }, GLOBAL.CLUSTERS.OPEN);

View File

@@ -13,7 +13,7 @@ type ClusterSlotsRawReply = Array<[
...replicas: Array<ClusterSlotsRawNode> ...replicas: Array<ClusterSlotsRawNode>
]>; ]>;
type ClusterSlotsNode = { export interface ClusterSlotsNode {
ip: string; ip: string;
port: number; port: number;
id: string; id: string;

View File

@@ -0,0 +1,40 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './HEXPIRE';
import { HASH_EXPIRATION_TIME } from './HEXPIRETIME';
describe('HEXPIRE', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('string', () => {
assert.deepEqual(
transformArguments('key', 'field', 1),
['HEXPIRE', 'key', '1', 'FIELDS', '1', 'field']
);
});
it('array', () => {
assert.deepEqual(
transformArguments('key', ['field1', 'field2'], 1),
['HEXPIRE', 'key', '1', 'FIELDS', '2', 'field1', 'field2']
);
});
it('with set option', () => {
assert.deepEqual(
transformArguments('key', ['field1'], 1, 'NX'),
['HEXPIRE', 'key', '1', 'NX', 'FIELDS', '1', 'field1']
);
});
});
testUtils.testWithClient('hexpire', async client => {
assert.deepEqual(
await client.hExpire('key', ['field1'], 0),
[HASH_EXPIRATION_TIME.FIELD_NOT_EXISTS]
);
}, {
...GLOBAL.SERVERS.OPEN
});
});

View File

@@ -0,0 +1,44 @@
import { RedisCommandArgument } from '.';
import { pushVerdictArgument } from './generic-transformers';
/**
* @readonly
* @enum {number}
*/
export const HASH_EXPIRATION = {
/** @property {number} */
/** The field does not exist */
FIELD_NOT_EXISTS: -2,
/** @property {number} */
/** Specified NX | XX | GT | LT condition not met */
CONDITION_NOT_MET: 0,
/** @property {number} */
/** Expiration time was set or updated */
UPDATED: 1,
/** @property {number} */
/** Field deleted because the specified expiration time is in the past */
DELETED: 2
} as const;
export type HashExpiration = typeof HASH_EXPIRATION[keyof typeof HASH_EXPIRATION];
export const FIRST_KEY_INDEX = 1;
export function transformArguments(
key: RedisCommandArgument,
fields: RedisCommandArgument| Array<RedisCommandArgument>,
seconds: number,
mode?: 'NX' | 'XX' | 'GT' | 'LT',
) {
const args = ['HEXPIRE', key, seconds.toString()];
if (mode) {
args.push(mode);
}
args.push('FIELDS');
return pushVerdictArgument(args, fields);
}
export declare function transformReply(): Array<HashExpiration>;

View File

@@ -0,0 +1,49 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './HEXPIREAT';
import { HASH_EXPIRATION_TIME } from './HEXPIRETIME';
describe('HEXPIREAT', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('string + number', () => {
assert.deepEqual(
transformArguments('key', 'field', 1),
['HEXPIREAT', 'key', '1', 'FIELDS', '1', 'field']
);
});
it('array + number', () => {
assert.deepEqual(
transformArguments('key', ['field1', 'field2'], 1),
['HEXPIREAT', 'key', '1', 'FIELDS', '2', 'field1', 'field2']
);
});
it('date', () => {
const d = new Date();
assert.deepEqual(
transformArguments('key', ['field1'], d),
['HEXPIREAT', 'key', Math.floor(d.getTime() / 1000).toString(), 'FIELDS', '1', 'field1']
);
});
it('with set option', () => {
assert.deepEqual(
transformArguments('key', 'field1', 1, 'GT'),
['HEXPIREAT', 'key', '1', 'GT', 'FIELDS', '1', 'field1']
);
});
});
testUtils.testWithClient('expireAt', async client => {
assert.deepEqual(
await client.hExpireAt('key', 'field1', 1),
[HASH_EXPIRATION_TIME.FIELD_NOT_EXISTS]
);
}, {
...GLOBAL.SERVERS.OPEN,
});
});

View File

@@ -0,0 +1,28 @@
import { RedisCommandArgument } from '.';
import { pushVerdictArgument, transformEXAT } from './generic-transformers';
import { HashExpiration } from './HEXPIRE';
export const FIRST_KEY_INDEX = 1;
export function transformArguments(
key: RedisCommandArgument,
fields: RedisCommandArgument | Array<RedisCommandArgument>,
timestamp: number | Date,
mode?: 'NX' | 'XX' | 'GT' | 'LT'
) {
const args = [
'HEXPIREAT',
key,
transformEXAT(timestamp)
];
if (mode) {
args.push(mode);
}
args.push('FIELDS')
return pushVerdictArgument(args, fields);
}
export declare function transformReply(): Array<HashExpiration>;

View File

@@ -0,0 +1,32 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { HASH_EXPIRATION_TIME, transformArguments } from './HEXPIRETIME';
describe('HEXPIRETIME', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('string', () => {
assert.deepEqual(
transformArguments('key', 'field'),
['HEXPIRETIME', 'key', 'FIELDS', '1', 'field']
);
});
it('array', () => {
assert.deepEqual(
transformArguments('key', ['field1', 'field2']),
['HEXPIRETIME', 'key', 'FIELDS', '2', 'field1', 'field2']
);
});
})
testUtils.testWithClient('hExpireTime', async client => {
assert.deepEqual(
await client.hExpireTime('key', 'field1'),
[HASH_EXPIRATION_TIME.FIELD_NOT_EXISTS]
);
}, {
...GLOBAL.SERVERS.OPEN,
});
});

View File

@@ -0,0 +1,21 @@
import { RedisCommandArgument } from '.';
import { pushVerdictArgument } from './generic-transformers';
export const HASH_EXPIRATION_TIME = {
/** @property {number} */
/** The field does not exist */
FIELD_NOT_EXISTS: -2,
/** @property {number} */
/** The field exists but has no associated expire */
NO_EXPIRATION: -1,
} as const;
export const FIRST_KEY_INDEX = 1
export const IS_READ_ONLY = true;
export function transformArguments(key: RedisCommandArgument, fields: RedisCommandArgument | Array<RedisCommandArgument>) {
return pushVerdictArgument(['HEXPIRETIME', key, 'FIELDS'], fields);
}
export declare function transformReply(): Array<number>;

View File

@@ -0,0 +1,33 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './HPERSIST';
import { HASH_EXPIRATION_TIME } from './HEXPIRETIME';
describe('HPERSIST', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('string', () => {
assert.deepEqual(
transformArguments('key', 'field'),
['HPERSIST', 'key', 'FIELDS', '1', 'field']
);
});
it('array', () => {
assert.deepEqual(
transformArguments('key', ['field1', 'field2']),
['HPERSIST', 'key', 'FIELDS', '2', 'field1', 'field2']
);
});
})
testUtils.testWithClient('hPersist', async client => {
assert.deepEqual(
await client.hPersist('key', 'field1'),
[HASH_EXPIRATION_TIME.FIELD_NOT_EXISTS]
);
}, {
...GLOBAL.SERVERS.OPEN,
});
});

View File

@@ -0,0 +1,10 @@
import { RedisCommandArgument } from '.';
import { pushVerdictArgument } from './generic-transformers';
export const FIRST_KEY_INDEX = 1;
export function transformArguments(key: RedisCommandArgument, fields: RedisCommandArgument | Array<RedisCommandArgument>) {
return pushVerdictArgument(['HPERSIST', key, 'FIELDS'], fields);
}
export declare function transformReply(): Array<number> | null;

View File

@@ -0,0 +1,40 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './HPEXPIRE';
import { HASH_EXPIRATION_TIME } from './HEXPIRETIME';
describe('HEXPIRE', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('string', () => {
assert.deepEqual(
transformArguments('key', 'field', 1),
['HPEXPIRE', 'key', '1', 'FIELDS', '1', 'field']
);
});
it('array', () => {
assert.deepEqual(
transformArguments('key', ['field1', 'field2'], 1),
['HPEXPIRE', 'key', '1', 'FIELDS', '2', 'field1', 'field2']
);
});
it('with set option', () => {
assert.deepEqual(
transformArguments('key', ['field1'], 1, 'NX'),
['HPEXPIRE', 'key', '1', 'NX', 'FIELDS', '1', 'field1']
);
});
});
testUtils.testWithClient('hexpire', async client => {
assert.deepEqual(
await client.hpExpire('key', ['field1'], 0),
[HASH_EXPIRATION_TIME.FIELD_NOT_EXISTS]
);
}, {
...GLOBAL.SERVERS.OPEN
});
});

View File

@@ -0,0 +1,24 @@
import { RedisCommandArgument } from '.';
import { pushVerdictArgument } from './generic-transformers';
import { HashExpiration } from "./HEXPIRE";
export const FIRST_KEY_INDEX = 1;
export function transformArguments(
key: RedisCommandArgument,
fields: RedisCommandArgument | Array<RedisCommandArgument>,
ms: number,
mode?: 'NX' | 'XX' | 'GT' | 'LT',
) {
const args = ['HPEXPIRE', key, ms.toString()];
if (mode) {
args.push(mode);
}
args.push('FIELDS')
return pushVerdictArgument(args, fields);
}
export declare function transformReply(): Array<HashExpiration> | null;

View File

@@ -0,0 +1,48 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './HPEXPIREAT';
import { HASH_EXPIRATION_TIME } from './HEXPIRETIME';
describe('HPEXPIREAT', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('string + number', () => {
assert.deepEqual(
transformArguments('key', 'field', 1),
['HPEXPIREAT', 'key', '1', 'FIELDS', '1', 'field']
);
});
it('array + number', () => {
assert.deepEqual(
transformArguments('key', ['field1', 'field2'], 1),
['HPEXPIREAT', 'key', '1', 'FIELDS', '2', 'field1', 'field2']
);
});
it('date', () => {
const d = new Date();
assert.deepEqual(
transformArguments('key', ['field1'], d),
['HPEXPIREAT', 'key', d.getTime().toString(), 'FIELDS', '1', 'field1']
);
});
it('with set option', () => {
assert.deepEqual(
transformArguments('key', ['field1'], 1, 'XX'),
['HPEXPIREAT', 'key', '1', 'XX', 'FIELDS', '1', 'field1']
);
});
});
testUtils.testWithClient('hpExpireAt', async client => {
assert.deepEqual(
await client.hpExpireAt('key', ['field1'], 1),
[HASH_EXPIRATION_TIME.FIELD_NOT_EXISTS]
);
}, {
...GLOBAL.SERVERS.OPEN,
});
});

View File

@@ -0,0 +1,25 @@
import { RedisCommandArgument } from '.';
import { pushVerdictArgument, transformEXAT, transformPXAT } from './generic-transformers';
import { HashExpiration } from './HEXPIRE';
export const FIRST_KEY_INDEX = 1;
export const IS_READ_ONLY = true;
export function transformArguments(
key: RedisCommandArgument,
fields: RedisCommandArgument | Array<RedisCommandArgument>,
timestamp: number | Date,
mode?: 'NX' | 'XX' | 'GT' | 'LT'
) {
const args = ['HPEXPIREAT', key, transformPXAT(timestamp)];
if (mode) {
args.push(mode);
}
args.push('FIELDS')
return pushVerdictArgument(args, fields);
}
export declare function transformReply(): Array<HashExpiration> | null;

View File

@@ -0,0 +1,33 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './HPEXPIRETIME';
import { HASH_EXPIRATION_TIME } from './HEXPIRETIME';
describe('HPEXPIRETIME', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('string', () => {
assert.deepEqual(
transformArguments('key', 'field'),
['HPEXPIRETIME', 'key', 'FIELDS', '1', 'field']
);
});
it('array', () => {
assert.deepEqual(
transformArguments('key', ['field1', 'field2']),
['HPEXPIRETIME', 'key', 'FIELDS', '2', 'field1', 'field2']
);
});
});
testUtils.testWithClient('hpExpireTime', async client => {
assert.deepEqual(
await client.hpExpireTime('key', 'field1'),
[HASH_EXPIRATION_TIME.FIELD_NOT_EXISTS]
);
}, {
...GLOBAL.SERVERS.OPEN
});
});

View File

@@ -0,0 +1,11 @@
import { RedisCommandArgument } from '.';
import { pushVerdictArgument } from './generic-transformers';
export const FIRST_KEY_INDEX = 1;
export const IS_READ_ONLY = true;
export function transformArguments(key: RedisCommandArgument, fields: RedisCommandArgument | Array<RedisCommandArgument>) {
return pushVerdictArgument(['HPEXPIRETIME', key, 'FIELDS'], fields);
}
export declare function transformReply(): Array<number> | null;

View File

@@ -0,0 +1,33 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './HPTTL';
import { HASH_EXPIRATION_TIME } from './HEXPIRETIME';
describe('HPTTL', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('string', () => {
assert.deepEqual(
transformArguments('key', 'field'),
['HPTTL', 'key', 'FIELDS', '1', 'field']
);
});
it('array', () => {
assert.deepEqual(
transformArguments('key', ['field1', 'field2']),
['HPTTL', 'key', 'FIELDS', '2', 'field1', 'field2']
);
});
});
testUtils.testWithClient('hpTTL', async client => {
assert.deepEqual(
await client.hpTTL('key', 'field1'),
[HASH_EXPIRATION_TIME.FIELD_NOT_EXISTS]
);
}, {
...GLOBAL.SERVERS.OPEN
});
});

View File

@@ -0,0 +1,11 @@
import { RedisCommandArgument } from '.';
import { pushVerdictArgument } from './generic-transformers';
export const FIRST_KEY_INDEX = 1;
export const IS_READ_ONLY = true;
export function transformArguments(key: RedisCommandArgument, fields: RedisCommandArgument | Array<RedisCommandArgument>) {
return pushVerdictArgument(['HPTTL', key, 'FIELDS'], fields);
}
export declare function transformReply(): Array<number> | null;

View File

@@ -73,5 +73,18 @@ describe('HSCAN', () => {
tuples: [] tuples: []
} }
); );
await Promise.all([
client.hSet('key', 'a', '1'),
client.hSet('key', 'b', '2')
]);
assert.deepEqual(
await client.hScan('key', 0),
{
cursor: 0,
tuples: [{field: 'a', value: '1'}, {field: 'b', value: '2'}]
}
);
}, GLOBAL.SERVERS.OPEN); }, GLOBAL.SERVERS.OPEN);
}); });

View File

@@ -16,7 +16,7 @@ export function transformArguments(
], cursor, options); ], cursor, options);
} }
type HScanRawReply = [RedisCommandArgument, Array<RedisCommandArgument>]; export type HScanRawReply = [RedisCommandArgument, Array<RedisCommandArgument>];
export interface HScanTuple { export interface HScanTuple {
field: RedisCommandArgument; field: RedisCommandArgument;

View File

@@ -0,0 +1,79 @@
import { strict as assert } from 'assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments, transformReply } from './HSCAN_NOVALUES';
describe('HSCAN_NOVALUES', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('cusror only', () => {
assert.deepEqual(
transformArguments('key', 0),
['HSCAN', 'key', '0', 'NOVALUES']
);
});
it('with MATCH', () => {
assert.deepEqual(
transformArguments('key', 0, {
MATCH: 'pattern'
}),
['HSCAN', 'key', '0', 'MATCH', 'pattern', 'NOVALUES']
);
});
it('with COUNT', () => {
assert.deepEqual(
transformArguments('key', 0, {
COUNT: 1
}),
['HSCAN', 'key', '0', 'COUNT', '1', 'NOVALUES']
);
});
});
describe('transformReply', () => {
it('without keys', () => {
assert.deepEqual(
transformReply(['0', []]),
{
cursor: 0,
keys: []
}
);
});
it('with keys', () => {
assert.deepEqual(
transformReply(['0', ['key1', 'key2']]),
{
cursor: 0,
keys: ['key1', 'key2']
}
);
});
});
testUtils.testWithClient('client.hScanNoValues', async client => {
assert.deepEqual(
await client.hScanNoValues('key', 0),
{
cursor: 0,
keys: []
}
);
await Promise.all([
client.hSet('key', 'a', '1'),
client.hSet('key', 'b', '2')
]);
assert.deepEqual(
await client.hScanNoValues('key', 0),
{
cursor: 0,
keys: ['a', 'b']
}
);
}, GLOBAL.SERVERS.OPEN);
});

View File

@@ -0,0 +1,27 @@
import { RedisCommandArgument, RedisCommandArguments } from '.';
import { ScanOptions } from './generic-transformers';
import { HScanRawReply, transformArguments as transformHScanArguments } from './HSCAN';
export { FIRST_KEY_INDEX, IS_READ_ONLY } from './HSCAN';
export function transformArguments(
key: RedisCommandArgument,
cursor: number,
options?: ScanOptions
): RedisCommandArguments {
const args = transformHScanArguments(key, cursor, options);
args.push('NOVALUES');
return args;
}
interface HScanNoValuesReply {
cursor: number;
keys: Array<RedisCommandArgument>;
}
export function transformReply([cursor, rawData]: HScanRawReply): HScanNoValuesReply {
return {
cursor: Number(cursor),
keys: rawData
};
}

View File

@@ -0,0 +1,34 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import { transformArguments } from './HTTL';
import { HASH_EXPIRATION_TIME } from './HEXPIRETIME';
describe('HTTL', () => {
testUtils.isVersionGreaterThanHook([7, 4]);
describe('transformArguments', () => {
it('string', () => {
assert.deepEqual(
transformArguments('key', 'field'),
['HTTL', 'key', 'FIELDS', '1', 'field']
);
});
it('array', () => {
assert.deepEqual(
transformArguments('key', ['field1', 'field2']),
['HTTL', 'key', 'FIELDS', '2', 'field1', 'field2']
);
});
});
testUtils.testWithClient('hTTL', async client => {
assert.deepEqual(
await client.hTTL('key', 'field1'),
[HASH_EXPIRATION_TIME.FIELD_NOT_EXISTS]
);
}, {
...GLOBAL.SERVERS.OPEN
});
});

View File

@@ -0,0 +1,11 @@
import { RedisCommandArgument } from '.';
import { pushVerdictArgument } from './generic-transformers';
export const FIRST_KEY_INDEX = 1;
export const IS_READ_ONLY = true;
export function transformArguments(key: RedisCommandArgument, fields: RedisCommandArgument | Array<RedisCommandArgument>) {
return pushVerdictArgument(['HTTL', key, 'FIELDS'], fields);
}
export declare function transformReply(): Array<number> | null;

View File

@@ -24,9 +24,5 @@ describe('LATENCY GRAPH', () => {
typeof await client.latencyGraph('command'), typeof await client.latencyGraph('command'),
'string' 'string'
); );
}, { }, GLOBAL.SERVERS.OPEN);
serverArguments: testUtils.isVersionGreaterThan([7]) ?
['--enable-debug-command', 'yes'] :
GLOBAL.SERVERS.OPEN.serverArguments
});
}); });

View File

@@ -0,0 +1,26 @@
import {strict as assert} from 'assert';
import testUtils, {GLOBAL} from '../test-utils';
import { transformArguments } from './LATENCY_HISTORY';
describe('LATENCY HISTORY', () => {
it('transformArguments', () => {
assert.deepEqual(
transformArguments('command'),
['LATENCY', 'HISTORY', 'command']
);
});
testUtils.testWithClient('client.latencyHistory', async client => {
await Promise.all([
client.configSet('latency-monitor-threshold', '100'),
client.sendCommand(['DEBUG', 'SLEEP', '1'])
]);
const latencyHisRes = await client.latencyHistory('command');
assert.ok(Array.isArray(latencyHisRes));
for (const [timestamp, latency] of latencyHisRes) {
assert.equal(typeof timestamp, 'number');
assert.equal(typeof latency, 'number');
}
}, GLOBAL.SERVERS.OPEN);
});

View File

@@ -0,0 +1,27 @@
export type EventType = (
'active-defrag-cycle' |
'aof-fsync-always' |
'aof-stat' |
'aof-rewrite-diff-write' |
'aof-rename' |
'aof-write' |
'aof-write-active-child' |
'aof-write-alone' |
'aof-write-pending-fsync' |
'command' |
'expire-cycle' |
'eviction-cycle' |
'eviction-del' |
'fast-command' |
'fork' |
'rdb-unlink-temp-file'
);
export function transformArguments(event: EventType) {
return ['LATENCY', 'HISTORY', event];
}
export declare function transformReply(): Array<[
timestamp: number,
latency: number,
]>;

View File

@@ -0,0 +1,27 @@
import {strict as assert} from 'assert';
import testUtils, {GLOBAL} from '../test-utils';
import { transformArguments } from './LATENCY_LATEST';
describe('LATENCY LATEST', () => {
it('transformArguments', () => {
assert.deepEqual(
transformArguments(),
['LATENCY', 'LATEST']
);
});
testUtils.testWithClient('client.latencyLatest', async client => {
await Promise.all([
client.configSet('latency-monitor-threshold', '100'),
client.sendCommand(['DEBUG', 'SLEEP', '1'])
]);
const latency = await client.latencyLatest();
assert.ok(Array.isArray(latency));
for (const [name, timestamp, latestLatency, allTimeLatency] of latency) {
assert.equal(typeof name, 'string');
assert.equal(typeof timestamp, 'number');
assert.equal(typeof latestLatency, 'number');
assert.equal(typeof allTimeLatency, 'number');
}
}, GLOBAL.SERVERS.OPEN);
});

View File

@@ -0,0 +1,12 @@
import { RedisCommandArguments } from '.';
export function transformArguments(): RedisCommandArguments {
return ['LATENCY', 'LATEST'];
}
export declare function transformReply(): Array<[
name: string,
timestamp: number,
latestLatency: number,
allTimeLatency: number
]>;

View File

@@ -1,8 +1,24 @@
import { strict as assert } from 'assert'; import { strict as assert } from 'assert';
import testUtils, { GLOBAL } from '../test-utils'; import testUtils, { GLOBAL } from '../test-utils';
import RedisClient from '../client'; import { transformArguments } from './PING';
describe('PING', () => { describe('PING', () => {
describe('transformArguments', () => {
it('default', () => {
assert.deepEqual(
transformArguments(),
['PING']
);
});
it('with message', () => {
assert.deepEqual(
transformArguments('message'),
['PING', 'message']
);
});
});
describe('client.ping', () => { describe('client.ping', () => {
testUtils.testWithClient('string', async client => { testUtils.testWithClient('string', async client => {
assert.equal( assert.equal(
@@ -13,7 +29,7 @@ describe('PING', () => {
testUtils.testWithClient('buffer', async client => { testUtils.testWithClient('buffer', async client => {
assert.deepEqual( assert.deepEqual(
await client.ping(RedisClient.commandOptions({ returnBuffers: true })), await client.ping(client.commandOptions({ returnBuffers: true })),
Buffer.from('PONG') Buffer.from('PONG')
); );
}, GLOBAL.SERVERS.OPEN); }, GLOBAL.SERVERS.OPEN);

View File

@@ -1,7 +1,12 @@
import { RedisCommandArgument } from '.'; import { RedisCommandArgument, RedisCommandArguments } from '.';
export function transformArguments(): Array<string> { export function transformArguments(message?: RedisCommandArgument): RedisCommandArguments {
return ['PING']; const args: RedisCommandArguments = ['PING'];
if (message) {
args.push(message);
}
return args;
} }
export declare function transformReply(): RedisCommandArgument; export declare function transformReply(): RedisCommandArgument;

Some files were not shown because too many files have changed in this diff Show More