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'
tag-template: 'v$NEXT_PATCH_VERSION'
name-template: 'json@$NEXT_PATCH_VERSION'
tag-template: 'json@$NEXT_PATCH_VERSION'
autolabeler:
- label: 'chore'
files:
@@ -28,8 +28,15 @@ categories:
- 'bugfix'
- 'bug'
- title: '🧰 Maintenance'
label: 'chore'
label:
- 'chore'
- 'maintenance'
- 'documentation'
- 'docs'
change-template: '- $TITLE (#$NUMBER)'
include-paths:
- 'packages/json'
exclude-labels:
- 'skip-changelog'
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:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -10,11 +10,11 @@ jobs:
documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2.3.4
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Use Node.js
uses: actions/setup-node@v2.3.0
uses: actions/setup-node@v3
- name: Install Packages
run: npm ci
- 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
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-config.yml
config-name: release-drafter/json-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/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:
fail-fast: false
matrix:
node-version: ['14', '16', '18', '19']
redis-version: ['5', '6.0', '6.2', '7.0']
node-version: ['18', '20']
redis-version: ['5', '6.0', '6.2', '7.0', '7.2', '7.4-rc2']
steps:
- uses: actions/checkout@v2.3.4
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2.3.0
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Update npm

35
LICENSE
View File

@@ -1,24 +1,21 @@
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
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 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.
## 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
@@ -51,11 +64,9 @@ Looking for a high-level library to handle object mapping? See [redis-om-node](h
```typescript
import { createClient } from 'redis';
const client = createClient();
client.on('error', (err) => console.log('Redis Client Error', err));
await client.connect();
const client = await createClient()
.on('error', err => console.log('Redis Client Error', err))
.connect();
await client.set('key', 'value');
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
Subscribing to a channel requires a dedicated stand-alone connection. You can easily get one by `.duplicate()`ing an existing Redis connection.
```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);
```
See the [Pub/Sub overview](./docs/pub-sub.md).
### 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:
| Event name | Scenes | Arguments to be passed to the listener |
|----------------|-------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
| `connect` | The client is initiating a connection to the server. | _No argument_ |
| `ready` | The client successfully initiated the connection to the server. | _No argument_ |
| `end` | The client disconnected the connection to the server via `.quit()` or `.disconnect()`. | _No argument_ |
| `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]` |
| `reconnecting` | The client is trying to reconnect to the server. | _No argument_ |
| Name | When | Listener arguments |
|-------------------------|------------------------------------------------------------------------------------|------------------------------------------------------------|
| `connect` | Initiating a connection to the server | *No arguments* |
| `ready` | Client is ready to use | *No arguments* |
| `end` | Connection has been closed (via `.quit()` or `.disconnect()`) | *No arguments* |
| `error` | An error has occurred—usually a network issue such as "Socket closed unexpectedly" | `(error: Error)` |
| `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

View File

