diff --git a/docker-compose.yaml b/docker-compose.yaml index 76ee255f..ff3a9e53 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,11 @@ services: - redis: + redis-redlock: image: redis:7.4.0-alpine command: redis-server --lazyfree-lazy-user-del yes ports: - "6379:6379" + redis-sesamecare-redlock: + image: redis:7.4.0-alpine + command: redis-server --lazyfree-lazy-user-del yes + ports: + - "6380:6379" diff --git a/package-lock.json b/package-lock.json index e1913e1d..47932161 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,10 @@ "resolved": "packages/redlock", "link": true }, + "node_modules/@anchan828/nest-sesamecare-redlock": { + "resolved": "packages/sesamecare-redlock", + "link": true + }, "node_modules/@babel/code-frame": { "version": "7.24.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", @@ -1250,8 +1254,7 @@ "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", - "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", - "dev": true + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -3159,6 +3162,18 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "dev": true }, + "node_modules/@sesamecare-oss/redlock": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@sesamecare-oss/redlock/-/redlock-1.3.1.tgz", + "integrity": "sha512-HXpio3BlhMsAhhIVKASARmnLDXEpBtjQsys28Nka5QtmhDqf+TGmWjObCQ2u3MeKo6/6O9cfghSHBir0YRDQXQ==", + "license": "UNLICENSED", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "ioredis": ">=5" + } + }, "node_modules/@sigstore/bundle": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.2.0.tgz", @@ -4623,7 +4638,6 @@ "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==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5198,7 +5212,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.1.2" @@ -5293,7 +5306,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "dev": true, "engines": { "node": ">=0.10" } @@ -6992,7 +7004,6 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", - "dev": true, "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", @@ -9323,14 +9334,12 @@ "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true + "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": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "dev": true + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", @@ -9910,8 +9919,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multer": { "version": "1.4.4-lts.1", @@ -11132,7 +11140,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "dev": true, "engines": { "node": ">=4" } @@ -11141,7 +11148,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "dev": true, "dependencies": { "redis-errors": "^1.0.0" }, @@ -11760,8 +11766,7 @@ "node_modules/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==", - "dev": true + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" }, "node_modules/statuses": { "version": "2.0.1", @@ -13111,6 +13116,23 @@ "reflect-metadata": "0.2.2", "rxjs": "7.8.1" } + }, + "packages/sesamecare-redlock": { + "name": "@anchan828/nest-sesamecare-redlock", + "version": "0.2.44", + "license": "MIT", + "dependencies": { + "@sesamecare-oss/redlock": "^1.3.1" + }, + "devDependencies": { + "@nestjs/common": "10.4.4", + "@nestjs/core": "10.4.4", + "@nestjs/platform-express": "10.4.4", + "@nestjs/testing": "10.4.4", + "ioredis": "5.4.1", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.1" + } } } } diff --git a/packages/sesamecare-redlock/.npmignore b/packages/sesamecare-redlock/.npmignore new file mode 100644 index 00000000..69373f34 --- /dev/null +++ b/packages/sesamecare-redlock/.npmignore @@ -0,0 +1,3 @@ +* +!dist/**/* +dist/**/*.tsbuildinfo diff --git a/packages/sesamecare-redlock/.prettierrc.js b/packages/sesamecare-redlock/.prettierrc.js new file mode 100644 index 00000000..f80081cd --- /dev/null +++ b/packages/sesamecare-redlock/.prettierrc.js @@ -0,0 +1,5 @@ +const basePrettierConfig = require("../../.prettierrc"); + +module.exports = { + ...basePrettierConfig, +}; diff --git a/packages/sesamecare-redlock/README.md b/packages/sesamecare-redlock/README.md new file mode 100644 index 00000000..4684695e --- /dev/null +++ b/packages/sesamecare-redlock/README.md @@ -0,0 +1,159 @@ +# @anchan828/nest-sesamecare-redlock + +![npm](https://img.shields.io/npm/v/@anchan828/nest-sesamecare-redlock.svg) +![NPM](https://img.shields.io/npm/l/@anchan828/nest-sesamecare-redlock.svg) + +This is a [Nest](https://github.com/nestjs/nest) implementation of the redlock algorithm for distributed redis locks. + +This package uses [@sesamecare-oss/redlock](https://github.com/sesamecare/redlock). + +> [!NOTE] +> This is one of the solutions to provisionally address the various issues with node-redlock that don't seem likely to be resolved soon. For details, please see https://github.com/anchan828/nest-redlock/pull/723. + +## Installation + +```bash +$ npm i --save @anchan828/nest-sesamecare-redlock ioredis +``` + +## Quick Start + +### 1. Import module + +```ts +import { RedlockModule } from "@anchan828/nest-sesamecare-redlock"; +import Redis from "ioredis"; + +@Module({ + imports: [ + RedlockModule.register({ + // See https://github.com/sesamecare/redlock#configuration + clients: [new Redis({ host: "localhost" })], + settings: { + driftFactor: 0.01, + retryCount: 10, + retryDelay: 200, + retryJitter: 200, + automaticExtensionThreshold: 500, + }, + // Default duratiuon to use with Redlock decorator + duration: 1000, + }), + ], +}) +export class AppModule {} +``` + +### 2. Add `Redlock` decorator + +```ts +import { Redlock } from "@anchan828/nest-sesamecare-redlock"; + +@Injectable() +export class ExampleService { + @Redlock("lock-key") + public async addComment(projectId: number, comment: string): Promise {} +} +``` + +This is complete. redlock is working correctly! +See [node-redlock](https://github.com/sesamecare/redlock) for more information on redlock. + +## Define complex resources (lock keys) + +Using constants causes the same lock key to be used for all calls. Let's reduce the scope a bit more. + +In this example, only certain projects are now locked. + +```ts +import { Redlock } from "@anchan828/nest-sesamecare-redlock"; + +@Injectable() +export class ExampleService { + // The arguments define the class object to which the decorator is being added and the method arguments in order. + @Redlock( + (target: ExampleService, projectId: number, comment: string) => `projects/${projectId}/comments`, + ) + public async addComment(projectId: number, comment: string): Promise {} +} +``` + +Of course, you can lock multiple keys. + +```ts +@Injectable() +export class ExampleService { + @Redlock( + (target: ExampleService, projectId: number, args: Array<{ commentId: number; comment: string }>) => + args.map((arg) => `projects/${projectId}/comments/${arg.commentId}`), + ) + public async updateComments(projectId: number, args: Array<{ commentId: number; comment: string }>): Promise {} +} +``` + +## Using Redlock service + +If you want to use node-redlock as is, use RedlockService. + +```ts +import { RedlockService } from "@anchan828/nest-sesamecare-redlock"; + +@Injectable() +export class ExampleService { + constructor(private readonly redlock: RedlockService) {} + + public async addComment(projectId: number, comment: string): Promise { + await this.redlock.using([`projects/${projectId}/comments`], 5000, (signal) => { + // Do something... + + if (signal.aborted) { + throw signal.error; + } + }); + } +} +``` + +## Using fake RedlockService + +If you do not want to use Redis in your Unit tests, define the fake class as RedlockService. + +```ts +const app = await Test.createTestingModule({ + providers: [TestService, { provide: RedlockService, useClass: FakeRedlockService }], +}).compile(); +``` + +## Troubleshooting + +### Nest can't resolve dependencies of the XXX. Please make sure that the "@redlockService" property is available in the current context. + +This is the error output when using the Redlock decorator without importing the RedlockModule. + +```ts +import { RedlockModule } from "@anchan828/nest-sesamecare-redlock"; +import Redis from "ioredis"; + +@Module({ + imports: [ + RedlockModule.register({ + clients: [new Redis({ host: "localhost" })], + }), + ], +}) +export class AppModule {} +``` + +#### What should I do with Unit tests, I don't want to use Redis. + +Use `FakeRedlockService` class. Register FakeRedlockService with the provider as RedlockService. + +```ts +const app = await Test.createTestingModule({ + providers: [TestService, { provide: RedlockService, useClass: FakeRedlockService }], +}).compile(); +``` + +## License + +[MIT](LICENSE) diff --git a/packages/sesamecare-redlock/jest.config.js b/packages/sesamecare-redlock/jest.config.js new file mode 100644 index 00000000..1a4eea82 --- /dev/null +++ b/packages/sesamecare-redlock/jest.config.js @@ -0,0 +1,4 @@ +const base = require("../../jest.config"); +module.exports = { + ...base, +}; diff --git a/packages/sesamecare-redlock/package.json b/packages/sesamecare-redlock/package.json new file mode 100644 index 00000000..4aac7938 --- /dev/null +++ b/packages/sesamecare-redlock/package.json @@ -0,0 +1,60 @@ +{ + "name": "@anchan828/nest-sesamecare-redlock", + "version": "0.2.44", + "description": "This is a [Nest](https://github.com/nestjs/nest) implementation of the redlock algorithm for distributed redis locks.", + "homepage": "https://github.com/anchan828/nest-redlock#readme", + "bugs": { + "url": "https://github.com/anchan828/nest-redlock/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/anchan828/nest-redlock.git" + }, + "license": "MIT", + "author": "anchan828 ", + "main": "dist/cjs/index.js", + "types": "dist/cjs/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json", + "copy:license": "cp ../../LICENSE ./", + "lint": "TIMING=1 eslint '**/*.ts'", + "lint:fix": "npm run lint -- --fix", + "test": "jest --coverage --runInBand --detectOpenHandles", + "test:debug": "node --inspect-brk ../../node_modules/jest/bin/jest --runInBand --logHeapUsage", + "test:watch": "npm run test -- --watch", + "watch": "tsc --watch" + }, + "dependencies": { + "@sesamecare-oss/redlock": "^1.3.1" + }, + "devDependencies": { + "@nestjs/common": "10.4.4", + "@nestjs/core": "10.4.4", + "@nestjs/platform-express": "10.4.4", + "@nestjs/testing": "10.4.4", + "ioredis": "5.4.1", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.1" + }, + "volta": { + "node": "20.17.0" + }, + "publishConfig": { + "access": "public" + }, + "packageManager": "npm@10.8.3", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + }, + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + } +} diff --git a/packages/sesamecare-redlock/src/index.ts b/packages/sesamecare-redlock/src/index.ts new file mode 100644 index 00000000..9f1ef936 --- /dev/null +++ b/packages/sesamecare-redlock/src/index.ts @@ -0,0 +1,11 @@ +export { Redlock } from "./redlock.decorator"; +export { FakeRedlockService } from "./redlock.fake-service"; +export { + LockedKeysHookArgs, + PreLockedKeysHookArgs, + RedlockKeyFunction, + RedlockModuleOptions, + UnlockedKeysHookArgs, +} from "./redlock.interface"; +export { RedlockModule } from "./redlock.module"; +export { RedlockService } from "./redlock.service"; diff --git a/packages/sesamecare-redlock/src/redlock.constants.ts b/packages/sesamecare-redlock/src/redlock.constants.ts new file mode 100644 index 00000000..afc8b3c9 --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.constants.ts @@ -0,0 +1 @@ +export const DEFAULT_DURATION = 5000; diff --git a/packages/sesamecare-redlock/src/redlock.decorator.spec.ts b/packages/sesamecare-redlock/src/redlock.decorator.spec.ts new file mode 100644 index 00000000..027f5255 --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.decorator.spec.ts @@ -0,0 +1,291 @@ +/*eslint no-async-promise-executor: "off"*/ +import { Test } from "@nestjs/testing"; +import Redis from "ioredis"; +import { setTimeout } from "timers/promises"; +import { Redlock } from "./redlock.decorator"; +import { RedlockModule } from "./redlock.module"; + +describe("Redlock", () => { + let client: Redis; + + beforeEach(async () => { + client = new Redis({ host: "localhost", port: 6380 }); + }); + + it("should throw error - RedlockModule not imported", async () => { + class TestService { + @Redlock("test") + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async testMethod(): Promise {} + } + + await expect( + Test.createTestingModule({ + providers: [TestService], + exports: [TestService], + }).compile(), + ).rejects.toThrowError("Nest can't resolve dependencies of the TestService."); + await client.quit(); + }); + + it("should do nothing - disabled", async () => { + const messages: string[] = []; + + class TestService { + @Redlock("test1") + public async testMethod1(): Promise { + await setTimeout(500); + return messages.push("testMethod1"); + } + + @Redlock("test1") + public async testMethod2(): Promise { + return messages.push("testMethod2"); + } + + @Redlock("test2") + public async testMethod3(): Promise { + return messages.push("testMethod3"); + } + } + + const app = await Test.createTestingModule({ + imports: [ + RedlockModule.register({ + clients: [client], + decoratorEnabled: false, + }), + ], + providers: [TestService], + exports: [TestService], + }).compile(); + + const service = app.get(TestService); + + await expect( + Promise.all([ + service.testMethod1(), + new Promise(async (resolve) => { + // Always ensure that testMethod1 is called first. + await setTimeout(100); + resolve(await service.testMethod2()); + }), + new Promise(async (resolve) => { + // Always ensure that testMethod2 is called second. + await setTimeout(200); + resolve(await service.testMethod3()); + }), + ]), + ).resolves.toEqual([3, 1, 2]); + + expect(messages).toEqual(["testMethod2", "testMethod3", "testMethod1"]); + + await app.close(); + }); + + it("should added messages in the correct order - single key", async () => { + const messages: string[] = []; + + class TestService { + @Redlock("test1") + public async testMethod1(): Promise { + await setTimeout(500); + return messages.push("testMethod1"); + } + + @Redlock("test1") + public async testMethod2(): Promise { + return messages.push("testMethod2"); + } + + @Redlock("test2") + public async testMethod3(): Promise { + return messages.push("testMethod3"); + } + } + + const app = await Test.createTestingModule({ + imports: [ + RedlockModule.register({ + clients: [client], + }), + ], + providers: [TestService], + exports: [TestService], + }).compile(); + + const service = app.get(TestService); + + await expect( + Promise.all([ + service.testMethod1(), + new Promise(async (resolve) => { + // Always ensure that testMethod1 is called first. + await setTimeout(100); + resolve(await service.testMethod2()); + }), + new Promise(async (resolve) => { + // Always ensure that testMethod2 is called second. + await setTimeout(200); + resolve(await service.testMethod3()); + }), + ]), + ).resolves.toEqual([2, 3, 1]); + + expect(messages).toEqual(["testMethod3", "testMethod1", "testMethod2"]); + + await app.close(); + }); + + it("should added messages in the correct order - multiple key", async () => { + const messages: Array<{ id: number; text: string }> = []; + + class TestService { + @Redlock((target: TestService, args: Array<{ id: number; text: string }>) => + args.map((arg) => `keys/${arg.id}`), + ) + public async testMethod(args: Array<{ id: number; text: string }>, delay = 0): Promise { + await setTimeout(delay); + return messages.push(...args); + } + } + + const app = await Test.createTestingModule({ + imports: [ + RedlockModule.register({ + clients: [client], + }), + ], + providers: [TestService], + exports: [TestService], + }).compile(); + + const service = app.get(TestService); + + await expect( + Promise.all([ + service.testMethod( + [ + { id: 1, text: "text1" }, + { id: 2, text: "text2" }, + ], + 1000, + ), + new Promise(async (resolve) => { + await setTimeout(100); + resolve( + await service.testMethod([ + { id: 1, text: "text3" }, + { id: 2, text: "text4" }, + ]), + ); + }), + new Promise(async (resolve) => { + await setTimeout(200); + resolve( + await service.testMethod([ + { id: 3, text: "text5" }, + { id: 4, text: "text6" }, + ]), + ); + }), + ]), + ).resolves.toEqual([4, 6, 2]); + + expect(messages).toEqual([ + { id: 3, text: "text5" }, + { id: 4, text: "text6" }, + { id: 1, text: "text1" }, + { id: 2, text: "text2" }, + { id: 1, text: "text3" }, + { id: 2, text: "text4" }, + ]); + + await app.close(); + }); + + it("should call hooks", async () => { + const messages: string[] = []; + + class TestService { + @Redlock("test1") + public async testMethod1(): Promise { + await setTimeout(500); + return messages.push("testMethod1"); + } + + @Redlock("test2") + public async testMethod2(): Promise { + return messages.push("testMethod2"); + } + + @Redlock("test3") + public async testMethod3(): Promise { + return messages.push("testMethod3"); + } + } + + const lockedKeysHook = jest.fn(); + const preLockKeysHook = jest.fn(); + const unlockedKeysHook = jest.fn(); + + const app = await Test.createTestingModule({ + imports: [ + RedlockModule.register({ + clients: [client], + decoratorHooks: { + lockedKeys: lockedKeysHook, + preLockKeys: preLockKeysHook, + unlockedKeys: unlockedKeysHook, + }, + }), + ], + providers: [TestService], + exports: [TestService], + }).compile(); + + const service = app.get(TestService); + + await expect( + Promise.all([ + service.testMethod1(), + service.testMethod1(), + new Promise(async (resolve) => { + // Always ensure that testMethod1 is called first. + await setTimeout(100); + resolve(await service.testMethod2()); + }), + new Promise(async (resolve) => { + // Always ensure that testMethod2 is called second. + await setTimeout(200); + resolve(await service.testMethod3()); + }), + ]), + ).resolves.toEqual([3, 4, 1, 2]); + + expect(messages).toEqual(["testMethod2", "testMethod3", "testMethod1", "testMethod1"]); + + expect(preLockKeysHook.mock.calls).toEqual([ + [{ duration: 5000, keys: ["test1"] }], + [{ duration: 5000, keys: ["test1"] }], + [{ duration: 5000, keys: ["test2"] }], + [{ duration: 5000, keys: ["test3"] }], + ]); + + expect(lockedKeysHook.mock.calls).toEqual([ + [{ duration: 5000, elapsedTime: expect.any(Number), keys: ["test1"] }], + [{ duration: 5000, elapsedTime: expect.any(Number), keys: ["test2"] }], + [{ duration: 5000, elapsedTime: expect.any(Number), keys: ["test3"] }], + [{ duration: 5000, elapsedTime: expect.any(Number), keys: ["test1"] }], + ]); + + expect(unlockedKeysHook.mock.calls).toEqual([ + [{ duration: 5000, elapsedTime: expect.any(Number), keys: ["test2"] }], + [{ duration: 5000, elapsedTime: expect.any(Number), keys: ["test3"] }], + [{ duration: 5000, elapsedTime: expect.any(Number), keys: ["test1"] }], + [{ duration: 5000, elapsedTime: expect.any(Number), keys: ["test1"] }], + ]); + + await app.close(); + }); +}); diff --git a/packages/sesamecare-redlock/src/redlock.decorator.ts b/packages/sesamecare-redlock/src/redlock.decorator.ts new file mode 100644 index 00000000..4be9c966 --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.decorator.ts @@ -0,0 +1,77 @@ +import { Inject } from "@nestjs/common"; +import { RedlockAbortSignal, Settings } from "@sesamecare-oss/redlock"; +import { DEFAULT_DURATION } from "./redlock.constants"; +import { RedlockKeyFunction } from "./redlock.interface"; +import { RedlockService } from "./redlock.service"; + +export function Redlock any = (...args: any) => any>( + key: string | string[] | RedlockKeyFunction, + duration?: number, + settings: Partial = {}, +): MethodDecorator { + const injectRedlockService = Inject(RedlockService); + + return (target: object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor) => { + const serviceSymbol = "@redlockService"; + + injectRedlockService(target, serviceSymbol); + + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const descriptorThis = this; + const redlockService = (descriptorThis as any)[serviceSymbol] as RedlockService; + + if (redlockService?.options?.decoratorEnabled !== undefined && !redlockService.options.decoratorEnabled) { + return await originalMethod.apply(descriptorThis, args); + } + + const keys = getKeys(key, descriptorThis, args); + const useDuration = duration || redlockService.options?.duration || DEFAULT_DURATION; + + await redlockService.options?.decoratorHooks?.preLockKeys?.({ keys, duration: useDuration }); + const startTime = Date.now(); + return await redlockService + .using(keys, useDuration, settings, async (signal: RedlockAbortSignal) => { + if (signal.aborted) { + throw signal.error; + } + + await redlockService.options?.decoratorHooks?.lockedKeys?.({ + keys, + duration: useDuration, + elapsedTime: Date.now() - startTime, + }); + + const result = await originalMethod.apply(descriptorThis, args); + + return result; + }) + .finally(async () => { + await redlockService.options?.decoratorHooks?.unlockedKeys?.({ + keys, + duration: useDuration, + elapsedTime: Date.now() - startTime, + }); + }); + }; + return descriptor; + }; +} + +function getKeys( + key: string | string[] | RedlockKeyFunction, + descriptorThis: TypedPropertyDescriptor, + args: any[], +): string[] { + const keys = new Set(); + if (typeof key === "string") { + keys.add(key); + } else if (Array.isArray(key)) { + key.forEach((k) => keys.add(k)); + } else if (typeof key === "function") { + [key(descriptorThis, ...args)].flat().forEach((k) => keys.add(k)); + } + return Array.from(keys); +} diff --git a/packages/sesamecare-redlock/src/redlock.fake-service.spec.ts b/packages/sesamecare-redlock/src/redlock.fake-service.spec.ts new file mode 100644 index 00000000..59c8319b --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.fake-service.spec.ts @@ -0,0 +1,69 @@ +import { Test } from "@nestjs/testing"; +import { Redlock } from "./redlock.decorator"; +import { FakeRedlockService } from "./redlock.fake-service"; +import { RedlockService } from "./redlock.service"; + +describe("FakeRedlockService", () => { + it("should set fake for unit testing", async () => { + class TestService { + @Redlock("test") + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async testMethod(): Promise {} + } + + const app = await Test.createTestingModule({ + providers: [TestService, { provide: RedlockService, useClass: FakeRedlockService }], + exports: [RedlockService], + }).compile(); + + expect(app).toBeDefined(); + + const service = app.get(RedlockService); + + { + // quit + await service.quit(); + } + + { + // acquire + let lock = await service.acquire(["a"], 5000); + // Do something... + + // Extend the lock. Note that this returns a new `Lock` instance. + lock = await lock.extend(5000); + + // Do something else... + + // Release the lock. + await lock.release(); + } + + { + // release + const lock = await service.acquire(["a"], 5000); + await service.release(lock); + } + + { + // extend + const lock = await service.acquire(["a"], 5000); + await lock.extend(5000); + } + + { + // using + await expect( + service.using([], 1000, async () => { + return "ok"; + }), + ).resolves.toEqual("ok"); + + await expect( + service.using([], 1000, {}, async () => { + return "ok"; + }), + ).resolves.toEqual("ok"); + } + }); +}); diff --git a/packages/sesamecare-redlock/src/redlock.fake-service.ts b/packages/sesamecare-redlock/src/redlock.fake-service.ts new file mode 100644 index 00000000..e58d65d3 --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.fake-service.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { ExecutionResult, Lock, RedlockAbortSignal, Settings } from "@sesamecare-oss/redlock"; +import { EventEmitter } from "events"; + +export class FakeRedlockService extends EventEmitter { + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async quit(): Promise {} + + public async acquire(keys: string[], duration: number, settings?: Partial | undefined): Promise { + return createLockFake(); + } + + public async release(lock: Lock, settings?: Partial | undefined): Promise { + return { attempts: [], start: Date.now() }; + } + + public async extend(existing: Lock, duration: number, settings?: Partial | undefined): Promise { + return createLockFake(); + } + + public async using( + keys: string[], + duration: number, + settings: Partial, + routine?: ((signal: RedlockAbortSignal) => Promise) | undefined, + ): Promise; + + public async using( + keys: string[], + duration: number, + routine: (signal: RedlockAbortSignal) => Promise, + ): Promise; + + public async using( + keys: unknown, + duration: unknown, + settingsOrRoutine: unknown, + routine?: (signal: RedlockAbortSignal) => Promise, + ): Promise { + const routineFunc = typeof settingsOrRoutine === "function" ? settingsOrRoutine : routine; + return await routineFunc?.({ aborted: false } as RedlockAbortSignal); + } +} + +function createLockFake(): Lock { + let lock: Lock; + + // eslint-disable-next-line prefer-const + lock = { + release: async (): Promise => ({ attempts: [], start: Date.now() }), + extend: async (duration: number): Promise => lock, + } as Lock; + + return lock; +} diff --git a/packages/sesamecare-redlock/src/redlock.interface.ts b/packages/sesamecare-redlock/src/redlock.interface.ts new file mode 100644 index 00000000..4784f7f6 --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.interface.ts @@ -0,0 +1,52 @@ +import Redis, { Cluster } from "ioredis"; +import { Settings } from "@sesamecare-oss/redlock"; + +export type PreLockedKeysHookArgs = { keys: string[]; duration: number }; +export type LockedKeysHookArgs = { keys: string[]; duration: number; elapsedTime: number }; +export type UnlockedKeysHookArgs = { keys: string[]; duration: number; elapsedTime: number }; + +export type RedlockModuleOptions = { + clients: Iterable; + + /** + * Default: true + * Used only with @Redlock decorator. + */ + decoratorEnabled?: boolean; + + settings?: Partial; + scripts?: { + readonly acquireScript?: string | ((script: string) => string); + readonly extendScript?: string | ((script: string) => string); + readonly releaseScript?: string | ((script: string) => string); + }; + /** + * Hooks called when using @Redlock decorator. + */ + decoratorHooks?: { + /** + * Called before redlock.using + */ + readonly preLockKeys?: (args: PreLockedKeysHookArgs) => void | Promise; + /** + * Called first when the redlock.using callback is invoked. + */ + readonly lockedKeys?: (args: LockedKeysHookArgs) => void | Promise; + /** + * Called after when the redlock.using callback is finished. + */ + readonly unlockedKeys?: (args: UnlockedKeysHookArgs) => void | Promise; + }; + + /** + * Default duratiuon to use with Redlock decorator + * + * @type {number} + */ + duration?: number; +}; + +export type RedlockKeyFunction any = (...args: any) => any> = ( + target: any, + ...args: Parameters +) => string[] | string; diff --git a/packages/sesamecare-redlock/src/redlock.module-definition.ts b/packages/sesamecare-redlock/src/redlock.module-definition.ts new file mode 100644 index 00000000..fe542cdd --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.module-definition.ts @@ -0,0 +1,5 @@ +import { ConfigurableModuleBuilder } from "@nestjs/common"; +import { RedlockModuleOptions } from "./redlock.interface"; + +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = + new ConfigurableModuleBuilder().build(); diff --git a/packages/sesamecare-redlock/src/redlock.module.spec.ts b/packages/sesamecare-redlock/src/redlock.module.spec.ts new file mode 100644 index 00000000..e3c9a598 --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.module.spec.ts @@ -0,0 +1,70 @@ +import { Global, Module } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import Redis from "ioredis"; +import { RedlockModule } from "./redlock.module"; +import { RedlockService } from "./redlock.service"; + +describe("RedlockModule", () => { + let client: Redis; + + beforeEach(async () => { + client = new Redis({ host: "localhost", port: 6380 }); + await client.flushdb(); + }); + + describe("register", () => { + it("should compile", async () => { + const app = await Test.createTestingModule({ + imports: [ + RedlockModule.register({ + clients: [client], + duration: 1000, + }), + ], + }).compile(); + expect(app).toBeDefined(); + expect(app.get(RedlockService)).toBeDefined(); + await app.close(); + }); + }); + + describe("registerAsync", () => { + it("should compile", async () => { + const app = await Test.createTestingModule({ + imports: [ + RedlockModule.registerAsync({ + useFactory: () => ({ + clients: [client], + duration: 1000, + }), + }), + ], + }).compile(); + expect(app).toBeDefined(); + expect(app.get(RedlockService)).toBeDefined(); + await app.close(); + }); + }); + + describe("use global scope", () => { + it("should compile", async () => { + @Global() + @Module({ + imports: [ + RedlockModule.register({ + clients: [client], + duration: 1000, + }), + ], + }) + class AppModule {} + + const app = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + expect(app).toBeDefined(); + expect(app.get(RedlockService)).toBeDefined(); + await app.close(); + }); + }); +}); diff --git a/packages/sesamecare-redlock/src/redlock.module.ts b/packages/sesamecare-redlock/src/redlock.module.ts new file mode 100644 index 00000000..99abd41d --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.module.ts @@ -0,0 +1,35 @@ +import { Inject, Module, OnApplicationShutdown } from "@nestjs/common"; +import { RedlockModuleOptions } from "./redlock.interface"; +import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } from "./redlock.module-definition"; +import { RedlockService } from "./redlock.service"; + +@Module({ + providers: [ + { + provide: RedlockService, + inject: [MODULE_OPTIONS_TOKEN], + useFactory: (options: RedlockModuleOptions) => new RedlockService(options), + }, + ], + exports: [RedlockService], +}) +export class RedlockModule extends ConfigurableModuleClass implements OnApplicationShutdown { + constructor(@Inject(MODULE_OPTIONS_TOKEN) private readonly options: RedlockModuleOptions) { + super(); + } + + public async onApplicationShutdown(): Promise { + for (const client of this.options.clients) { + switch (client.status) { + case "end": + continue; + case "ready": + await client.quit(); + break; + default: + client.disconnect(); + break; + } + } + } +} diff --git a/packages/sesamecare-redlock/src/redlock.service.spec.ts b/packages/sesamecare-redlock/src/redlock.service.spec.ts new file mode 100644 index 00000000..60a1f05a --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.service.spec.ts @@ -0,0 +1,149 @@ +/*eslint no-async-promise-executor: "off"*/ +import { Injectable } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import Redis from "ioredis"; +import { setTimeout } from "timers/promises"; +import { RedlockModule } from "./redlock.module"; +import { RedlockService } from "./redlock.service"; + +describe("RedlockService", () => { + let client: Redis; + + beforeEach(async () => { + client = new Redis({ host: "localhost", port: 6380 }); + await client.flushdb(); + }); + + it("should added messages in the correct order - single key", async () => { + const messages: string[] = []; + + @Injectable() + class TestService { + constructor(private readonly redlock: RedlockService) {} + + public async testMethod1(): Promise { + return await this.redlock.using(["test1"], 5000, async () => { + await setTimeout(500); + return messages.push("testMethod1"); + }); + } + + public async testMethod2(): Promise { + return await this.redlock.using(["test1"], 5000, async () => { + return messages.push("testMethod2"); + }); + } + + public async testMethod3(): Promise { + return await this.redlock.using(["test2"], 5000, async () => { + return messages.push("testMethod3"); + }); + } + } + + const app = await Test.createTestingModule({ + imports: [ + RedlockModule.register({ + clients: [client], + }), + ], + providers: [TestService], + exports: [TestService], + }).compile(); + + const service = app.get(TestService); + + await expect( + Promise.all([ + service.testMethod1(), + new Promise(async (resolve) => { + // Always ensure that testMethod1 is called first. + await setTimeout(100); + resolve(await service.testMethod2()); + }), + new Promise(async (resolve) => { + // Always ensure that testMethod2 is called second. + await setTimeout(200); + resolve(await service.testMethod3()); + }), + ]), + ).resolves.toEqual([2, 3, 1]); + + expect(messages).toEqual(["testMethod3", "testMethod1", "testMethod2"]); + + await app.close(); + }); + + it("should added messages in the correct order - multiple key", async () => { + const messages: Array<{ id: number; text: string }> = []; + + @Injectable() + class TestService { + constructor(private readonly redlock: RedlockService) {} + + public async testMethod(args: Array<{ id: number; text: string }>, delay = 0): Promise { + return await this.redlock.using( + args.map((arg) => `keys/${arg.id}`), + 5000, + async () => { + await setTimeout(delay); + return messages.push(...args); + }, + ); + } + } + + const app = await Test.createTestingModule({ + imports: [ + RedlockModule.register({ + clients: [client], + }), + ], + providers: [TestService], + exports: [TestService], + }).compile(); + + const service = app.get(TestService); + + await expect( + Promise.all([ + service.testMethod( + [ + { id: 1, text: "text1" }, + { id: 2, text: "text2" }, + ], + 1000, + ), + new Promise(async (resolve) => { + await setTimeout(100); + resolve( + await service.testMethod([ + { id: 1, text: "text3" }, + { id: 2, text: "text4" }, + ]), + ); + }), + new Promise(async (resolve) => { + await setTimeout(200); + resolve( + await service.testMethod([ + { id: 3, text: "text5" }, + { id: 4, text: "text6" }, + ]), + ); + }), + ]), + ).resolves.toEqual([4, 6, 2]); + + expect(messages).toEqual([ + { id: 3, text: "text5" }, + { id: 4, text: "text6" }, + { id: 1, text: "text1" }, + { id: 2, text: "text2" }, + { id: 1, text: "text3" }, + { id: 2, text: "text4" }, + ]); + + await app.close(); + }); +}); diff --git a/packages/sesamecare-redlock/src/redlock.service.ts b/packages/sesamecare-redlock/src/redlock.service.ts new file mode 100644 index 00000000..865c6326 --- /dev/null +++ b/packages/sesamecare-redlock/src/redlock.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { Redlock } from "@sesamecare-oss/redlock"; +import { RedlockModuleOptions } from "./redlock.interface"; +import { MODULE_OPTIONS_TOKEN } from "./redlock.module-definition"; + +@Injectable() +export class RedlockService extends Redlock { + constructor(@Inject(MODULE_OPTIONS_TOKEN) public readonly options: RedlockModuleOptions) { + super(options.clients, options.settings); + } +} diff --git a/packages/sesamecare-redlock/tsconfig.cjs.json b/packages/sesamecare-redlock/tsconfig.cjs.json new file mode 100644 index 00000000..4bca5e83 --- /dev/null +++ b/packages/sesamecare-redlock/tsconfig.cjs.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist/cjs" + }, + "exclude": [ + "node_modules", + "**/*spec.ts", + "dist" + ] +} \ No newline at end of file diff --git a/packages/sesamecare-redlock/tsconfig.esm.json b/packages/sesamecare-redlock/tsconfig.esm.json new file mode 100644 index 00000000..cc7fde40 --- /dev/null +++ b/packages/sesamecare-redlock/tsconfig.esm.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "outDir": "dist/esm" + }, + "exclude": [ + "node_modules", + "**/*spec.ts", + "dist" + ] +} \ No newline at end of file diff --git a/packages/sesamecare-redlock/tsconfig.json b/packages/sesamecare-redlock/tsconfig.json new file mode 100644 index 00000000..ef45d973 --- /dev/null +++ b/packages/sesamecare-redlock/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "moduleResolution": "node" + }, + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file