diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 99f8d55..48519c6 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -5,6 +5,9 @@ on: - pull_request - workflow_call +permissions: + contents: read + jobs: lint: uses: adonisjs/.github/.github/workflows/lint.yml@main @@ -13,4 +16,34 @@ jobs: uses: adonisjs/.github/.github/workflows/typecheck.yml@main tests: - uses: adonisjs/.github/.github/workflows/test.yml@main + runs-on: ubuntu-latest + env: + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + strategy: + matrix: + node-version: ['lts/krypton', 'latest'] + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b750bf5..cad5c91 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,7 +1,7 @@ -name: "Close stale issues and PRs" +name: 'Close stale issues and PRs' on: schedule: - - cron: "30 0 * * *" + - cron: '30 0 * * *' jobs: stale: @@ -9,9 +9,9 @@ jobs: steps: - uses: actions/stale@v9 with: - stale-issue-message: "This issue has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still need help on this issue" - stale-pr-message: "This pull request has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still intend to submit this pull request" - close-issue-message: "This issue has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still need help on this issue" - close-pr-message: "This pull request has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still intend to submit this pull request" + stale-issue-message: 'This issue has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still need help on this issue' + stale-pr-message: 'This pull request has been marked as stale because it has been inactive for more than 21 days. Please reopen if you still intend to submit this pull request' + close-issue-message: 'This issue has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still need help on this issue' + close-pr-message: 'This pull request has been automatically closed because it has been inactive for more than 4 weeks. Please reopen if you still intend to submit this pull request' days-before-stale: 21 days-before-close: 5 diff --git a/package.json b/package.json index e72d0dc..34651b2 100644 --- a/package.json +++ b/package.json @@ -36,30 +36,31 @@ "version": "npm run build" }, "dependencies": { - "@boringnode/queue": "^0.6.0" + "@boringnode/queue": "^0.7.1" }, "devDependencies": { - "@adonisjs/assembler": "^8.4.0", - "@adonisjs/core": "^7.3.4", + "@adonisjs/assembler": "^8.5.0", + "@adonisjs/core": "^7.5.0", "@adonisjs/eslint-config": "^3.1.0", "@adonisjs/lucid": "^22.4.2", "@adonisjs/prettier-config": "^1.5.0", - "@adonisjs/redis": "^10.0.0", + "@adonisjs/redis": "^10.0.1", "@adonisjs/tsconfig": "^2.0.0", "@japa/assert": "^4.2.0", "@japa/file-system": "^3.0.0", "@japa/runner": "^5.3.0", "@poppinss/ts-exec": "^1.4.4", - "@release-it/conventional-changelog": "^11.0.1", - "@types/node": "~24.11.2", - "c8": "^11.0.0", + "@release-it/conventional-changelog": "^12.0.0", + "@types/node": "~26.3.0", + "better-sqlite3": "^13.0.3", + "c8": "^12.0.0", "cpy-cli": "^7.0.0", "del-cli": "^7.0.0", - "eslint": "^9.39.4", - "kysely": "^0.29.3", - "prettier": "^3.9.1", - "release-it": "^20.2.1", - "typescript": "^5.9.3" + "eslint": "^10.9.1", + "kysely": "^0.29.5", + "prettier": "^3.9.6", + "release-it": "^21.0.2", + "typescript": "^6.0.3" }, "peerDependencies": { "@adonisjs/assembler": "^8.0.0", diff --git a/providers/queue_provider.ts b/providers/queue_provider.ts index c6ad226..1478665 100644 --- a/providers/queue_provider.ts +++ b/providers/queue_provider.ts @@ -47,7 +47,13 @@ export default class QueueProvider { } async start() { - if (this.app.getEnvironment() === 'console') { + /** + * Nothing dispatches or processes jobs in the console environment or in + * an app warming up, since a warmed up app never becomes ready. The + * "getMode" method is missing in the older versions of the framework core + * without the "warmup" mode. + */ + if (this.app.getEnvironment() === 'console' || this.app.getMode?.() === 'warmup') { return } diff --git a/src/drivers.ts b/src/drivers.ts index eb06624..4e2afc3 100644 --- a/src/drivers.ts +++ b/src/drivers.ts @@ -58,8 +58,18 @@ export const drivers: { const redis = await app.container.make('redis') const { redis: redisAdapter } = await import('@boringnode/queue/drivers/redis_adapter') - const connection = redis.connection(config?.connectionName) - return redisAdapter((connection as any).ioConnection) + /** + * The connection is acquired from within the factory and not when the + * config provider is resolved. The queue manager resolves every adapter + * when the app starts, whereas it invokes the factory only when the + * adapter is used for the first time. Acquiring the connection eagerly + * would open a socket during the app start, even when nothing ever + * touches the queue + */ + return () => { + const connection = redis.connection(config?.connectionName) + return redisAdapter((connection as any).ioConnection)() + } }) }, @@ -69,9 +79,15 @@ export const drivers: { const { knex } = await import('@boringnode/queue/drivers/knex_adapter') const connectionName = config?.connectionName || db.primaryConnectionName - const connection = db.connection(connectionName) - return knex(connection.getWriteClient(), config?.tableName) + /** + * Same as the redis driver. The connection is acquired when the adapter + * is used for the first time and not when the app starts + */ + return () => { + const connection = db.connection(connectionName) + return knex(connection.getWriteClient(), config?.tableName)() + } }) }, diff --git a/tests/drivers/database.spec.ts b/tests/drivers/database.spec.ts new file mode 100644 index 0000000..35c66cd --- /dev/null +++ b/tests/drivers/database.spec.ts @@ -0,0 +1,119 @@ +/* + * @adonisjs/queue + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' + +import { defineConfig, drivers } from '../../index.js' +import { resolveAdapters } from '../../src/utils.js' +import { getDatabaseConfig, setupApp } from '../helpers.js' +import type { QueueConfig } from '../../src/types/main.js' + +const databaseProvider = () => import('@adonisjs/lucid/database_provider') + +test.group('drivers | database', () => { + test('do not acquire a connection when resolving the adapter', async ({ assert }) => { + const app = await setupApp( + 'console', + { + database: getDatabaseConfig(), + queue: defineConfig({ + default: 'database', + adapters: { + database: drivers.database({ connectionName: 'jobs' }), + }, + }), + }, + [databaseProvider] + ) + + const db = await app.container.make('lucid.db') + const config = app.config.get('queue') + const resolvedAdapters = await resolveAdapters(config, app) + + assert.isFunction(resolvedAdapters.database) + assert.isFalse(db.manager.isConnected('jobs')) + assert.isFalse(db.manager.isConnected('main')) + }) + + test('acquire the configured connection when creating the adapter', async ({ assert }) => { + const app = await setupApp( + 'console', + { + database: getDatabaseConfig(), + queue: defineConfig({ + default: 'database', + adapters: { + database: drivers.database({ connectionName: 'jobs' }), + }, + }), + }, + [databaseProvider] + ) + + const db = await app.container.make('lucid.db') + const config = app.config.get('queue') + const resolvedAdapters = await resolveAdapters(config, app) + + resolvedAdapters.database() + assert.isTrue(db.manager.isConnected('jobs')) + assert.isFalse(db.manager.isConnected('main')) + }) + + test('use the primary connection when no connection name is configured', async ({ assert }) => { + const app = await setupApp( + 'console', + { + database: getDatabaseConfig(), + queue: defineConfig({ + default: 'database', + adapters: { + database: drivers.database(), + }, + }), + }, + [databaseProvider] + ) + + const db = await app.container.make('lucid.db') + const config = app.config.get('queue') + const resolvedAdapters = await resolveAdapters(config, app) + + resolvedAdapters.database() + assert.isTrue(db.manager.isConnected('main')) + assert.isFalse(db.manager.isConnected('jobs')) + }) + + test('do not acquire a connection when the app starts', async ({ assert }) => { + const app = await setupApp( + 'web', + { + database: getDatabaseConfig(), + queue: defineConfig({ + default: 'database', + adapters: { + database: drivers.database(), + }, + }), + }, + [databaseProvider] + ) + + /** + * Starting the app in the "web" environment initializes the queue + * manager from the provider's start hook. Doing so must not open any + * connection, since nothing releases them in a process that never + * becomes ready (for example, the "codegen" command) + */ + await app.start(() => {}) + + const db = await app.container.make('lucid.db') + assert.isFalse(db.manager.isConnected('main')) + assert.isFalse(db.manager.isConnected('jobs')) + }) +}) diff --git a/tests/drivers/redis.spec.ts b/tests/drivers/redis.spec.ts new file mode 100644 index 0000000..bd33e47 --- /dev/null +++ b/tests/drivers/redis.spec.ts @@ -0,0 +1,116 @@ +/* + * @adonisjs/queue + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' + +import { defineConfig, drivers } from '../../index.js' +import { resolveAdapters } from '../../src/utils.js' +import { getRedisConfig, setupApp } from '../helpers.js' +import type { QueueConfig } from '../../src/types/main.js' + +const redisProvider = () => import('@adonisjs/redis/redis_provider') + +test.group('drivers | redis', () => { + test('do not acquire a connection when resolving the adapter', async ({ assert }) => { + const app = await setupApp( + 'console', + { + redis: getRedisConfig(), + queue: defineConfig({ + default: 'redis', + adapters: { + redis: drivers.redis({ connectionName: 'jobs' as any }), + }, + }), + }, + [redisProvider] + ) + + const redis = await app.container.make('redis') + const config = app.config.get('queue') + const resolvedAdapters = await resolveAdapters(config, app) + + assert.isFunction(resolvedAdapters.redis) + assert.equal(redis.activeConnectionsCount, 0) + }) + + test('acquire the configured connection when creating the adapter', async ({ assert }) => { + const app = await setupApp( + 'console', + { + redis: getRedisConfig(), + queue: defineConfig({ + default: 'redis', + adapters: { + redis: drivers.redis({ connectionName: 'jobs' as any }), + }, + }), + }, + [redisProvider] + ) + + const redis = await app.container.make('redis') + const config = app.config.get('queue') + const resolvedAdapters = await resolveAdapters(config, app) + + resolvedAdapters.redis() + assert.equal(redis.activeConnectionsCount, 1) + assert.property(redis.activeConnections, 'jobs') + }) + + test('use the default connection when no connection name is configured', async ({ assert }) => { + const app = await setupApp( + 'console', + { + redis: getRedisConfig(), + queue: defineConfig({ + default: 'redis', + adapters: { + redis: drivers.redis(), + }, + }), + }, + [redisProvider] + ) + + const redis = await app.container.make('redis') + const config = app.config.get('queue') + const resolvedAdapters = await resolveAdapters(config, app) + + resolvedAdapters.redis() + assert.property(redis.activeConnections, 'main') + }) + + test('do not acquire a connection when the app starts', async ({ assert }) => { + const app = await setupApp( + 'web', + { + redis: getRedisConfig(), + queue: defineConfig({ + default: 'redis', + adapters: { + redis: drivers.redis(), + }, + }), + }, + [redisProvider] + ) + + /** + * Starting the app in the "web" environment initializes the queue + * manager from the provider's start hook. Doing so must not open any + * connection, since nothing releases them in a process that never + * becomes ready (for example, the "codegen" command) + */ + await app.start(() => {}) + + const redis = await app.container.make('redis') + assert.equal(redis.activeConnectionsCount, 0) + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts index bd6fc59..69cdcd4 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -9,23 +9,70 @@ import { getActiveTest } from '@japa/runner' import { IgnitorFactory } from '@adonisjs/core/factories' -import type { AppEnvironments } from '@adonisjs/core/types/app' +import type { AppEnvironments, ApplicationModes } from '@adonisjs/core/types/app' + +import { defineConfig as defineRedisConfig } from '@adonisjs/redis' +import { defineConfig as defineDatabaseConfig } from '@adonisjs/lucid' import { defineConfig, drivers } from '../index.js' const BASE_URL = new URL('./tmp/', import.meta.url) +/** + * Returns the redis config with "main" and "jobs" connections pointing to + * the redis server from the environment + */ +export function getRedisConfig() { + const connection = { + host: process.env.REDIS_HOST || '127.0.0.1', + port: process.env.REDIS_PORT || 6379, + } + + return defineRedisConfig({ + connection: 'main', + connections: { + main: { ...connection }, + jobs: { ...connection }, + }, + }) +} + +/** + * Returns the database config with "main" and "jobs" in-memory sqlite + * connections, so the tests can open a real connection without any + * external database server + */ +export function getDatabaseConfig() { + const connection = { + client: 'better-sqlite3' as const, + connection: { filename: ':memory:' }, + useNullAsDefault: true, + } + + return defineDatabaseConfig({ + connection: 'main', + connections: { + main: { ...connection }, + jobs: { ...connection }, + }, + }) +} + export async function setupApp( env?: AppEnvironments, config: { queue?: ReturnType - } = {} + [key: string]: unknown + } = {}, + providers: (() => Promise<{ default: any }>)[] = [], + mode?: ApplicationModes ) { const ignitor = new IgnitorFactory() .withCoreProviders() .withCoreConfig() .merge({ config: { + ...config, queue: config.queue || defineConfig({ @@ -36,7 +83,7 @@ export async function setupApp( }), }, rcFileContents: { - providers: [() => import('../providers/queue_provider.js')], + providers: [() => import('../providers/queue_provider.js'), ...providers], }, }) .create(BASE_URL, { @@ -50,6 +97,10 @@ export async function setupApp( }) const app = ignitor.createApp(env || 'web') + if (mode) { + app.setMode(mode) + } + await app.init().then(() => app.boot()) getActiveTest()?.cleanup(() => app.terminate()) diff --git a/tests/provider.spec.ts b/tests/provider.spec.ts index 87b39b4..551fc25 100644 --- a/tests/provider.spec.ts +++ b/tests/provider.spec.ts @@ -207,4 +207,39 @@ test.group('Provider', (group) => { assert.deepEqual(order, ['router', 'loadJobs', 'start', 'destroy']) }) + + test('should not load jobs when warming up the app', async ({ assert, fs }) => { + await fs.create( + 'app/jobs/provider_warmup_job.ts', + ` + import { Job } from '@boringnode/queue' + + export default class ProviderWarmupJob extends Job { + async execute() {} + } + ` + ) + + const app = await setupApp( + 'web', + { + queue: defineConfig({ + default: 'sync', + adapters: { + sync: sync(), + }, + locations: [`${fs.basePath}/app/jobs/provider_warmup_job.ts`], + }), + }, + [], + 'warmup' + ) + + assert.isUndefined(Locator.get('ProviderWarmupJob')) + + await app.warmUp() + + assert.equal(app.getState(), 'warmed') + assert.isUndefined(Locator.get('ProviderWarmupJob')) + }) })