@@ -1,6 +1,6 @@
{
"name": "@redis/client-benchmark",
"lockfileVersion": 2,
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
@@ -8,39 +8,9 @@
"dependencies": {
"@redis/client": "../packages/client",
"hdr-histogram-js": "3.0.0",
"ioredis": "5.2.2",
"ioredis": "5.3.2",
"redis-v3": "npm:redis@3.1.2",
"yargs": "17.5.1"
}
},
"../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"
"yargs": "17.7.2"
}
},
"node_modules/@assemblyscript/loader": {
@@ -49,13 +19,22 @@
"integrity": "sha512-ulkCYfFbYj01ie1MDOyxv2F6SpRN1TOj7fQxbP07D6HmeR+gr2JLSmINKjga2emB+b1L2KGrFKBTc+e00p54nw=="
},
"node_modules/@ioredis/commands": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.1.1.tgz",
"integrity": "sha512-fsR4P/ROllzf/7lXYyElUJCheWdTJVJvOTps8v9IWKFATxR61ANOlnoPqhH099xYLrJGpc2ZQ28B3rMeUt5VQg=="
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="
},
"node_modules/@redis/client": {
"resolved": "../packages/client",
"link": true
"version": "1.5.7",
"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": {
"version": "5.0.1",
@@ -99,19 +78,22 @@
]
},
"node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/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==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": {
"node": ">=0.10.0"
}
@@ -149,9 +131,9 @@
}
},
"node_modules/denque": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz",
"integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"engines": {
"node": ">=0.10"
}
@@ -169,6 +151,14 @@
"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": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -191,14 +181,14 @@
}
},
"node_modules/ioredis": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.2.2.tgz",
"integrity": "sha512-wryKc1ur8PcCmNwfcGkw5evouzpbDXxxkMkzPK8wl4xQfQf7lHe11Jotell5ikMVAtikXJEu/OJVaoV51BggRQ==",
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz",
"integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==",
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.0.1",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
@@ -224,12 +214,12 @@
"node_modules/lodash.defaults": {
"version": "4.2.0",
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo="
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="
},
"node_modules/ms": {
"version": "2.1.2",
@@ -249,7 +239,7 @@
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"engines": {
"node": ">=4"
}
@@ -257,7 +247,7 @@
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"dependencies": {
"redis-errors": "^1.0.0"
},
@@ -295,7 +285,7 @@
"node_modules/require-directory": {
"version": "2.1.1",
"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": {
"node": ">=0.10.0"
}
@@ -353,288 +343,35 @@
"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": {
"version": "17.5.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.5.1.tgz",
"integrity": "sha512-t6YAJcxDkNX7NFYiVtKvWUz8l+PaKTLiL63mJYWR2GnHq2gjEWISzsLp9wg3aY36dY1j+gfIEL3pIF+XlJJfbA==",
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dependencies": {
"cliui": "^7.0.2",
"cliui": "^8.0.1",
"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": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/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==",
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"engines": {
"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": {
"@redis/client": "../packages/client",
"hdr-histogram-js": "3.0.0",
"ioredis": "5.2.2",
"ioredis": "5.3.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?
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.

View File

@@ -15,7 +15,7 @@
| 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)) |
| 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) |
| modules | | Included [Redis Modules](../README.md#packages) |
| 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 |
| legacyMode | `false` | Maintain some backwards compatibility (see the [Migration Guide](./v3-to-v4.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
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.
- Returns `number | Error`:
- `number`: wait time in milliseconds prior to attempting a reconnect.
- `Error`: closes the client and flushes internal command queues.
By default the strategy is `Math.min(retries * 50, 500)`, but it can be overwritten like so:
```javascript
createClient({
socket: {
reconnectStrategy: retries => Math.min(retries * 50, 1000)
}
});
```
## TLS
@@ -44,7 +52,7 @@ To enable TLS, set `socket.tls` to `true`. Below are some basic examples.
### Create a SSL client
```typescript
```javascript
createClient({
socket: {
tls: true,
@@ -56,7 +64,7 @@ createClient({
### Create a SSL client using a self-signed certificate
```typescript
```javascript
createClient({
socket: {
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 |
| 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 |
| 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 |
| nodeAddressMap | | Defines the [node address mapping](#node-address-map) |
| modules | | Included [Redis Modules](../README.md#packages) |
| scripts | | Script definitions (see [Lua Scripts](../README.md#lua-scripts)) |
| functions | | Function definitions (see [Functions](../README.md#functions)) |
## Auth with password and username
## Node Address Map
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:
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.
```javascript
createCluster({
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: {
'10.0.0.1:30001': {
host: 'external-host-1.io',
host: 'external-host.io',
port: 30001
},
'10.0.0.2:30002': {
host: 'external-host-2.io',
host: 'external-host.io',
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.

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.
| File Name | Description |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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). |
| `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. |
| `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). |
| `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. |
| `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. |

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', {
name: {
type: SchemaFieldTypes.TEXT,
sortable: true
SORTABLE: true
},
species: SchemaFieldTypes.TAG,
age: SchemaFieldTypes.NUMERIC

View File

@@ -15,7 +15,7 @@ try {
await client.ft.create('idx:users', {
'$.name': {
type: SchemaFieldTypes.TEXT,
SORTABLE: 'UNF'
SORTABLE: true
},
'$.age': {
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",
"description": "A modern, high performance Redis client",
"version": "4.5.1",
"version": "4.7.0",
"license": "MIT",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -23,18 +23,18 @@
"gh-pages": "gh-pages -d ./documentation -e ./documentation -u 'documentation-bot <documentation@bot>'"
},
"dependencies": {
"@redis/bloom": "1.1.0",
"@redis/client": "1.4.2",
"@redis/graph": "1.1.0",
"@redis/json": "1.0.4",
"@redis/search": "1.1.0",
"@redis/time-series": "1.0.4"
"@redis/bloom": "1.2.0",
"@redis/client": "1.6.0",
"@redis/graph": "1.1.1",
"@redis/json": "1.0.7",
"@redis/search": "1.2.0",
"@redis/time-series": "1.1.0"
},
"devDependencies": {
"@tsconfig/node14": "^1.0.3",
"gh-pages": "^4.0.0",
"release-it": "^15.3.0",
"typescript": "^4.7.4"
"@tsconfig/node14": "^14.1.0",
"gh-pages": "^6.0.0",
"release-it": "^16.1.5",
"typescript": "^5.2.2"
},
"repository": {
"type": "git",
@@ -43,5 +43,8 @@
"bugs": {
"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",
"version": "1.1.0",
"version": "1.2.0",
"license": "MIT",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -18,12 +18,24 @@
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@redis/test-utils": "*",
"@types/node": "^18.11.6",
"@types/node": "^20.6.2",
"nyc": "^15.1.0",
"release-it": "^15.5.0",
"release-it": "^16.1.5",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.1",
"typedoc": "^0.23.18",
"typescript": "^4.8.4"
}
"typedoc": "^0.25.1",
"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 { GeoReplyWith } from './lib/commands/generic-transformers';
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;
if (length === -1) {
return this.returnArrayReply(null, arraysToKeep);
return this.returnArrayReply(null, arraysToKeep, chunk);
} else if (length === 0) {
return this.returnArrayReply([], arraysToKeep);
return this.returnArrayReply([], arraysToKeep, chunk);
}
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;
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]!;
to.array[to.pushCounter] = item;
if (++to.pushCounter === to.array.length) {
return this.returnArrayReply(
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 { AbortError, ErrorReply } from '../errors';
import { RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply } from '../commands';
import { RedisCommandArguments, RedisCommandRawReply } from '../commands';
import RESP2Decoder from './RESP2/decoder';
import encodeCommand from './RESP2/encoder';
import { ChannelListeners, PubSub, PubSubCommand, PubSubListener, PubSubType, PubSubTypeListeners } from './pub-sub';
export interface QueueCommandOptions {
asap?: boolean;
chainId?: symbol;
signal?: AbortSignal;
returnBuffers?: boolean;
ignorePubSubMode?: boolean;
}
interface CommandWaitingToBeSent extends CommandWaitingForReply {
export interface CommandWaitingToBeSent extends CommandWaitingForReply {
args: RedisCommandArguments;
chainId?: symbol;
abort?: {
@@ -28,27 +28,9 @@ interface CommandWaitingForReply {
returnBuffers?: boolean;
}
export enum PubSubSubscribeCommands {
SUBSCRIBE = 'SUBSCRIBE',
PSUBSCRIBE = 'PSUBSCRIBE'
}
const PONG = Buffer.from('pong');
export enum PubSubUnsubscribeCommands {
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 type OnShardedChannelMoved = (channel: string, listeners: ChannelListeners) => void;
export default class RedisCommandsQueue {
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 #waitingToBeSent = new LinkedList<CommandWaitingToBeSent>();
readonly #waitingForReply = new LinkedList<CommandWaitingForReply>();
readonly #onShardedChannelMoved: OnShardedChannelMoved;
readonly #pubSubState = {
isActive: false,
subscribing: 0,
subscribed: 0,
unsubscribing: 0,
listeners: {
channels: new Map(),
patterns: new Map()
}
};
readonly #pubSub = new PubSub();
static readonly #PUB_SUB_MESSAGES = {
message: Buffer.from('message'),
pMessage: Buffer.from('pmessage'),
subscribe: Buffer.from('subscribe'),
pSubscribe: Buffer.from('psubscribe'),
unsubscribe: Buffer.from('unsubscribe'),
pUnsubscribe: Buffer.from('punsubscribe')
};
get isPubSubActive() {
return this.#pubSub.isActive;
}
#chainInExecution: symbol | undefined;
#decoder = new RESP2Decoder({
returnStringsAsBuffers: () => {
return !!this.#waitingForReply.head?.value.returnBuffers ||
this.#pubSubState.isActive;
this.#pubSub.isActive;
},
onReply: reply => {
if (this.#handlePubSubReply(reply)) {
return;
} else if (!this.#waitingForReply.length) {
throw new Error('Got an unexpected reply from Redis');
if (this.#pubSub.isActive && Array.isArray(reply)) {
if (this.#pubSub.handleMessageReply(reply as Array<Buffer>)) return;
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()!;
if (reply instanceof ErrorReply) {
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.#onShardedChannelMoved = onShardedChannelMoved;
}
addCommand<T = RedisCommandRawReply>(args: RedisCommandArguments, options?: QueueCommandOptions): Promise<T> {
if (this.#pubSubState.isActive && !options?.ignorePubSubMode) {
return Promise.reject(new Error('Cannot send commands in PubSub mode'));
} else if (this.#maxLength && this.#waitingToBeSent.length + this.#waitingForReply.length >= this.#maxLength) {
if (this.#maxLength && this.#waitingToBeSent.length + this.#waitingForReply.length >= this.#maxLength) {
return Promise.reject(new Error('The queue is full'));
} else if (options?.signal?.aborted) {
return Promise.reject(new AbortError());
@@ -173,158 +144,76 @@ export default class RedisCommandsQueue {
}
subscribe<T extends boolean>(
command: PubSubSubscribeCommands,
channels: RedisCommandArgument | Array<RedisCommandArgument>,
type: PubSubType,
channels: string | Array<string>,
listener: PubSubListener<T>,
returnBuffers?: T
): Promise<void> {
const channelsToSubscribe: Array<RedisCommandArgument> = [],
listenersMap = command === PubSubSubscribeCommands.SUBSCRIBE ?
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);
) {
return this.#pushPubSubCommand(
this.#pubSub.subscribe(type, channels, listener, returnBuffers)
);
}
unsubscribe<T extends boolean>(
command: PubSubUnsubscribeCommands,
type: PubSubType,
channels?: string | Array<string>,
listener?: PubSubListener<T>,
returnBuffers?: T
): Promise<void> {
const listeners = command === PubSubUnsubscribeCommands.UNSUBSCRIBE ?
this.#pubSubState.listeners.channels :
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);
) {
return this.#pushPubSubCommand(
this.#pubSub.unsubscribe(type, channels, listener, returnBuffers)
);
}
#pushPubSubCommand(command: PubSubSubscribeCommands | PubSubUnsubscribeCommands, channels: number | Array<RedisCommandArgument>): Promise<void> {
return new Promise((resolve, reject) => {
const isSubscribe = command === PubSubSubscribeCommands.SUBSCRIBE || command === PubSubSubscribeCommands.PSUBSCRIBE,
inProgressKey = isSubscribe ? 'subscribing' : 'unsubscribing',
commandArgs: Array<RedisCommandArgument> = [command];
resubscribe(): Promise<any> | undefined {
const commands = this.#pubSub.resubscribe();
if (!commands.length) return;
let channelsCounter: number;
if (typeof channels === 'number') { // unsubscribe only
channelsCounter = channels;
} else {
commandArgs.push(...channels);
channelsCounter = channels.length;
}
return Promise.all(
commands.map(command => this.#pushPubSubCommand(command))
);
}
this.#pubSubState.isActive = true;
this.#pubSubState[inProgressKey] += channelsCounter;
extendPubSubChannelListeners(
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({
args: commandArgs,
channelsCounter,
args: command.args,
channelsCounter: command.channelsCounter,
returnBuffers: true,
resolve: () => {
this.#pubSubState[inProgressKey] -= channelsCounter;
this.#pubSubState.subscribed += channelsCounter * (isSubscribe ? 1 : -1);
this.#updatePubSubActiveState();
command.resolve();
resolve();
},
reject: err => {
this.#pubSubState[inProgressKey] -= channelsCounter * (isSubscribe ? 1 : -1);
this.#updatePubSubActiveState();
command.reject?.();
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 {
const toSend = this.#waitingToBeSent.shift();
if (!toSend) return;
@@ -351,39 +240,9 @@ export default class RedisCommandsQueue {
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 {
this.#decoder.reset();
this.#pubSubState.isActive = false;
this.#pubSub.reset();
RedisCommandsQueue.#flushQueue(this.#waitingForReply, err);
if (!this.#chainInExecution) return;
@@ -396,6 +255,8 @@ export default class RedisCommandsQueue {
}
flushAll(err: Error): void {
this.#decoder.reset();
this.#pubSub.reset();
RedisCommandsQueue.#flushQueue(this.#waitingForReply, 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_ID from '../commands/CLIENT_ID';
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_TOUCH from '../commands/CLIENT_NO-TOUCH';
import * as CLIENT_PAUSE from '../commands/CLIENT_PAUSE';
import * as CLIENT_SETNAME from '../commands/CLIENT_SETNAME';
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_MEET from '../commands/CLUSTER_MEET';
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_REPLICAS from '../commands/CLUSTER_REPLICAS';
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 LATENCY_DOCTOR from '../commands/LATENCY_DOCTOR';
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 MEMORY_DOCTOR from '../commands/MEMORY_DOCTOR';
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_NUMPAT from '../commands/PUBSUB_NUMPAT';
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 READONLY from '../commands/READONLY';
import * as READWRITE from '../commands/READWRITE';
@@ -164,6 +171,10 @@ export default {
clientKill: CLIENT_KILL,
'CLIENT_NO-EVICT': CLIENT_NO_EVICT,
clientNoEvict: CLIENT_NO_EVICT,
'CLIENT_NO-TOUCH': CLIENT_NO_TOUCH,
clientNoTouch: CLIENT_NO_TOUCH,
CLIENT_LIST,
clientList: CLIENT_LIST,
CLIENT_PAUSE,
clientPause: CLIENT_PAUSE,
CLIENT_SETNAME,
@@ -208,6 +219,8 @@ export default {
clusterMeet: CLUSTER_MEET,
CLUSTER_MYID,
clusterMyId: CLUSTER_MYID,
CLUSTER_MYSHARDID,
clusterMyShardId: CLUSTER_MYSHARDID,
CLUSTER_NODES,
clusterNodes: CLUSTER_NODES,
CLUSTER_REPLICAS,
@@ -286,6 +299,10 @@ export default {
latencyDoctor: LATENCY_DOCTOR,
LATENCY_GRAPH,
latencyGraph: LATENCY_GRAPH,
LATENCY_HISTORY,
latencyHistory: LATENCY_HISTORY,
LATENCY_LATEST,
latencyLatest: LATENCY_LATEST,
LOLWUT,
lolwut: LOLWUT,
MEMORY_DOCTOR,
@@ -314,6 +331,10 @@ export default {
pubSubNumPat: PUBSUB_NUMPAT,
PUBSUB_NUMSUB,
pubSubNumSub: PUBSUB_NUMSUB,
PUBSUB_SHARDCHANNELS,
pubSubShardChannels: PUBSUB_SHARDCHANNELS,
PUBSUB_SHARDNUMSUB,
pubSubShardNumSub: PUBSUB_SHARDNUMSUB,
RANDOMKEY,
randomKey: RANDOMKEY,
READONLY,

View File

@@ -2,14 +2,16 @@ import { strict as assert } from 'assert';
import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils';
import RedisClient, { RedisClientType } from '.';
import { RedisClientMultiCommandType } from './multi-command';
import { RedisCommandArguments, RedisCommandRawReply, RedisModules, RedisFunctions, RedisScripts } from '../commands';
import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, SocketClosedUnexpectedlyError, WatchError } from '../errors';
import { RedisCommandRawReply, RedisModules, RedisFunctions, RedisScripts } from '../commands';
import { AbortError, ClientClosedError, ClientOfflineError, ConnectionTimeoutError, DisconnectsClientError, ErrorReply, MultiErrorReply, SocketClosedUnexpectedlyError, WatchError } from '../errors';
import { defineScript } from '../lua-script';
import { spy } from 'sinon';
import { once } from 'events';
import { ClientKillFilters } from '../commands/CLIENT_KILL';
import { promisify } from 'util';
import {version} from '../../package.json';
export const SQUARE_SCRIPT = defineScript({
SCRIPT: 'return ARGV[1] * ARGV[1];',
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', () => {
testUtils.testWithClient('Client should be authenticated', async client => {
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 => {
assert.equal(
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 => {
assert.equal(
await promisify(client.set).call(client, 'a', 'b'),
@@ -484,14 +571,23 @@ describe('Client', () => {
);
}, GLOBAL.SERVERS.OPEN);
testUtils.testWithClient('execAsPipeline', async client => {
assert.deepEqual(
await client.multi()
.ping()
.exec(true),
['PONG']
);
}, GLOBAL.SERVERS.OPEN);
describe('execAsPipeline', () => {
testUtils.testWithClient('exec(true)', async client => {
assert.deepEqual(
await client.multi()
.ping()
.exec(true),
['PONG']
);
}, 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 => {
await client.multi()
@@ -506,6 +602,23 @@ describe('Client', () => {
...GLOBAL.SERVERS.OPEN,
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 => {
@@ -564,11 +677,41 @@ describe('Client', () => {
}
});
testUtils.testWithClient('executeIsolated', async client => {
const id = await client.clientId(),
isolatedId = await client.executeIsolated(isolatedClient => isolatedClient.clientId());
assert.ok(id !== isolatedId);
}, GLOBAL.SERVERS.OPEN);
describe('isolationPool', () => {
testUtils.testWithClient('executeIsolated', async client => {
const id = await client.clientId(),
isolatedId = await client.executeIsolated(isolatedClient => isolatedClient.clientId());
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<
M extends RedisModules,
@@ -604,6 +747,9 @@ describe('Client', () => {
});
testUtils.testWithClient('should propagated errors from "isolated" clients', client => {
client.on('error', () => {
// ignore errors
});
return client.executeIsolated(isolated => killClient(isolated, client));
}, GLOBAL.SERVERS.OPEN);
@@ -642,6 +788,31 @@ describe('Client', () => {
assert.deepEqual(hash, results);
}, 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 => {
const members = new Set<string>();
for (let i = 0; i < 100; i++) {
@@ -685,7 +856,7 @@ describe('Client', () => {
members.map<MemberTuple>(member => [member.value, member.score]).sort(sort)
);
}, GLOBAL.SERVERS.OPEN);
describe('PubSub', () => {
testUtils.testWithClient('should be able to publish and subscribe to messages', async publisher => {
function assertStringListener(message: string, channel: string) {

View File

@@ -1,7 +1,7 @@
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 RedisCommandsQueue, { PubSubListener, PubSubSubscribeCommands, PubSubUnsubscribeCommands, QueueCommandOptions } from './commands-queue';
import RedisCommandsQueue, { QueueCommandOptions } from './commands-queue';
import RedisClientMultiCommand, { RedisClientMultiCommandType } from './multi-command';
import { RedisMultiQueuedCommand } from '../multi-command';
import { EventEmitter } from 'events';
@@ -11,27 +11,71 @@ import { ScanCommandOptions } from '../commands/SCAN';
import { HScanTuple } from '../commands/HSCAN';
import { attachCommands, attachExtensions, fCallArguments, transformCommandArguments, transformCommandReply, transformLegacyCommandArguments } from '../commander';
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 { TcpSocketConnectOpts } from 'net';
import { PubSubType, PubSubListener, PubSubTypeListeners, ChannelListeners } from './pub-sub';
import {version} from '../../package.json';
export interface RedisClientOptions<
M extends RedisModules = RedisModules,
F extends RedisFunctions = RedisFunctions,
S extends RedisScripts = RedisScripts
> 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;
/**
* Socket connection properties
*/
socket?: RedisSocketOptions;
/**
* ACL username ([see ACL guide](https://redis.io/topics/acl))
*/
username?: string;
/**
* ACL password or the old "--requirepass" password
*/
password?: string;
/**
* Client name ([see `CLIENT SETNAME`](https://redis.io/commands/client-setname))
*/
name?: string;
/**
* Redis database number (see [`SELECT`](https://redis.io/commands/select) command)
*/
database?: number;
/**
* Maximum length of the client's internal command queue
*/
commandsQueueMaxLength?: number;
/**
* When `true`, commands are rejected when the client is reconnecting.
* When `false`, commands are queued for execution after reconnection.
*/
disableOfflineQueue?: boolean;
/**
* Connect in [`READONLY`](https://redis.io/commands/readonly) mode
*/
readonly?: boolean;
legacyMode?: boolean;
isolationPoolOptions?: PoolOptions;
/**
* Send `PING` command at interval (in ms).
* Useful with Redis deployments that do not use TCP Keep-Alive.
*/
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 = {
@@ -155,7 +199,7 @@ export default class RedisClient<
readonly #options?: RedisClientOptions<M, F, S>;
readonly #socket: RedisSocket;
readonly #queue: RedisCommandsQueue;
readonly #isolationPool: Pool<RedisClientType<M, F, S>>;
#isolationPool?: Pool<RedisClientType<M, F, S>>;
readonly #v4: Record<string, any> = {};
#selectedDB = 0;
@@ -171,6 +215,10 @@ export default class RedisClient<
return this.#socket.isReady;
}
get isPubSubActive() {
return this.#queue.isPubSubActive;
}
get v4(): Record<string, any> {
if (!this.#options?.legacyMode) {
throw new Error('the client is not in "legacy mode"');
@@ -184,16 +232,9 @@ export default class RedisClient<
this.#options = this.#initiateOptions(options);
this.#queue = this.#initiateQueue();
this.#socket = this.#initiateSocket();
this.#isolationPool = 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()
}, options?.isolationPoolOptions);
// should be initiated in connect, not here
// TODO: consider breaking in v5
this.#isolationPool = this.#initiateIsolationPool();
this.#legacyMode();
}
@@ -215,7 +256,10 @@ export default class RedisClient<
}
#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 {
@@ -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) {
promises.push(
this.#queue.addCommand(
@@ -295,6 +366,19 @@ export default class RedisClient<
.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 {
if (!this.#options?.legacyMode) return;
@@ -302,13 +386,15 @@ export default class RedisClient<
(this as any).sendCommand = (...args: Array<any>): void => {
const result = this.#legacySendCommand(...args);
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)) {
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
@@ -339,21 +425,21 @@ export default class RedisClient<
promise.catch(err => this.emit('error', err));
}
#defineLegacyCommand(this: any, name: string, command?: RedisCommand): void {
this.#v4[name] = this[name].bind(this);
this[name] = command && command.TRANSFORM_LEGACY_REPLY && command.transformReply ?
#defineLegacyCommand(name: string, command?: RedisCommand): void {
this.#v4[name] = (this as any)[name].bind(this);
(this as any)[name] = command && command.TRANSFORM_LEGACY_REPLY && command.transformReply ?
(...args: Array<unknown>) => {
const result = this.#legacySendCommand(name, ...args);
if (result) {
result.promise.then((reply: any) => {
result.callback(null, command.transformReply!(reply));
});
result.promise
.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 {
if (!this.#options?.pingInterval || !this.#socket.isReady) return;
@@ -362,7 +448,8 @@ export default class RedisClient<
this.#pingTimer = setTimeout(() => {
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))
.catch(err => this.emit('error', err))
.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();
return this as unknown as RedisClientType<M, F, S>;
}
async commandsExecutor<C extends RedisCommand>(
@@ -415,7 +505,7 @@ export default class RedisClient<
);
} else if (!this.#socket.isReady && this.#options?.disableOfflineQueue) {
return Promise.reject(new ClientOfflineError());
}
}
const promise = this.#queue.addCommand<T>(args, options);
this.#tick();
@@ -499,18 +589,9 @@ export default class RedisClient<
select = this.SELECT;
#subscribe<T extends boolean>(
command: PubSubSubscribeCommands,
channels: string | Array<string>,
listener: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
const promise = this.#queue.subscribe(
command,
channels,
listener,
bufferMode
);
#pubSubCommand(promise: Promise<void> | undefined) {
if (promise === undefined) return Promise.resolve();
this.#tick();
return promise;
}
@@ -520,77 +601,128 @@ export default class RedisClient<
listener: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
return this.#subscribe(
PubSubSubscribeCommands.SUBSCRIBE,
channels,
listener,
bufferMode
return this.#pubSubCommand(
this.#queue.subscribe(
PubSubType.CHANNELS,
channels,
listener,
bufferMode
)
);
}
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>(
channels?: string | Array<string>,
listener?: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
return this.#unsubscribe(
PubSubUnsubscribeCommands.UNSUBSCRIBE,
channels,
listener,
bufferMode
return this.#pubSubCommand(
this.#queue.unsubscribe(
PubSubType.CHANNELS,
channels,
listener,
bufferMode
)
);
}
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>(
patterns?: string | Array<string>,
listener?: PubSubListener<T>,
bufferMode?: T
): Promise<void> {
return this.#unsubscribe(
PubSubUnsubscribeCommands.PUNSUBSCRIBE,
patterns,
listener,
bufferMode
return this.#pubSubCommand(
this.#queue.unsubscribe(
PubSubType.PATTERNS,
patterns,
listener,
bufferMode
)
);
}
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> {
return this.#socket.quit(async () => {
const quitPromise = this.#queue.addCommand<string>(['QUIT'], {
ignorePubSubMode: true
});
if (this.#pingTimer) clearTimeout(this.#pingTimer);
const quitPromise = this.#queue.addCommand<string>(['QUIT']);
this.#tick();
const [reply] = await Promise.all([
quitPromise,
@@ -618,6 +750,7 @@ export default class RedisClient<
}
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);
}
@@ -639,11 +772,14 @@ export default class RedisClient<
return Promise.reject(new ClientClosedError());
}
const promise = Promise.all(
commands.map(({ args }) => {
return this.#queue.addCommand(args, { chainId });
})
);
const promise = chainId ?
// if `chainId` has a value, it's a `MULTI` (and not "pipeline") - need to add the `MULTI` and `EXEC` commands
Promise.all([
this.#queue.addCommand(['MULTI'], { chainId }),
this.#addMultiCommands(commands, chainId),
this.#queue.addCommand(['EXEC'], { chainId })
]) :
this.#addMultiCommands(commands);
this.#tick();
@@ -656,6 +792,12 @@ export default class RedisClient<
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> {
let cursor = 0;
do {
@@ -678,6 +820,17 @@ export default class RedisClient<
} 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> {
let cursor = 0;
do {
@@ -701,14 +854,16 @@ export default class RedisClient<
}
async disconnect(): Promise<void> {
if (this.#pingTimer) clearTimeout(this.#pingTimer);
this.#queue.flushAll(new DisconnectsClientError());
this.#socket.disconnect();
await this.#destroyIsolationPool();
}
async #destroyIsolationPool(): Promise<void> {
await this.#isolationPool.drain();
await this.#isolationPool.clear();
await this.#isolationPool!.drain();
await this.#isolationPool!.clear();
this.#isolationPool = undefined;
}
ref(): void {

View File

@@ -119,7 +119,7 @@ export default class RedisClientMultiCommand {
for (const [ name, command ] of Object.entries(COMMANDS as RedisCommands)) {
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();
}
const commands = this.#multi.exec();
if (!commands) return [];
return this.#multi.handleExecReplies(
await this.#executor(
commands,
this.#multi.queue,
this.#selectedDB,
RedisMultiCommand.generateChainId()
)
@@ -185,6 +182,8 @@ export default class RedisClientMultiCommand {
EXEC = this.exec;
async execAsPipeline(): Promise<Array<RedisCommandRawReply>> {
if (this.#multi.queue.length === 0) return [];
return this.#multi.transformReplies(
await this.#executor(
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 { spy } from 'sinon';
import { once } from 'events';
import RedisSocket, { RedisSocketOptions } from './socket';
describe('Socket', () => {
@@ -17,16 +18,42 @@ describe('Socket', () => {
}
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 () => {
const numberOfRetries = 10;
const numberOfRetries = 3;
const reconnectStrategy = spy((retries: number) => {
assert.equal(retries + 1, reconnectStrategy.callCount);
if (retries === numberOfRetries) return new Error(`${numberOfRetries}`);
const time = retries * 2;
return time;
return 0;
});
const socket = createSocket({

View File

@@ -6,10 +6,26 @@ import { ConnectionTimeoutError, ClientClosedError, SocketClosedUnexpectedlyErro
import { promiseTimeout } from '../utils';
export interface RedisSocketCommonOptions {
/**
* Connection Timeout (in milliseconds)
*/
connectTimeout?: number;
/**
* Toggle [`Nagle's algorithm`](https://nodejs.org/api/net.html#net_socket_setnodelay_nodelay)
*/
noDelay?: boolean;
/**
* Toggle [`keep-alive`](https://nodejs.org/api/net.html#net_socket_setkeepalive_enable_initialdelay)
*/
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> & {
@@ -83,23 +99,42 @@ export default class RedisSocket extends EventEmitter {
this.#options = RedisSocket.#initiateOptions(options);
}
reconnectStrategy(retries: number): number | Error {
if (this.#options.reconnectStrategy) {
#reconnectStrategy(retries: number, cause: Error) {
if (this.#options.reconnectStrategy === false) {
return false;
} else if (typeof this.#options.reconnectStrategy === 'number') {
return this.#options.reconnectStrategy;
} else if (this.#options.reconnectStrategy) {
try {
const retryIn = this.#options.reconnectStrategy(retries);
if (typeof retryIn !== 'number' && !(retryIn instanceof Error)) {
throw new TypeError('Reconnect strategy should return `number | Error`');
const retryIn = this.#options.reconnectStrategy(retries, cause);
if (retryIn !== false && !(retryIn instanceof Error) && typeof retryIn !== 'number') {
throw new TypeError(`Reconnect strategy should return \`false | Error | number\`, got ${retryIn} instead`);
}
return retryIn;
} catch (err) {
this.emit('error', err);
this.emit('error', err);
}
}
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> {
if (this.#isOpen) {
throw new Error('Socket already opened');
@@ -109,13 +144,9 @@ export default class RedisSocket extends EventEmitter {
return this.#connect();
}
async #connect(hadError?: boolean): Promise<void> {
async #connect(): Promise<void> {
let retries = 0;
do {
if (retries > 0 || hadError) {
this.emit('reconnecting');
}
try {
this.#socket = await this.#createSocket();
this.#writableNeedDrain = false;
@@ -131,17 +162,15 @@ export default class RedisSocket extends EventEmitter {
this.#isReady = true;
this.emit('ready');
} catch (err) {
const retryIn = this.reconnectStrategy(retries);
if (retryIn instanceof Error) {
this.#isOpen = false;
this.emit('error', err);
throw new ReconnectStrategyError(retryIn, err);
const retryIn = this.#shouldReconnect(retries++, err as Error);
if (typeof retryIn !== 'number') {
throw retryIn;
}
this.emit('error', err);
await promiseTimeout(retryIn);
this.emit('reconnecting');
}
retries++;
} while (this.#isOpen && !this.#isReady);
}
@@ -200,12 +229,14 @@ export default class RedisSocket extends EventEmitter {
}
#onSocketError(err: Error): void {
const wasReady = this.#isReady;
this.#isReady = false;
this.emit('error', err);
if (!this.#isOpen) return;
this.#connect(true).catch(() => {
if (!wasReady || !this.#isOpen || typeof this.#shouldReconnect(0, err) !== 'number') return;
this.emit('reconnecting');
this.#connect().catch(() => {
// the error was already emitted, silently ignore it
});
}
@@ -261,7 +292,7 @@ export default class RedisSocket extends EventEmitter {
this.#socket.cork();
this.#isCorked = true;
queueMicrotask(() => {
setImmediate(() => {
this.#socket?.uncork();
this.#isCorked = false;
});

View File

@@ -1,23 +1,17 @@
import RedisClient, { InstantiableRedisClient, RedisClientType } from '../client';
import { RedisClusterMasterNode, RedisClusterReplicaNode } from '../commands/CLUSTER_NODES';
import { RedisClusterClientOptions, RedisClusterOptions } from '.';
import { RedisCommandArgument, RedisFunctions, RedisModules, RedisScripts } from '../commands';
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
// function that are exported as 'module.exports = function`, without esModuleInterop
// set to true.
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 {
host: string;
port: number;
@@ -27,133 +21,236 @@ export type NodeAddressMap = {
[address: string]: NodeAddress;
} | ((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,
F extends RedisFunctions,
S extends RedisScripts
> {
master: ClusterNode<M, F, S>;
replicas: Array<ClusterNode<M, F, S>>;
clientIterator: IterableIterator<RedisClientType<M, F, S>> | undefined;
address: string;
client?: ClientOrPromise<M, F, S>;
}
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<
M extends RedisModules,
F extends RedisFunctions,
S extends RedisScripts
> {
static #SLOTS = 16384;
readonly #options: RedisClusterOptions<M, F, S>;
readonly #Client: InstantiableRedisClient<M, F, S>;
readonly #onError: OnError;
readonly #nodeByAddress = new Map<string, ClusterNode<M, F, S>>();
readonly #slots: Array<SlotNodes<M, F, S>> = [];
readonly #emit: EventEmitter['emit'];
slots = new Array<Shard<M, F, S>>(RedisClusterSlots.#SLOTS);
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) {
this.#options = options;
this.#Client = RedisClient.extend(options);
this.#onError = onError;
#isOpen = false;
get isOpen() {
return this.#isOpen;
}
async connect(): Promise<void> {
for (const rootNode of this.#options.rootNodes) {
if (await this.#discoverNodes(rootNode)) return;
constructor(
options: RedisClusterOptions<M, F, S>,
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();
}
async #discoverNodes(clientOptions?: RedisClusterClientOptions): Promise<boolean> {
const client = this.#initiateClient(clientOptions);
#resetSlots() {
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();
try {
await this.#reset(await client.clusterNodes());
return true;
} catch (err) {
this.#onError(err);
return false;
// using `CLUSTER SLOTS` and not `CLUSTER SHARDS` to support older versions
return await client.clusterSlots();
} 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 {
switch (typeof this.#options.nodeAddressMap) {
case 'object':
@@ -164,111 +261,123 @@ export default class RedisClusterSlots<
}
}
#initiateClientForNode(
nodeData: RedisClusterMasterNode | RedisClusterReplicaNode,
readonly: boolean,
clientsInUse: Set<string>,
promises: Array<Promise<void>>
): ClusterNode<M, F, S> {
const address = `${nodeData.host}:${nodeData.port}`;
clientsInUse.add(address);
#clientOptionsDefaults(
options?: RedisClusterClientOptions,
disableReconnect?: boolean
): RedisClusterClientOptions | undefined {
let result: RedisClusterClientOptions | undefined;
if (this.#options.defaults) {
let socket;
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) {
node = {
id: nodeData.id,
client: this.#initiateClient({
socket: this.#getNodeAddress(address) ?? {
host: nodeData.host,
port: nodeData.port
},
readonly
})
id,
host: ip,
port,
address,
readonly,
client: undefined
};
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;
}
getSlotMaster(slot: number): ClusterNode<M, F, S> {
return this.#slots[slot].master;
}
*#slotClientIterator(slotNumber: number): IterableIterator<RedisClientType<M, F, S>> {
const slot = this.#slots[slotNumber];
yield slot.master.client;
for (const replica of slot.replicas) {
yield replica.client;
}
}
#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
async #createClient(
node: ShardNode<M, F, S>,
readonly = node.readonly
) {
const client = new this.#Client(
this.#clientOptionsDefaults({
socket: this.#getNodeAddress(node.address) ?? {
host: node.host,
port: node.port
},
readonly
})
);
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> {
@@ -280,14 +389,233 @@ export default class RedisClusterSlots<
}
async #destroy(fn: (client: RedisClientType<M, F, S>) => Promise<unknown>): Promise<void> {
this.#isOpen = false;
const promises = [];
for (const { client } of this.#nodeByAddress.values()) {
promises.push(fn(client));
for (const { master, replicas } of this.shards) {
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.#slots.splice(0);
this.#resetSlots();
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 HDEL from '../commands/HDEL';
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 HGETALL from '../commands/HGETALL';
import * as HINCRBY from '../commands/HINCRBY';
@@ -60,13 +63,20 @@ import * as HINCRBYFLOAT from '../commands/HINCRBYFLOAT';
import * as HKEYS from '../commands/HKEYS';
import * as HLEN from '../commands/HLEN';
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 from '../commands/HRANDFIELD_COUNT';
import * as HRANDFIELD from '../commands/HRANDFIELD';
import * as HSCAN from '../commands/HSCAN';
import * as HSCAN_NOVALUES from '../commands/HSCAN_NOVALUES';
import * as HSET from '../commands/HSET';
import * as HSETNX from '../commands/HSETNX';
import * as HSTRLEN from '../commands/HSTRLEN';
import * as HTTL from '../commands/HTTL';
import * as HVALS from '../commands/HVALS';
import * as INCR from '../commands/INCR';
import * as INCRBY from '../commands/INCRBY';
@@ -110,6 +120,7 @@ import * as PTTL from '../commands/PTTL';
import * as PUBLISH from '../commands/PUBLISH';
import * as RENAME from '../commands/RENAME';
import * as RENAMENX from '../commands/RENAMENX';
import * as RESTORE from '../commands/RESTORE';
import * as RPOP_COUNT from '../commands/RPOP_COUNT';
import * as RPOP from '../commands/RPOP';
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 from '../commands/SORT';
import * as SPOP from '../commands/SPOP';
import * as SPUBLISH from '../commands/SPUBLISH';
import * as SRANDMEMBER_COUNT from '../commands/SRANDMEMBER_COUNT';
import * as SRANDMEMBER from '../commands/SRANDMEMBER';
import * as SREM from '../commands/SREM';
@@ -319,6 +331,12 @@ export default {
hDel: HDEL,
HEXISTS,
hExists: HEXISTS,
HEXPIRE,
hExpire: HEXPIRE,
HEXPIREAT,
hExpireAt: HEXPIREAT,
HEXPIRETIME,
hExpireTime: HEXPIRETIME,
HGET,
hGet: HGET,
HGETALL,
@@ -333,6 +351,16 @@ export default {
hLen: HLEN,
HMGET,
hmGet: HMGET,
HPERSIST,
hPersist: HPERSIST,
HPEXPIRE,
hpExpire: HPEXPIRE,
HPEXPIREAT,
hpExpireAt: HPEXPIREAT,
HPEXPIRETIME,
hpExpireTime: HPEXPIRETIME,
HPTTL,
hpTTL: HPTTL,
HRANDFIELD_COUNT_WITHVALUES,
hRandFieldCountWithValues: HRANDFIELD_COUNT_WITHVALUES,
HRANDFIELD_COUNT,
@@ -341,12 +369,16 @@ export default {
hRandField: HRANDFIELD,
HSCAN,
hScan: HSCAN,
HSCAN_NOVALUES,
hScanNoValues: HSCAN_NOVALUES,
HSET,
hSet: HSET,
HSETNX,
hSetNX: HSETNX,
HSTRLEN,
hStrLen: HSTRLEN,
HTTL,
hTTL: HTTL,
HVALS,
hVals: HVALS,
INCR,
@@ -433,6 +465,8 @@ export default {
rename: RENAME,
RENAMENX,
renameNX: RENAMENX,
RESTORE,
restore: RESTORE,
RPOP_COUNT,
rPopCount: RPOP_COUNT,
RPOP,
@@ -483,6 +517,8 @@ export default {
sort: SORT,
SPOP,
sPop: SPOP,
SPUBLISH,
sPublish: SPUBLISH,
SRANDMEMBER_COUNT,
sRandMemberCount: SRANDMEMBER_COUNT,
SRANDMEMBER,

View File

@@ -1,25 +1,30 @@
import { strict as assert } from 'assert';
import testUtils, { GLOBAL } from '../test-utils';
import testUtils, { GLOBAL, waitTillBeenCalled } from '../test-utils';
import RedisCluster from '.';
import { ClusterSlotStates } from '../commands/CLUSTER_SETSLOT';
import { commandOptions } from '../command-options';
import { SQUARE_SCRIPT } from '../client/index.spec';
import { RootNodesUnavailableError } from '../errors';
// We need to use 'require', because it's not possible with Typescript to import
// function that are exported as 'module.exports = function`, without esModuleInterop
// set to true.
const calculateSlot = require('cluster-key-slot');
import { spy } from 'sinon';
import { promiseTimeout } from '../utils';
import RedisClient from '../client';
describe('Cluster', () => {
testUtils.testWithCluster('sendCommand', async cluster => {
await cluster.publish('channel', 'message');
await cluster.set('a', 'b');
await cluster.set('a{a}', 'bb');
await cluster.set('aa', 'bb');
await cluster.get('aa');
await cluster.get('aa');
await cluster.get('aa');
await cluster.get('aa');
assert.equal(
await cluster.sendCommand(undefined, true, ['PING']),
'PONG'
);
}, GLOBAL.CLUSTERS.OPEN);
testUtils.testWithCluster('isOpen', async cluster => {
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);
testUtils.testWithCluster('multi', async cluster => {
@@ -64,54 +69,321 @@ describe('Cluster', () => {
});
testUtils.testWithCluster('should handle live resharding', async cluster => {
const key = 'key',
const slot = 12539,
key = 'key',
value = 'value';
await cluster.set(key, value);
const slot = calculateSlot(key),
source = cluster.getSlotMaster(slot),
destination = cluster.getMasters().find(node => node.id !== source.id)!;
const importing = cluster.slots[0].master,
migrating = cluster.slots[slot].master,
[ importingClient, migratingClient ] = await Promise.all([
cluster.nodeClient(importing),
cluster.nodeClient(migrating)
]);
await Promise.all([
source.client.clusterSetSlot(slot, ClusterSlotStates.MIGRATING, destination.id),
destination.client.clusterSetSlot(slot, ClusterSlotStates.IMPORTING, destination.id)
importingClient.clusterSetSlot(slot, ClusterSlotStates.IMPORTING, migrating.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(
await cluster.get(key),
value
);
await Promise.all([
source.client.migrate(
'127.0.0.1',
(<any>destination.client.options).socket.port,
key,
0,
10
)
importingClient.clusterSetSlot(slot, ClusterSlotStates.NODE, importing.id),
migratingClient.clusterSetSlot(slot, ClusterSlotStates.NODE, importing.id),
]);
// should be able to get the key from the destination node using the "ASKING" command
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
// should handle `MOVED` errors
assert.equal(
await cluster.get(key),
value
);
}, {
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 { RedisCommand, RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply, RedisCommandReply, RedisFunctions, RedisModules, RedisExtensions, RedisScript, RedisScripts, RedisCommandSignature, RedisFunction } from '../commands';
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 { EventEmitter } from 'events';
import RedisClusterMultiCommand, { InstantiableRedisClusterMultiCommandType, RedisClusterMultiCommandType } from './multi-command';
import { RedisMultiQueuedCommand } from '../multi-command';
import { PubSubListener } from '../client/pub-sub';
import { ErrorReply } from '../errors';
export type RedisClusterClientOptions = Omit<
RedisClientOptions,
@@ -17,10 +19,34 @@ export interface RedisClusterOptions<
F extends RedisFunctions = Record<string, never>,
S extends RedisScripts = Record<string, never>
> 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>;
/**
* 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>;
/**
* 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;
/**
* The maximum number of times a command will be redirected due to `MOVED` or `ASK` errors.
*/
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;
}
@@ -70,14 +96,44 @@ export default class RedisCluster<
}
readonly #options: RedisClusterOptions<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>;
get isOpen() {
return this.#slots.isOpen;
}
constructor(options: RedisClusterOptions<M, F, S>) {
super();
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);
}
@@ -88,7 +144,7 @@ export default class RedisCluster<
});
}
async connect(): Promise<void> {
connect() {
return this.#slots.connect();
}
@@ -96,11 +152,11 @@ export default class RedisCluster<
command: C,
args: Array<unknown>
): Promise<RedisCommandReply<C>> {
const { args: redisArgs, options } = transformCommandArguments(command, args);
const { jsArgs, args: redisArgs, options } = transformCommandArguments(command, args);
return transformCommandReply(
command,
await this.sendCommand(
RedisCluster.extractFirstKey(command, args, redisArgs),
RedisCluster.extractFirstKey(command, jsArgs, redisArgs),
command.IS_READ_ONLY,
redisArgs,
options
@@ -188,34 +244,33 @@ export default class RedisCluster<
executor: (client: RedisClientType<M, F, S>) => Promise<Reply>
): Promise<Reply> {
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++) {
try {
return await executor(client);
} catch (err) {
if (++i > maxCommandRedirections || !(err instanceof Error)) {
if (++i > maxCommandRedirections || !(err instanceof ErrorReply)) {
throw err;
}
if (err.message.startsWith('ASK')) {
const address = err.message.substring(err.message.lastIndexOf(' ') + 1);
if (this.#slots.getNodeByAddress(address)?.client === client) {
await client.asking();
continue;
let redirectTo = await this.#slots.getMasterByAddress(address);
if (!redirectTo) {
await this.#slots.rediscover(client);
redirectTo = await this.#slots.getMasterByAddress(address);
}
await this.#slots.rediscover(client);
const redirectTo = this.#slots.getNodeByAddress(address);
if (!redirectTo) {
throw new Error(`Cannot find node ${address}`);
}
await redirectTo.client.asking();
client = redirectTo.client;
await redirectTo.asking();
client = redirectTo;
continue;
} else if (err.message.startsWith('MOVED')) {
await this.#slots.rediscover(client);
client = this.#slots.getClient(firstKey, isReadonly);
client = await this.#slots.getClient(firstKey, isReadonly);
continue;
}
@@ -239,14 +294,94 @@ export default class RedisCluster<
multi = this.MULTI;
getMasters(): Array<ClusterNode<M, F, S>> {
return this.#slots.getMasters();
async SUBSCRIBE<T extends boolean = false>(
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> {
return this.#slots.getSlotMaster(slot);
subscribe = this.SUBSCRIBE;
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> {
return this.#slots.quit();
}
@@ -254,6 +389,32 @@ export default class RedisCluster<
disconnect(): Promise<void> {
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({

View File

@@ -120,11 +120,8 @@ export default class RedisClusterMultiCommand {
return this.execAsPipeline();
}
const commands = this.#multi.exec();
if (!commands) return [];
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,
args: Array<unknown>
): {
jsArgs: Array<unknown>;
args: RedisCommandArguments;
options: CommandOptions<T> | undefined;
} {
@@ -118,6 +119,7 @@ export function transformCommandArguments<T = ClientCommandOptions>(
}
return {
jsArgs: args,
args: command.transformArguments(...args),
options
};

View File

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

View File

@@ -1,7 +1,10 @@
import { strict as assert } from 'assert';
import { transformArguments, transformReply } from './CLIENT_INFO';
import testUtils, { GLOBAL } from '../test-utils';
describe('CLIENT INFO', () => {
testUtils.isVersionGreaterThanHook([6, 2]);
it('transformArguments', () => {
assert.deepEqual(
transformArguments(),
@@ -9,34 +12,39 @@ describe('CLIENT INFO', () => {
);
});
it('transformReply', () => {
assert.deepEqual(
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'),
{
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,
qbufFree: 40928,
argvMem: 10,
obl: 0,
oll: 0,
omem: 0,
totMem: 61466,
events: 'r',
cmd: 'client',
user: 'default',
redir: -1
}
);
});
testUtils.testWithClient('client.clientInfo', async client => {
const reply = await client.clientInfo();
assert.equal(typeof reply.id, 'number');
assert.equal(typeof reply.addr, 'string');
assert.equal(typeof reply.laddr, 'string');
assert.equal(typeof reply.fd, 'number');
assert.equal(typeof reply.name, 'string');
assert.equal(typeof reply.age, 'number');
assert.equal(typeof reply.idle, 'number');
assert.equal(typeof reply.flags, 'string');
assert.equal(typeof reply.db, 'number');
assert.equal(typeof reply.sub, 'number');
assert.equal(typeof reply.psub, 'number');
assert.equal(typeof reply.multi, 'number');
assert.equal(typeof reply.qbuf, 'number');
assert.equal(typeof reply.qbufFree, 'number');
assert.equal(typeof reply.argvMem, 'number');
assert.equal(typeof reply.obl, 'number');
assert.equal(typeof reply.oll, 'number');
assert.equal(typeof reply.omem, 'number');
assert.equal(typeof reply.totMem, 'number');
assert.equal(typeof reply.events, 'string');
assert.equal(typeof reply.cmd, 'string');
assert.equal(typeof reply.user, 'string');
assert.equal(typeof reply.redir, 'number');
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> {
return ['CLIENT', 'INFO'];
}
interface ClientInfoReply {
export interface ClientInfoReply {
id: number;
addr: string;
laddr: string;
laddr?: string; // 6.2
fd: number;
name: string;
age: number;
@@ -14,72 +16,79 @@ interface ClientInfoReply {
db: number;
sub: number;
psub: number;
ssub?: number; // 7.0.3
multi: number;
qbuf: number;
qbufFree: number;
argvMem: number;
argvMem?: number; // 6.0
multiMem?: number; // 7.0
obl: number;
oll: number;
omem: number;
totMem: number;
totMem?: number; // 6.0
events: string;
cmd: string;
user: string;
redir: number;
user?: string; // 6.0
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 {
const [
[, id],
[, addr],
[, laddr],
[, fd],
[, name],
[, age],
[, idle],
[, flags],
[, db],
[, sub],
[, psub],
[, multi],
[, qbuf],
[, qbufFree],
[, argvMem],
[, obl],
[, oll],
[, omem],
[, totMem],
[, events],
[, cmd],
[, user],
[, redir]
] = [...reply.matchAll(REGEX)];
export function transformReply(rawReply: string): ClientInfoReply {
const map: Record<string, string> = {};
for (const item of rawReply.matchAll(CLIENT_INFO_REGEX)) {
map[item[1]] = item[2];
}
return {
id: Number(id),
addr,
laddr,
fd: Number(fd),
name,
age: Number(age),
idle: Number(idle),
flags,
db: Number(db),
sub: Number(sub),
psub: Number(psub),
multi: Number(multi),
qbuf: Number(qbuf),
qbufFree: Number(qbufFree),
argvMem: Number(argvMem),
obl: Number(obl),
oll: Number(oll),
omem: Number(omem),
totMem: Number(totMem),
events,
cmd,
user,
redir: Number(redir)
const reply: ClientInfoReply = {
id: Number(map.id),
addr: map.addr,
fd: Number(map.fd),
name: map.name,
age: Number(map.age),
idle: Number(map.idle),
flags: map.flags,
db: Number(map.db),
sub: Number(map.sub),
psub: Number(map.psub),
multi: Number(map.multi),
qbuf: Number(map.qbuf),
qbufFree: Number(map['qbuf-free']),
argvMem: Number(map['argv-mem']),
obl: Number(map.obl),
oll: Number(map.oll),
omem: Number(map.omem),
totMem: Number(map['tot-mem']),
events: map.events,
cmd: map.cmd,
user: map.user,
libName: map['lib-name'],
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', () => {
it('undefined', () => {
assert.deepEqual(

View File

@@ -6,7 +6,8 @@ export enum ClientKillFilters {
ID = 'ID',
TYPE = 'TYPE',
USER = 'USER',
SKIP_ME = 'SKIPME'
SKIP_ME = 'SKIPME',
MAXAGE = 'MAXAGE'
}
interface KillFilter<T extends ClientKillFilters> {
@@ -37,7 +38,11 @@ type KillSkipMe = ClientKillFilters.SKIP_ME | (KillFilter<ClientKillFilters.SKIP
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 {
const args = ['CLIENT', 'KILL'];
@@ -89,6 +94,10 @@ function pushFilter(args: RedisCommandArguments, filter: KillFilters): void {
case ClientKillFilters.SKIP_ME:
args.push(filter.skipMe ? 'yes' : 'no');
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 => {
const client = await cluster.nodeClient(cluster.masters[0]);
assert.equal(
typeof await cluster.getSlotMaster(0).client.clusterBumpEpoch(),
typeof await client.clusterBumpEpoch(),
'string'
);
}, GLOBAL.SERVERS.OPEN);

View File

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

View File

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

View File

@@ -11,7 +11,8 @@ describe('CLUSTER GETKEYSINSLOT', () => {
});
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));
for (const item of reply) {
assert.equal(typeof item, 'string');

View File

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

View File

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

View File

@@ -13,7 +13,8 @@ describe('CLUSTER LINKS', () => {
});
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));
for (const link of links) {
assert.equal(typeof link.direction, 'string');

View File

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

View File

@@ -13,7 +13,7 @@ type ClusterSlotsRawReply = Array<[
...replicas: Array<ClusterSlotsRawNode>
]>;
type ClusterSlotsNode = {
export interface ClusterSlotsNode {
ip: string;
port: number;
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: []
}
);
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);
});

View File

@@ -16,7 +16,7 @@ export function transformArguments(
], cursor, options);
}
type HScanRawReply = [RedisCommandArgument, Array<RedisCommandArgument>];
export type HScanRawReply = [RedisCommandArgument, Array<RedisCommandArgument>];
export interface HScanTuple {
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'),
'string'
);
}, {
serverArguments: testUtils.isVersionGreaterThan([7]) ?
['--enable-debug-command', 'yes'] :
GLOBAL.SERVERS.OPEN.serverArguments
});
}, GLOBAL.SERVERS.OPEN);
});

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

View File

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

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