diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4410412 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5054bc..c81e5f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,10 @@ jobs: - name: Run tests run: npm run test:coverage + env: + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: Test code compilation run: npm run build @@ -51,6 +55,10 @@ jobs: - name: Run tests run: npm run test:coverage + env: + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: Test code compilation run: npm run build diff --git a/configurer/index.ts b/configurer/index.ts index f17bf72..b496be4 100644 --- a/configurer/index.ts +++ b/configurer/index.ts @@ -39,6 +39,10 @@ export default class QueueConfigurer extends BaseConfigurer { return new File('./queue').copy(Path.config(`queue.${Path.ext()}`)) }) + task.addPromise(`Create worker.${Path.ext()} config file`, () => { + return new File('./worker').copy(Path.config(`worker.${Path.ext()}`)) + }) + task.addPromise('Update commands of .athennarc.json', () => { return this.rc .setTo( diff --git a/configurer/migration b/configurer/migration index 3f08ada..808d926 100644 --- a/configurer/migration +++ b/configurer/migration @@ -6,12 +6,12 @@ export class CreateJobsTable extends BaseMigration { public async up(db: DatabaseImpl) { return db.createTable(this.tableName, builder => { builder.increments('id') - builder.string('queue').notNullable() - builder.string('formerQueue').nullable() + builder.string('queue').notNullable().index() builder.string('data').notNullable() - builder.integer('attemptsLeft').defaultTo(1) - builder.enu('status', ['pending', 'processing']).defaultTo('pending') - builder.timestamps(true, true, true) + builder.tinyint('attempts').defaultTo(1).unsigned() + builder.integer('availableAt').nullable().unsigned() + builder.integer('reservedUntil').nullable().unsigned() + builder.integer('createdAt').nullable().unsigned() }) } diff --git a/configurer/queue b/configurer/queue index 12a044f..990da5e 100644 --- a/configurer/queue +++ b/configurer/queue @@ -12,7 +12,7 @@ export default { | */ - default: Env('QUEUE_CONNECTION', 'vanilla'), + default: Env('QUEUE_CONNECTION', 'memory'), /* |-------------------------------------------------------------------------- @@ -23,14 +23,21 @@ export default { | is used by your application. A default configuration has been added | for each back-end shipped with Athenna. You are free to add more. | - | Drivers: "vanilla", "database", "fake" + | Drivers: "memory", "database", "awsSqs", "fake" | */ connections: { - vanilla: { - driver: 'vanilla', - queue: 'default', + memory: { + driver: 'memory', + queue: 'queue_name', + deadletter: 'deadletter_queue_name', + attempts: 3 + }, + + awsSqs: { + driver: 'aws_sqs', + queue: 'queue_name', deadletter: 'deadletter_queue_name', attempts: 3 }, @@ -39,7 +46,7 @@ export default { driver: 'database', table: 'jobs', connection: 'postgres', - queue: 'default', + queue: 'queue_name', deadletter: 'deadletter_queue_name', attempts: 3 }, diff --git a/configurer/worker b/configurer/worker new file mode 100644 index 0000000..62f3e92 --- /dev/null +++ b/configurer/worker @@ -0,0 +1,32 @@ +export default { + /* + |-------------------------------------------------------------------------- + | Configurations for cls-rtracer plugin. + |-------------------------------------------------------------------------- + | + | This values defines all the configurations for cls-rtracer plugins. Check + | the documentation for more information: + | + | https://github.com/puzpuzpuz/cls-rtracer + | + */ + + rTracer: { + enabled: true + }, + + /* + |-------------------------------------------------------------------------- + | Log worker tasks + |-------------------------------------------------------------------------- + | + | This value defines if WorkerKernel will register a Logger to log all the + | worker tasks. + | + */ + + logger: { + enabled: Env('LOG_WORKER', true), + prettifyException: Env('LOG_PRETTY', true) + }, +} diff --git a/package-lock.json b/package-lock.json index 08542ad..6a51157 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "@athenna/queue", - "version": "5.9.0", + "version": "5.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/queue", - "version": "5.9.0", + "version": "5.10.0", "license": "MIT", + "dependencies": { + "@aws-sdk/client-sqs": "^3.859.0" + }, "devDependencies": { "@athenna/artisan": "^5.7.0", "@athenna/common": "^5.14.0", @@ -206,6 +209,131 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-lambda-powertools/commons": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/@aws-lambda-powertools/commons/-/commons-1.18.1.tgz", @@ -234,6 +362,523 @@ } } }, + "node_modules/@aws-sdk/client-sqs": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.859.0.tgz", + "integrity": "sha512-u019wKVqtk6RxINTaTCpV0p7o5cun0aZSlhs1JNYGFHtSRUjxqbhRIMzkTZgUkABUwkbmPg4k72YlE/N17zdSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/credential-provider-node": "3.859.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-sdk-sqs": "3.857.0", + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.858.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/md5-js": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.858.0.tgz", + "integrity": "sha512-iXuZQs4KH6a3Pwnt0uORalzAZ5EXRPr3lBYAsdNwkP8OYyoUz5/TE3BLyw7ceEh0rj4QKGNnNALYo1cDm0EV8w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.858.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.858.0.tgz", + "integrity": "sha512-iWm4QLAS+/XMlnecIU1Y33qbBr1Ju+pmWam3xVCPlY4CSptKpVY+2hXOnmg9SbHAX9C005fWhrIn51oDd00c9A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.7.2", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.858.0.tgz", + "integrity": "sha512-kZsGyh2BoSRguzlcGtzdLhw/l/n3KYAC+/l/H0SlsOq3RLHF6tO/cRdsLnwoix2bObChHUp03cex63o1gzdx/Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.858.0.tgz", + "integrity": "sha512-GDnfYl3+NPJQ7WQQYOXEA489B212NinpcIDD7rpsB6IWUPo8yDjT5NceK4uUkIR3MFpNCGt9zd/z6NNLdB2fuQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.859.0.tgz", + "integrity": "sha512-KsccE1T88ZDNhsABnqbQj014n5JMDilAroUErFbGqu5/B3sXqUsYmG54C/BjvGTRUFfzyttK9lB9P9h6ddQ8Cw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/credential-provider-env": "3.858.0", + "@aws-sdk/credential-provider-http": "3.858.0", + "@aws-sdk/credential-provider-process": "3.858.0", + "@aws-sdk/credential-provider-sso": "3.859.0", + "@aws-sdk/credential-provider-web-identity": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.859.0.tgz", + "integrity": "sha512-ZRDB2xU5aSyTR/jDcli30tlycu6RFvQngkZhBs9Zoh2BiYXrfh2MMuoYuZk+7uD6D53Q2RIEldDHR9A/TPlRuA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.858.0", + "@aws-sdk/credential-provider-http": "3.858.0", + "@aws-sdk/credential-provider-ini": "3.859.0", + "@aws-sdk/credential-provider-process": "3.858.0", + "@aws-sdk/credential-provider-sso": "3.859.0", + "@aws-sdk/credential-provider-web-identity": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.858.0.tgz", + "integrity": "sha512-l5LJWZJMRaZ+LhDjtupFUKEC5hAjgvCRrOvV5T60NCUBOy0Ozxa7Sgx3x+EOwiruuoh3Cn9O+RlbQlJX6IfZIw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.859.0.tgz", + "integrity": "sha512-BwAqmWIivhox5YlFRjManFF8GoTvEySPk6vsJNxDsmGsabY+OQovYxFIYxRCYiHzH7SFjd4Lcd+riJOiXNsvRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.858.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/token-providers": "3.859.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.858.0.tgz", + "integrity": "sha512-8iULWsH83iZDdUuiDsRb83M0NqIlXjlDbJUIddVsIrfWp4NmanKw77SV6yOZ66nuJjPsn9j7RDb9bfEPCy5SWA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.840.0.tgz", + "integrity": "sha512-ub+hXJAbAje94+Ya6c6eL7sYujoE8D4Bumu1NUI8TXjUhVVn0HzVWQjpRLshdLsUp1AW7XyeJaxyajRaJQ8+Xg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.840.0.tgz", + "integrity": "sha512-lSV8FvjpdllpGaRspywss4CtXV8M7NNNH+2/j86vMH+YCOZ6fu2T/TyFd/tHwZ92vDfHctWkRbQxg0bagqwovA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.840.0.tgz", + "integrity": "sha512-Gu7lGDyfddyhIkj1Z1JtrY5NHb5+x/CRiB87GjaSrKxkDaydtX2CU977JIABtt69l9wLbcGDIQ+W0uJ5xPof7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-sqs": { + "version": "3.857.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.857.0.tgz", + "integrity": "sha512-m2CyA7Qhesqkx5esqkwYsb6M16Ny5HX/x38A1/qCdAxYAdkmD3mZoHaXHSb4L6kg/HCPkbxxZO8db1xm4P8t1w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.858.0.tgz", + "integrity": "sha512-pC3FT/sRZ6n5NyXiTVu9dpf1D9j3YbJz3XmeOOwJqO/Mib2PZyIQktvNMPgwaC5KMVB1zWqS5bmCwxpMOnq0UQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@smithy/core": "^3.7.2", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.858.0.tgz", + "integrity": "sha512-ChdIj80T2whoWbovmO7o8ICmhEB2S9q4Jes9MBnKAPm69PexcJAK2dQC8yI4/iUP8b3+BHZoUPrYLWjBxIProQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.858.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.858.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.2", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-retry": "^4.1.18", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.9", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.25", + "@smithy/util-defaults-mode-node": "^4.0.25", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.840.0.tgz", + "integrity": "sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.859.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.859.0.tgz", + "integrity": "sha512-6P2wlvm9KBWOvRNn0Pt8RntnXg8fzOb5kEShvWsOsAocZeqKNaYbihum5/Onq1ZPoVtkdb++8eWDocDnM4k85Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.858.0", + "@aws-sdk/nested-clients": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.840.0.tgz", + "integrity": "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.848.0.tgz", + "integrity": "sha512-fY/NuFFCq/78liHvRyFKr+aqq1aA/uuVSANjzr5Ym8c+9Z3HRPE9OrExAHoMrZ6zC8tHerQwlsXYYH5XZ7H+ww==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.804.0.tgz", + "integrity": "sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.840.0.tgz", + "integrity": "sha512-JdyZM3EhhL4PqwFpttZu1afDpPJCCc3eyZOLi+srpX11LsGj6sThf47TYQN75HT1CarZ7cCdQHGzP2uy3/xHfQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.858.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.858.0.tgz", + "integrity": "sha512-T1m05QlN8hFpx5/5duMjS8uFSK5e6EXP45HQRkZULVkL3DK+jMaxsnh3KLl5LjUoHn/19M4HM0wNUBhYp4Y2Yw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.858.0", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.821.0.tgz", + "integrity": "sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1215,240 +1860,850 @@ "dev": true, "license": "MIT", "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@poppinss/cliui/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/@poppinss/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@poppinss/cliui/node_modules/supports-color": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.0.0.tgz", + "integrity": "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/cliui/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.5.tgz", + "integrity": "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.2.tgz", + "integrity": "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@poppinss/hooks": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/@poppinss/hooks/-/hooks-7.2.6.tgz", + "integrity": "sha512-+bZhb1CrIvhgnypjE0W/NZVkRnRDZL37HDDI6zvIo8h3PVs1lKj5Dyl54V/EpU6SFSZAS5dilgZ0V7zhjyJMgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@poppinss/inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@poppinss/inspect/-/inspect-1.0.1.tgz", + "integrity": "sha512-kLeEaBSGhlleyYvKc7c9s3uE6xv7cwyulE0EgHf4jU/CL96h0yC4mkdw1wvC1l1PYYQozCGy46FwMBAAMOobCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@poppinss/macroable": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@poppinss/macroable/-/macroable-1.0.5.tgz", + "integrity": "sha512-6u61y1HHd090MEk1Av0/1btDmm2Hh/+XoJj+HgFYRh9koUPI822ybJbwLHuqjLNCiY+o1gRykg2igEqOf/VBZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@poppinss/object-builder": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@poppinss/object-builder/-/object-builder-1.1.0.tgz", + "integrity": "sha512-FOrOq52l7u8goR5yncX14+k+Ewi5djnrt1JwXeS/FvnwAPOiveFhiczCDuvXdssAwamtrV2hp5Rw9v+n2T7hQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.6.0" + } + }, + "node_modules/@poppinss/string": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@poppinss/string/-/string-1.7.0.tgz", + "integrity": "sha512-IuCtWaUwmJeAdby0n1a5cTYsBLe7fPymdc4oNTTl1b6l+Ok+14XpSX0ILOEU6UtZ9D2XI3f4TVUh4Titkk1xgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "@types/bytes": "^3.1.5", + "@types/pluralize": "^0.0.33", + "bytes": "^3.1.2", + "case-anything": "^3.1.2", + "pluralize": "^8.0.0", + "slugify": "^1.6.6", + "truncatise": "^0.0.8" + } + }, + "node_modules/@poppinss/utils": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@poppinss/utils/-/utils-6.10.0.tgz", + "integrity": "sha512-IJWgX5KQ1KEG9NqVF4IsNTjyeJFnz03p2PRfBvttx4cyLB0QKOBdGy0Wbn1AkB7EPZ0gAFryO0bNqD2V9UgU/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.1", + "@poppinss/object-builder": "^1.1.0", + "@poppinss/string": "^1.3.0", + "flattie": "^1.1.1", + "safe-stable-stringify": "^2.5.0", + "secure-json-parse": "^4.0.0" + }, + "engines": { + "node": ">=18.16.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, + "node_modules/@smithy/abort-controller": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.5.tgz", + "integrity": "sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.5.tgz", + "integrity": "sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.8.0.tgz", + "integrity": "sha512-EYqsIYJmkR1VhVE9pccnk353xhs+lB6btdutJEtsp7R055haMJp2yE16eSxw8fv+G0WUY6vqxyYOP8kOqawxYQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.0.9", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-stream": "^4.2.4", + "@smithy/util-utf8": "^4.0.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.7.tgz", + "integrity": "sha512-dDzrMXA8d8riFNiPvytxn0mNwR4B3h8lgrQ5UjAGu6T9z/kRg/Xncf4tEQHE/+t25sY8IH3CowcmWi+1U5B1Gw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.4", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.1.tgz", + "integrity": "sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.3", + "@smithy/querystring-builder": "^4.0.5", + "@smithy/types": "^4.3.2", + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.5.tgz", + "integrity": "sha512-cv1HHkKhpyRb6ahD8Vcfb2Hgz67vNIXEp2vnhzfxLFGRukLCNEA5QdsorbUEzXma1Rco0u3rx5VTqbM06GcZqQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.5.tgz", + "integrity": "sha512-IVnb78Qtf7EJpoEVo7qJ8BEXQwgC4n3igeJNNKEj/MLYtapnx8A67Zt/J3RXAj2xSO1910zk0LdFiygSemuLow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.5.tgz", + "integrity": "sha512-8n2XCwdUbGr8W/XhMTaxILkVlw2QebkVTn5tm3HOcbPbOpWg89zr6dPXsH8xbeTsbTXlJvlJNTQsKAIoqQGbdA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.5.tgz", + "integrity": "sha512-l1jlNZoYzoCC7p0zCtBDE5OBXZ95yMKlRlftooE5jPWQn4YBPLgsp+oeHp7iMHaTGoUdFqmHOPa8c9G3gBsRpQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.18.tgz", + "integrity": "sha512-ZhvqcVRPZxnZlokcPaTwb+r+h4yOIOCJmx0v2d1bpVlmP465g3qpVSf7wxcq5zZdu4jb0H4yIMxuPwDJSQc3MQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.8.0", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-middleware": "^4.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.1.19", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.19.tgz", + "integrity": "sha512-X58zx/NVECjeuUB6A8HBu4bhx72EoUz+T5jTMIyeNKx2lf+Gs9TmWPNNkH+5QF0COjpInP/xSpJGJ7xEnAklQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.4", + "@smithy/protocol-http": "^5.1.3", + "@smithy/service-error-classification": "^4.0.7", + "@smithy/smithy-client": "^4.4.10", + "@smithy/types": "^4.3.2", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.9.tgz", + "integrity": "sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.5.tgz", + "integrity": "sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.4.tgz", + "integrity": "sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.1.tgz", + "integrity": "sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/querystring-builder": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.5.tgz", + "integrity": "sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.3.tgz", + "integrity": "sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.5.tgz", + "integrity": "sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "@smithy/util-uri-escape": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.5.tgz", + "integrity": "sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.7.tgz", + "integrity": "sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.5.tgz", + "integrity": "sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.3.tgz", + "integrity": "sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.10.tgz", + "integrity": "sha512-iW6HjXqN0oPtRS0NK/zzZ4zZeGESIFcxj2FkWed3mcK8jdSdHzvnCKXSjvewESKAgGKAbJRA+OsaqKhkdYRbQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.8.0", + "@smithy/middleware-endpoint": "^4.1.18", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "@smithy/util-stream": "^4.2.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.2.tgz", + "integrity": "sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18.0.0" } }, - "node_modules/@poppinss/cliui/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/url-parser": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.5.tgz", + "integrity": "sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==", + "license": "Apache-2.0", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "@smithy/querystring-parser": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "node": ">=18.0.0" } }, - "node_modules/@poppinss/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-base64": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", + "license": "Apache-2.0", "dependencies": { - "ansi-regex": "^6.0.1" + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18.0.0" } }, - "node_modules/@poppinss/cliui/node_modules/supports-color": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.0.0.tgz", - "integrity": "sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@poppinss/cliui/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", + "license": "Apache-2.0", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=18.0.0" } }, - "node_modules/@poppinss/colors": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.5.tgz", - "integrity": "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", + "license": "Apache-2.0", "dependencies": { - "kleur": "^4.1.5" + "@smithy/is-array-buffer": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@poppinss/exception": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.2.tgz", - "integrity": "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@poppinss/hooks": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/@poppinss/hooks/-/hooks-7.2.6.tgz", - "integrity": "sha512-+bZhb1CrIvhgnypjE0W/NZVkRnRDZL37HDDI6zvIo8h3PVs1lKj5Dyl54V/EpU6SFSZAS5dilgZ0V7zhjyJMgA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@poppinss/inspect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@poppinss/inspect/-/inspect-1.0.1.tgz", - "integrity": "sha512-kLeEaBSGhlleyYvKc7c9s3uE6xv7cwyulE0EgHf4jU/CL96h0yC4mkdw1wvC1l1PYYQozCGy46FwMBAAMOobCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@poppinss/macroable": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@poppinss/macroable/-/macroable-1.0.5.tgz", - "integrity": "sha512-6u61y1HHd090MEk1Av0/1btDmm2Hh/+XoJj+HgFYRh9koUPI822ybJbwLHuqjLNCiY+o1gRykg2igEqOf/VBZw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@poppinss/object-builder": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@poppinss/object-builder/-/object-builder-1.1.0.tgz", - "integrity": "sha512-FOrOq52l7u8goR5yncX14+k+Ewi5djnrt1JwXeS/FvnwAPOiveFhiczCDuvXdssAwamtrV2hp5Rw9v+n2T7hQg==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.26", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.26.tgz", + "integrity": "sha512-xgl75aHIS/3rrGp7iTxQAOELYeyiwBu+eEgAk4xfKwJJ0L8VUjhO2shsDpeil54BOFsqmk5xfdesiewbUY5tKQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.5", + "@smithy/smithy-client": "^4.4.10", + "@smithy/types": "^4.3.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=20.6.0" + "node": ">=18.0.0" } }, - "node_modules/@poppinss/string": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@poppinss/string/-/string-1.7.0.tgz", - "integrity": "sha512-IuCtWaUwmJeAdby0n1a5cTYsBLe7fPymdc4oNTTl1b6l+Ok+14XpSX0ILOEU6UtZ9D2XI3f4TVUh4Titkk1xgw==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.26", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.26.tgz", + "integrity": "sha512-z81yyIkGiLLYVDetKTUeCZQ8x20EEzvQjrqJtb/mXnevLq2+w3XCEWTJ2pMp401b6BkEkHVfXb/cROBpVauLMQ==", + "license": "Apache-2.0", "dependencies": { - "@lukeed/ms": "^2.0.2", - "@types/bytes": "^3.1.5", - "@types/pluralize": "^0.0.33", - "bytes": "^3.1.2", - "case-anything": "^3.1.2", - "pluralize": "^8.0.0", - "slugify": "^1.6.6", - "truncatise": "^0.0.8" + "@smithy/config-resolver": "^4.1.5", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/property-provider": "^4.0.5", + "@smithy/smithy-client": "^4.4.10", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@poppinss/utils": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@poppinss/utils/-/utils-6.10.0.tgz", - "integrity": "sha512-IJWgX5KQ1KEG9NqVF4IsNTjyeJFnz03p2PRfBvttx4cyLB0QKOBdGy0Wbn1AkB7EPZ0gAFryO0bNqD2V9UgU/Q==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-endpoints": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.7.tgz", + "integrity": "sha512-klGBP+RpBp6V5JbrY2C/VKnHXn3d5V2YrifZbmMY8os7M6m8wdYFoO6w/fe5VkP+YVwrEktW3IWYaSQVNZJ8oQ==", + "license": "Apache-2.0", "dependencies": { - "@poppinss/exception": "^1.2.1", - "@poppinss/object-builder": "^1.1.0", - "@poppinss/string": "^1.3.0", - "flattie": "^1.1.1", - "safe-stable-stringify": "^2.5.0", - "secure-json-parse": "^4.0.0" + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" }, "engines": { - "node": ">=18.16.0" + "node": ">=18.0.0" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", - "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.16" + "node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@smithy/util-middleware": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.5.tgz", + "integrity": "sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==", + "license": "Apache-2.0", "dependencies": { - "type-detect": "4.0.8" + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@smithy/util-retry": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.7.tgz", + "integrity": "sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==", + "license": "Apache-2.0", "dependencies": { - "@sinonjs/commons": "^3.0.1" + "@smithy/service-error-classification": "^4.0.7", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", - "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@smithy/util-stream": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.4.tgz", + "integrity": "sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==", + "license": "Apache-2.0", "dependencies": { - "@sinonjs/commons": "^3.0.1", - "type-detect": "^4.1.0" + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/types": "^4.3.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@sinonjs/samsam/node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", + "node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=4" + "node": ">=18.0.0" } }, - "node_modules/@sinonjs/text-encoding": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", - "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", - "dev": true, - "license": "(Unlicense OR Apache-2.0)" + "node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, "node_modules/@szmarczak/http-timer": { "version": "5.0.1", @@ -1595,6 +2850,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.38.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", @@ -2386,6 +3647,12 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -5442,6 +6709,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastify": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.4.0.tgz", @@ -11661,6 +12946,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supports-color": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", @@ -12232,7 +13529,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { diff --git a/package.json b/package.json index ce30e41..0c8ee8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/queue", - "version": "5.9.0", + "version": "5.10.0", "description": "The Athenna queue handler.", "license": "MIT", "author": "João Lenon ", @@ -49,9 +49,13 @@ "#src": "./src/index.js", "#src/types": "./src/types/index.js", "#src/debug": "./src/debug/index.js", + "#src/utils": "./src/utils/index.js", "#tests/*": "./tests/*.js", "#tests": "./tests/index.js" }, + "dependencies": { + "@aws-sdk/client-sqs": "^3.859.0" + }, "devDependencies": { "@athenna/artisan": "^5.7.0", "@athenna/common": "^5.14.0", diff --git a/src/annotations/Worker.ts b/src/annotations/Worker.ts index 53aed5e..e7f23d0 100644 --- a/src/annotations/Worker.ts +++ b/src/annotations/Worker.ts @@ -20,11 +20,13 @@ import type { WorkerOptions } from '#src/types' export function Worker(options?: WorkerOptions): ClassDecorator { return (target: any) => { options = Options.create(options, { - alias: `App/Workers/${target.name}`, + name: target.name, + connection: Config.get('queue.default'), + alias: `App/Queue/Workers/${target.name}`, type: 'transient' }) - debug('Registering validator metadata for the service container %o', { + debug('Registering worker metadata for the service container %o', { ...options, name: target.name }) diff --git a/src/drivers/AwsSqsDriver.ts b/src/drivers/AwsSqsDriver.ts new file mode 100644 index 0000000..dcd32f4 --- /dev/null +++ b/src/drivers/AwsSqsDriver.ts @@ -0,0 +1,469 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { + SQSClient, + PurgeQueueCommand, + SendMessageCommand, + DeleteMessageCommand, + ReceiveMessageCommand, + GetQueueAttributesCommand, + ChangeMessageVisibilityCommand +} from '@aws-sdk/client-sqs' + +import { Log } from '@athenna/logger' +import { createHash } from 'node:crypto' +import { Driver } from '#src/drivers/Driver' +import { Is, Options, Uuid } from '@athenna/common' +import type { ConnectionOptions } from '#src/types' +import { ConnectionFactory } from '#src/factories/ConnectionFactory' +import { NotFifoSqsQueueTypeException } from '#src/exceptions/NotFifoSqsQueueTypeException' + +export class AwsSqsDriver extends Driver { + /** + * Set the acked ids of the driver. + */ + private static ackedIds = new Set() + + private type: 'standard' | 'fifo' + private region: string + private awsAccessKeyId: string + private awsSecretAccessKey: string + + /** + * Convert milliseconds to seconds. + */ + private msToS(v: number) { + const s = Math.ceil(v / 1000) + return Math.max(0, Math.min(43200, s)) + } + + private fifoContentBasedDedup?: boolean + private fifoGroupId?: string + private dlqFifoGroupId?: string + + /** + * Ensure the FIFO attributes are loaded. + */ + private async ensureFifoAttrsLoaded() { + if (this.type !== 'fifo' || this.fifoContentBasedDedup !== undefined) { + return + } + + const { Attributes } = await this.client.send( + new GetQueueAttributesCommand({ + QueueUrl: this.queueName, + AttributeNames: ['FifoQueue', 'ContentBasedDeduplication'] + }) + ) + + const isFifo = Attributes?.FifoQueue === 'true' + + if (!isFifo || !this.queueName.endsWith('.fifo')) { + throw new NotFifoSqsQueueTypeException(this.queueName) + } + + this.fifoContentBasedDedup = + Attributes?.ContentBasedDeduplication === 'true' + } + + /** + * Generate a deduplication id for the job. + */ + private genDedupId(body: string) { + const hash = createHash('sha256').update(body).digest('hex') + + return `${hash}:${Date.now()}`.slice(0, 128) + } + + public constructor( + con: string, + client: any = null, + options?: ConnectionOptions['options'] + ) { + super(con, client, options) + + const config = Config.get(`queue.connections.${con}`) + + this.type = options?.type || config?.type || 'standard' + this.region = options?.region || config?.region || Env('AWS_REGION') + this.awsAccessKeyId = + options?.awsAccessKeyId || + config?.awsAccessKeyId || + Env('AWS_ACCESS_KEY_ID') + this.awsSecretAccessKey = + options?.awsSecretAccessKey || + config?.awsSecretAccessKey || + Env('AWS_SECRET_ACCESS_KEY') + + this.fifoGroupId = + options?.messageGroupId || config?.messageGroupId || 'default' + this.dlqFifoGroupId = + options?.dlqMessageGroupId || config?.dlqMessageGroupId || 'dlq' + + if ( + Is.Boolean( + options?.contentBasedDeduplication ?? config?.contentBasedDeduplication + ) + ) { + this.fifoContentBasedDedup = Boolean( + options?.contentBasedDeduplication ?? config?.contentBasedDeduplication + ) + } + } + + /** + * Connect to client. + * + * @example + * ```ts + * Queue.connection('my-con').connect() + * ``` + */ + public connect(options: ConnectionOptions = {}): void { + options = Options.create(options, { + force: false, + connect: true, + saveOnFactory: true + }) + + if (!options.connect) { + return + } + + if (this.isConnected && !options.force) { + return + } + + this.client = new SQSClient({ + region: this.region, + credentials: { + accessKeyId: this.awsAccessKeyId, + secretAccessKey: this.awsSecretAccessKey + } + }) + this.isConnected = true + this.isSavedOnFactory = options.saveOnFactory + + if (options.saveOnFactory) { + ConnectionFactory.setClient(this.connection, this.client) + } + } + + /** + * Close the connection with queue in this instance. + * + * @example + * ```ts + * await Queue.connection('my-con').close() + * ``` + */ + public async close(): Promise { + if (!this.isConnected) { + return + } + + this.isConnected = false + + this.client.destroy() + + ConnectionFactory.setClient(this.connection, null) + } + + /** + * Delete all the data of queues. + * + * @example + * ```ts + * await Queue.truncate() + * ``` + */ + public async truncate() { + const cmd = new PurgeQueueCommand({ QueueUrl: this.queueName }) + + await this.client.send(cmd) + + if (this.deadletter) { + const cmd = new PurgeQueueCommand({ QueueUrl: this.deadletter }) + + await this.client.send(cmd) + } + } + + /** + * Define which queue is going to be used to + * perform operations. If not defined, the default + * set on the connection configuration will be used. + * + * @example + * ```ts + * await Queue.queue('mail').add({ email: 'lenon@athenna.io' }) + * ``` + */ + public async add(data: any) { + data = Is.Object(data) ? JSON.stringify(data) : String(data) + + const params: any = { + QueueUrl: this.queueName, + MessageBody: data + } + + if (this.type === 'fifo') { + await this.ensureFifoAttrsLoaded() + + params.MessageGroupId = this.fifoGroupId + + if (!this.fifoContentBasedDedup) { + params.MessageDeduplicationId = this.genDedupId(data) + } + } + + const cmd = new SendMessageCommand(params) + + await this.client.send(cmd) + } + + /** + * Peek the next job to be processed from the queue and + * return. This method automatically removes the job from the queue. + * + * @example + * ```ts + * await Queue.add({ name: 'lenon' }) + * + * const job = await Queue.pop() + * ``` + */ + public async pop() { + const params: any = { + QueueUrl: this.queueName, + MaxNumberOfMessages: 1, + WaitTimeSeconds: 20, + MessageSystemAttributeNames: ['All', 'ApproximateReceiveCount'] + } + + if (this.type === 'fifo') { + params.ReceiveRequestAttemptId = Uuid.generate() + } + + const cmd = new ReceiveMessageCommand(params) + + const { Messages } = await this.client.send(cmd) + + if (!Messages?.length) { + return null + } + + const job = Messages[0] + const { Body, ...rest } = job + const data = Is.Json(Body) ? JSON.parse(Body) : Body + const receiveCount = Number(job.Attributes?.ApproximateReceiveCount || '1') + const attempts = Math.max(this.attempts - receiveCount, 0) + + if (this.visibilityTimeout) { + await this.changeJobVisibility( + job.ReceiptHandle!, + this.msToS(this.visibilityTimeout) + ) + } + + return { + id: job.ReceiptHandle, + attempts, + data, + metadata: rest + } as any + } + + /** + * Peek the next job to be processed from the queue and + * return. This method does not remove the job from the queue. + * + * @example + * ```ts + * await Queue.add({ name: 'lenon' }) + * + * const job = await Queue.peek() + * ``` + */ + public async peek() { + return null + } + + /** + * Return how many jobs are defined inside the queue. + * + * @example + * ```ts + * await Queue.add({ name: 'lenon' }) + * + * const length = await Queue.length() + * ``` + */ + public async length() { + const cmd = new GetQueueAttributesCommand({ + QueueUrl: this.queueName, + AttributeNames: ['All', 'ApproximateNumberOfMessages'] + }) + + const { Attributes } = await this.client.send(cmd) + + return Number(Attributes?.ApproximateNumberOfMessages || 0) + } + + /** + * Acknowledge the job removing it from the queue. + * + * @example + * ```ts + * await Queue.ack(id) + * ``` + */ + public async ack(id: string) { + const cmd = new DeleteMessageCommand({ + QueueUrl: this.queueName, + ReceiptHandle: id + }) + + await this.client.send(cmd) + + AwsSqsDriver.ackedIds.add(id) + } + + /** + * Verify if there are jobs on the queue. + * + * @example + * ```ts + * if (await Queue.isEmpty()) { + * } + * ``` + */ + public async isEmpty() { + const length = await this.length() + + return length === 0 + } + + /** + * Process the next job of the queue with a handler. + * + * @example + * ```ts + * await Queue.add({ email: 'lenon@athenna.io' }) + * + * await Queue.process(async (user) => { + * await Mail.to(user.email).subject('Hello!').send() + * }) + * ``` + */ + public async process(processor: (data: unknown) => any | Promise) { + const job = await this.pop() + const requeueJitterMs = Math.floor(Math.random() * this.workerInterval) + + if (!job) { + return + } + + AwsSqsDriver.ackedIds.delete(job.id) + + try { + await processor({ + id: job.id, + attempts: job.attempts, + data: job.data + }) + + if (!AwsSqsDriver.ackedIds.has(job.id)) { + await this.changeJobVisibility( + job.id, + this.msToS(this.noAckDelayMs + requeueJitterMs) + ) + } + } catch (err) { + const receiveCount = Number( + job.metadata.Attributes?.ApproximateReceiveCount ?? '1' + ) + const attempts = Math.max(this.attempts - receiveCount, 0) + const shouldRetry = attempts > 0 + + if (Config.is('worker.logger.prettifyException')) { + Log.channelOrVanilla('exception').error( + await err.toAthennaException().prettify() + ) + } else { + Log.channelOrVanilla('exception').error({ + msg: `failed to process job: ${err.message}`, + queue: this.queueName, + deadletter: this.deadletter, + name: err.name, + code: err.code, + help: err.help, + details: err.details, + metadata: err.metadata, + stack: err.stack, + job + }) + } + + if (shouldRetry) { + const delay = this.calculateBackoffDelay(job.attempts) + + await this.changeJobVisibility( + job.id, + this.msToS(delay + requeueJitterMs) + ) + + return + } + + if (this.deadletter) { + await this.sendJobToDLQ(job) + } + + await this.ack(job.id) + } + } + + /** + * Send a job to the deadletter quue. + */ + private async sendJobToDLQ(job: any) { + if (Is.Object(job.data)) { + job.data = JSON.stringify(job.data) + } + + const params: any = { + QueueUrl: this.deadletter, + MessageBody: job.data + } + + if (this.type === 'fifo' || this.deadletter?.endsWith?.('.fifo')) { + params.MessageGroupId = this.dlqFifoGroupId + params.MessageDeduplicationId = this.genDedupId(job.data) + } + + const cmd = new SendMessageCommand(params) + + await this.client.send(cmd) + } + + /** + * Change the job visibility values in SQS. + */ + private async changeJobVisibility(id: string, seconds: number) { + const cmd = new ChangeMessageVisibilityCommand({ + QueueUrl: this.queueName, + ReceiptHandle: id, + VisibilityTimeout: Math.max(0, Math.min(43200, Math.floor(seconds))) + }) + + await this.client.send(cmd) + } +} diff --git a/src/drivers/DatabaseDriver.ts b/src/drivers/DatabaseDriver.ts index 92600a3..30a6d26 100644 --- a/src/drivers/DatabaseDriver.ts +++ b/src/drivers/DatabaseDriver.ts @@ -19,7 +19,7 @@ export class DatabaseDriver extends Driver { /** * Set the acked ids of the driver. */ - private ackedIds = new Set() + private static ackedIds = new Set() /** * The `connection` database that is being used. @@ -31,13 +31,17 @@ export class DatabaseDriver extends Driver { */ public table: string - public constructor(con: string, client: any = null) { - super(con, client) + public constructor( + con: string, + client: any = null, + options?: ConnectionOptions['options'] + ) { + super(con, client, options) - const { table, connection } = Config.get(`queue.connections.${con}`) + const config = Config.get(`queue.connections.${con}`) - this.table = table - this.dbConnection = connection + this.table = options?.table || config?.table + this.dbConnection = options?.connection || config?.connection } /** @@ -118,12 +122,27 @@ export class DatabaseDriver extends Driver { public async add(data: unknown) { await this.client.table(this.table).create({ queue: this.queueName, - data, - status: 'pending', - attemptsLeft: this.attempts + attempts: this.attempts, + availableAt: Date.now(), + reservedUntil: null, + createdAt: Date.now(), + data }) } + /** + * Release any job that has expired leases. + */ + public async releaseExpiredLeases() { + const now = Date.now() + + await this.client + .table(this.table) + .where('queue', this.queueName) + .where('reservedUntil', '<=', now) + .update({ reservedUntil: null }) + } + /** * Remove a job from the queue and return. * @@ -135,18 +154,29 @@ export class DatabaseDriver extends Driver { * ``` */ public async pop() { - const data = await this.peek() + const now = Date.now() + + const data = await this.client + .table(this.table) + .where('queue', this.queueName) + .where('availableAt', '<=', now) + .where((qb: any) => + qb.whereNull('reservedUntil').orWhere('reservedUntil', '<=', now) + ) + .orderBy('availableAt', 'asc') + .orderBy('createdAt', 'asc') + .find() if (!data) { return null } - await this.client.table(this.table).where('id', data.id).delete() - if (Is.Json(data.data)) { data.data = JSON.parse(data.data) } + await this.client.table(this.table).where('id', data.id).delete() + return data } @@ -162,10 +192,19 @@ export class DatabaseDriver extends Driver { * ``` */ public async peek() { + const now = Date.now() + + await this.releaseExpiredLeases() + const data = await this.client .table(this.table) .where('queue', this.queueName) - .latest() + .where('availableAt', '<=', now) + .where((qb: any) => + qb.whereNull('reservedUntil').orWhere('reservedUntil', '<=', now) + ) + .orderBy('availableAt', 'asc') + .orderBy('createdAt', 'asc') .find() if (!data) { @@ -179,24 +218,6 @@ export class DatabaseDriver extends Driver { return data } - /** - * Acknowledge the job removing it from the queue. - * - * @example - * ```ts - * await Queue.ack(id) - * ``` - */ - public async ack(id: string) { - this.ackedIds.add(id) - - await this.client - .table(this.table) - .where('id', id) - .where('status', 'processing') - .delete() - } - /** * Return how many jobs are defined inside the queue. * @@ -216,6 +237,24 @@ export class DatabaseDriver extends Driver { return parseInt(count) } + /** + * Acknowledge the job removing it from the queue. + * + * @example + * ```ts + * await Queue.ack(id) + * ``` + */ + public async ack(id: string) { + DatabaseDriver.ackedIds.add(id) + + await this.client + .table(this.table) + .where('queue', this.queueName) + .where('id', id) + .delete() + } + /** * Verify if there are jobs on the queue. * @@ -226,12 +265,9 @@ export class DatabaseDriver extends Driver { * ``` */ public async isEmpty() { - const count = await this.client - .table(this.table) - .where('queue', this.queueName) - .count() + const length = await this.length() - return parseInt(count) <= 0 + return length === 0 } /** @@ -248,77 +284,94 @@ export class DatabaseDriver extends Driver { */ public async process(processor: (data: unknown) => any | Promise) { const job = await this.peek() + const requeueJitterMs = Math.floor(Math.random() * this.workerInterval) if (!job) { return } - this.ackedIds.delete(job.id) + DatabaseDriver.ackedIds.delete(job.id) - job.attemptsLeft-- - job.status = 'processing' + job.attempts-- + job.reservedUntil = Date.now() + this.visibilityTimeout await this.client.table(this.table).where('id', job.id).update({ - status: 'processing', - attemptsLeft: job.attemptsLeft + attempts: job.attempts, + reservedUntil: job.reservedUntil }) try { - await processor(job) + await processor({ + id: job.id, + attempts: job.attempts, + data: job.data + }) /** * If the job still exists after processing, it means that the job was - * not processed for some reason, so we need to put it back the pending - * status. + * not processed for some reason, so we need to make it available again + * after a delay. */ - if (!this.ackedIds.has(job.id)) { - job.status = 'pending' + if (!DatabaseDriver.ackedIds.has(job.id)) { + job.reservedUntil = null + job.availableAt = Date.now() + this.noAckDelayMs + requeueJitterMs + + await this.client + .table(this.table) + .where('queue', this.queueName) + .where('id', job.id) + .update({ + availableAt: job.availableAt, + reservedUntil: job.reservedUntil + }) } } catch (err) { - const shouldRetry = job.attemptsLeft > 0 - - await this.ack(job.id) - - Log.channelOrVanilla('exception').error({ - msg: `failed to process job: ${err.message}`, - queue: this.queueName, - deadletter: this.deadletter, - name: err.name, - code: err.code, - help: err.help, - details: err.details, - metadata: err.metadata, - stack: err.stack, - job - }) - - if (shouldRetry) { - job.status = 'pending' + const shouldRetry = job.attempts > 0 + + if (Config.is('worker.logger.prettifyException')) { + Log.channelOrVanilla('exception').error( + await err.toAthennaException().prettify() + ) + } else { + Log.channelOrVanilla('exception').error({ + msg: `failed to process job: ${err.message}`, + queue: this.queueName, + deadletter: this.deadletter, + name: err.name, + code: err.code, + help: err.help, + details: err.details, + metadata: err.metadata, + stack: err.stack, + job + }) + } - const delay = this.calculateBackoffDelay(job.attemptsLeft) + if (!shouldRetry) { + await this.ack(job.id) - setTimeout(async () => { + if (this.deadletter) { await this.client.table(this.table).create({ - id: job.id, - queue: this.queueName, - status: 'pending', - attemptsLeft: job.attemptsLeft, - data: job.data + ...job, + queue: this.deadletter, + reservedUntil: null, + attempts: 0 }) - }, delay) + } return } - if (this.deadletter) { - await this.client.table(this.table).create({ - attemptsLeft: 0, - queue: this.deadletter, - formerQueue: this.queueName, - status: 'pending', - data: job.data + await this.client + .table(this.table) + .where('id', job.id) + .update({ + reservedUntil: null, + availableAt: + Date.now() + + this.calculateBackoffDelay(job.attempts) + + requeueJitterMs }) - } } } } diff --git a/src/drivers/Driver.ts b/src/drivers/Driver.ts index 01e32f4..e66d2f9 100644 --- a/src/drivers/Driver.ts +++ b/src/drivers/Driver.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import { Utils } from '#src/utils' import { Config } from '@athenna/config' import type { ConnectionOptions } from '#src/types' @@ -46,6 +47,21 @@ export abstract class Driver { */ public attempts: number + /** + * Set the default visibility timeout of the driver. + */ + public visibilityTimeout: number + + /** + * Set the default no ack delay of the driver. + */ + public noAckDelayMs: number + + /** + * Set the default worker interval of the driver. + */ + public workerInterval: number + /** * Set the driver backoff of the driver. */ @@ -65,10 +81,19 @@ export abstract class Driver { ) { const config = Config.get(`queue.connections.${connection}`) + this.workerInterval = + options?.workerInterval || config.workerInterval || 1000 + this.noAckDelayMs = Utils.computeNoAckDelayMs( + this.workerInterval, + `${this.connection}:${this.queueName}:noack` + ) + this.queueName = options?.queue || config.queue this.backoff = options?.backoff || config.backoff || null this.attempts = options?.attempts || config.attempts || 1 this.deadletter = options?.deadletter || config.deadletter + this.visibilityTimeout = + options?.visibilityTimeout || config.visibilityTimeout || 30000 this.connection = connection if (client) { @@ -106,17 +131,19 @@ export abstract class Driver { /** * Calculate the backoff delay. */ - public calculateBackoffDelay(attemptsLeft: number) { + public calculateBackoffDelay(attempts: number) { if (!this.backoff) { return 0 } - const { type, delay, jitter } = this.backoff + let { type, delay, jitter } = this.backoff + + if (jitter < 0) { + jitter = 0 + } const baseDelay = - type === 'fixed' - ? delay - : Math.pow(2, this.attempts - attemptsLeft) * delay + type === 'fixed' ? delay : Math.pow(2, this.attempts - attempts) * delay const max = baseDelay * jitter const random = Math.floor(Math.random() * (max - baseDelay + 1)) + baseDelay @@ -162,7 +189,7 @@ export abstract class Driver { * Get the first job from the queue without removing * and return. */ - public abstract peek(): Promise + public abstract peek(workerId: string): Promise /** * Acknowledge the job removing it from the queue. diff --git a/src/drivers/FakeDriver.ts b/src/drivers/FakeDriver.ts index ca83c5f..ded1880 100644 --- a/src/drivers/FakeDriver.ts +++ b/src/drivers/FakeDriver.ts @@ -34,6 +34,9 @@ export class FakeDriver { public static connection = 'fake' public static client: unknown = null public static attempts: number = 1 + public static visibilityTimeout: number = 30000 + public static workerInterval: number = 1000 + public static noAckDelayMs: number = 1700 public static backoff: { type: 'fixed' | 'exponential' delay: number diff --git a/src/drivers/VanillaDriver.ts b/src/drivers/MemoryDriver.ts similarity index 68% rename from src/drivers/VanillaDriver.ts rename to src/drivers/MemoryDriver.ts index d093e9d..74b2687 100644 --- a/src/drivers/VanillaDriver.ts +++ b/src/drivers/MemoryDriver.ts @@ -8,16 +8,16 @@ */ import { Log } from '@athenna/logger' -import { Options, Uuid } from '@athenna/common' import { Driver } from '#src/drivers/Driver' +import { Options, Uuid } from '@athenna/common' import type { ConnectionOptions } from '#src/types' import { ConnectionFactory } from '#src/factories/ConnectionFactory' -export class VanillaDriver extends Driver { +export class MemoryDriver extends Driver { /** * Set the acked ids of the driver. */ - private ackedIds = new Set() + private static ackedIds = new Set() private defineQueue() { if (!this.client.queues[this.queueName]) { @@ -108,14 +108,27 @@ export class VanillaDriver extends Driver { this.client.queues[this.queueName].push({ id: Uuid.generate(), - status: 'pending', - attemptsLeft: this.attempts, - createdAt: new Date(), - updatedAt: new Date(), + attempts: this.attempts, + availableAt: Date.now(), + reservedUntil: null, + createdAt: Date.now(), data }) } + /** + * Release any job that has expired leases. + */ + public async releaseExpiredLeases() { + const now = Date.now() + + this.client.queues[this.queueName].forEach(job => { + if (job.reservedUntil && job.reservedUntil <= now) { + job.reservedUntil = null + } + }) + } + /** * Peek the next job to be processed from the queue and * return. This method automatically removes the job from the queue. @@ -151,11 +164,17 @@ export class VanillaDriver extends Driver { public async peek() { this.defineQueue() + await this.releaseExpiredLeases() + if (!this.client.queues[this.queueName].length) { return null } - return this.client.queues[this.queueName].find(j => j.status === 'pending') + const now = Date.now() + + return this.client.queues[this.queueName].find(job => { + return job.availableAt <= now && !job.reservedUntil + }) } /** @@ -174,20 +193,6 @@ export class VanillaDriver extends Driver { return this.client.queues[this.queueName].length } - /** - * Find a job by its id. - * - * @example - * ```ts - * const job = await Queue.getJobById(id) - * ``` - */ - public async getJobById(id: string) { - this.defineQueue() - - return this.client.queues[this.queueName].find(j => j.id === id) - } - /** * Acknowledge the job removing it from the queue. * @@ -199,21 +204,16 @@ export class VanillaDriver extends Driver { public async ack(id: string) { this.defineQueue() - this.ackedIds.add(id) - - const job = await this.getJobById(id) - - if (!job) { - return - } + const index = this.client.queues[this.queueName].findIndex( + job => job.id === id + ) - if (job.status !== 'processing') { + if (index === -1) { return } - this.client.queues[this.queueName] = this.client.queues[ - this.queueName - ].filter(j => j.id !== id) + this.client.queues[this.queueName].splice(index, 1) + MemoryDriver.ackedIds.add(id) } /** @@ -245,62 +245,72 @@ export class VanillaDriver extends Driver { */ public async process(processor: (data: unknown) => any | Promise) { const job = await this.peek() + const requeueJitterMs = Math.floor(Math.random() * this.workerInterval) if (!job) { return } - this.ackedIds.delete(job.id) + MemoryDriver.ackedIds.delete(job.id) - job.attemptsLeft-- - job.status = 'processing' + job.attempts-- + job.reservedUntil = Date.now() + this.visibilityTimeout try { - await processor(job) + await processor({ + id: job.id, + attempts: job.attempts, + data: job.data + }) /** * If the job still exists after processing, it means that the job was - * not processed for some reason, so we need to put it back the pending - * status. + * not processed for some reason, so we need to make it available again + * after a delay. */ - if (!this.ackedIds.has(job.id)) { - job.status = 'pending' + if (!MemoryDriver.ackedIds.has(job.id)) { + job.reservedUntil = null + job.availableAt = Date.now() + this.noAckDelayMs + requeueJitterMs } } catch (err) { - const shouldRetry = job.attemptsLeft > 0 - - await this.ack(job.id) - - Log.channelOrVanilla('exception').error({ - msg: `failed to process job: ${err.message}`, - queue: this.queueName, - deadletter: this.deadletter, - name: err.name, - code: err.code, - help: err.help, - details: err.details, - metadata: err.metadata, - stack: err.stack, - job - }) + const shouldRetry = job.attempts > 0 + + job.reservedUntil = null + + if (Config.is('worker.logger.prettifyException')) { + Log.channelOrVanilla('exception').error( + await err.toAthennaException().prettify() + ) + } else { + Log.channelOrVanilla('exception').error({ + msg: `failed to process job: ${err.message}`, + queue: this.queueName, + deadletter: this.deadletter, + name: err.name, + code: err.code, + help: err.help, + details: err.details, + metadata: err.metadata, + stack: err.stack, + job + }) + } if (shouldRetry) { - job.status = 'pending' - job.updatedAt = new Date() - const delay = this.calculateBackoffDelay(job.attemptsLeft) - - setTimeout(() => this.client.queues[this.queueName].push(job), delay) + job.availableAt = + Date.now() + + this.calculateBackoffDelay(job.attempts) + + requeueJitterMs return } + await this.ack(job.id) + if (this.deadletter) { this.client.queues[this.deadletter].push({ ...job, - attemptsLeft: 0, - queue: this.deadletter, - formerQueue: this.queueName, - status: 'pending' + attempts: 0 }) } } diff --git a/src/exceptions/NotFifoSqsQueueTypeException.ts b/src/exceptions/NotFifoSqsQueueTypeException.ts new file mode 100644 index 0000000..407e92a --- /dev/null +++ b/src/exceptions/NotFifoSqsQueueTypeException.ts @@ -0,0 +1,22 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Path, Exception } from '@athenna/common' + +export class NotFifoSqsQueueTypeException extends Exception { + public constructor(queue: string) { + const message = `The queue ${queue} is not configured as a FIFO queue in AWS SQS.` + + super({ + message, + code: 'E_NOT_FIFO_SQS_QUEUE_TYPE_ERROR', + help: `The queue ${queue} is not configured as a FIFO queue in AWS SQS. Change your queue type to fifo or update the type to standard in your config/queue.${Path.ext()} file.` + }) + } +} diff --git a/src/exceptions/NotFoundWorkerTaskException.ts b/src/exceptions/NotFoundWorkerTaskException.ts new file mode 100644 index 0000000..134eeac --- /dev/null +++ b/src/exceptions/NotFoundWorkerTaskException.ts @@ -0,0 +1,22 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Exception } from '@athenna/common' + +export class NotFoundWorkerTaskException extends Exception { + public constructor(taskName: string) { + const message = `The worker task ${taskName} has not been found.` + + super({ + message, + code: 'E_NOT_FOUND_WORKER_TASK_ERROR', + help: `Make sure that the worker task ${taskName} is registered and started. Use the Worker.getWorkerTasks() method to get all registered worker tasks.` + }) + } +} diff --git a/src/facades/Worker.ts b/src/facades/Worker.ts new file mode 100644 index 0000000..844046d --- /dev/null +++ b/src/facades/Worker.ts @@ -0,0 +1,13 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Facade } from '@athenna/ioc' +import type { WorkerImpl } from '#src/worker/WorkerImpl' + +export const Worker = Facade.createFor('Athenna/Core/Worker') diff --git a/src/factories/ConnectionFactory.ts b/src/factories/ConnectionFactory.ts index 40d435e..0c7223e 100644 --- a/src/factories/ConnectionFactory.ts +++ b/src/factories/ConnectionFactory.ts @@ -9,12 +9,13 @@ import { debug } from '#src/debug' import type { Driver } from '#src/drivers/Driver' +import type { ConnectionOptions } from '#src/types' import { FakeDriver } from '#src/drivers/FakeDriver' -import { VanillaDriver } from '#src/drivers/VanillaDriver' +import { AwsSqsDriver } from '#src/drivers/AwsSqsDriver' +import { MemoryDriver } from '#src/drivers/MemoryDriver' import { DatabaseDriver } from '#src/drivers/DatabaseDriver' import { NotFoundDriverException } from '#src/exceptions/NotFoundDriverException' import { NotImplementedConfigException } from '#src/exceptions/NotImplementedConfigException' -import type { ConnectionOptions } from '#src/types/index' export class ConnectionFactory { /** @@ -27,28 +28,34 @@ export class ConnectionFactory { */ public static drivers: Map = new Map() .set('fake', FakeDriver) - .set('vanilla', VanillaDriver) + .set('aws_sqs', AwsSqsDriver) + .set('memory', MemoryDriver) .set('database', DatabaseDriver) public static fabricate( - con: 'vanilla', + con: 'memory', options?: ConnectionOptions['options'] - ): VanillaDriver + ): MemoryDriver public static fabricate( con: 'database', options?: ConnectionOptions['options'] ): DatabaseDriver + public static fabricate( + con: 'aws_sqs', + options?: ConnectionOptions['options'] + ): AwsSqsDriver + public static fabricate( con: 'fake', options?: ConnectionOptions['options'] ): typeof FakeDriver public static fabricate( - con: 'vanilla' | 'database' | 'fake' | string, + con: 'memory' | 'database' | 'fake' | 'aws_sqs' | string, options?: ConnectionOptions['options'] - ): VanillaDriver | DatabaseDriver | typeof FakeDriver + ): MemoryDriver | DatabaseDriver | AwsSqsDriver | typeof FakeDriver /** * Fabricate a new connection for a specific driver. diff --git a/src/index.ts b/src/index.ts index a28371e..946584a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,16 +8,17 @@ */ export * from '#src/types' -export * from '#src/workers/BaseWorker' export * from '#src/annotations/Worker' export * from '#src/queue/QueueImpl' export * from '#src/drivers/Driver' export * from '#src/drivers/FakeDriver' -export * from '#src/drivers/VanillaDriver' +export * from '#src/drivers/AwsSqsDriver' +export * from '#src/drivers/MemoryDriver' export * from '#src/drivers/DatabaseDriver' export * from '#src/factories/ConnectionFactory' export * from '#src/facades/Queue' -export * from '#src/providers/WorkerProvider' export * from '#src/providers/QueueProvider' +export * from '#src/providers/WorkerProvider' +export * from '#src/worker/WorkerTaskBuilder' diff --git a/src/kernels/WorkerKernel.ts b/src/kernels/WorkerKernel.ts new file mode 100644 index 0000000..f6ff8e2 --- /dev/null +++ b/src/kernels/WorkerKernel.ts @@ -0,0 +1,153 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * @athenna/http + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'reflect-metadata' + +import { debug } from '#src/debug' +import { Config } from '@athenna/config' +import { Queue } from '#src/facades/Queue' +import { File, Path, Module } from '@athenna/common' +import { sep, isAbsolute, resolve } from 'node:path' +import { Annotation, type ServiceMeta } from '@athenna/ioc' + +export class WorkerKernel { + /** + * Register the cls-rtracer plugin in the Worker. + */ + public async registerRTracer(): Promise { + const rTracerPlugin = await Module.safeImport('cls-rtracer') + + if (Config.is('worker.rTracer.enabled', false)) { + debug( + 'Not able to register rTracer plugin. Set the worker.rTracer.enabled configuration as true.' + ) + + return + } + + if (!rTracerPlugin) { + debug('Not able to register tracer plugin. Install cls-rtracer package.') + + return + } + + Queue.worker().setRTracerPlugin(rTracerPlugin) + } + + /** + * Register the job logger in the Worker. + */ + public async registerLogger(): Promise { + if (Config.is('worker.logger.enabled', false)) { + debug( + 'Not able to register worker job logger. Enable it in your worker.logger.enabled configuration.' + ) + + return + } + + Queue.worker().setLogger(true) + } + + /** + * Register all the workers found inside "rc.workers" config + * inside the service provider. + */ + public async registerWorkers(): Promise { + const workers = Config.get('rc.workers', []) + + await workers.athenna.concurrently(async path => { + const Worker = await Module.resolve(path, this.getMeta()) + + if (Annotation.isAnnotated(Worker)) { + this.registerUsingMeta(Worker) + + return + } + + ioc.transient(`App/Queue/Workers/${Worker.name}`, Worker) + + Queue.worker() + .task() + .handler(ctx => { + const worker = ioc.safeUse(`App/Queue/Workers/${Worker.name}`) + + return worker.handle(ctx) + }) + }) + } + + /** + * Register the route file by importing the file. + */ + public async registerRoutes(path: string) { + if (path.startsWith('#')) { + await Module.resolve(path, this.getMeta()) + + return + } + + if (!isAbsolute(path)) { + path = resolve(path) + } + + if (!(await File.exists(path))) { + return + } + + await Module.resolve(path, this.getMeta()) + } + + /** + * Register the workers using the meta information + * defined by annotations. + */ + private registerUsingMeta(target: any): ServiceMeta { + const meta = Annotation.getMeta(target) + const builder = Queue.worker().task() + + ioc[meta.type](meta.alias, target) + + if (meta.name) { + builder.name(meta.name) + ioc.alias(meta.name, meta.alias) + } + + if (meta.camelAlias) { + ioc.alias(meta.camelAlias, meta.alias) + } + + builder.connection(meta.connection).handler(ctx => { + const worker = + ioc.use(meta.name) || + ioc.use(meta.camelAlias) || + ioc.safeUse(meta.alias) + + return worker.handle(ctx) + }) + + return meta + } + + /** + * Get the meta URL of the project. + */ + private getMeta() { + return Config.get('rc.parentURL', Path.toHref(Path.pwd() + sep)) + } +} diff --git a/src/providers/WorkerProvider.ts b/src/providers/WorkerProvider.ts index 75dfb6b..0e42f96 100644 --- a/src/providers/WorkerProvider.ts +++ b/src/providers/WorkerProvider.ts @@ -7,94 +7,21 @@ * file that was distributed with this source code. */ -import { sep } from 'node:path' -import { Module, Path } from '@athenna/common' -import { Annotation, ServiceProvider } from '@athenna/ioc' -import type { BaseWorker } from '#src/workers/BaseWorker' +import { ServiceProvider } from '@athenna/ioc' +import { WorkerImpl } from '#src/worker/WorkerImpl' export class WorkerProvider extends ServiceProvider { - /** - * Hold the intervals for each worker so when shutting - * down the application we can clear it. - */ - public intervals = [] - - /** - * Hold the workers classes and aliases to set up the - * intervals. - */ - public workers: { alias: string; Worker: typeof BaseWorker }[] = [] - - /** - * Register the workers from `rc.workers` of `.athennarc.json`. - */ - public async boot() { - const workers = Config.get('rc.workers', []) - - await workers.athenna.concurrently(async path => { - const Worker = await Module.resolve(path, this.getMeta()) - - if (Annotation.isAnnotated(Worker)) { - this.registerWorkerByMeta(Worker) - - return - } - - const alias = `App/Workers/${Worker.name}` - - this.container.transient(alias, Worker) - - this.workers.push({ alias, Worker }) - }) - - this.intervals = this.workers.map(({ alias, Worker }) => { - const interval = Worker.interval() - - return setInterval(async () => { - const worker = this.container.safeUse(alias) - const queue = worker.queue() - - if (await queue.isEmpty()) { - return - } - - await queue.process(worker.handle.bind(worker)) - }, interval) - }) + public async register() { + this.container.transient('Athenna/Core/Worker', WorkerImpl) } - /** - * Shutdown the workers by clearing the it intervals. - */ public async shutdown() { - this.intervals.forEach(interval => clearInterval(interval)) - } - - /** - * Register the worker by the annotation metadata. - */ - public async registerWorkerByMeta(Worker: typeof BaseWorker) { - const meta = Annotation.getMeta(Worker) - - this.container[meta.type](meta.alias, Worker) + const worker = this.container.use('Athenna/Core/Worker') - this.workers.push({ alias: meta.alias, Worker }) - - if (meta.name) { - this.container.alias(meta.name, meta.alias) - } - - if (meta.camelAlias) { - this.container.alias(meta.camelAlias, meta.alias) + if (!worker) { + return } - return meta - } - - /** - * Get the meta URL of the project. - */ - public getMeta() { - return Config.get('rc.parentURL', Path.toHref(Path.pwd() + sep)) + worker.close().truncate() } } diff --git a/src/queue/QueueImpl.ts b/src/queue/QueueImpl.ts index d24648b..4bda073 100644 --- a/src/queue/QueueImpl.ts +++ b/src/queue/QueueImpl.ts @@ -8,10 +8,12 @@ */ import { Macroable } from '@athenna/common' -import type { ConnectionOptions } from '#src/types' +import { Worker } from '#src/facades/Worker' +import type { Job, ConnectionOptions } from '#src/types' import type { FakeDriver } from '#src/drivers/FakeDriver' +import type { AwsSqsDriver } from '#src/drivers/AwsSqsDriver' import type { Driver as DriverImpl } from '#src/drivers/Driver' -import type { VanillaDriver } from '#src/drivers/VanillaDriver' +import type { MemoryDriver } from '#src/drivers/MemoryDriver' import type { DatabaseDriver } from '#src/drivers/DatabaseDriver' import { ConnectionFactory } from '#src/factories/ConnectionFactory' @@ -24,7 +26,11 @@ export class QueueImpl extends Macroable { /** * The drivers responsible for handling queue operations. */ - public driver: VanillaDriver | DatabaseDriver | typeof FakeDriver = null + public driver: + | MemoryDriver + | DatabaseDriver + | AwsSqsDriver + | typeof FakeDriver = null /** * Creates a new instance of QueueImpl. @@ -41,26 +47,32 @@ export class QueueImpl extends Macroable { } public connection( - con: 'vanilla', + con: 'memory', options?: ConnectionOptions - ): QueueImpl + ): QueueImpl public connection( con: 'database', options?: ConnectionOptions ): QueueImpl + public connection( + con: 'aws_sqs', + options?: ConnectionOptions + ): QueueImpl + public connection( con: 'fake', options?: ConnectionOptions ): QueueImpl public connection( - con: 'fake' | 'vanilla' | 'database' | string, + con: 'fake' | 'memory' | 'database' | 'aws_sqs' | string, options?: ConnectionOptions ): - | QueueImpl + | QueueImpl | QueueImpl + | QueueImpl | QueueImpl /** @@ -72,7 +84,7 @@ export class QueueImpl extends Macroable { * ``` */ public connection( - con: 'fake' | 'vanilla' | 'database' | string, + con: 'fake' | 'memory' | 'database' | 'aws_sqs' | string, options?: ConnectionOptions ): QueueImpl { const driver = ConnectionFactory.fabricate(con, options?.options) @@ -188,13 +200,13 @@ export class QueueImpl extends Macroable { } /** - * Remove an item from the queue and return. + * Get an item from the queue without removing it and return. * * @example * ```ts * await Queue.add({ name: 'lenon' }) * - * const user = await Queue.pop() + * const user = await Queue.peek() * ``` */ public async peek() { @@ -252,7 +264,19 @@ export class QueueImpl extends Macroable { * }) * ``` */ - public async process(processor: (item: unknown) => any | Promise) { + public async process(processor: (job: Job) => any | Promise) { return this.driver.process(processor) } + + /** + * Return the Worker facade. + * + * @example + * ```ts + * const worker = Queue.worker() + * ``` + */ + public worker() { + return Worker + } } diff --git a/src/types/ConnectionOptions.ts b/src/types/ConnectionOptions.ts index 0a066dc..fb6af3b 100644 --- a/src/types/ConnectionOptions.ts +++ b/src/types/ConnectionOptions.ts @@ -44,6 +44,8 @@ export type ConnectionOptions = { * Define the options for your connection. */ options?: { + [key: string]: any + /** * Define the number of attempts that your worker will * try to process a job. By default, the `attempts` option @@ -90,5 +92,14 @@ export type ConnectionOptions = { * @default Config.get(`queue.connections.${connection}.queue`) */ queue?: string + + /** + * Define the visibility timeout of the worker in seconds. + * If your worker is not able to process a job in the + * defined time, the job will be put back in the queue. + * + * @default 30 + */ + visibilityTimeout?: number } } diff --git a/src/types/Context.ts b/src/types/Context.ts index 4150d93..db0a58c 100644 --- a/src/types/Context.ts +++ b/src/types/Context.ts @@ -7,12 +7,12 @@ * file that was distributed with this source code. */ +import type { Job, ConnectionOptions } from '#src/types' + export type Context = { - id: string - data: T - attemptsLeft: number - queue: string - status: 'pending' | 'processing' - createdAt: Date - updatedAt: Date + name: string + connection: string + options: ConnectionOptions['options'] + traceId?: string + job: Job } diff --git a/src/types/Job.ts b/src/types/Job.ts new file mode 100644 index 0000000..e46da2e --- /dev/null +++ b/src/types/Job.ts @@ -0,0 +1,15 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export type Job = { + id: string + attempts: number + data: T + metadata?: Record +} diff --git a/src/types/WorkerHandler.ts b/src/types/WorkerHandler.ts new file mode 100644 index 0000000..8a99641 --- /dev/null +++ b/src/types/WorkerHandler.ts @@ -0,0 +1,12 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Context } from '#src/types/Context' + +export type WorkerHandler = (ctx?: Context) => any | Promise diff --git a/src/types/WorkerOptions.ts b/src/types/WorkerOptions.ts index f7fff22..424b285 100644 --- a/src/types/WorkerOptions.ts +++ b/src/types/WorkerOptions.ts @@ -8,6 +8,20 @@ */ export type WorkerOptions = { + /** + * The name of the worker. + * + * @default target.name + */ + name?: string + + /** + * The queue connection that will be used to get the configurations. + * + * @default Config.get('queue.default') + */ + connection?: string + /** * The alias that will be used to register the worker inside * the service container. diff --git a/src/types/index.ts b/src/types/index.ts index 53c0e9c..03db38e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +export * from '#src/types/Job' export * from '#src/types/Context' export * from '#src/types/WorkerOptions' export * from '#src/types/ConnectionOptions' diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..6bb9f34 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,53 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export class Utils { + /** + * Hash a string. + */ + public static hash32(s: string) { + let h = 0x811c9dc5 + + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + + return h >>> 0 + } + + /** + * Generate a random number between 0 and 1. + */ + public static prng01(seed: string) { + const x = this.hash32(seed) + + return x / 0xffffffff + } + + /** + * Calculate the noAckDelayMs based on the workerInterval. + * Factor ~ φ (1.618) +/- deterministic variation + * Never an exact multiple of the interval + */ + public static computeNoAckDelayMs(baseMs: number, seed: string) { + const spread = 0.25 + const PHI = (1 + Math.sqrt(5)) / 2 + const prng01 = this.prng01(seed) + const factor = PHI * (1 - spread + prng01 * (2 * spread)) + + let delay = Math.round(baseMs * factor) + + if (baseMs > 0 && delay % baseMs === 0) { + delay += Math.max(1, Math.round(baseMs * 0.137)) + } + + return Math.max(1, delay) + } +} diff --git a/src/worker/WorkerImpl.ts b/src/worker/WorkerImpl.ts new file mode 100644 index 0000000..3d3f5b2 --- /dev/null +++ b/src/worker/WorkerImpl.ts @@ -0,0 +1,194 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { WorkerTaskBuilder } from '#src/worker/WorkerTaskBuilder' +import { NotFoundWorkerTaskException } from '#src/exceptions/NotFoundWorkerTaskException' + +export class WorkerImpl { + public static loggerIsSet = false + public static rTracerPlugin: any + public static tasks: WorkerTaskBuilder[] = [] + + /** + * Create a new worker task. + * + * @example + * ```ts + * Worker.task().name('my_worker') + * .connection('memory') + * .handler((ctx) => console.log(`worker ${ctx.name} is running`)) + * .start() + * ``` + */ + public task() { + return new WorkerTaskBuilder() + } + + /** + * Set if the worker logger should be set or not. + * + * @example + * ```ts + * Worker.setLogger(true) + * ``` + */ + public setLogger(isToSetLogger: boolean) { + WorkerImpl.loggerIsSet = isToSetLogger + + return this + } + + /** + * Set if the rTracer plugin should be set or not. + * + * @example + * ```ts + * Worker.setRTracerPlugin(true) + * ``` + */ + public setRTracerPlugin(rTracerPlugin: any) { + WorkerImpl.rTracerPlugin = rTracerPlugin + + return this + } + + /** + * Returns a map with all worker tasks that has been registered. + * + * @example + * ```ts + * const tasks = Worker.getWorkerTasks() + * + * tasks.map(task => task.stop()) + * ``` + */ + public getWorkerTasks(): WorkerTaskBuilder[] { + return WorkerImpl.tasks + } + + /** + * Get a worker task by name. + * + * @example + * ```ts + * const task = Worker.getWorkerTaskByName('my_worker') + * ``` + */ + public getWorkerTaskByName(name: string): WorkerTaskBuilder | undefined { + return WorkerImpl.tasks.find(task => task.worker.name === name) + } + + /** + * Start all worker tasks. + * + * @example + * ```ts + * Worker.start() + * ``` + */ + public start() { + const workerTasks = this.getWorkerTasks() + + workerTasks.forEach(workerTask => workerTask.start()) + + return this + } + + /** + * Close all worker tasks. + * + * @example + * ```ts + * Worker.close() + * ``` + */ + public close() { + const workerTasks = this.getWorkerTasks() + + workerTasks.forEach(workerTask => workerTask.stop()) + + return this + } + + /** + * Force run a worker task by name. + * + * @example + * ```ts + * Worker.runByName('my_worker') + * ``` + */ + public async runByName(name: string) { + const workerTask = WorkerImpl.tasks.find(task => task.worker.name === name) + + if (!workerTask) { + throw new NotFoundWorkerTaskException(name) + } + + await workerTask.run() + } + + /** + * Start a worker task by name. + * + * @example + * ```ts + * Worker.startTaskByName('my_worker') + * ``` + */ + public startTaskByName(name: string) { + const workerTask = WorkerImpl.tasks.find(task => task.worker.name === name) + + if (!workerTask) { + throw new NotFoundWorkerTaskException(name) + } + + workerTask.start() + + return this + } + + /** + * Close a worker task by name. + * + * @example + * ```ts + * Worker.closeTaskByName('my_worker') + * ``` + */ + public closeTaskByName(name: string) { + const workerTask = WorkerImpl.tasks.find(task => task.worker.name === name) + + if (!workerTask) { + throw new NotFoundWorkerTaskException(name) + } + + workerTask.stop() + + return this + } + + /** + * Delete all worker tasks. + * + * @example + * ```ts + * Worker.truncate() + * ``` + */ + public truncate() { + const workerTasks = this.getWorkerTasks() + + workerTasks.forEach(workerTask => workerTask.stop()) + + WorkerImpl.tasks = [] + + return this + } +} diff --git a/src/worker/WorkerTaskBuilder.ts b/src/worker/WorkerTaskBuilder.ts new file mode 100644 index 0000000..9dda30a --- /dev/null +++ b/src/worker/WorkerTaskBuilder.ts @@ -0,0 +1,332 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Is } from '@athenna/common' +import { Log } from '@athenna/logger' +import { Queue } from '#src/facades/Queue' +import { WorkerImpl } from '#src/worker/WorkerImpl' +import type { Context, ConnectionOptions } from '#src/types' +import type { WorkerHandler } from '#src/types/WorkerHandler' + +export class WorkerTaskBuilder { + public worker: { + /** + * The name of the worker task. + */ + name?: string + + /** + * The queue connection of the worker task. + */ + connection?: string + + /** + * The interval instance of the worker task. + */ + interval?: NodeJS.Timeout + + /** + * Define if the worker task is registered. + */ + isRegistered?: boolean + + /** + * The custom options of the worker task. + */ + options?: ConnectionOptions['options'] + + /** + * The handler of the worker task. + */ + handler?: (ctx: Context) => any | Promise + + /** + * Define if the worker task is running. + */ + isRunning?: boolean + } = {} + + public constructor() { + this.worker.connection = Config.get('queue.default') + } + + /** + * Set the name of the worker task. + * + * @example + * ```ts + * new WorkerTaskBuilder().name('my_worker_task') + * ``` + */ + public name(name: string) { + this.worker.name = name + + return this + } + + /** + * Set the handler of the worker task. + * + * @example + * ```ts + * new WorkerTaskBuilder() + * .name('my_worker') + * .handler((ctx) => { + * console.log(ctx) + * }) + * .start() + * ``` + */ + public handler(handler: WorkerHandler) { + const logIfEnabled = (ctx: any) => { + if (WorkerImpl.loggerIsSet) { + const channel = Config.get('worker.logger.channel', 'worker') + const isToLogRequest = Config.get('worker.logger.isToLogRequest') + + if (!isToLogRequest) { + return Log.channelOrVanilla(channel).info(ctx) + } + + if (isToLogRequest(ctx)) { + return Log.channelOrVanilla(channel).info(ctx) + } + } + } + + this.worker.handler = async ctx => { + if (WorkerImpl.rTracerPlugin) { + return WorkerImpl.rTracerPlugin.runWithId(async ctx => { + await handler(ctx) + + logIfEnabled(ctx) + }) + } + + await handler(ctx) + + logIfEnabled(ctx) + } + + const task = WorkerImpl.tasks.find( + task => task.worker.name === this.worker.name + ) + + if (task) { + task.worker.isRegistered = true + task.worker.handler = this.worker.handler + + return this + } + + this.worker.isRegistered = true + + WorkerImpl.tasks.push(this) + + return this + } + + /** + * Set the custom options of the worker task. + * + * @example + * ```ts + * new WorkerTaskBuilder().options({ queue: 'my_queue_name' }) + * ``` + */ + public options(options: ConnectionOptions['options']) { + this.worker.options = options + + return this + } + + /** + * Set the connection of the worker task. + * + * @example + * ```ts + * new WorkerTaskBuilder().connection('memory') + * ``` + */ + public connection(connection: string) { + this.worker.connection = connection + + return this + } + + /** + * Force run the worker task. + * + * @example + * ```ts + * new WorkerTaskBuilder() + * .name('my_worker') + * .connection('memory') + * .handler((ctx) => console.log(`worker ${ctx.name} is running`)) + * .run() + * ``` + */ + public async run() { + const queue = Queue.connection(this.worker.connection, { + options: this.worker.options + }) + + await queue.process(job => { + const ctx = { + name: this.worker.name, + traceId: WorkerImpl.rTracerPlugin + ? WorkerImpl.rTracerPlugin.id() + : null, + connection: this.worker.connection, + options: this.worker.options, + job + } + + return this.worker.handler(ctx) + }) + } + + /** + * Start the worker task. + * + * @example + * ```ts + * new WorkerTaskBuilder() + * .name('my_worker') + * .connection('memory') + * .handler((ctx) => console.log(`worker ${ctx.name} is running`)) + * .start() + * ``` + */ + public start() { + if (!this.worker.isRegistered) { + return + } + + const intervalToRun = + this.worker.options?.workerInterval || + Config.get( + `queue.connections.${this.worker.connection}.workerInterval`, + 1000 + ) + + const initialOffset = this.computeInitialOffset(intervalToRun) + + this.worker.interval = setTimeout(async () => { + if (!this.worker.isRunning) { + this.worker.isRunning = true + + await this.run() + this.worker.isRunning = false + } + + this.scheduleNext(intervalToRun) + }, initialOffset) + } + + /** + * Stop the worker task. + * + * @example + * ```ts + * new WorkerTaskBuilder().stop() + * ``` + */ + public stop() { + if (!this.worker.isRegistered) { + return + } + + if (!this.worker.interval) { + return + } + + this.worker.isRegistered = false + this.worker.isRunning = false + + if (this.worker.interval) { + clearTimeout(this.worker.interval) + this.worker.interval = undefined + } + } + + /** + * Create the hash code of the worker task. + */ + private hashCode(s: string) { + let h = 0 + + for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0 + + return Math.abs(h) + } + + /** + * Compute the initial offset of the worker task. + */ + private computeInitialOffset(baseMs: number) { + const configured = + this.worker.options?.workerInitialOffsetMs ?? + Config.get( + `queue.connections.${this.worker.connection}.workerInitialOffsetMs`, + null + ) + + if (Is.Number(configured)) { + return Math.max(0, configured) + } + + const seed = `${this.worker.name}|${this.worker.connection}|${process.pid}` + + return this.hashCode(seed) % Math.max(1, baseMs) + } + + /** + * Compute the jitter of the worker task. + */ + private computeJitter(baseMs: number) { + const maxDefault = Math.min(250, Math.floor(baseMs / 2)) + const max = + this.worker.options?.workerJitterMaxMs ?? + Config.get( + `queue.connections.${this.worker.connection}.workerJitterMaxMs`, + maxDefault + ) + + if (!max || max <= 0) { + return 0 + } + + return Math.floor(Math.random() * (max + 1)) + } + + /** + * Schedule the next worker task. + */ + private scheduleNext(baseMs: number) { + if (!this.worker.isRegistered) { + return + } + + const delay = baseMs + this.computeJitter(baseMs) + + this.worker.interval = setTimeout(async () => { + if (this.worker.isRunning) { + return this.scheduleNext(baseMs) + } + + this.worker.isRunning = true + + await this.run() + + this.worker.isRunning = false + + this.scheduleNext(baseMs) + }, delay) + } +} diff --git a/src/workers/BaseWorker.ts b/src/workers/BaseWorker.ts deleted file mode 100644 index 0b004b4..0000000 --- a/src/workers/BaseWorker.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @athenna/queue - * - * (c) João Lenon - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { Queue } from '#src/facades/Queue' - -export class BaseWorker { - /** - * Define the connection queue that is going to be - * used by your worker class to process the data from - * your queue. - * - * @default Config.get('queue.default') - */ - public static connection() { - return Config.get('queue.default') - } - - /** - * Define the queue from where your worker will retrieve - * data from. By default, the `queue` option from your - * connection will be used. - * - * @default Config.get(`queue.connections.${connection}.queue`) - */ - public static queue() { - const connection = this.connection() - - return Config.get(`queue.connections.${connection}.queue`) - } - - /** - * Define the number of attempts that your worker will - * try to process a job. By default, the `attempts` option - * from your connection will be used and if not defined, - * the default value will be `1`. - * - * @default Config.get(`queue.connections.${connection}.attempts`, 1) - */ - public static attempts() { - const connection = this.connection() - - return Config.get(`queue.connections.${connection}.attempts`, 1) - } - - /** - * Define the backoff configuration for your worker re-attempts. - * By default, the `backoff` option from your connection - * will be used and if not defined, the default value - * will be `null`. - * - * @default Config.get(`queue.connections.${connection}.backoff`, null) - */ - public static backoff() { - const connection = this.connection() - - return Config.get(`queue.connections.${connection}.backoff`, null) - } - - /** - * Define the deadletter queue of your worker. If any - * problem happens when trying to consume your event, - * it will be added to the deadletter queue. - * - * @default Config.get(`queue.connections.${connection}.deadletter`) - */ - public static deadletter() { - const connection = this.connection() - - return Config.get(`queue.connections.${connection}.deadletter`, null) - } - - /** - * Define the interval in milliseconds where the worker will - * try to look for data in the queue. - * - * @default Config.get(`queue.connections.${connection}.workerInterval`, 1000) - */ - public static interval() { - const connection = this.connection() - - return Config.get(`queue.connections.${connection}.workerInterval`, 1000) - } - - /** - * Return an instance of the `Queue` facade. This - * is the same of doing: - * - * @example - * ```ts - * const queue = Queue.connection(Job.connection()).queue(Job.queue()) - * - * await queue.add({ hello: 'world' }) - * ``` - */ - public queue() { - const Job = this.constructor as typeof BaseWorker - - return Queue.connection(Job.connection(), { - options: { - queue: Job.queue(), - backoff: Job.backoff(), - attempts: Job.attempts(), - interval: Job.interval(), - deadletter: Job.deadletter() - } - }) - } -} diff --git a/templates/worker.edge b/templates/worker.edge index efc3b6e..1369eba 100644 --- a/templates/worker.edge +++ b/templates/worker.edge @@ -1,6 +1,8 @@ -import { Worker, BaseWorker } from '@athenna/queue' +import { Worker, type Context } from '@athenna/queue' @Worker() -export class {{ namePascal }} extends BaseWorker { - public async handle(data: unknown): Promise {} +export class {{ namePascal }} { + public async handle(ctx: Context) { + // + } } diff --git a/tests/fixtures/config/queue.ts b/tests/fixtures/config/queue.ts index ad8f70f..473d335 100644 --- a/tests/fixtures/config/queue.ts +++ b/tests/fixtures/config/queue.ts @@ -21,7 +21,7 @@ export default { | */ - default: Env('QUEUE_CONNECTION', 'vanilla'), + default: Env('QUEUE_CONNECTION', 'memory'), /* |-------------------------------------------------------------------------- @@ -32,19 +32,19 @@ export default { | is used by your application. A default configuration has been added | for each back-end shipped with Athenna. You are free to add more. | - | Drivers: "vanilla", "database", "fake" + | Drivers: "memory", "database", "awsSqs", "fake" | */ connections: { - vanilla: { - driver: 'vanilla', + memory: { + driver: 'memory', queue: 'default', deadletter: 'deadletter' }, - vanillaBackoff: { - driver: 'vanilla', + memoryBackoff: { + driver: 'memory', queue: 'default', deadletter: 'deadletter', attempts: 2, @@ -55,6 +55,26 @@ export default { } }, + awsSqs: { + driver: 'aws_sqs', + type: 'standard', + queue: 'https://sqs.sa-east-1.amazonaws.com/528757804004/athenna_queue', + deadletter: 'https://sqs.sa-east-1.amazonaws.com/528757804004/athenna_queue_dlq' + }, + + awsSqsBackoff: { + driver: 'aws_sqs', + type: 'standard', + queue: 'https://sqs.sa-east-1.amazonaws.com/528757804004/athenna_queue', + deadletter: 'https://sqs.sa-east-1.amazonaws.com/528757804004/athenna_queue_dlq', + attempts: 2, + backoff: { + type: 'fixed', + delay: 1000, + jitter: 0.5 + } + }, + database: { driver: 'database', table: 'jobs', diff --git a/tests/fixtures/constants/index.ts b/tests/fixtures/constants/index.ts index f4073cf..f02c56c 100644 --- a/tests/fixtures/constants/index.ts +++ b/tests/fixtures/constants/index.ts @@ -8,5 +8,11 @@ */ export const constants = { - PRODUCTS: [] + PRODUCTS: [], + RUN_MAP: { + helloWorker: false, + productWorker: false, + annotatedWorker: false, + decoratedWorker: false + } } diff --git a/tests/fixtures/drivers/TestDriver.ts b/tests/fixtures/drivers/TestDriver.ts index f4d3901..90db1bc 100644 --- a/tests/fixtures/drivers/TestDriver.ts +++ b/tests/fixtures/drivers/TestDriver.ts @@ -7,6 +7,6 @@ * file that was distributed with this source code. */ -import { VanillaDriver } from '#src' +import { MemoryDriver } from '#src' -export class TestDriver extends VanillaDriver {} +export class TestDriver extends MemoryDriver {} diff --git a/tests/fixtures/routes/worker.ts b/tests/fixtures/routes/worker.ts new file mode 100644 index 0000000..653d499 --- /dev/null +++ b/tests/fixtures/routes/worker.ts @@ -0,0 +1,15 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Queue } from '#src' + +Queue.worker() + .task() + .name('route_worker') + .handler(() => {}) diff --git a/tests/fixtures/routes/worker_absolute.ts b/tests/fixtures/routes/worker_absolute.ts new file mode 100644 index 0000000..330aa90 --- /dev/null +++ b/tests/fixtures/routes/worker_absolute.ts @@ -0,0 +1,15 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Queue } from '#src' + +Queue.worker() + .task() + .name('route_worker_absolute') + .handler(() => {}) diff --git a/tests/fixtures/routes/worker_partial.ts b/tests/fixtures/routes/worker_partial.ts new file mode 100644 index 0000000..96acf76 --- /dev/null +++ b/tests/fixtures/routes/worker_partial.ts @@ -0,0 +1,15 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Queue } from '#src' + +Queue.worker() + .task() + .name('route_worker_partial') + .handler(() => {}) diff --git a/tests/fixtures/workers/AnnotatedWorker.ts b/tests/fixtures/workers/AnnotatedWorker.ts index 22c1a5d..fd32d84 100644 --- a/tests/fixtures/workers/AnnotatedWorker.ts +++ b/tests/fixtures/workers/AnnotatedWorker.ts @@ -7,14 +7,17 @@ * file that was distributed with this source code. */ -import { Worker, BaseWorker, type Context } from '#src' +import { Worker } from '#src' +import { constants } from '#tests/fixtures/constants/index' @Worker({ type: 'singleton', - alias: 'annotatedWorker' + alias: 'decoratedWorker', + camelAlias: 'annotatedWorker' }) -export class AnnotatedWorker extends BaseWorker { - public async handle(data: Context) { - console.log(data) +export class AnnotatedWorker { + public async handle() { + constants.RUN_MAP.decoratedWorker = true + constants.RUN_MAP.annotatedWorker = true } } diff --git a/tests/fixtures/workers/HelloWorker.ts b/tests/fixtures/workers/HelloWorker.ts index 98ba615..43fcdc1 100644 --- a/tests/fixtures/workers/HelloWorker.ts +++ b/tests/fixtures/workers/HelloWorker.ts @@ -7,11 +7,10 @@ * file that was distributed with this source code. */ -import type { Context } from '#src' -import { BaseWorker } from '#src/workers/BaseWorker' +import { constants } from '#tests/fixtures/constants/index' -export class HelloWorker extends BaseWorker { - public async handle(data: Context) { - console.log(data) +export class HelloWorker { + public async handle() { + constants.RUN_MAP.helloWorker = true } } diff --git a/tests/fixtures/workers/ProductWorker.ts b/tests/fixtures/workers/ProductWorker.ts index b03cdc6..97d6a5f 100644 --- a/tests/fixtures/workers/ProductWorker.ts +++ b/tests/fixtures/workers/ProductWorker.ts @@ -7,44 +7,21 @@ * file that was distributed with this source code. */ -import { Worker, BaseWorker, type Context } from '#src' +import { Worker, type Context } from '#src' import { constants } from '#tests/fixtures/constants/index' -@Worker() -export class ProductWorker extends BaseWorker { - public static interval() { - return 100 - } - - public static queue() { - return 'products' - } - - public static deadletter() { - return 'products-deadletter' - } - - public static attempts() { - return 2 - } - - public static backoff() { - return { - type: 'fixed', - delay: 1000, - jitter: 0.5 - } - } - - public async handle(data: Context) { - if (data.data.failOnAllAttempts) { +@Worker({ connection: 'memory' }) +export class ProductWorker { + public async handle(ctx: Context) { + if (ctx.job.data.failOnAllAttempts) { throw new Error('testing') } - if (data.data.failOnFirstAttemptOnly && data.attemptsLeft >= 1) { + if (ctx.job.data.failOnFirstAttemptOnly && ctx.job.attempts >= 1) { throw new Error('testing') } - constants.PRODUCTS.push(data.data) + constants.PRODUCTS.push(ctx.job.data) + constants.RUN_MAP.productWorker = true } } diff --git a/tests/helpers/BaseTest.ts b/tests/helpers/BaseTest.ts index d47409f..124f7a9 100644 --- a/tests/helpers/BaseTest.ts +++ b/tests/helpers/BaseTest.ts @@ -8,12 +8,45 @@ */ import { Module } from '@athenna/common' -import { AfterEach } from '@athenna/test' +import { SQSClient, ReceiveMessageCommand, DeleteMessageBatchCommand } from '@aws-sdk/client-sqs' export class BaseTest { - @AfterEach() - public baseAfterEach() { - ioc.reconstruct() + /** + * Drain the SQS queue instead of purging it. + */ + public async drainAwsSqsQueue(queue: string, maxPolls = 30) { + const sqs = new SQSClient({ + region: Env('AWS_REGION'), + credentials: { + accessKeyId: Env('AWS_ACCESS_KEY_ID'), + secretAccessKey: Env('AWS_SECRET_ACCESS_KEY') + } + }) + + for (let i = 0; i < maxPolls; i++) { + const res = await sqs.send( + new ReceiveMessageCommand({ + QueueUrl: queue, + MaxNumberOfMessages: 10, + WaitTimeSeconds: 0, + AttributeNames: ['All'], + MessageAttributeNames: ['All'] + }) + ) + + const messages = res.Messages ?? [] + if (messages.length === 0) break + + await sqs.send( + new DeleteMessageBatchCommand({ + QueueUrl: queue, + Entries: messages.map(m => ({ + Id: m.MessageId!, + ReceiptHandle: m.ReceiptHandle! + })) + }) + ) + } } /** diff --git a/tests/unit/annotations/WorkerAnnotationTest.ts b/tests/unit/annotations/WorkerAnnotationTest.ts index 89339b1..850ef7a 100644 --- a/tests/unit/annotations/WorkerAnnotationTest.ts +++ b/tests/unit/annotations/WorkerAnnotationTest.ts @@ -9,9 +9,14 @@ import { Annotation } from '@athenna/ioc' import { BaseTest } from '#tests/helpers/BaseTest' -import { Test, type Context, Cleanup } from '@athenna/test' +import { Test, type Context, Cleanup, AfterEach } from '@athenna/test' export default class WorkerAnnotationTest extends BaseTest { + @AfterEach() + public async afterEach() { + ioc.reconstruct() + } + @Test() public async shouldBeAbleToPreregisterWorkersUsingWorkerAnnotation({ assert }: Context) { const ProductWorker = await this.import('#tests/fixtures/workers/ProductWorker') @@ -21,13 +26,15 @@ export default class WorkerAnnotationTest extends BaseTest { assert.isFalse(metadata.registered) assert.isUndefined(metadata.camelAlias) assert.equal(metadata.type, 'transient') - assert.equal(metadata.alias, 'App/Workers/ProductWorker') + assert.equal(metadata.name, 'ProductWorker') + assert.equal(metadata.connection, 'memory') + assert.equal(metadata.alias, 'App/Queue/Workers/ProductWorker') } @Test() @Cleanup(() => ioc.reconstruct()) public async shouldNotReRegisterTheWorkerAliasIfItIsAlreadyRegisteredInTheServiceContainer({ assert }: Context) { - ioc.singleton('App/Workers/ProductWorker', () => {}) + ioc.singleton('App/Queue/Workers/ProductWorker', () => {}) const ProductWorker = await this.import('#tests/fixtures/workers/ProductWorker') diff --git a/tests/unit/drivers/AwsSqsDriverTest.ts b/tests/unit/drivers/AwsSqsDriverTest.ts new file mode 100644 index 0000000..450f3c1 --- /dev/null +++ b/tests/unit/drivers/AwsSqsDriverTest.ts @@ -0,0 +1,214 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Is, Path } from '@athenna/common' +import { EnvHelper } from '@athenna/config' +import { LoggerProvider } from '@athenna/logger' +import { BaseTest } from '#tests/helpers/BaseTest' +import { Queue, QueueProvider, WorkerProvider } from '#src' +import { Test, type Context, BeforeEach, AfterEach, Skip } from '@athenna/test' + +export class AwsSqsDriverTest extends BaseTest { + @BeforeEach() + public async beforeEach() { + EnvHelper.resolveFilePath(Path.pwd('.env')) + await Config.loadAll(Path.fixtures('config')) + + new QueueProvider().register() + new WorkerProvider().register() + new LoggerProvider().register() + } + + @AfterEach() + public async afterEach() { + await Queue.closeAll() + + Queue.worker().close() + + ioc.reconstruct() + + Config.clear() + } + + @Test() + public async shouldBeAbleToConnectToDriver({ assert }: Context) { + Queue.connection('awsSqs') + + assert.isTrue(Queue.isConnected()) + } + + @Test() + public async shouldBeAbleToCloseTheConnectionWithDriver({ assert }: Context) { + const queue = Queue.connection('awsSqs') + + await queue.close() + + assert.isFalse(queue.isConnected()) + } + + @Test() + public async shouldBeAbleToCloneTheQueueInstance({ assert }: Context) { + const driver = Queue.connection('awsSqs').driver + const otherDriver = driver.clone() + + driver.isConnected = false + + assert.isTrue(otherDriver.isConnected) + } + + @Test() + public async shouldBeAbleToGetDriverClient({ assert }: Context) { + const client = Queue.connection('awsSqs').driver.getClient() + + assert.isDefined(client) + } + + @Test() + public async shouldBeAbleToSetDifferentClientForDriver({ assert }: Context) { + const driver = Queue.connection('awsSqs').driver + + driver.setClient({ hello: 'world' } as any) + + assert.deepEqual(driver.client, { hello: 'world' }) + } + + @Test() + public async shouldBeAbleToSeeHowManyJobsAreInsideTheQueue({ assert }: Context) { + const length = await Queue.connection('awsSqs').length() + + assert.isTrue(Is.Number(length)) + } + + @Test() + public async shouldBeAbleToAddJobsToTheQueue({ assert }: Context) { + const queue = Queue.connection('awsSqs') + + await queue.add({ hello: 'world' }) + + const isEmpty = await queue.isEmpty() + + assert.isFalse(isEmpty) + } + + @Test() + public async shouldBeAbleToVerifyIfTheQueueIsEmpty({ assert }: Context) { + const queue = Queue.connection('awsSqs') + + const isEmpty = await queue.isEmpty() + + assert.isTrue(Is.Boolean(isEmpty)) + } + + @Test() + @Skip('Peek is not supported in SQS.') + public async shouldBeAbleToPeekTheNextJobWithoutRemovingItFromTheQueue({ assert }: Context) { + const queue = Queue.connection('awsSqs') + + await queue.add({ name: 'lenon' }) + + const job = await queue.peek() + const length = await queue.length() + + assert.deepEqual(length, 1) + assert.containSubset(job, { + attempts: 1, + data: { name: 'lenon' } + }) + } + + @Test() + public async shouldBeAbleToPopTheNextJobRemovingItFromTheQueue({ assert }: Context) { + const queue = Queue.connection('awsSqs') + + await queue.add({ name: 'lenon' }) + + const job = await queue.pop() + + assert.containSubset(job, { + data: { name: 'lenon' } + }) + } + + @Test() + public async shouldBeAbleToProcessTheNextJobFromTheQueueWithAProcessor({ assert }: Context) { + assert.plan(1) + + const queue = Queue.connection('awsSqs') + + await queue.add({ name: 'lenon' }) + + await queue.process(async job => { + assert.containSubset(job, { + attempts: 1, + queue: 'default', + data: { name: 'lenon' } + }) + }) + } + + @Test() + public async shouldBeAbleToSendTheJobToDeadletterQueueIfProcessorFails({ assert }: Context) { + const queue = Queue.connection('awsSqs') + + await queue.add({ name: 'lenon' }) + + await queue.process(async () => { + throw new Error('testing') + }) + + const isEmpty = await queue.queue(Config.get('queue.connections.awsSqs.deadletter')).isEmpty() + + assert.isFalse(isEmpty) + } + + @Test() + public async shouldBeAbleToRetryTheJobIfBackoffIsConfiguredToQueue({ assert }: Context) { + assert.plan(3) + + const queue = Queue.connection('awsSqsBackoff') + + await queue.add({ name: 'lenon' }) + + await queue.process(async job => { + assert.containSubset(job, { + attempts: 1, + data: { name: 'lenon' } + }) + + throw new Error('testing') + }) + + await queue.process(async job => { + assert.containSubset(job, { + attempts: 0, + data: { name: 'lenon' } + }) + + throw new Error('testing') + }) + + const isEmpty = await queue.queue(Config.get('queue.connections.awsSqs.deadletter')).isEmpty() + + assert.isFalse(isEmpty) + } + + @Test() + @Skip('PurgeQueue can only be called every 60 seconds.') + public async shouldBeAbleToTruncateAllJobs({ assert }: Context) { + const queue = Queue.connection('awsSqs') + + await queue.add({ name: 'lenon' }) + + await queue.truncate() + + const isEmpty = await queue.isEmpty() + + assert.isTrue(isEmpty) + } +} diff --git a/tests/unit/drivers/DatabaseDriverTest.ts b/tests/unit/drivers/DatabaseDriverTest.ts index d3125f0..a1b09ce 100644 --- a/tests/unit/drivers/DatabaseDriverTest.ts +++ b/tests/unit/drivers/DatabaseDriverTest.ts @@ -7,8 +7,8 @@ * file that was distributed with this source code. */ -import { Path, Sleep } from '@athenna/common' import { Queue, QueueProvider } from '#src' +import { Path, Sleep } from '@athenna/common' import { LoggerProvider } from '@athenna/logger' import { Test, type Context, BeforeEach, AfterEach } from '@athenna/test' import { Database, DatabaseImpl, DatabaseProvider } from '@athenna/database' @@ -24,12 +24,12 @@ export class DatabaseDriverTest { await Database.createTable('jobs', builder => { builder.increments('id') - builder.string('queue').notNullable() - builder.string('formerQueue').nullable() + builder.string('queue').notNullable().index() builder.string('data').notNullable() - builder.integer('attemptsLeft').defaultTo(1) - builder.enu('status', ['pending', 'processing']).defaultTo('pending') - builder.timestamps(true, true, true) + builder.tinyint('attempts').defaultTo(1).unsigned() + builder.integer('availableAt').nullable().unsigned() + builder.integer('reservedUntil').nullable().unsigned() + builder.integer('createdAt').nullable().unsigned() }) } @@ -137,8 +137,7 @@ export class DatabaseDriverTest { assert.deepEqual(length, 1) assert.containSubset(job, { - status: 'pending', - attemptsLeft: 1, + attempts: 1, queue: 'default', data: { name: 'lenon' } }) @@ -155,8 +154,7 @@ export class DatabaseDriverTest { assert.deepEqual(length, 0) assert.containSubset(job, { - status: 'pending', - attemptsLeft: 1, + attempts: 1, queue: 'default', data: { name: 'lenon' } }) @@ -172,8 +170,7 @@ export class DatabaseDriverTest { await queue.process(async job => { assert.containSubset(job, { - status: 'processing', - attemptsLeft: 1, + attempts: 1, queue: 'default', data: { name: 'lenon' } }) @@ -197,34 +194,31 @@ export class DatabaseDriverTest { @Test() public async shouldBeAbleToRetryTheJobIfBackoffIsConfiguredToQueue({ assert }: Context) { + assert.plan(3) const queue = Queue.connection('databaseBackoff') await queue.add({ name: 'lenon' }) - await queue.process(async () => { + await queue.process(async job => { + assert.containSubset(job, { + attempts: 1, + data: { name: 'lenon' } + }) + throw new Error('testing') }) - await Sleep.for(1000).milliseconds().wait() + await Sleep.for(2000).milliseconds().wait() - const jobFirstAttempt = await queue.peek() - - assert.containSubset(jobFirstAttempt, { - status: 'pending', - attemptsLeft: 1, - data: { name: 'lenon' } - }) + await queue.process(async job => { + assert.containSubset(job, { + attempts: 0, + data: { name: 'lenon' } + }) - await queue.process(async () => { throw new Error('testing') }) - await Sleep.for(1000).milliseconds().wait() - - const jobSecondAttempt = await queue.peek() - - assert.isNull(jobSecondAttempt) - const length = await queue.queue('deadletter').length() assert.deepEqual(length, 1) diff --git a/tests/unit/drivers/VanillaDriverTest.ts b/tests/unit/drivers/MemoryDriverTest.ts similarity index 81% rename from tests/unit/drivers/VanillaDriverTest.ts rename to tests/unit/drivers/MemoryDriverTest.ts index 4c86cba..e57c676 100644 --- a/tests/unit/drivers/VanillaDriverTest.ts +++ b/tests/unit/drivers/MemoryDriverTest.ts @@ -12,7 +12,7 @@ import { Path, Sleep } from '@athenna/common' import { LoggerProvider } from '@athenna/logger' import { Test, type Context, BeforeEach, AfterEach } from '@athenna/test' -export class VanillaDriverTest { +export class MemoryDriverTest { @BeforeEach() public async beforeEach() { await Config.loadAll(Path.fixtures('config')) @@ -31,14 +31,14 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToConnectToDriver({ assert }: Context) { - Queue.connection('vanilla') + Queue.connection('memory') assert.isTrue(Queue.isConnected()) } @Test() public async shouldBeAbleToCloseTheConnectionWithDriver({ assert }: Context) { - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') await queue.close() @@ -47,7 +47,7 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToCloneTheQueueInstance({ assert }: Context) { - const driver = Queue.connection('vanilla').driver + const driver = Queue.connection('memory').driver const otherDriver = driver.clone() driver.isConnected = false @@ -57,14 +57,14 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToGetDriverClient({ assert }: Context) { - const client = Queue.connection('vanilla').driver.getClient() + const client = Queue.connection('memory').driver.getClient() assert.isDefined(client) } @Test() public async shouldBeAbleToSetDifferentClientForDriver({ assert }: Context) { - const driver = Queue.connection('vanilla').driver + const driver = Queue.connection('memory').driver driver.setClient({ hello: 'world' } as any) @@ -73,14 +73,14 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToSeeHowManyJobsAreInsideTheQueue({ assert }: Context) { - const length = await Queue.connection('vanilla').length() + const length = await Queue.connection('memory').length() assert.deepEqual(length, 0) } @Test() public async shouldBeAbleToAddJobsToTheQueue({ assert }: Context) { - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') await queue.add({ hello: 'world' }) @@ -93,7 +93,7 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToAddJobsToADifferentQueue({ assert }: Context) { - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') await queue.queue('other').add({ hello: 'world' }) @@ -106,7 +106,7 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToVerifyIfTheQueueIsEmpty({ assert }: Context) { - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') const isEmpty = await queue.isEmpty() @@ -115,7 +115,7 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToPeekTheNextJobWithoutRemovingItFromTheQueue({ assert }: Context) { - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') await queue.add({ name: 'lenon' }) @@ -124,15 +124,14 @@ export class VanillaDriverTest { assert.deepEqual(length, 1) assert.containSubset(job, { - status: 'pending', - attemptsLeft: 1, + attempts: 1, data: { name: 'lenon' } }) } @Test() public async shouldBeAbleToPopTheNextJobRemovingItFromTheQueue({ assert }: Context) { - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') await queue.add({ name: 'lenon' }) @@ -141,8 +140,7 @@ export class VanillaDriverTest { assert.deepEqual(length, 0) assert.containSubset(job, { - status: 'pending', - attemptsLeft: 1, + attempts: 1, data: { name: 'lenon' } }) } @@ -151,15 +149,13 @@ export class VanillaDriverTest { public async shouldBeAbleToProcessTheNextJobFromTheQueueWithAProcessor({ assert }: Context) { assert.plan(1) - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') await queue.add({ name: 'lenon' }) await queue.process(async job => { assert.containSubset(job, { - status: 'processing', - attemptsLeft: 1, - queue: 'default', + attempts: 1, data: { name: 'lenon' } }) }) @@ -167,7 +163,7 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToSendTheJobToDeadletterQueueIfProcessorFails({ assert }: Context) { - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') await queue.add({ name: 'lenon' }) @@ -182,7 +178,7 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToRetryTheJobIfBackoffIsConfiguredToQueue({ assert }: Context) { - const queue = Queue.connection('vanillaBackoff') + const queue = Queue.connection('memoryBackoff') await queue.add({ name: 'lenon' }) @@ -190,13 +186,12 @@ export class VanillaDriverTest { throw new Error('testing') }) - await Sleep.for(1000).milliseconds().wait() + await Sleep.for(1500).milliseconds().wait() const jobFirstAttempt = await queue.peek() assert.containSubset(jobFirstAttempt, { - status: 'pending', - attemptsLeft: 1, + attempts: 1, data: { name: 'lenon' } }) @@ -217,7 +212,7 @@ export class VanillaDriverTest { @Test() public async shouldBeAbleToTruncateAllJobs({ assert }: Context) { - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') await queue.add({ name: 'lenon' }) diff --git a/tests/unit/factories/ConnectionFactoryTest.ts b/tests/unit/factories/ConnectionFactoryTest.ts index 0388638..8ef20f9 100644 --- a/tests/unit/factories/ConnectionFactoryTest.ts +++ b/tests/unit/factories/ConnectionFactoryTest.ts @@ -11,8 +11,8 @@ import { Path } from '@athenna/common' import { TestDriver } from '#tests/fixtures/drivers/TestDriver' import { AfterEach, BeforeEach, Test, type Context } from '@athenna/test' import { NotFoundDriverException } from '#src/exceptions/NotFoundDriverException' -import { ConnectionFactory, FakeDriver, DatabaseDriver, VanillaDriver } from '#src' import { NotImplementedConfigException } from '#src/exceptions/NotImplementedConfigException' +import { FakeDriver, MemoryDriver, AwsSqsDriver, ConnectionFactory, DatabaseDriver } from '#src' export class ConnectionFactoryTest { @BeforeEach() @@ -31,7 +31,7 @@ export class ConnectionFactoryTest { public async shouldBeAbleToGetAllAvailableDrivers({ assert }: Context) { const availableDrivers = ConnectionFactory.availableDrivers() - assert.deepEqual(availableDrivers, ['fake', 'vanilla', 'database']) + assert.deepEqual(availableDrivers, ['fake', 'aws_sqs', 'memory', 'database']) } @Test() @@ -58,10 +58,10 @@ export class ConnectionFactoryTest { } @Test() - public async shouldBeAbleToFabricateNewConnectionsAndReturnVanillaDriverInstance({ assert }: Context) { - const driver = ConnectionFactory.fabricate('vanilla') + public async shouldBeAbleToFabricateNewConnectionsAndReturnMemoryDriverInstance({ assert }: Context) { + const driver = ConnectionFactory.fabricate('memory') - assert.instanceOf(driver, VanillaDriver) + assert.instanceOf(driver, MemoryDriver) } @Test() @@ -71,6 +71,13 @@ export class ConnectionFactoryTest { assert.instanceOf(driver, DatabaseDriver) } + @Test() + public async shouldBeAbleToFabricateNewConnectionsAndReturnAwsSqsDriverInstance({ assert }: Context) { + const driver = ConnectionFactory.fabricate('awsSqs') + + assert.instanceOf(driver, AwsSqsDriver) + } + @Test() public async shouldThrowNotFoundDriverExceptionWhenTryingToUseANotImplementedDriver({ assert }: Context) { assert.throws(() => ConnectionFactory.fabricate('not-found'), NotFoundDriverException) diff --git a/tests/unit/kernels/WorkerKernelTest.ts b/tests/unit/kernels/WorkerKernelTest.ts new file mode 100644 index 0000000..56a47ab --- /dev/null +++ b/tests/unit/kernels/WorkerKernelTest.ts @@ -0,0 +1,196 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Queue } from '#src/facades/Queue' +import { Worker } from '#src/facades/Worker' +import { Path, Sleep } from '@athenna/common' +import { LoggerProvider } from '@athenna/logger' +import { WorkerImpl } from '#src/worker/WorkerImpl' +import { WorkerKernel } from '#src/kernels/WorkerKernel' +import { constants } from '#tests/fixtures/constants/index' +import { QueueProvider } from '#src/providers/QueueProvider' +import { WorkerProvider } from '#src/providers/WorkerProvider' +import { Test, BeforeEach, AfterEach, type Context, Mock } from '@athenna/test' + +export class WorkerKernelTest { + @BeforeEach() + public async beforeEach() { + ioc.reconstruct() + + WorkerImpl.loggerIsSet = false + WorkerImpl.rTracerPlugin = undefined + + await Config.loadAll(Path.fixtures('config')) + new LoggerProvider().register() + new QueueProvider().register() + new WorkerProvider().register() + } + + @AfterEach() + public async afterEach() { + Mock.restoreAll() + + new WorkerProvider().shutdown() + + constants.RUN_MAP.helloWorker = false + constants.RUN_MAP.productWorker = false + constants.RUN_MAP.annotatedWorker = false + constants.RUN_MAP.decoratedWorker = false + } + + @Test() + public async shouldBeAbleToRegisterRTracerPluginInWorkerHandler({ assert }: Context) { + const kernel = new WorkerKernel() + + await kernel.registerRTracer() + + assert.isDefined(WorkerImpl.rTracerPlugin) + } + + @Test() + public async shouldNotRegisterRTracerPluginInWorkerHandlerIfRTracerConfigIsDisabled({ assert }: Context) { + Config.set('worker.rTracer.enabled', false) + + const kernel = new WorkerKernel() + + await kernel.registerRTracer() + + assert.isUndefined(WorkerImpl.rTracerPlugin) + } + + @Test() + public async shouldBeAbleToGetTraceIdInHandlerWhenRTracerPluginIsEnabled({ assert }: Context) { + const kernel = new WorkerKernel() + + await kernel.registerRTracer() + + let traceId = null + + Worker.task() + .name('r_tracer') + .connection('memory') + .handler(ctx => { + traceId = ctx.traceId + }) + .start() + + await Sleep.for(1500).milliseconds().wait() + + assert.isDefined(traceId) + } + + @Test() + public async shouldBeAbleToRegisterWorkersOfTheRcFileWithAndWithoutAnnotations({ assert }: Context) { + const kernel = new WorkerKernel() + await kernel.registerWorkers() + + assert.isFalse(ioc.has('helloWorker')) + assert.isTrue(ioc.has('App/Queue/Workers/HelloWorker')) + assert.equal(ioc.getRegistration('App/Queue/Workers/HelloWorker').lifetime, 'TRANSIENT') + + assert.isTrue(ioc.has('decoratedWorker')) + assert.isTrue(ioc.has('annotatedWorker')) + assert.isFalse(ioc.has('App/Queue/Workers/AnnotatedWorker')) + assert.equal(ioc.getRegistration('decoratedWorker').lifetime, 'SINGLETON') + } + + @Test() + public async shouldBeAbleToRegisterWorkerRouteFileByImportAlias({ assert }: Context) { + const kernel = new WorkerKernel() + + await kernel.registerRoutes('#tests/fixtures/routes/worker') + + const worker = Worker.getWorkerTaskByName('route_worker') + + assert.isDefined(worker) + } + + @Test() + public async shouldBeAbleToRegisterWorkerRouteFileByFullPath({ assert }: Context) { + const kernel = new WorkerKernel() + + await kernel.registerRoutes(Path.fixtures('routes/worker_absolute.ts')) + + const worker = Worker.getWorkerTaskByName('route_worker_absolute') + + assert.isDefined(worker) + } + + @Test() + public async shouldBeAbleToRegisterWorkerRouteFileByPartialPath({ assert }: Context) { + const kernel = new WorkerKernel() + + await kernel.registerRoutes('./tests/fixtures/routes/worker_partial.ts') + + const worker = Worker.getWorkerTaskByName('route_worker_partial') + + assert.isDefined(worker) + } + + @Test() + public async shouldBeAbleToRunWorkerRegisteredByWorkerKernel({ assert }: Context) { + const kernel = new WorkerKernel() + + await kernel.registerWorkers() + + await Queue.add({ test: 1 }) + + Queue.worker().start() + + await Sleep.for(20000).milliseconds().wait() + + assert.isTrue(constants.RUN_MAP.helloWorker) + assert.isTrue(constants.RUN_MAP.annotatedWorker) + assert.isTrue(constants.RUN_MAP.decoratedWorker) + assert.isTrue(constants.RUN_MAP.productWorker) + } + + @Test() + public async shouldBeAbleToRegisterRTracerPluginInWorkerHandlerAndRunAWorker({ assert }: Context) { + const kernel = new WorkerKernel() + + await kernel.registerRTracer() + await kernel.registerWorkers() + + await Queue.add({ test: 1 }) + + await Queue.worker().runByName('AnnotatedWorker') + + assert.isTrue(constants.RUN_MAP.annotatedWorker) + } + + @Test() + public async shouldBeAbleToRegisterLoggerInWorkerHandlerAndRunAWorker({ assert }: Context) { + const kernel = new WorkerKernel() + + await kernel.registerLogger() + await kernel.registerWorkers() + + await Queue.add({ test: 1 }) + + await Queue.worker().runByName('AnnotatedWorker') + + assert.isTrue(constants.RUN_MAP.annotatedWorker) + } + + @Test() + public async shouldBeAbleToRegisterLoggerAndRTracerPluginInWorkerHandlerAndRunAWorker({ assert }: Context) { + const kernel = new WorkerKernel() + + await kernel.registerLogger() + await kernel.registerRTracer() + await kernel.registerWorkers() + + await Queue.add({ test: 1 }) + + await Queue.worker().runByName('AnnotatedWorker') + + assert.isTrue(constants.RUN_MAP.annotatedWorker) + } +} diff --git a/tests/unit/providers/QueueProviderTest.ts b/tests/unit/providers/QueueProviderTest.ts index d145d0a..5fec0bf 100644 --- a/tests/unit/providers/QueueProviderTest.ts +++ b/tests/unit/providers/QueueProviderTest.ts @@ -45,7 +45,7 @@ export class QueueProviderTest { queueProvider.register() - const queue = Queue.connection('vanilla') + const queue = Queue.connection('memory') assert.isTrue(queue.isConnected()) @@ -53,4 +53,9 @@ export class QueueProviderTest { assert.isFalse(queue.isConnected()) } + + @Test() + public async shouldNotThrowErrorIfProviderIsNotRegisteredWhenShuttingDown({ assert }: Context) { + await assert.doesNotReject(() => new QueueProvider().shutdown()) + } } diff --git a/tests/unit/providers/WorkerProviderTest.ts b/tests/unit/providers/WorkerProviderTest.ts index e7d855c..104aa15 100644 --- a/tests/unit/providers/WorkerProviderTest.ts +++ b/tests/unit/providers/WorkerProviderTest.ts @@ -7,94 +7,49 @@ * file that was distributed with this source code. */ -import { Path, Sleep } from '@athenna/common' -import { LoggerProvider } from '@athenna/logger' -import { constants } from '#tests/fixtures/constants/index' +import { Path } from '@athenna/common' +import { Config } from '@athenna/config' import { Queue, QueueProvider, WorkerProvider } from '#src' -import { Test, AfterEach, BeforeEach, type Context } from '@athenna/test' +import { Test, Mock, BeforeEach, AfterEach, type Context } from '@athenna/test' export class WorkerProviderTest { - public workerProvider: WorkerProvider = new WorkerProvider() - @BeforeEach() public async beforeEach() { await Config.loadAll(Path.fixtures('config')) - - new QueueProvider().register() - new LoggerProvider().register() - - await this.workerProvider.boot() } @AfterEach() public async afterEach() { - const productWorker = ioc.safeUse('App/Workers/ProductWorker') - - await productWorker.queue().truncate() - await productWorker.queue().queue('products-deadletter').truncate() - - constants.PRODUCTS = [] - - await this.workerProvider.shutdown() - + Mock.restoreAll() ioc.reconstruct() Config.clear() } @Test() - public async shouldBeAbleToRegisterWorkersFromRcFile({ assert }: Context) { - assert.isTrue(ioc.has('annotatedWorker')) - assert.isTrue(ioc.has('App/Workers/HelloWorker')) - assert.isTrue(ioc.has('App/Workers/ProductWorker')) + public async shouldBeAbleToRegisterWorkerImplementationInTheContainer({ assert }: Context) { + new WorkerProvider().register() - assert.lengthOf(this.workerProvider.intervals, 3) + assert.isTrue(ioc.has('Athenna/Core/Worker')) } @Test() - public async shouldBeAbleToProcessEventsOfQueueUsingWorker({ assert }: Context) { - const productWorker = ioc.safeUse('App/Workers/ProductWorker') - - for (let i = 1; i <= 10; i++) { - await productWorker.queue().add({ name: 'iPhone' + ' ' + i }) - } - - await Sleep.for(2).seconds().wait() + public async shouldBeAbleToUseWorkerImplementationFromFacade({ assert }: Context) { + new QueueProvider().register() + new WorkerProvider().register() - assert.lengthOf(constants.PRODUCTS, 10) - assert.deepEqual(constants.PRODUCTS[0], { name: 'iPhone 1' }) + assert.deepEqual(Queue.worker().facadeAccessor, 'Athenna/Core/Worker') } @Test() - public async shouldBeAbleToRetryTheJobConfiguredInTheWorkerIfWorkerFailsToProcessIt({ assert }: Context) { - const productWorker = ioc.safeUse('App/Workers/ProductWorker') - - await productWorker.queue().add({ name: 'iPhone 1', failOnFirstAttemptOnly: true }) - - await Sleep.for(3).seconds().wait() - - const deadletterSize = await productWorker.queue().queue('products-deadletter').length() + public async shouldBeAbleToShutdownOpenWorkers({ assert }: Context) { + new QueueProvider().register() + new WorkerProvider().register() - assert.lengthOf(constants.PRODUCTS, 1) - assert.deepEqual(deadletterSize, 0) - assert.deepEqual(constants.PRODUCTS[0], { name: 'iPhone 1', failOnFirstAttemptOnly: true }) + assert.lengthOf(Queue.worker().getWorkerTasks(), 0) } @Test() - public async shouldBeAbleToSendJobToDeadletterIfWorkerFailsToProcessItInAllAttempts({ assert }: Context) { - const productWorker = ioc.safeUse('App/Workers/ProductWorker') - - await productWorker.queue().add({ name: 'iPhone 1', failOnAllAttempts: true }) - - await Sleep.for(3).seconds().wait() - - const jobInDeadletter = await Queue.connection('vanilla').queue('products-deadletter').peek() - - assert.containSubset(jobInDeadletter, { - attemptsLeft: 0, - status: 'pending', - queue: 'products-deadletter', - formerQueue: 'products', - data: { name: 'iPhone 1', failOnAllAttempts: true } - }) + public async shouldNotThrowErrorIfProviderIsNotRegisteredWhenShuttingDown({ assert }: Context) { + await assert.doesNotReject(() => new WorkerProvider().shutdown()) } } diff --git a/tests/unit/worker/WorkerImplTest.ts b/tests/unit/worker/WorkerImplTest.ts new file mode 100644 index 0000000..be2f762 --- /dev/null +++ b/tests/unit/worker/WorkerImplTest.ts @@ -0,0 +1,223 @@ +/** + * @athenna/queue + * + * (c) João Lenon + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Queue } from '#src' +import { Path, Sleep } from '@athenna/common' +import { LoggerProvider } from '@athenna/logger' +import { WorkerImpl } from '#src/worker/WorkerImpl' +import { QueueProvider } from '#src/providers/QueueProvider' +import { WorkerProvider } from '#src/providers/WorkerProvider' +import { Test, BeforeEach, AfterEach, type Context } from '@athenna/test' +import { NotFoundWorkerTaskException } from '#src/exceptions/NotFoundWorkerTaskException' + +export class WorkerImplTest { + @BeforeEach() + public async beforeEach() { + await Config.loadAll(Path.fixtures('config')) + + new LoggerProvider().register() + new QueueProvider().register() + new WorkerProvider().register() + } + + @AfterEach() + public async afterEach() { + WorkerImpl.loggerIsSet = false + WorkerImpl.rTracerPlugin = undefined + + await new QueueProvider().shutdown() + await new WorkerProvider().shutdown() + + ioc.reconstruct() + Config.clear() + } + + @Test() + public async shouldBeAbleToCreateAWorkerTask({ assert }: Context) { + let hasRun = false + + await Queue.add({ test: 1 }) + + Queue.worker() + .task() + .name('test') + .handler(() => (hasRun = true)) + .start() + + await Sleep.for(1500).milliseconds().wait() + + assert.isTrue(hasRun) + } + + @Test() + public async shouldBeAbleToGetWorkerTaskByName({ assert }: Context) { + Queue.worker() + .task() + .name('getByName') + .handler(() => {}) + + const task = Queue.worker().getWorkerTaskByName('getByName') + + assert.isDefined(task) + } + + @Test() + public async shouldBeAbleToListAllWorkerTasks({ assert }: Context) { + Queue.worker() + .task() + .name('listAll') + .handler(() => {}) + + const tasks = Queue.worker().getWorkerTasks() + + assert.isTrue(tasks.length >= 1) + } + + @Test() + public async shouldBeAbleToStopAllWorkerTasks({ assert }: Context) { + let hasRun = false + + Queue.worker() + .task() + .name('stopAll') + .handler(() => (hasRun = true)) + .start() + + const task = Queue.worker().getWorkerTaskByName('stopAll') + + assert.isTrue(task?.worker.isRegistered) + + Queue.worker().close() + + assert.isFalse(hasRun) + } + + @Test() + public async shouldBeAbleToCreateAWorkerTaskWithName({ assert }: Context) { + Queue.worker() + .task() + .name('myTask') + .handler(() => {}) + .start() + + const task = Queue.worker().getWorkerTaskByName('myTask') + + assert.isDefined(task) + } + + @Test() + public async shouldBeAbleToManuallyRunAWorkerTaskByName({ assert }: Context) { + let hasRun = false + + await Queue.add({ test: 1 }) + + Queue.worker() + .task() + .name('manual_run') + .handler(() => (hasRun = true)) + + await Queue.worker().runByName('manual_run') + + assert.isTrue(hasRun) + } + + @Test() + public async shouldThrowNotFoundWorkerTaskExceptionIfTryingToManuallyRunAWorkerThatDoesNotExist({ assert }: Context) { + await assert.rejects(() => Queue.worker().runByName('not_found'), NotFoundWorkerTaskException) + } + + @Test() + public async shouldBeAbleToStartAWorkerTaskByName({ assert }: Context) { + let hasRun = false + + await Queue.add({ test: 1 }) + + Queue.worker() + .task() + .name('manual_run') + .handler(() => (hasRun = true)) + + Queue.worker().startTaskByName('manual_run') + + await Sleep.for(1500).milliseconds().wait() + + assert.isTrue(hasRun) + } + + @Test() + public async shouldThrowNotFoundWorkerTaskExceptionIfTryingToStartAWorkerThatDoesNotExist({ assert }: Context) { + await assert.rejects(() => Queue.worker().startTaskByName('not_found'), NotFoundWorkerTaskException) + } + + @Test() + public async shouldBeAbleToCreateAWorkerTaskWithCustomConnection({ assert }: Context) { + Queue.worker() + .task() + .name('custom_connection') + .connection('fake') + .handler(() => {}) + + const task = Queue.worker().getWorkerTaskByName('custom_connection') + + assert.deepEqual(task?.worker.connection, 'fake') + } + + @Test() + public async shouldBeAbleToCreateAWorkerTaskWithCustomOptions({ assert }: Context) { + Queue.worker() + .task() + .name('custom_options') + .options({ + workerInterval: 1000, + attempts: 2, + backoff: { + type: 'fixed', + delay: 100, + jitter: 0.5 + } + }) + .handler(() => {}) + + const task = Queue.worker().getWorkerTaskByName('custom_options') + + assert.deepEqual(task.worker.options.workerInterval, 1000) + assert.deepEqual(task.worker.options.attempts, 2) + assert.deepEqual(task.worker.options.backoff.type, 'fixed') + assert.deepEqual(task.worker.options.backoff.delay, 100) + assert.deepEqual(task.worker.options.backoff.jitter, 0.5) + } + + @Test() + public async shouldBeAbleToOverwriteSchedulersIfUsingSameName({ assert }: Context) { + let value = -1 + + await Queue.add({ test: 1 }) + + await Queue.worker() + .task() + .name('overwriteScheduler') + .handler(() => (value = 0)) + .run() + + await Queue.add({ test: 1 }) + + await Queue.worker() + .task() + .name('overwriteScheduler') + .handler(() => (value = 1)) + .run() + + const tasks = Queue.worker() + .getWorkerTasks() + .filter(task => task.worker.name === 'overwriteScheduler') + + assert.lengthOf(tasks, 1) + assert.deepEqual(value, 1) + } +}