diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..37f298f --- /dev/null +++ b/.env.test @@ -0,0 +1,8 @@ +REDIS_DB=0 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_USERNAME= +REDIS_PASSWORD=sOmE_sEcUrE_pAsS +REDIS_USE_TLS=false +REDIS_CONNECT_TIMEOUT= +REDIS_COMMAND_TIMEOUT= diff --git a/.eslintrc.json b/.eslintrc.json index 5068180..68d9e38 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,7 +14,7 @@ "ecmaVersion": 2022, "sourceType": "module", "tsconfigRootDir": "./", - "project": ["./tsconfig.json"] + "project": ["./tsconfig.lint.json"] }, "rules": { "@typescript-eslint/consistent-type-definitions": "off", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e37e6f4..e1263d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [20.x, 22.4] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 182071b..dd0c771 100644 --- a/.gitignore +++ b/.gitignore @@ -73,7 +73,6 @@ typings/ # dotenv environment variables file .env -.env.test # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/README.md b/README.md index 6a386b1..4b8c1dc 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Reusable plugins for Fastify. - [Split IO Plugin](#split-io-plugin) - [BugSnag Plugin](#bugsnag-plugin) - [Metrics Plugin](#metrics-plugin) + - [Bull MQ Metrics Plugin](#bullmq-metrics-plugin) - [NewRelic Transaction Manager Plugin](#newrelic-transaction-manager-plugin) - [UnhandledException Plugin](#unhandledexception-plugin) @@ -33,7 +34,8 @@ The following needs to be taken into consideration when adding new runtime depen - `@fastify/jwt`; - `fastify`; - `newrelic`; -- `pino`. +- `pino`; +- `bullmq`; ## Plugins @@ -132,6 +134,33 @@ Add the plugin to your Fastify instance by registering it with the following opt The plugin exposes a `GET /metrics` route in your Fastify app to retrieve Prometheus metrics. If something goes wrong while starting the Prometheus metrics server, an `Error` is thrown. Otherwise, a success message is displayed when the plugin has been loaded. +### BullMQ Metrics Plugin + +Plugin to auto-discover BullMQ queues which can regularly collect metrics for them and expose via `fastify-metrics` global Prometheus registry. If used together with `metricsPlugin`, it will show these metrics on `GET /metrics` route. + +This plugin depends on the following peer-installed packages: + +- `bullmq` +- `ioredis` + +Add the plugin to your Fastify instance by registering it with the following possible options: + +- `redisClient`, a Redis client instance which is used by the BullMQ: plugin uses it to discover the queues; +- `bullMqPrefix` (optional, default: `bull`). The prefix used by BullMQ to store the queues in Redis; +- `metricsPrefix` (optional, default: `bullmq`). The prefix for the metrics in Prometheus; +- `queueDiscoverer` (optional, default: `BackgroundJobsBasedQueueDiscoverer`). The queue discoverer to use. The default one relies on the logic implemented by `@lokalise/background-jobs-common` where queue names are registered by the background job processors; If you are not using `@lokalise/background-jobs-common`, you can use your own queue discoverer by instantiating a `RedisBasedQueueDiscoverer` or implementing a `QueueDiscoverer` interface; +- `excludedQueues` (optional, default: `[]`). An array of queue names to exclude from metrics collection; +- `histogramBuckets` (optional, default: `[20, 50, 150, 400, 1000, 3000, 8000, 22000, 60000, 150000]`). Buckets for the histogram metrics (such as job completion or overall processing time). +- `collectionOptions` (optional, default: `{ type: 'interval', intervalInMs: 5000 }`). Allows to configure how metrics are collected. Supports the following properties: + - `type`. Can be either `interval` or `manual`. + - With `interval` type, plugin automatically loops and updates metrics at the specified interval. + - With `manual` type, you need to call `app.bullMqMetrics.collect()` to update the metrics; that allows you to build your own logic for scheduling the updates. + - `intervalInMs` (only for `type: 'interval'`). The interval in milliseconds at which the metrics are collected; + +This plugin exposes `bullMqMetrics.collect()` method on the Fastify instance to manually trigger the metrics collection. + +If something goes wrong while starting the BullMQ metrics plugin, an `Error` is thrown. + ### NewRelic Transaction Manager Plugin Plugin to create custom NewRelic spans for background jobs. diff --git a/lib/index.ts b/lib/index.ts index feedf70..d4f2cb1 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -34,6 +34,14 @@ export type { HealthcheckMetricsPluginOptions, } from './plugins/healthcheck/healthcheckMetricsPlugin' +export { bullMqMetricsPlugin } from './plugins/bullMqMetricsPlugin' +export type { BullMqMetricsPluginOptions } from './plugins/bullMqMetricsPlugin' +export { + RedisBasedQueueDiscoverer, + BackgroundJobsBasedQueueDiscoverer, +} from './plugins/bull-mq-metrics/queueDiscoverers' +export type { QueueDiscoverer } from './plugins/bull-mq-metrics/queueDiscoverers' + export { metricsPlugin } from './plugins/metricsPlugin' export type { ErrorObjectResolver, MetricsPluginOptions } from './plugins/metricsPlugin' diff --git a/lib/plugins/bull-mq-metrics/CollectionScheduler.ts b/lib/plugins/bull-mq-metrics/CollectionScheduler.ts new file mode 100644 index 0000000..52dbddb --- /dev/null +++ b/lib/plugins/bull-mq-metrics/CollectionScheduler.ts @@ -0,0 +1,28 @@ +import { setTimeout } from 'node:timers/promises' + +export type CollectionScheduler = { + start: () => void + stop: () => void +} + +export class PromiseBasedCollectionScheduler implements CollectionScheduler { + private active = true + private readonly collectionIntervalInMs: number + private readonly collect: () => Promise + + constructor(collectionIntervalInMs: number, collect: () => Promise) { + this.collectionIntervalInMs = collectionIntervalInMs + this.collect = collect + } + + async start(): Promise { + while (this.active) { + await this.collect() + await setTimeout(this.collectionIntervalInMs) + } + } + + stop(): void { + this.active = false + } +} diff --git a/lib/plugins/bull-mq-metrics/MetricsCollector.ts b/lib/plugins/bull-mq-metrics/MetricsCollector.ts new file mode 100644 index 0000000..084918c --- /dev/null +++ b/lib/plugins/bull-mq-metrics/MetricsCollector.ts @@ -0,0 +1,133 @@ +import { PromisePool } from '@supercharge/promise-pool' +import type { FastifyBaseLogger } from 'fastify' +import type { Redis } from 'ioredis' +import * as prometheus from 'prom-client' + +import { ObservableQueue } from './ObservableQueue' +import type { QueueDiscoverer } from './queueDiscoverers' + +export type Metrics = { + completedGauge: prometheus.Gauge + activeGauge: prometheus.Gauge + delayedGauge: prometheus.Gauge + failedGauge: prometheus.Gauge + waitingGauge: prometheus.Gauge + completedDuration: prometheus.Histogram + processedDuration: prometheus.Histogram +} + +export type MetricCollectorOptions = { + redisClient: Redis + bullMqPrefix: string + metricsPrefix: string + queueDiscoverer: QueueDiscoverer + excludedQueues: string[] + histogramBuckets: number[] +} + +const getMetrics = (prefix: string, histogramBuckets: number[]): Metrics => ({ + completedGauge: new prometheus.Gauge({ + name: `${prefix}_jobs_completed`, + help: 'Total number of completed jobs', + labelNames: ['queue'], + }), + activeGauge: new prometheus.Gauge({ + name: `${prefix}_jobs_active`, + help: 'Total number of active jobs (currently being processed)', + labelNames: ['queue'], + }), + failedGauge: new prometheus.Gauge({ + name: `${prefix}_jobs_failed`, + help: 'Total number of failed jobs', + labelNames: ['queue'], + }), + delayedGauge: new prometheus.Gauge({ + name: `${prefix}_jobs_delayed`, + help: 'Total number of jobs that will run in the future', + labelNames: ['queue'], + }), + waitingGauge: new prometheus.Gauge({ + name: `${prefix}_jobs_waiting`, + help: 'Total number of jobs waiting to be processed', + labelNames: ['queue'], + }), + processedDuration: new prometheus.Histogram({ + name: `${prefix}_jobs_processed_duration`, + help: 'Processing time for completed jobs (processing until completed)', + buckets: histogramBuckets, + labelNames: ['queue'], + }), + completedDuration: new prometheus.Histogram({ + name: `${prefix}_jobs_completed_duration`, + help: 'Completion time for jobs (created until completed)', + buckets: histogramBuckets, + labelNames: ['queue'], + }), +}) + +export class MetricsCollector { + private readonly redis: Redis + private readonly metrics: Metrics + private observedQueues: ObservableQueue[] | undefined + + constructor( + private readonly options: MetricCollectorOptions, + private readonly registry: prometheus.Registry, + private readonly logger: FastifyBaseLogger, + ) { + this.redis = options.redisClient + this.metrics = this.registerMetrics(this.registry, this.options) + } + + /** + * Updates metrics for all discovered queues + */ + async collect() { + if (!this.observedQueues) { + this.observedQueues = (await this.options.queueDiscoverer.discoverQueues()) + .filter((name) => !this.options.excludedQueues.includes(name)) + .map((name) => new ObservableQueue(name, this.redis, this.metrics, this.logger)) + } + + await PromisePool.for(this.observedQueues).process((queue) => { + void queue.collect() + }) + } + + /** + * Stops the metrics collection and cleans up resources + */ + async dispose() { + for (const queue of this.observedQueues ?? []) { + await queue.dispose() + } + } + + private registerMetrics( + registry: prometheus.Registry, + { metricsPrefix, histogramBuckets }: MetricCollectorOptions, + ): Metrics { + const metrics = getMetrics(metricsPrefix, histogramBuckets) + const metricNames = Object.keys(metrics) + + // If metrics are already registered, just return them to avoid triggering a Prometheus error + if (metricNames.length > 0 && registry.getSingleMetric(metricNames[0])) { + const retrievedMetrics = registry.getMetricsAsArray() + const returnValue: Record = {} + + for (const metric of retrievedMetrics) { + if (metricNames.includes(metric.name)) { + returnValue[metric.name as keyof Metrics] = metric + } + } + + return returnValue as unknown as Metrics + } + + for (const metric of Object.values(metrics)) { + registry.registerMetric(metric) + } + + return metrics + } +} diff --git a/lib/plugins/bull-mq-metrics/ObservableQueue.ts b/lib/plugins/bull-mq-metrics/ObservableQueue.ts new file mode 100644 index 0000000..356d96c --- /dev/null +++ b/lib/plugins/bull-mq-metrics/ObservableQueue.ts @@ -0,0 +1,60 @@ +import { Queue, QueueEvents } from 'bullmq' +import type { FastifyBaseLogger } from 'fastify' +import type { Redis } from 'ioredis' + +import type { Metrics } from './MetricsCollector' + +export class ObservableQueue { + private readonly queue: Queue + private readonly events: QueueEvents + + constructor( + readonly name: string, + private readonly redis: Redis, + private readonly metrics: Metrics, + private readonly logger: FastifyBaseLogger, + ) { + this.queue = new Queue(name, { connection: redis }) + this.events = new QueueEvents(name, { connection: redis }) + + this.events.on('completed', (completedJob: { jobId: string }) => { + this.queue + .getJob(completedJob.jobId) + .then((job) => { + if (!job) { + return + } + + if (job.finishedOn) { + metrics.completedDuration + .labels({ queue: name }) + .observe(job.finishedOn - job.timestamp) + + if (job.processedOn) { + metrics.processedDuration + .labels({ queue: name }) + .observe(job.finishedOn - job.processedOn) + } + } + }) + .catch((err) => { + this.logger.warn(err) + }) + }) + } + + async collect() { + const { completed, active, delayed, failed, waiting } = await this.queue.getJobCounts() + + this.metrics.activeGauge.labels({ queue: this.name }).set(active) + this.metrics.completedGauge.labels({ queue: this.name }).set(completed) + this.metrics.delayedGauge.labels({ queue: this.name }).set(delayed) + this.metrics.failedGauge.labels({ queue: this.name }).set(failed) + this.metrics.waitingGauge.labels({ queue: this.name }).set(waiting) + } + + async dispose() { + await this.events.close() + await this.queue.close() + } +} diff --git a/lib/plugins/bull-mq-metrics/queueDiscoverers.ts b/lib/plugins/bull-mq-metrics/queueDiscoverers.ts new file mode 100644 index 0000000..8ce4797 --- /dev/null +++ b/lib/plugins/bull-mq-metrics/queueDiscoverers.ts @@ -0,0 +1,39 @@ +import { AbstractBackgroundJobProcessor } from '@lokalise/background-jobs-common' +import type { Redis } from 'ioredis' + +export type QueueDiscoverer = { + discoverQueues: () => Promise +} + +export class RedisBasedQueueDiscoverer implements QueueDiscoverer { + constructor( + private readonly redis: Redis, + private readonly queuesPrefix: string, + ) {} + + async discoverQueues(): Promise { + const scanStream = this.redis.scanStream({ + match: `${this.queuesPrefix}:*:meta`, + }) + + const queues = new Set() + for await (const chunk of scanStream) { + // ESLint doesn't want a `;` here but Prettier does + // eslint-disable-next-line no-extra-semi + ;(chunk as string[]) + .map((key) => key.split(':')[1]) + .filter((value) => !!value) + .forEach((queue) => queues.add(queue)) + } + + return Array.from(queues).sort() + } +} + +export class BackgroundJobsBasedQueueDiscoverer implements QueueDiscoverer { + constructor(private readonly redis: Redis) {} + + async discoverQueues(): Promise { + return await AbstractBackgroundJobProcessor.getActiveQueueIds(this.redis) + } +} diff --git a/lib/plugins/bullMqMetricsPlugin.spec.ts b/lib/plugins/bullMqMetricsPlugin.spec.ts new file mode 100644 index 0000000..17e07fe --- /dev/null +++ b/lib/plugins/bullMqMetricsPlugin.spec.ts @@ -0,0 +1,149 @@ +import { setTimeout } from 'node:timers/promises' + +import { buildClient, sendGet, UNKNOWN_RESPONSE_SCHEMA } from '@lokalise/backend-http-client' +import type { + AbstractBackgroundJobProcessor, + BaseJobPayload, +} from '@lokalise/background-jobs-common' +import type { FastifyInstance } from 'fastify' +import fastify from 'fastify' +import type { Redis } from 'ioredis' + +import { TestBackgroundJobProcessor } from '../../test/mocks/TestBackgroundJobProcessor' +import { TestDepedendencies } from '../../test/mocks/TestDepedendencies' + +import { RedisBasedQueueDiscoverer } from './bull-mq-metrics/queueDiscoverers' +import type { BullMqMetricsPluginOptions } from './bullMqMetricsPlugin' +import { bullMqMetricsPlugin } from './bullMqMetricsPlugin' +import { metricsPlugin } from './metricsPlugin' + +type TestOptions = { + enableMetricsPlugin: boolean +} + +const DEFAULT_TEST_OPTIONS = { enableMetricsPlugin: true } + +export async function initAppWithBullMqMetrics( + pluginOptions: BullMqMetricsPluginOptions, + { enableMetricsPlugin }: TestOptions = DEFAULT_TEST_OPTIONS, +) { + const app = fastify() + + if (enableMetricsPlugin) { + await app.register(metricsPlugin, { + bindAddress: '0.0.0.0', + loggerOptions: false, + errorObjectResolver: (err: unknown) => err, + }) + } + + await app.register(bullMqMetricsPlugin, { + queueDiscoverer: new RedisBasedQueueDiscoverer(pluginOptions.redisClient, 'bull'), + collectionOptions: { + type: 'interval', + intervalInMs: 50, + }, + ...pluginOptions, + }) + + await app.ready() + return app +} + +type JobReturn = { + result: 'done' +} + +async function getMetrics() { + return await sendGet(buildClient('http://127.0.0.1:9080'), '/metrics', { + requestLabel: 'test', + responseSchema: UNKNOWN_RESPONSE_SCHEMA, + }) +} + +describe('bullMqMetricsPlugin', () => { + let app: FastifyInstance + let dependencies: TestDepedendencies + let processor: AbstractBackgroundJobProcessor + let redis: Redis + + beforeEach(async () => { + dependencies = new TestDepedendencies() + redis = dependencies.startRedis() + await redis?.flushall('SYNC') + + processor = new TestBackgroundJobProcessor( + dependencies.createMocksForBackgroundJobProcessor(), + { result: 'done' }, + 'test_job', + ) + await processor.start() + }) + + afterEach(async () => { + if (app) { + await app.close() + } + await dependencies.dispose() + await processor.dispose() + }) + + it('adds BullMQ metrics to Prometheus metrics endpoint', async () => { + app = await initAppWithBullMqMetrics({ + redisClient: redis, + }) + + await processor.schedule({ + metadata: { + correlationId: 'test', + }, + }) + + await setTimeout(100) + + const response = await getMetrics() + + expect(response.result.statusCode).toBe(200) + expect(response.result.body).toContain('bullmq_jobs_completed{queue="test_job"} 1') + }) + + it('throws if fastify-metrics was not initialized', async () => { + await expect(() => { + return initAppWithBullMqMetrics( + { + redisClient: redis, + }, + { + enableMetricsPlugin: false, + }, + ) + }).rejects.toThrowError( + 'No Prometheus Client found, BullMQ metrics plugin requires `fastify-metrics` plugin to be registered', + ) + }) + + it('exposes metrics collect() function', async () => { + app = await initAppWithBullMqMetrics({ + redisClient: redis, + collectionOptions: { + type: 'manual', + }, + }) + + await processor.schedule({ + metadata: { + correlationId: 'test', + }, + }) + + await setTimeout(100) + + const responseBefore = await getMetrics() + expect(responseBefore.result.body).not.toContain('bullmq_jobs_completed{queue="test_job"} 1') + + await app.bullMqMetrics.collect() + + const responseAfter = await getMetrics() + expect(responseAfter.result.body).toContain('bullmq_jobs_completed{queue="test_job"} 1') + }) +}) diff --git a/lib/plugins/bullMqMetricsPlugin.ts b/lib/plugins/bullMqMetricsPlugin.ts new file mode 100644 index 0000000..7130e79 --- /dev/null +++ b/lib/plugins/bullMqMetricsPlugin.ts @@ -0,0 +1,98 @@ +import type { FastifyInstance } from 'fastify' +import 'fastify-metrics' +import fp from 'fastify-plugin' +import type { Redis } from 'ioredis' + +import type { CollectionScheduler } from './bull-mq-metrics/CollectionScheduler' +import { PromiseBasedCollectionScheduler } from './bull-mq-metrics/CollectionScheduler' +import type { MetricCollectorOptions } from './bull-mq-metrics/MetricsCollector' +import { MetricsCollector } from './bull-mq-metrics/MetricsCollector' +import { BackgroundJobsBasedQueueDiscoverer } from './bull-mq-metrics/queueDiscoverers' + +// Augment existing FastifyRequest interface with new fields +declare module 'fastify' { + interface FastifyInstance { + bullMqMetrics: { + collect: () => Promise + } + } +} + +export type BullMqMetricsPluginOptions = { + redisClient: Redis + collectionOptions?: + | { + type: 'interval' + intervalInMs: number + } + | { + type: 'manual' + } +} & Partial + +function plugin( + fastify: FastifyInstance, + pluginOptions: BullMqMetricsPluginOptions, + next: (err?: Error) => void, +) { + if (!fastify.metrics) { + return next( + new Error( + 'No Prometheus Client found, BullMQ metrics plugin requires `fastify-metrics` plugin to be registered', + ), + ) + } + + const options = { + bullMqPrefix: 'bull', + metricsPrefix: 'bullmq', + queueDiscoverer: new BackgroundJobsBasedQueueDiscoverer(pluginOptions.redisClient), + excludedQueues: [], + histogramBuckets: [20, 50, 150, 400, 1000, 3000, 8000, 22000, 60000, 150000], + collectionOptions: { + type: 'interval', + intervalInMs: 5000, + }, + ...pluginOptions, + } + + try { + const collector = new MetricsCollector(options, fastify.metrics.client.register, fastify.log) + const collectFn = async () => await collector.collect() + let scheduler: CollectionScheduler + + if (options.collectionOptions.type === 'interval') { + scheduler = new PromiseBasedCollectionScheduler( + options.collectionOptions.intervalInMs, + collectFn, + ) + + // Void is set so the scheduler can run indefinitely + void scheduler.start() + } + + fastify.addHook('onClose', async () => { + if (scheduler) { + scheduler.stop() + } + await collector.dispose() + }) + + fastify.decorate('bullMqMetrics', { + collect: collectFn, + }) + + next() + } catch (err: unknown) { + return next( + err instanceof Error + ? err + : new Error('Unknown error in bull-mq-metrics-plugin', { cause: err }), + ) + } +} + +export const bullMqMetricsPlugin = fp(plugin, { + fastify: '4.x', + name: 'bull-mq-metrics-plugin', +}) diff --git a/package.json b/package.json index b5dae12..d453eab 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,9 @@ }, "dependencies": { "@bugsnag/js": "^7.22.7", + "@supercharge/promise-pool": "^3.2.0", "@lokalise/error-utils": "^1.4.0", + "@lokalise/background-jobs-common": "^4.0.1", "@splitsoftware/splitio": "^10.25.2", "@amplitude/analytics-node": "^1.3.5", "fastify-metrics": "^11.0.0", @@ -57,7 +59,9 @@ "@fastify/jwt": "^8.0.1", "newrelic": "^11.13.0", "pino": "^9.0.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "bullmq": "^5.8.4", + "ioredis": "^5.4.1" }, "devDependencies": { "@lokalise/node-core": "^11.1.0", @@ -69,9 +73,11 @@ "@typescript-eslint/parser": "^7.11.0", "@vitest/coverage-v8": "1.6.0", "auto-changelog": "^2.4.0", + "bullmq": "^5.8.4", "eslint": "^8.57.0", "eslint-plugin-import": "^2.29.1", "eslint-plugin-vitest": "0.4.1", + "ioredis": "^5.4.1", "fastify": "^4.27.0", "newrelic": "11.23.1", "pino": "^9.1.0", diff --git a/test/mocks/TestBackgroundJobProcessor.ts b/test/mocks/TestBackgroundJobProcessor.ts new file mode 100644 index 0000000..3f77edf --- /dev/null +++ b/test/mocks/TestBackgroundJobProcessor.ts @@ -0,0 +1,38 @@ +import type { + BackgroundJobProcessorDependencies, + BaseJobPayload, +} from '@lokalise/background-jobs-common' +import { AbstractBackgroundJobProcessor } from '@lokalise/background-jobs-common' +import { generateMonotonicUuid } from '@lokalise/id-utils' + +import { getTestRedisConfig } from '../setup' + +export class TestBackgroundJobProcessor< + JobData extends BaseJobPayload, + JobReturn, +> extends AbstractBackgroundJobProcessor { + private readonly returnValue: JobReturn + + constructor( + dependencies: BackgroundJobProcessorDependencies, + returnValue: JobReturn, + queueId: string = generateMonotonicUuid(), + ) { + super(dependencies, { + queueId, + ownerName: 'test', + isTest: true, + workerOptions: { concurrency: 1 }, + redisConfig: getTestRedisConfig(), + }) + this.returnValue = returnValue + } + + schedule(jobData: JobData): Promise { + return super.schedule(jobData, { attempts: 1 }) + } + + protected override process(): Promise { + return Promise.resolve(this.returnValue) + } +} diff --git a/test/mocks/TestDepedendencies.ts b/test/mocks/TestDepedendencies.ts new file mode 100644 index 0000000..b993691 --- /dev/null +++ b/test/mocks/TestDepedendencies.ts @@ -0,0 +1,69 @@ +import type { BackgroundJobProcessorDependencies } from '@lokalise/background-jobs-common' +import { CommonBullmqFactory } from '@lokalise/background-jobs-common' +import { globalLogger } from '@lokalise/node-core' +import { Redis } from 'ioredis' +import type { MockInstance } from 'vitest' + +const testLogger = globalLogger +export let lastInfoSpy: MockInstance +export let lastErrorSpy: MockInstance + +export class TestDepedendencies { + private client?: Redis + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createMocksForBackgroundJobProcessor(): BackgroundJobProcessorDependencies { + const originalChildFn = testLogger.child.bind(testLogger) + + const originalMethodSpy = vitest.spyOn(testLogger, 'child') + originalMethodSpy.mockImplementation((...args) => { + const childLogger = originalChildFn.apply(testLogger, args) + lastInfoSpy = vitest.spyOn(childLogger, 'info') + lastErrorSpy = vitest.spyOn(childLogger, 'error') + return childLogger + }) + + return { + bullmqFactory: new CommonBullmqFactory(), + transactionObservabilityManager: { + start: vi.fn(), + stop: vi.fn(), + } as never, + logger: testLogger, + errorReporter: { + report: vi.fn(), + } as never, + } + } + + async dispose(): Promise { + await this.client?.quit() + } + + startRedis(): Redis { + const db = process.env.REDIS_DB ? Number.parseInt(process.env.REDIS_DB) : undefined + const host = process.env.REDIS_HOST + const port = process.env.REDIS_PORT ? Number(process.env.REDIS_PORT) : undefined + const username = process.env.REDIS_USERNAME + const password = process.env.REDIS_PASSWORD + const connectTimeout = process.env.REDIS_CONNECT_TIMEOUT + ? Number.parseInt(process.env.REDIS_CONNECT_TIMEOUT, 10) + : undefined + const commandTimeout = process.env.REDIS_COMMAND_TIMEOUT + ? Number.parseInt(process.env.REDIS_COMMAND_TIMEOUT, 10) + : undefined + this.client = new Redis({ + host, + db, + port, + username, + password, + connectTimeout, + commandTimeout, + maxRetriesPerRequest: null, + enableReadyCheck: false, + }) + + return this.client + } +} diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..75355d2 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,12 @@ +import type { RedisConfig } from '@lokalise/node-core' + +process.loadEnvFile('./.env.test') + +export const getTestRedisConfig = (): RedisConfig => { + return { + host: process.env.REDIS_HOST ?? 'localhost', + password: process.env.REDIS_PASSWORD, + port: Number(process.env.REDIS_PORT), + useTls: false, + } +} diff --git a/tsconfig.lint.json b/tsconfig.lint.json new file mode 100644 index 0000000..5e07b01 --- /dev/null +++ b/tsconfig.lint.json @@ -0,0 +1,5 @@ +{ + "extends": ["./tsconfig.json"], + "include": ["lib/**/*", "test/**/*", "vitest.config.mts"], + "exclude": [] +}